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
+1
View File
@@ -61,6 +61,7 @@ type ProviderRegistry interface {
type AuthStore interface {
RegisterUser(ctx context.Context, email, passwordHash string) (store.User, store.Project, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUserByID(ctx context.Context, userID uuid.UUID) (store.User, error)
GetUserProject(ctx context.Context, userID uuid.UUID) (store.Project, error)
}
+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))
}
+132
View File
@@ -20,6 +20,7 @@ import (
type mockAuthStore struct {
registerUserFn func(ctx context.Context, email, passwordHash string) (store.User, store.Project, error)
getUserByEmailFn func(ctx context.Context, email string) (store.User, error)
getUserByIDFn func(ctx context.Context, userID uuid.UUID) (store.User, error)
getUserProjectFn func(ctx context.Context, userID uuid.UUID) (store.Project, error)
}
@@ -31,6 +32,10 @@ func (m *mockAuthStore) GetUserByEmail(ctx context.Context, email string) (store
return m.getUserByEmailFn(ctx, email)
}
func (m *mockAuthStore) GetUserByID(ctx context.Context, userID uuid.UUID) (store.User, error) {
return m.getUserByIDFn(ctx, userID)
}
func (m *mockAuthStore) GetUserProject(ctx context.Context, userID uuid.UUID) (store.Project, error) {
return m.getUserProjectFn(ctx, userID)
}
@@ -125,6 +130,59 @@ func TestAuthRegister_Success(t *testing.T) {
}
}
// TestAuthRegister_NormalizesEmail verifies the fix for the email-consistency
// gap: a padded/mixed-case email is trimmed+lowercased before it reaches the
// store, so storage and later lookups are always consistent.
func TestAuthRegister_NormalizesEmail(t *testing.T) {
a, authStore, _ := newTestAuthAPI()
userID := uuid.New()
var gotEmail string
authStore.registerUserFn = func(_ context.Context, email, passwordHash string) (store.User, store.Project, error) {
gotEmail = email
return store.User{ID: userID, Email: email}, store.Project{ID: uuid.New(), UserID: userID, Name: "default"}, nil
}
router := NewRouter(a)
body := `{"email":" Alice@X.com ","password":"correct-horse"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
if gotEmail != "alice@x.com" {
t.Fatalf("expected normalized email passed to RegisterUser, got %q", gotEmail)
}
}
// TestAuthRegister_DuplicateEmailReturns409 verifies the fix for the
// duplicate-registration gap: RegisterUser reporting store.ErrEmailTaken
// must surface as 409, not a generic 500.
func TestAuthRegister_DuplicateEmailReturns409(t *testing.T) {
a, authStore, _ := newTestAuthAPI()
authStore.registerUserFn = func(context.Context, string, string) (store.User, store.Project, error) {
return store.User{}, store.Project{}, store.ErrEmailTaken
}
router := NewRouter(a)
body := `{"email":"dup@example.com","password":"correct-horse"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
var got map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["error"] != "email already registered" {
t.Fatalf(`expected error "email already registered", got %q`, got["error"])
}
}
// --- login ---
func TestAuthLogin_CorrectPassword(t *testing.T) {
@@ -159,6 +217,40 @@ func TestAuthLogin_CorrectPassword(t *testing.T) {
}
}
// TestAuthLogin_NormalizesEmail verifies that a login for a padded/mixed-case
// email reaches GetUserByEmail already trimmed+lowercased — the same
// normalization applied on register, so "Alice@X.com" at registration and
// "alice@x.com" at login resolve to the same account.
func TestAuthLogin_NormalizesEmail(t *testing.T) {
a, authStore, _ := newTestAuthAPI()
hash, err := auth.HashPassword("correct-horse")
if err != nil {
t.Fatal(err)
}
userID := uuid.New()
var gotEmail string
authStore.getUserByEmailFn = func(_ context.Context, email string) (store.User, error) {
gotEmail = email
return store.User{ID: userID, Email: email, PasswordHash: hash}, nil
}
authStore.getUserProjectFn = func(_ context.Context, uid uuid.UUID) (store.Project, error) {
return store.Project{ID: uuid.New(), UserID: uid, Name: "default"}, nil
}
router := NewRouter(a)
body := `{"email":" Alice@X.com ","password":"correct-horse"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
if gotEmail != "alice@x.com" {
t.Fatalf("expected normalized email passed to GetUserByEmail, got %q", gotEmail)
}
}
func TestAuthLogin_WrongPassword(t *testing.T) {
a, authStore, _ := newTestAuthAPI()
hash, err := auth.HashPassword("correct-horse")
@@ -240,6 +332,46 @@ func TestAuthLogout_ClearsSessionAndDestroys(t *testing.T) {
}
}
// --- me ---
// TestAuthMe_ReturnsRealEmail verifies the fix for the /me gap: the handler
// now resolves the authenticated user via GetUserByID and returns their real
// email, instead of leaving it blank.
func TestAuthMe_ReturnsRealEmail(t *testing.T) {
a, authStore, _ := newTestAuthAPI()
userID := uuid.New()
projectID := uuid.New()
authStore.getUserByIDFn = func(_ context.Context, id uuid.UUID) (store.User, error) {
if id != userID {
t.Fatalf("unexpected user id: %s", id)
}
return store.User{ID: userID, Email: "me@example.com"}, nil
}
authStore.getUserProjectFn = func(_ context.Context, uid uuid.UUID) (store.Project, error) {
return store.Project{ID: projectID, UserID: uid, Name: "default"}, nil
}
router := NewRouter(a)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxKeyUserID{}, userID))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
var got authResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.User.ID != userID.String() || got.User.Email != "me@example.com" {
t.Fatalf("unexpected user in /me response: %+v", got.User)
}
if got.Project.ID != projectID.String() {
t.Fatalf("unexpected project in /me response: %+v", got.Project)
}
}
// errNoRowsForTest stands in for a "not found" error a real store would
// return (e.g. pgx.ErrNoRows) — handlers must not distinguish it from any
// other GetUserByEmail failure in the response they send.