auth.go
⎇
Raw
1package web
2
3import (
4 "encoding/json"
5 "errors"
6 "net/http"
7 neturl "net/url"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11
12 "hearthforge/internal/avatar"
13 "hearthforge/internal/db"
14 "hearthforge/internal/util"
15 "hearthforge/internal/web/views"
16)
17
18// minPasswordLength is the minimum password length. Existing accounts were
19// created under this rule.
20const minPasswordLength = 8
21
22// redirectTo sends a 302. The end-to-end tests assert that exact status.
23func redirectTo(w http.ResponseWriter, r *http.Request, url string) {
24 http.Redirect(w, r, url, http.StatusFound)
25}
26
27// queryEscape encodes one query-string value for a URL path context: a space
28// becomes %20, not the form-encoded "+".
29func queryEscape(s string) string {
30 return strings.ReplaceAll(neturl.QueryEscape(s), "+", "%20")
31}
32
33// encodeQuery renders a query string with queryEscape's encoding.
34func encodeQuery(v neturl.Values) string {
35 return strings.ReplaceAll(v.Encode(), "+", "%20")
36}
37
38// parseUploadForm parses a form that may arrive as multipart or as
39// urlencoded. ParseMultipartForm rejects a urlencoded body with
40// ErrNotMultipart. Both body shapes are valid here, so that is not an error.
41// The caller must still nil-check r.MultipartForm.
42func parseUploadForm(r *http.Request, maxMemory int64) error {
43 err := r.ParseMultipartForm(maxMemory)
44 if errors.Is(err, http.ErrNotMultipart) {
45 return nil
46 }
47 return err
48}
49
50// writeJSON sends a JSON body with the given status.
51func writeJSON(w http.ResponseWriter, status int, v any) {
52 w.Header().Set("Content-Type", "application/json")
53 w.WriteHeader(status)
54 json.NewEncoder(w).Encode(v)
55}
56
57// jsonError sends {"error": msg}, the shape the passkey page scripts read.
58func jsonError(w http.ResponseWriter, status int, msg string) {
59 writeJSON(w, status, map[string]string{"error": msg})
60}
61
62// isUniqueViolation reports a SQLite UNIQUE constraint failure.
63func isUniqueViolation(err error) bool {
64 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
65}
66
67// authRoutes registers sign-in, registration and passkey endpoints.
68func (s *Server) authRoutes(r chi.Router) {
69 r.Get("/login", s.loginPage)
70 r.Post("/login", s.login)
71 r.Get("/register", s.registerPage)
72 r.Post("/register", s.register)
73 r.Post("/logout", s.logout)
74
75 r.Post("/auth/passkey/create-user", s.passkeyCreateUser)
76 r.Post("/auth/passkey/register/options", s.passkeyRegisterOptions)
77 r.Post("/auth/passkey/register/verify", s.passkeyRegisterVerify)
78 r.Post("/auth/passkey/login/options", s.passkeyLoginOptions)
79 r.Post("/auth/passkey/login/verify", s.passkeyLoginVerify)
80}
81
82func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
83 views.Render(w, http.StatusOK, views.Login(s.Cfg, ""))
84}
85
86func (s *Server) loginError(w http.ResponseWriter, msg string) {
87 views.Render(w, http.StatusOK, views.Login(s.Cfg, msg))
88}
89
90func (s *Server) login(w http.ResponseWriter, r *http.Request) {
91 if s.limited(w, r, loginLimiter, true) {
92 return
93 }
94 if err := r.ParseForm(); err != nil {
95 http.Error(w, "Bad request", http.StatusBadRequest)
96 return
97 }
98 user, err := s.DB.UserByName(r.Context(), r.FormValue("username"))
99 if err != nil {
100 http.Error(w, "Database error", http.StatusInternalServerError)
101 return
102 }
103 if user == nil || user.PasswordHash == nil {
104 // Spend the same argon2 time as a real check, so the response time
105 // does not reveal whether the username exists.
106 db.VerifyDummyPassword(r.FormValue("password"))
107 s.loginError(w, "Invalid username or password")
108 return
109 }
110 ok, err := db.VerifyPassword(*user.PasswordHash, r.FormValue("password"))
111 if err != nil || !ok {
112 s.loginError(w, "Invalid username or password")
113 return
114 }
115 if user.IsPending {
116 s.loginError(w, "Your account is awaiting approval.")
117 return
118 }
119 cookie, err := s.newSession(r.Context(), user.ID)
120 if err != nil {
121 http.Error(w, "Database error", http.StatusInternalServerError)
122 return
123 }
124 http.SetCookie(w, cookie)
125 redirectTo(w, r, "/")
126}
127
128func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
129 s.clearSession(w, r)
130 redirectTo(w, r, "/")
131}
132
133func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) {
134 if s.Cfg.RegistrationType == "disabled" {
135 http.Error(w, "Registration is disabled", http.StatusForbidden)
136 return
137 }
138 views.Render(w, http.StatusOK, views.Register(s.Cfg, "", s.Cfg.RegisterQuestion, false))
139}
140
141func (s *Server) registerError(w http.ResponseWriter, msg string) {
142 views.Render(w, http.StatusOK, views.Register(s.Cfg, msg, s.Cfg.RegisterQuestion, false))
143}
144
145func (s *Server) register(w http.ResponseWriter, r *http.Request) {
146 if s.Cfg.RegistrationType == "disabled" {
147 http.Error(w, "Registration is disabled", http.StatusForbidden)
148 return
149 }
150 if s.limited(w, r, registrationLimiter, true) {
151 return
152 }
153 if err := r.ParseForm(); err != nil {
154 http.Error(w, "Bad request", http.StatusBadRequest)
155 return
156 }
157 username := r.FormValue("username")
158 password := r.FormValue("password")
159 application := r.FormValue("application")
160
161 if tooLong(w, username, s.Cfg.MaxUsernameBytes) ||
162 tooLong(w, password, s.Cfg.MaxPasswordBytes) ||
163 tooLong(w, application, s.Cfg.MaxTextBodyBytes) {
164 return
165 }
166 if !util.ValidUsername(username) {
167 s.registerError(w, "Username may only contain letters, numbers, hyphens, and underscores")
168 return
169 }
170 if username == db.AdminUsername {
171 s.registerError(w, "That username is reserved")
172 return
173 }
174 if strings.TrimSpace(password) == "" {
175 s.registerError(w, "Password is required (use the passkey button for passwordless registration)")
176 return
177 }
178 if password != r.FormValue("password2") {
179 s.registerError(w, "Passwords do not match")
180 return
181 }
182 if len(password) < minPasswordLength {
183 s.registerError(w, "Password must be at least 8 characters")
184 return
185 }
186
187 hash, err := db.HashPassword(password)
188 if err != nil {
189 http.Error(w, "Server error", http.StatusInternalServerError)
190 return
191 }
192 id, err := s.createRegisteredUser(r, username, &hash, application)
193 if isUniqueViolation(err) {
194 s.registerError(w, "Username already taken")
195 return
196 }
197 if err != nil {
198 http.Error(w, "Database error", http.StatusInternalServerError)
199 return
200 }
201
202 if s.Cfg.RegistrationType == "queue" {
203 views.Render(w, http.StatusOK, views.Register(s.Cfg, "", s.Cfg.RegisterQuestion, true))
204 return
205 }
206 cookie, err := s.newSession(r.Context(), id)
207 if err != nil {
208 http.Error(w, "Database error", http.StatusInternalServerError)
209 return
210 }
211 http.SetCookie(w, cookie)
212 redirectTo(w, r, "/")
213}
214
215// createRegisteredUser inserts the account and writes its default avatar.
216// It marks the account pending when registration runs as a queue.
217func (s *Server) createRegisteredUser(r *http.Request, username string, hash *string, application string) (int64, error) {
218 var app *string
219 if application != "" {
220 app = &application
221 }
222 pending := s.Cfg.RegistrationType == "queue"
223 id, err := s.DB.CreateUser(r.Context(), username, hash, db.NowISO(), pending, app)
224 if err != nil {
225 return 0, err
226 }
227 // A missing avatar file is not worth failing the registration for; the
228 // avatar route regenerates it on the next request.
229 _ = avatar.SaveDefault(s.Cfg.AvatarsDir(), id, username)
230 return id, nil
231}
232
233// passkeyCreateUser creates the account before a passwordless registration.
234// It shares the registration limiter with the password path so it cannot be
235// used to bypass that limit.
236func (s *Server) passkeyCreateUser(w http.ResponseWriter, r *http.Request) {
237 if s.Cfg.RegistrationType == "disabled" {
238 jsonError(w, http.StatusBadRequest, "Registration is disabled")
239 return
240 }
241 if !s.allowed(r, registrationLimiter, true) {
242 jsonError(w, http.StatusTooManyRequests, "Too many registration attempts. Please try again later.")
243 return
244 }
245 var body struct {
246 Username string `json:"username"`
247 Application string `json:"application"`
248 }
249 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
250 jsonError(w, http.StatusBadRequest, "Invalid request")
251 return
252 }
253 if len(body.Username) > s.Cfg.MaxUsernameBytes || len(body.Application) > s.Cfg.MaxTextBodyBytes {
254 jsonError(w, http.StatusUnprocessableEntity, "Request too large")
255 return
256 }
257 if !util.ValidUsername(body.Username) {
258 jsonError(w, http.StatusBadRequest, "Invalid username")
259 return
260 }
261 if body.Username == db.AdminUsername {
262 jsonError(w, http.StatusBadRequest, "That username is reserved")
263 return
264 }
265
266 id, err := s.createRegisteredUser(r, body.Username, nil, body.Application)
267 if isUniqueViolation(err) {
268 jsonError(w, http.StatusBadRequest, "Username already taken")
269 return
270 }
271 if err != nil {
272 jsonError(w, http.StatusInternalServerError, "Database error")
273 return
274 }
275 // Provisional until the ceremony stores a credential. Until then the row
276 // has no password and no passkey, so the cleanup sweep removes it.
277 if err := s.DB.SetPasskeySetupStarted(r.Context(), id, db.NowISO()); err != nil {
278 jsonError(w, http.StatusInternalServerError, "Database error")
279 return
280 }
281
282 // The session cookie is set in queue mode too. The passkey ceremony that
283 // follows needs it to identify the new account. The cookie grants nothing
284 // else: every other route resolves users through SessionUser, which
285 // rejects a pending account.
286 cookie, err := s.newSession(r.Context(), id)
287 if err != nil {
288 jsonError(w, http.StatusInternalServerError, "Database error")
289 return
290 }
291 http.SetCookie(w, cookie)
292 if s.Cfg.RegistrationType == "queue" {
293 writeJSON(w, http.StatusOK, map[string]bool{"ok": true, "pending": true})
294 return
295 }
296 writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
297}
298