package web import ( "encoding/json" "errors" "net/http" neturl "net/url" "strings" "github.com/go-chi/chi/v5" "hearthforge/internal/avatar" "hearthforge/internal/db" "hearthforge/internal/util" "hearthforge/internal/web/views" ) // minPasswordLength is the minimum password length. Existing accounts were // created under this rule. const minPasswordLength = 8 // redirectTo sends a 302. The end-to-end tests assert that exact status. func redirectTo(w http.ResponseWriter, r *http.Request, url string) { http.Redirect(w, r, url, http.StatusFound) } // queryEscape encodes one query-string value for a URL path context: a space // becomes %20, not the form-encoded "+". func queryEscape(s string) string { return strings.ReplaceAll(neturl.QueryEscape(s), "+", "%20") } // encodeQuery renders a query string with queryEscape's encoding. func encodeQuery(v neturl.Values) string { return strings.ReplaceAll(v.Encode(), "+", "%20") } // parseUploadForm parses a form that may arrive as multipart or as // urlencoded. ParseMultipartForm rejects a urlencoded body with // ErrNotMultipart. Both body shapes are valid here, so that is not an error. // The caller must still nil-check r.MultipartForm. func parseUploadForm(r *http.Request, maxMemory int64) error { err := r.ParseMultipartForm(maxMemory) if errors.Is(err, http.ErrNotMultipart) { return nil } return err } // writeJSON sends a JSON body with the given status. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } // jsonError sends {"error": msg}, the shape the passkey page scripts read. func jsonError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // isUniqueViolation reports a SQLite UNIQUE constraint failure. func isUniqueViolation(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") } // authRoutes registers sign-in, registration and passkey endpoints. func (s *Server) authRoutes(r chi.Router) { r.Get("/login", s.loginPage) r.Post("/login", s.login) r.Get("/register", s.registerPage) r.Post("/register", s.register) r.Post("/logout", s.logout) r.Post("/auth/passkey/create-user", s.passkeyCreateUser) r.Post("/auth/passkey/register/options", s.passkeyRegisterOptions) r.Post("/auth/passkey/register/verify", s.passkeyRegisterVerify) r.Post("/auth/passkey/login/options", s.passkeyLoginOptions) r.Post("/auth/passkey/login/verify", s.passkeyLoginVerify) } func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { views.Render(w, http.StatusOK, views.Login(s.Cfg, "")) } func (s *Server) loginError(w http.ResponseWriter, msg string) { views.Render(w, http.StatusOK, views.Login(s.Cfg, msg)) } func (s *Server) login(w http.ResponseWriter, r *http.Request) { if s.limited(w, r, loginLimiter, true) { return } if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } user, err := s.DB.UserByName(r.Context(), r.FormValue("username")) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if user == nil || user.PasswordHash == nil { // Spend the same argon2 time as a real check, so the response time // does not reveal whether the username exists. db.VerifyDummyPassword(r.FormValue("password")) s.loginError(w, "Invalid username or password") return } ok, err := db.VerifyPassword(*user.PasswordHash, r.FormValue("password")) if err != nil || !ok { s.loginError(w, "Invalid username or password") return } if user.IsPending { s.loginError(w, "Your account is awaiting approval.") return } cookie, err := s.newSession(r.Context(), user.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } http.SetCookie(w, cookie) redirectTo(w, r, "/") } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { s.clearSession(w, r) redirectTo(w, r, "/") } func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) { if s.Cfg.RegistrationType == "disabled" { http.Error(w, "Registration is disabled", http.StatusForbidden) return } views.Render(w, http.StatusOK, views.Register(s.Cfg, "", s.Cfg.RegisterQuestion, false)) } func (s *Server) registerError(w http.ResponseWriter, msg string) { views.Render(w, http.StatusOK, views.Register(s.Cfg, msg, s.Cfg.RegisterQuestion, false)) } func (s *Server) register(w http.ResponseWriter, r *http.Request) { if s.Cfg.RegistrationType == "disabled" { http.Error(w, "Registration is disabled", http.StatusForbidden) return } if s.limited(w, r, registrationLimiter, true) { return } if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } username := r.FormValue("username") password := r.FormValue("password") application := r.FormValue("application") if tooLong(w, username, s.Cfg.MaxUsernameBytes) || tooLong(w, password, s.Cfg.MaxPasswordBytes) || tooLong(w, application, s.Cfg.MaxTextBodyBytes) { return } if !util.ValidUsername(username) { s.registerError(w, "Username may only contain letters, numbers, hyphens, and underscores") return } if username == db.AdminUsername { s.registerError(w, "That username is reserved") return } if strings.TrimSpace(password) == "" { s.registerError(w, "Password is required (use the passkey button for passwordless registration)") return } if password != r.FormValue("password2") { s.registerError(w, "Passwords do not match") return } if len(password) < minPasswordLength { s.registerError(w, "Password must be at least 8 characters") return } hash, err := db.HashPassword(password) if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } id, err := s.createRegisteredUser(r, username, &hash, application) if isUniqueViolation(err) { s.registerError(w, "Username already taken") return } if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } if s.Cfg.RegistrationType == "queue" { views.Render(w, http.StatusOK, views.Register(s.Cfg, "", s.Cfg.RegisterQuestion, true)) return } cookie, err := s.newSession(r.Context(), id) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } http.SetCookie(w, cookie) redirectTo(w, r, "/") } // createRegisteredUser inserts the account and writes its default avatar. // It marks the account pending when registration runs as a queue. func (s *Server) createRegisteredUser(r *http.Request, username string, hash *string, application string) (int64, error) { var app *string if application != "" { app = &application } pending := s.Cfg.RegistrationType == "queue" id, err := s.DB.CreateUser(r.Context(), username, hash, db.NowISO(), pending, app) if err != nil { return 0, err } // A missing avatar file is not worth failing the registration for; the // avatar route regenerates it on the next request. _ = avatar.SaveDefault(s.Cfg.AvatarsDir(), id, username) return id, nil } // passkeyCreateUser creates the account before a passwordless registration. // It shares the registration limiter with the password path so it cannot be // used to bypass that limit. func (s *Server) passkeyCreateUser(w http.ResponseWriter, r *http.Request) { if s.Cfg.RegistrationType == "disabled" { jsonError(w, http.StatusBadRequest, "Registration is disabled") return } if !s.allowed(r, registrationLimiter, true) { jsonError(w, http.StatusTooManyRequests, "Too many registration attempts. Please try again later.") return } var body struct { Username string `json:"username"` Application string `json:"application"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "Invalid request") return } if len(body.Username) > s.Cfg.MaxUsernameBytes || len(body.Application) > s.Cfg.MaxTextBodyBytes { jsonError(w, http.StatusUnprocessableEntity, "Request too large") return } if !util.ValidUsername(body.Username) { jsonError(w, http.StatusBadRequest, "Invalid username") return } if body.Username == db.AdminUsername { jsonError(w, http.StatusBadRequest, "That username is reserved") return } id, err := s.createRegisteredUser(r, body.Username, nil, body.Application) if isUniqueViolation(err) { jsonError(w, http.StatusBadRequest, "Username already taken") return } if err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } // Provisional until the ceremony stores a credential. Until then the row // has no password and no passkey, so the cleanup sweep removes it. if err := s.DB.SetPasskeySetupStarted(r.Context(), id, db.NowISO()); err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } // The session cookie is set in queue mode too. The passkey ceremony that // follows needs it to identify the new account. The cookie grants nothing // else: every other route resolves users through SessionUser, which // rejects a pending account. cookie, err := s.newSession(r.Context(), id) if err != nil { jsonError(w, http.StatusInternalServerError, "Database error") return } http.SetCookie(w, cookie) if s.Cfg.RegistrationType == "queue" { writeJSON(w, http.StatusOK, map[string]bool{"ok": true, "pending": true}) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }