Accept email login identifiers
- SHA
29c771476165dbae240baae69184dbef83d552cf- Parents
-
e4f8a82 - Tree
13c9835
29c7714
29c771476165dbae240baae69184dbef83d552cfe4f8a82
13c9835| Status | File | + | - |
|---|---|---|---|
| M |
internal/web/handlers/auth/auth.go
|
15 | 4 |
| M |
internal/web/handlers/auth/auth_test.go
|
26 | 0 |
| M |
internal/web/templates/auth/login.html
|
1 | 1 |
internal/web/handlers/auth/auth.gomodified@@ -337,6 +337,17 @@ type loginForm struct { | ||
| 337 | 337 | Username string |
| 338 | 338 | } |
| 339 | 339 | |
| 340 | +func (h *Handlers) loginUser(ctx context.Context, identifier string) (usersdb.User, error) { | |
| 341 | + if strings.Contains(identifier, "@") { | |
| 342 | + em, err := h.q.GetUserEmailByAddress(ctx, h.d.Pool, identifier) | |
| 343 | + if err != nil { | |
| 344 | + return usersdb.User{}, err | |
| 345 | + } | |
| 346 | + return h.q.GetUserIncludingDeleted(ctx, h.d.Pool, em.UserID) | |
| 347 | + } | |
| 348 | + return h.q.GetUserByUsernameIncludingDeleted(ctx, h.d.Pool, identifier) | |
| 349 | +} | |
| 350 | + | |
| 340 | 351 | func (h *Handlers) loginForm(w http.ResponseWriter, r *http.Request) { |
| 341 | 352 | notice := "" |
| 342 | 353 | switch r.URL.Query().Get("notice") { |
@@ -363,7 +374,7 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 363 | 374 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") |
| 364 | 375 | return |
| 365 | 376 | } |
| 366 | - username := strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))) | |
| 377 | + identifier := strings.ToLower(strings.TrimSpace(r.PostFormValue("username"))) | |
| 367 | 378 | pw := r.PostFormValue("password") |
| 368 | 379 | next := r.PostFormValue("next") |
| 369 | 380 | |
@@ -371,13 +382,13 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 371 | 382 | h.renderPage(w, r, "auth/login", map[string]any{ |
| 372 | 383 | "Title": "Sign in", |
| 373 | 384 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 374 | - "Form": loginForm{Username: username}, | |
| 385 | + "Form": loginForm{Username: identifier}, | |
| 375 | 386 | "Error": msg, |
| 376 | 387 | "Next": next, |
| 377 | 388 | }) |
| 378 | 389 | } |
| 379 | 390 | |
| 380 | - throttleKey := fmt.Sprintf("ip:%s|%s", clientIP(r), username) | |
| 391 | + throttleKey := fmt.Sprintf("ip:%s|%s", clientIP(r), identifier) | |
| 381 | 392 | if err := h.d.Limiter.Hit(r.Context(), h.d.Pool, throttle.Limit{ |
| 382 | 393 | Scope: "login", Identifier: throttleKey, |
| 383 | 394 | Max: 6, Window: 15 * time.Minute, |
@@ -390,7 +401,7 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 390 | 401 | // IncludingDeleted lets us spot soft-deleted users so we can restore |
| 391 | 402 | // them on login during the grace window. Past the window they look |
| 392 | 403 | // indistinguishable from "doesn't exist" — same response, same timing. |
| 393 | - user, err := h.q.GetUserByUsernameIncludingDeleted(r.Context(), h.d.Pool, username) | |
| 404 | + user, err := h.loginUser(r.Context(), identifier) | |
| 394 | 405 | if err != nil { |
| 395 | 406 | // User doesn't exist — still hash to keep timing constant. |
| 396 | 407 | password.VerifyAgainstDummy(pw) |
internal/web/handlers/auth/auth_test.gomodified@@ -403,6 +403,32 @@ func TestSignup_Verify_Login_Logout(t *testing.T) { | ||
| 403 | 403 | _ = resp.Body.Close() |
| 404 | 404 | } |
| 405 | 405 | |
| 406 | +func TestLogin_AcceptsEmailAddress(t *testing.T) { | |
| 407 | + t.Parallel() | |
| 408 | + srv, sender := newTestServer(t, true) | |
| 409 | + cli := newClient(t, srv) | |
| 410 | + | |
| 411 | + mustSignup(t, cli, "emailuser", "emailuser@example.com", "correct horse battery staple") | |
| 412 | + tok := extractTokenFromMessage(t, sender.all()[0], "/verify-email") | |
| 413 | + resp := cli.get(t, "/verify-email/"+tok) | |
| 414 | + _ = resp.Body.Close() | |
| 415 | + | |
| 416 | + csrf := cli.extractCSRF(t, "/login") | |
| 417 | + resp = cli.post(t, "/login", url.Values{ | |
| 418 | + "csrf_token": {csrf}, | |
| 419 | + "username": {"EmailUser@Example.com"}, | |
| 420 | + "password": {"correct horse battery staple"}, | |
| 421 | + }) | |
| 422 | + if resp.StatusCode != http.StatusSeeOther { | |
| 423 | + body, _ := io.ReadAll(resp.Body) | |
| 424 | + t.Fatalf("email login: status %d body=%s", resp.StatusCode, body) | |
| 425 | + } | |
| 426 | + if loc := resp.Header.Get("Location"); loc != "/" { | |
| 427 | + t.Fatalf("email login redirect: %q, want /", loc) | |
| 428 | + } | |
| 429 | + _ = resp.Body.Close() | |
| 430 | +} | |
| 431 | + | |
| 406 | 432 | func TestPasswordReset_EndToEnd(t *testing.T) { |
| 407 | 433 | t.Parallel() |
| 408 | 434 | srv, sender := newTestServer(t, false) |
internal/web/templates/auth/login.htmlmodified@@ -7,7 +7,7 @@ | ||
| 7 | 7 | <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}"> |
| 8 | 8 | <input type="hidden" name="next" value="{{ .Next }}"> |
| 9 | 9 | <label> |
| 10 | - <span>Username</span> | |
| 10 | + <span>Username or email address</span> | |
| 11 | 11 | <input type="text" name="username" required autocomplete="username" autofocus |
| 12 | 12 | value="{{ .Form.Username }}"> |
| 13 | 13 | </label> |