fix(auth): wiring Auth/Sessions, нормализация email, GetUserByID для /me, 409 на дубль, timing-guard логина

This commit is contained in:
2026-07-03 20:29:05 +07:00
parent aa0ef1c6a9
commit 35ffe73ae3
8 changed files with 265 additions and 10 deletions
+48 -9
View File
@@ -2,17 +2,43 @@ package api
import (
"context"
"errors"
"log"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/auth"
"github.com/vasyakrg/dns-autoresolver/internal/store"
)
const sessionCookieName = "session"
// dummyPasswordHash is a valid-format argon2 hash with no real matching
// password. handleLogin runs VerifyPassword against it whenever the email
// lookup fails, so a login attempt for an unregistered email takes the same
// wall-clock time as one for a registered email with a wrong password —
// otherwise the timing difference would let an attacker enumerate which
// emails are registered.
var dummyPasswordHash string
func init() {
h, err := auth.HashPassword("dns-autoresolver-timing-guard-dummy")
if err != nil {
panic("api: failed to initialize dummy password hash: " + err.Error())
}
dummyPasswordHash = h
}
// normalizeEmail trims surrounding whitespace and lowercases the email so
// storage and lookup are always consistent regardless of how the client
// cased or padded the input.
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// ctxKeyUserID is a private context key carrying the authenticated user's ID.
// Task 4's RequireAuth middleware sets it after validating the session
// cookie; handleMe reads it back.
@@ -45,7 +71,8 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &req) {
return
}
if req.Email == "" || req.Password == "" {
email := normalizeEmail(req.Email)
if email == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "email and password are required")
return
}
@@ -57,8 +84,12 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
u, p, err := a.Auth.RegisterUser(r.Context(), req.Email, hash)
u, p, err := a.Auth.RegisterUser(r.Context(), email, hash)
if err != nil {
if errors.Is(err, store.ErrEmailTaken) {
writeErr(w, http.StatusConflict, "email already registered")
return
}
log.Printf("api: register user failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
@@ -87,9 +118,14 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &req) {
return
}
email := normalizeEmail(req.Email)
u, err := a.Auth.GetUserByEmail(r.Context(), req.Email)
u, err := a.Auth.GetUserByEmail(r.Context(), email)
if err != nil {
// No such user: still spend the argon2 verification cost against a
// fixed dummy hash (see dummyPasswordHash) so this path isn't
// distinguishable by timing from a wrong-password rejection below.
_, _ = auth.VerifyPassword(dummyPasswordHash, req.Password)
invalidCredentials(w)
return
}
@@ -131,8 +167,7 @@ func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
// handleMe returns the authenticated caller's identity + default project.
// The user ID comes from the request context, set by Task 4's RequireAuth
// middleware after validating the session cookie (tests set it directly via
// context.WithValue in the interim). AuthStore has no GetUserByID — the
// email field is intentionally left empty here; see task-3-report.md.
// context.WithValue in the interim).
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFromContext(r.Context())
if !ok {
@@ -140,6 +175,13 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
return
}
u, err := a.Auth.GetUserByID(r.Context(), userID)
if err != nil {
log.Printf("api: get user by id failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
p, err := a.Auth.GetUserProject(r.Context(), userID)
if err != nil {
log.Printf("api: get user project failed: %v", err)
@@ -147,8 +189,5 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, authResponse{
User: userResponse{ID: userID.String()},
Project: projectResponse{ID: p.ID.String(), Name: p.Name},
})
writeJSON(w, http.StatusOK, toAuthResponse(u, p))
}