287 lines
7.7 KiB
Go
287 lines
7.7 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
|
|
return
|
|
}
|
|
if req.Username == "" || req.Email == "" || req.Password == "" {
|
|
jsonErr(w, 400, "username, email and password required")
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "register", req.Email) {
|
|
return
|
|
}
|
|
if err := validatePassword(req.Password); err != "" {
|
|
jsonErr(w, 400, err)
|
|
return
|
|
}
|
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
jsonErr(w, 400, "invalid email")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
jsonErr(w, 500, "internal error")
|
|
return
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
id := make([]byte, 12)
|
|
rand.Read(id)
|
|
userID := hex.EncodeToString(id)
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
_, err = tx.Exec(
|
|
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
|
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE") {
|
|
jsonErr(w, 409, "username or email already taken")
|
|
return
|
|
}
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if _, err := issueEmailToken(tx, userID, "confirm", 48*time.Hour); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, map[string]string{"status": "confirmation_sent"})
|
|
}
|
|
|
|
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
tokenStr := r.URL.Query().Get("token")
|
|
if tokenStr == "" {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
s.renderPage(w, r, "confirm", webPage{Title: "confirm.title", Token: tokenStr})
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodGet, http.MethodPost)
|
|
return
|
|
}
|
|
tokenStr := ""
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
|
|
return
|
|
}
|
|
tokenStr = req.Token
|
|
} else if err := r.ParseForm(); err == nil {
|
|
if !s.requirePublicWebMutation(w, r, "/login") {
|
|
return
|
|
}
|
|
tokenStr = r.FormValue("token")
|
|
} else {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
|
|
return
|
|
}
|
|
if tokenStr == "" {
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
jsonErr(w, 400, "token required")
|
|
} else {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
|
|
}
|
|
return
|
|
}
|
|
var userID, expiresAt string
|
|
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='confirm'",
|
|
emailTokenHash(tokenStr)).Scan(&userID, &expiresAt)
|
|
if err != nil {
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
jsonErr(w, 400, "invalid or expired token")
|
|
} else {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
|
|
}
|
|
return
|
|
}
|
|
exp, err := time.Parse(time.RFC3339, expiresAt)
|
|
if err != nil || time.Now().After(exp) {
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
jsonErr(w, 400, "token expired")
|
|
} else {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
|
|
}
|
|
return
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if _, err := tx.Exec("DELETE FROM server_email_tokens WHERE token_hash=?", emailTokenHash(tokenStr)); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
log.Printf("confirm: user %s confirmed email", userID)
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
jsonOK(w, map[string]string{"status": "confirmed"})
|
|
} else {
|
|
http.Redirect(w, r, "/confirm/result", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
|
|
return
|
|
}
|
|
if req.Username == "" || req.Password == "" {
|
|
jsonErr(w, 400, "username and password required")
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "login", req.Username) {
|
|
return
|
|
}
|
|
var userID, hash string
|
|
var confirmed, blocked int
|
|
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
|
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
if err != nil {
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
if blocked != 0 {
|
|
jsonErr(w, 403, "account blocked")
|
|
return
|
|
}
|
|
if confirmed == 0 {
|
|
jsonErr(w, 403, "email not confirmed")
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
|
jsonErr(w, 401, "invalid credentials")
|
|
return
|
|
}
|
|
if _, err := s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
tok, _, err := s.createSession(sessionScopeUser, userID)
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
|
|
}
|
|
|
|
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
|
|
return
|
|
}
|
|
if req.Email == "" {
|
|
jsonErr(w, 400, "email required")
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "forgot", req.Email) {
|
|
return
|
|
}
|
|
var userID string
|
|
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
|
|
if err != nil {
|
|
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
|
return
|
|
}
|
|
if _, err := issueEmailToken(s.db, userID, "reset", time.Hour); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
|
}
|
|
|
|
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
var req struct {
|
|
Token string `json:"token"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
|
|
return
|
|
}
|
|
if req.Token == "" || req.NewPassword == "" {
|
|
jsonErr(w, 400, "token and new_password required")
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "reset", "") {
|
|
return
|
|
}
|
|
if err := validatePassword(req.NewPassword); err != "" {
|
|
jsonErr(w, 400, err)
|
|
return
|
|
}
|
|
_, err := s.resetPasswordWithToken(req.Token, req.NewPassword)
|
|
if err == errResetTokenInvalid {
|
|
jsonErr(w, 400, "invalid or expired token")
|
|
return
|
|
}
|
|
if err == errResetTokenExpired {
|
|
jsonErr(w, 400, "token expired")
|
|
return
|
|
}
|
|
if err != nil {
|
|
jsonErr(w, 500, "internal error")
|
|
return
|
|
}
|
|
jsonOK(w, map[string]string{"status": "password reset"})
|
|
}
|