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
82// localPath returns p when it is a path on this site, else "". It keeps a
83// return URL from sending the user to another host.
84func localPath(p string) string {
85 if _, err := neturl.Parse(p); err != nil ||
86 !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") || strings.Contains(p, "\\") {
87 return ""
88 }
89 return p
90}
91
92func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
93 views.Render(w, http.StatusOK, views.Login(s.Cfg, "", localPath(r.URL.Query().Get("next"))))
94}
95
96func (s *Server) loginError(w http.ResponseWriter, r *http.Request, msg string) {
97 views.Render(w, http.StatusOK, views.Login(s.Cfg, msg, localPath(r.FormValue("next"))))
98}
99
100func (s *Server) login(w http.ResponseWriter, r *http.Request) {
101 if s.limited(w, r, loginLimiter, true) {
102 return
103 }
104 if err := r.ParseForm(); err != nil {
105 http.Error(w, "Bad request", http.StatusBadRequest)
106 return
107 }
108 user, err := s.DB.UserByName(r.Context(), r.FormValue("username"))
109 if err != nil {
110 http.Error(w, "Database error", http.StatusInternalServerError)
111 return
112 }
113 if user == nil || user.PasswordHash == nil {
114 // Spend the same argon2 time as a real check, so the response time
115 // does not reveal whether the username exists.
116 db.VerifyDummyPassword(r.FormValue("password"))
117 s.loginError(w, r, "Invalid username or password")
118 return
119 }
120 ok, err := db.VerifyPassword(*user.PasswordHash, r.FormValue("password"))
121 if err != nil || !ok {
122 s.loginError(w, r, "Invalid username or password")
123 return
124 }
125 if user.IsPending {
126 s.loginError(w, r, "Your account is awaiting approval.")
127 return
128 }
129 cookie, err := s.newSession(r.Context(), user.ID)
130 if err != nil {
131 http.Error(w, "Database error", http.StatusInternalServerError)
132 return
133 }
134 http.SetCookie(w, cookie)
135 next := localPath(r.FormValue("next"))
136 if next == "" {
137 next = "/"
138 }
139 redirectTo(w, r, next)
140}
141
142func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
143 s.clearSession(w, r)
144 redirectTo(w, r, "/")
145}
146
147func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) {
148 if s.Cfg.RegistrationType == "disabled" {
149 http.Error(w, "Registration is disabled", http.StatusForbidden)
150 return
151 }
152 views.Render(w, http.StatusOK, views.Register(s.Cfg, "", s.Cfg.RegisterQuestion, false))
153}
154
155func (s *Server) registerError(w http.ResponseWriter, msg string) {
156 views.Render(w, http.StatusOK, views.Register(s.Cfg, msg, s.Cfg.RegisterQuestion, false))
157}
158
159func (s *Server) register(w http.ResponseWriter, r *http.Request) {
160 if s.Cfg.RegistrationType == "disabled" {
161 http.Error(w, "Registration is disabled", http.StatusForbidden)
162 return
163 }
164 if s.limited(w, r, registrationLimiter, true) {
165 return
166 }
167 if err := r.ParseForm(); err != nil {
168 http.Error(w, "Bad request", http.StatusBadRequest)
169 return
170 }
171 username := r.FormValue("username")
172 password := r.FormValue("password")
173 application := r.FormValue("application")
174
175 if tooLong(w, username, s.Cfg.MaxUsernameBytes) ||
176 tooLong(w, password, s.Cfg.MaxPasswordBytes) ||
177 tooLong(w, application, s.Cfg.MaxTextBodyBytes) {
178 return
179 }
180 if !util.ValidUsername(username) {
181 s.registerError(w, "Username may only contain letters, numbers, hyphens, and underscores")
182 return
183 }
184 if strings.EqualFold(username, db.AdminUsername) {
185 s.registerError(w, "That username is reserved")
186 return
187 }
188 if strings.TrimSpace(password) == "" {
189 s.registerError(w, "Password is required (use the passkey button for passwordless registration)")
190 return
191 }
192 if password != r.FormValue("password2") {
193 s.registerError(w, "Passwords do not match")
194 return
195 }
196 if len(password) < minPasswordLength {
197 s.registerError(w, "Password must be at least 8 characters")
198 return
199 }
200
201 hash, err := db.HashPassword(password)
202 if err != nil {
203 http.Error(w, "Server error", http.StatusInternalServerError)
204 return
205 }
206 id, err := s.createRegisteredUser(r, username, &hash, application)
207 if isUniqueViolation(err) {
208 s.registerError(w, "Username already taken")
209 return
210 }
211 if err != nil {
212 http.Error(w, "Database error", http.StatusInternalServerError)
213 return
214 }
215
216 if s.Cfg.RegistrationType == "queue" {
217 views.Render(w, http.StatusOK, views.Register(s.Cfg, "", s.Cfg.RegisterQuestion, true))
218 return
219 }
220 cookie, err := s.newSession(r.Context(), id)
221 if err != nil {
222 http.Error(w, "Database error", http.StatusInternalServerError)
223 return
224 }
225 http.SetCookie(w, cookie)
226 redirectTo(w, r, "/")
227}
228
229// createRegisteredUser inserts the account and writes its default avatar.
230// It marks the account pending when registration runs as a queue.
231func (s *Server) createRegisteredUser(r *http.Request, username string, hash *string, application string) (int64, error) {
232 var app *string
233 if application != "" {
234 app = &application
235 }
236 pending := s.Cfg.RegistrationType == "queue"
237 id, err := s.DB.CreateUser(r.Context(), username, hash, db.NowISO(), pending, app)
238 if err != nil {
239 return 0, err
240 }
241 // A missing avatar file is not worth failing the registration for; the
242 // avatar route regenerates it on the next request.
243 _ = avatar.SaveDefault(s.Cfg.AvatarsDir(), id, username)
244 return id, nil
245}
246
247// passkeyCreateUser creates the account before a passwordless registration.
248// It shares the registration limiter with the password path so it cannot be
249// used to bypass that limit.
250func (s *Server) passkeyCreateUser(w http.ResponseWriter, r *http.Request) {
251 if s.Cfg.RegistrationType == "disabled" {
252 jsonError(w, http.StatusBadRequest, "Registration is disabled")
253 return
254 }
255 if !s.allowed(r, registrationLimiter, true) {
256 jsonError(w, http.StatusTooManyRequests, "Too many registration attempts. Please try again later.")
257 return
258 }
259 var body struct {
260 Username string `json:"username"`
261 Application string `json:"application"`
262 }
263 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
264 jsonError(w, http.StatusBadRequest, "Invalid request")
265 return
266 }
267 if len(body.Username) > s.Cfg.MaxUsernameBytes || len(body.Application) > s.Cfg.MaxTextBodyBytes {
268 jsonError(w, http.StatusUnprocessableEntity, "Request too large")
269 return
270 }
271 if !util.ValidUsername(body.Username) {
272 jsonError(w, http.StatusBadRequest, "Invalid username")
273 return
274 }
275 if strings.EqualFold(body.Username, db.AdminUsername) {
276 jsonError(w, http.StatusBadRequest, "That username is reserved")
277 return
278 }
279
280 id, err := s.createRegisteredUser(r, body.Username, nil, body.Application)
281 if isUniqueViolation(err) {
282 jsonError(w, http.StatusBadRequest, "Username already taken")
283 return
284 }
285 if err != nil {
286 jsonError(w, http.StatusInternalServerError, "Database error")
287 return
288 }
289 // Provisional until the ceremony stores a credential. Until then the row
290 // has no password and no passkey, so the cleanup sweep removes it.
291 if err := s.DB.SetPasskeySetupStarted(r.Context(), id, db.NowISO()); err != nil {
292 jsonError(w, http.StatusInternalServerError, "Database error")
293 return
294 }
295
296 // The session cookie is set in queue mode too. The passkey ceremony that
297 // follows needs it to identify the new account. The cookie grants nothing
298 // else: every other route resolves users through SessionUser, which
299 // rejects a pending account.
300 cookie, err := s.newSession(r.Context(), id)
301 if err != nil {
302 jsonError(w, http.StatusInternalServerError, "Database error")
303 return
304 }
305 http.SetCookie(w, cookie)
306 if s.Cfg.RegistrationType == "queue" {
307 writeJSON(w, http.StatusOK, map[string]bool{"ok": true, "pending": true})
308 return
309 }
310 writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
311}
312