# Phase 2: Авторизация — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Frontend-задачи ДОПОЛНИТЕЛЬНО используют superpowers:frontend-design. Steps use checkbox (`- [ ]`) syntax. **Goal:** Регистрация/логин по email+паролю, cookie-сессии в БД, мультитенантность по пользователям (один проект на пользователя), закрытие IDOR из 1B. Снять зашитый `DEFAULT_PROJECT_ID` во фронте. **Architecture:** `internal/auth` (argon2id пароли + session store в БД). Auth-эндпоинты `/api/v1/auth/*`. Два middleware: `RequireAuth` (cookie→session→userID в контекст) и `RequireProjectAccess` (pid принадлежит userID, иначе 404). Рефакторинг check/apply/CRUD под `projectID` из контекста + scope `LoadDomainFull`. Фронт: `AuthContext` (текущий user+project из `GET /auth/me`), protected routes, `credentials:'include'`, projectId из контекста вместо зашитого. **Tech Stack:** Go, `golang.org/x/crypto/argon2`, `crypto/rand`, `crypto/sha256`, `crypto/subtle`, pgx/sqlc, chi; React + TanStack Query + react-router; Vitest. ## Global Constraints - Module `github.com/vasyakrg/dns-autoresolver`. Фронт в `web/` (npm; sqlc в `~/go/bin` — `export PATH="$(go env GOPATH)/bin:$PATH"`). Store-тесты — Docker (testcontainers). - **Один проект на пользователя** (создаётся при регистрации). Без шаринга/ролей/switcher. - Сессия: cookie `session`, `HttpOnly + Secure + SameSite=Lax + Path=/`; в БД — **sha256(token)**, не сам токен. Токен = base64url(`crypto/rand` 32 байта). TTL, например, 720h. - Пароль: **argon2id** (`m=64MB, t=1, p=4, keyLen=32, salt 16`), формат `$argon2id$v=19$m=65536,t=1,p=4$$`. Verify — `subtle.ConstantTimeCompare`. - Логин-ошибка — **единый 401** «invalid credentials» (не раскрывать, email или пароль неверны). - `RequireProjectAccess`: чужой/несуществующий `pid` → **404** (не 403). - IDOR: `LoadDomainFull` scoped `AND d.project_id=$2`; `service.Check/Apply` и `Loader.LoadDomain` принимают `projectID`; хендлеры берут `pid` из контекста (валидированный middleware). - Секрет учётки и `password_hash` НИКОГДА не в ответах/логах. Существующие инварианты 1A/1B не регрессировать. - Каждая задача — зелёные тесты (Go: `go test`; фронт: `npm run test`) и коммит. --- ### Task 1 [Docker]: Миграция sessions/password + store-методы (users/sessions/projects) **Files:** - Create: `internal/store/migrations/0003_auth.sql` - Create: `internal/store/queries/users.sql`, `internal/store/queries/sessions.sql`; Modify: `internal/store/queries/projects.sql` (создать, если нет) - Regenerate: `internal/store/db/*` (sqlc) - Modify: `internal/store/tenant.go` (методы-обёртки) - Create: `internal/store/auth_test.go` **Interfaces:** - Produces (методы `*Store`, uuid — google/uuid): - `CreateUser(ctx, email, passwordHash string) (User, error)`; `GetUserByEmail(ctx, email string) (User, error)` - `CreateProjectForUser(ctx, userID uuid.UUID, name string) (Project, error)`; `GetProjectOwned(ctx, projectID, userID uuid.UUID) (Project, error)`; `GetUserProject(ctx, userID uuid.UUID) (Project, error)` - `CreateSession(ctx, userID uuid.UUID, tokenHash string, expiresAt time.Time) error`; `GetSessionUser(ctx, tokenHash string) (uuid.UUID, error)` (только не истёкшие); `DeleteSession(ctx, tokenHash string) error` - типы `User{ID, Email string, PasswordHash string}`, `Project{ID, UserID uuid.UUID, Name string}` - `RegisterUser(ctx, email, passwordHash string) (User, Project, error)` — транзакция: user + его project - [ ] **Step 1: Миграция** `internal/store/migrations/0003_auth.sql`: ```sql -- +goose Up ALTER TABLE users ADD COLUMN password_hash text; CREATE TABLE sessions ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX sessions_token_hash_idx ON sessions (token_hash); -- +goose Down DROP TABLE sessions; ALTER TABLE users DROP COLUMN password_hash; ``` - [ ] **Step 2: SQL-запросы** `internal/store/queries/users.sql`: ```sql -- name: CreateUser :one INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3) RETURNING *; -- name: GetUserByEmail :one SELECT * FROM users WHERE email = $1; ``` `internal/store/queries/sessions.sql`: ```sql -- name: CreateSession :exec INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4); -- name: GetSessionUser :one SELECT user_id FROM sessions WHERE token_hash = $1 AND expires_at > now(); -- name: DeleteSession :exec DELETE FROM sessions WHERE token_hash = $1; ``` `internal/store/queries/projects.sql`: ```sql -- name: CreateProject :one INSERT INTO projects (id, user_id, name) VALUES ($1, $2, $3) RETURNING *; -- name: GetProjectOwned :one SELECT * FROM projects WHERE id = $1 AND user_id = $2; -- name: GetUserProject :one SELECT * FROM projects WHERE user_id = $1 ORDER BY created_at LIMIT 1; ``` - [ ] **Step 3: Сгенерировать sqlc** Run: `export PATH="$(go env GOPATH)/bin:$PATH" && sqlc generate` → `internal/store/db/` обновлён (users.sql.go, sessions.sql.go, projects.sql.go). `go build ./internal/store/db/` компилируется. - [ ] **Step 4: Store-обёртки** Добавить в `internal/store/tenant.go` доменные типы и методы (используй `db.Queries`, `uuid.New()`, транзакцию для RegisterUser по образцу `ImportDomains`): ```go type User struct { ID uuid.UUID Email string PasswordHash string } type Project struct { ID uuid.UUID UserID uuid.UUID Name string } func (s *Store) CreateUser(ctx context.Context, email, passwordHash string) (User, error) { /* db.CreateUser */ } func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { /* db.GetUserByEmail */ } func (s *Store) CreateProjectForUser(ctx context.Context, userID uuid.UUID, name string) (Project, error) func (s *Store) GetProjectOwned(ctx context.Context, projectID, userID uuid.UUID) (Project, error) func (s *Store) GetUserProject(ctx context.Context, userID uuid.UUID) (Project, error) func (s *Store) CreateSession(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) error func (s *Store) GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error // RegisterUser creates a user and their default project in one transaction. func (s *Store) RegisterUser(ctx context.Context, email, passwordHash string) (User, Project, error) { tx, err := s.pool.Begin(ctx) if err != nil { return User{}, Project{}, err } defer tx.Rollback(ctx) q := s.q.WithTx(tx) uid := uuid.New() dbu, err := q.CreateUser(ctx, db.CreateUserParams{ID: uid, Email: email, PasswordHash: ptr(passwordHash)}) if err != nil { return User{}, Project{}, err } dbp, err := q.CreateProject(ctx, db.CreateProjectParams{ID: uuid.New(), UserID: uid, Name: "default"}) if err != nil { return User{}, Project{}, err } if err := tx.Commit(ctx); err != nil { return User{}, Project{}, err } return toUser(dbu), toProject(dbp), nil } ``` > Реализатор: `password_hash` — nullable в БД → в сгенерированном типе `*string`/`pgtype.Text`. Хелперы `ptr`/`toUser`/`toProject` сверь с фактическим `models.go`. Пустой хэш никогда не пишется в реальном потоке (регистрация всегда передаёт argon2-хэш). - [ ] **Step 5: Интеграционные тесты** `internal/store/auth_test.go` [Docker]: `RegisterUser` создаёт user+project (проект принадлежит user); `GetUserByEmail` находит; `CreateSession`+`GetSessionUser` возвращает userID; истёкшая сессия (`expiresAt` в прошлом) НЕ возвращается; `DeleteSession` удаляет; `GetProjectOwned` чужого userID → ErrNoRows; `GetUserProject` возвращает единственный проект. - [ ] **Step 6: Проверки и коммит** Run: `go test ./internal/store/... -v` (Docker) → PASS; `go build ./...`; `go vet ./...`. ```bash git add internal/store/migrations/0003_auth.sql internal/store/queries/ internal/store/db/ internal/store/tenant.go internal/store/auth_test.go sqlc.yaml git commit -m "feat(store): миграция sessions/password + методы users/sessions/projects" ``` --- ### Task 2: Пакет `internal/auth` (argon2id + сессии) **Files:** - Create: `internal/auth/password.go`, `internal/auth/password_test.go` - Create: `internal/auth/session.go`, `internal/auth/session_test.go` **Interfaces:** - Consumes: store (через узкий интерфейс), `golang.org/x/crypto/argon2` - Produces: - `func HashPassword(password string) (string, error)`; `func VerifyPassword(encoded, password string) (bool, error)` - `type SessionStore interface { CreateSession(ctx, userID uuid.UUID, tokenHash string, expiresAt time.Time) error; GetSessionUser(ctx, tokenHash string) (uuid.UUID, error); DeleteSession(ctx, tokenHash string) error }` - `type Sessions struct { store SessionStore; ttl time.Duration }`; `func NewSessions(store SessionStore, ttl time.Duration) *Sessions` - `func (*Sessions) Create(ctx, userID uuid.UUID) (token string, expires time.Time, err error)` — генерит токен, пишет sha256(token) - `func (*Sessions) Validate(ctx, token string) (uuid.UUID, error)`; `func (*Sessions) Destroy(ctx, token string) error` - `func TokenHash(token string) string` — hex(sha256) - [ ] **Step 1: argon2id (падающий тест)** `internal/auth/password_test.go`: ```go package auth import "testing" func TestHashVerifyRoundTrip(t *testing.T) { h, err := HashPassword("s3cret-pw") if err != nil { t.Fatal(err) } if h == "s3cret-pw" || len(h) < 20 { t.Fatalf("bad hash %q", h) } ok, err := VerifyPassword(h, "s3cret-pw") if err != nil || !ok { t.Fatalf("verify failed: %v %v", ok, err) } bad, _ := VerifyPassword(h, "wrong") if bad { t.Fatal("wrong password must not verify") } } func TestHashNonDeterministic(t *testing.T) { a, _ := HashPassword("same") b, _ := HashPassword("same") if a == b { t.Fatal("salt must randomize hash") } } ``` - [ ] **Step 2: Реализовать password.go** ```go package auth import ( "crypto/rand" "crypto/subtle" "encoding/base64" "fmt" "strings" "golang.org/x/crypto/argon2" ) const ( argonTime = 1 argonMemory = 64 * 1024 argonThreads = 4 argonKeyLen = 32 argonSaltLen = 16 ) func HashPassword(password string) (string, error) { salt := make([]byte, argonSaltLen) if _, err := rand.Read(salt); err != nil { return "", err } key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) b64 := base64.RawStdEncoding.EncodeToString return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, b64(salt), b64(key)), nil } func VerifyPassword(encoded, password string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, fmt.Errorf("auth: bad hash format") } var m, t uint32 var p uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { return false, err } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false, err } want, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false, err } got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1, nil } ``` Run: `go test ./internal/auth/ -run Hash -v` → PASS. - [ ] **Step 3: Сессии (падающий тест — с мок-store)** `internal/auth/session_test.go`: ```go package auth import ( "context" "testing" "time" "github.com/google/uuid" ) type memStore struct { byHash map[string]uuid.UUID exp map[string]time.Time } func newMem() *memStore { return &memStore{byHash: map[string]uuid.UUID{}, exp: map[string]time.Time{}} } func (m *memStore) CreateSession(_ context.Context, uid uuid.UUID, h string, e time.Time) error { m.byHash[h] = uid m.exp[h] = e return nil } func (m *memStore) GetSessionUser(_ context.Context, h string) (uuid.UUID, error) { uid, ok := m.byHash[h] if !ok || time.Now().After(m.exp[h]) { return uuid.Nil, ErrNoSession } return uid, nil } func (m *memStore) DeleteSession(_ context.Context, h string) error { delete(m.byHash, h); return nil } func TestSessionCreateValidateDestroy(t *testing.T) { s := NewSessions(newMem(), time.Hour) uid := uuid.New() token, exp, err := s.Create(context.Background(), uid) if err != nil || token == "" || exp.Before(time.Now()) { t.Fatalf("create: %v %q", err, token) } got, err := s.Validate(context.Background(), token) if err != nil || got != uid { t.Fatalf("validate: %v %v", got, err) } if err := s.Destroy(context.Background(), token); err != nil { t.Fatal(err) } if _, err := s.Validate(context.Background(), token); err == nil { t.Fatal("destroyed session must not validate") } } func TestValidateUnknownToken(t *testing.T) { s := NewSessions(newMem(), time.Hour) if _, err := s.Validate(context.Background(), "nope"); err == nil { t.Fatal("unknown token must error") } } ``` - [ ] **Step 4: Реализовать session.go** ```go package auth import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "time" "github.com/google/uuid" ) var ErrNoSession = errors.New("auth: no such session") type SessionStore interface { CreateSession(ctx context.Context, userID uuid.UUID, tokenHash string, expiresAt time.Time) error GetSessionUser(ctx context.Context, tokenHash string) (uuid.UUID, error) DeleteSession(ctx context.Context, tokenHash string) error } type Sessions struct { store SessionStore ttl time.Duration } func NewSessions(store SessionStore, ttl time.Duration) *Sessions { return &Sessions{store: store, ttl: ttl} } func TokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (s *Sessions) Create(ctx context.Context, userID uuid.UUID) (string, time.Time, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", time.Time{}, err } token := base64.RawURLEncoding.EncodeToString(raw) exp := time.Now().Add(s.ttl) if err := s.store.CreateSession(ctx, userID, TokenHash(token), exp); err != nil { return "", time.Time{}, err } return token, exp, nil } func (s *Sessions) Validate(ctx context.Context, token string) (uuid.UUID, error) { return s.store.GetSessionUser(ctx, TokenHash(token)) } func (s *Sessions) Destroy(ctx context.Context, token string) error { return s.store.DeleteSession(ctx, TokenHash(token)) } ``` > Реализатор: `store.GetSessionUser` при отсутствии/истечении должен вернуть ошибку (в Task 1 — `ErrNoRows`); мок в тесте возвращает `ErrNoSession`. Оба трактуются как «нет сессии». - [ ] **Step 5: Тесты зелёные, коммит** Run: `go test ./internal/auth/ -v` → PASS. `go get golang.org/x/crypto/argon2` если не подтянут. ```bash git add internal/auth/ go.mod go.sum git commit -m "feat(auth): argon2id пароли + session store (sha256 токена)" ``` --- ### Task 3: Auth-хендлеры (register/login/logout/me) + cookie **Files:** - Create: `internal/api/auth_handlers.go`, `internal/api/auth_test.go` - Modify: `internal/api/api.go` (интерфейс `AuthService`, поля API, роуты `/api/v1/auth/*`) **Interfaces:** - Consumes: `store` (RegisterUser/GetUserByEmail/GetUserProject), `auth.Sessions`, `auth.HashPassword/VerifyPassword` - Produces: - `type AuthStore interface { RegisterUser(ctx, email, passwordHash string) (store.User, store.Project, error); GetUserByEmail(ctx, email string) (store.User, error); GetUserProject(ctx, userID uuid.UUID) (store.Project, error) }` - `type SessionManager interface { Create(ctx, userID uuid.UUID) (string, time.Time, error); Validate(ctx, token string) (uuid.UUID, error); Destroy(ctx, token string) error }` - хендлеры `handleRegister/handleLogin/handleLogout/handleMe`; DTO `authResponse{ user{id,email}, project{id,name} }` (без password_hash) - `func setSessionCookie(w, token string, exp time.Time)`, `func clearSessionCookie(w)`; const `sessionCookieName = "session"` - [ ] **Step 1: Падающий тест (httptest + мок AuthStore/SessionManager)** `internal/api/auth_test.go`: - `POST /api/v1/auth/register {email,password}` → 200, Set-Cookie `session`, тело `{user,project}` без password. Мок AuthStore.RegisterUser возвращает user+project. - `POST /login` с верным паролем (мок GetUserByEmail возвращает user с известным argon2-хэшем, `auth.HashPassword("pw")`) → 200 + cookie; с неверным → 401 «invalid credentials». - `POST /login` несуществующий email → 401 (тот же ответ, не раскрывать). - `POST /logout` → 200 + cookie очищена (Max-Age<=0), SessionManager.Destroy вызван. - Ответы НЕ содержат password_hash (grep по телу). - [ ] **Step 2: Запустить — падает** → FAIL. - [ ] **Step 3: Реализовать auth_handlers.go** Ключевое: register — `HashPassword` → `RegisterUser` → `Sessions.Create` → `setSessionCookie` → JSON `{user,project}`. login — `GetUserByEmail` (ошибка → 401), `VerifyPassword` (false → 401), `Sessions.Create` → cookie. logout — прочитать cookie, `Destroy`, `clearSessionCookie`. Cookie: ```go const sessionCookieName = "session" func setSessionCookie(w http.ResponseWriter, token string, exp time.Time) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, Expires: exp, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } ``` `handleMe` — из контекста `userID` (положит middleware в Task 4) → `GetUserProject` → `{user,project}`. Register/login публичны; me/logout — под `RequireAuth` (подключить в Task 4 или сразу, если middleware готов). Обновить `api.go`: `API` получает поля `Auth AuthStore`, `Sessions SessionManager`; роуты: ```go r.Route("/api/v1/auth", func(r chi.Router) { r.Post("/register", a.handleRegister) r.Post("/login", a.handleLogin) r.Post("/logout", a.handleLogout) // защитится RequireAuth в Task 4 r.Get("/me", a.handleMe) // защитится RequireAuth в Task 4 }) ``` - [ ] **Step 4: Тесты зелёные, коммит** Run: `go test ./internal/api/ -run Auth -v` → PASS. `go build ./...`; `go vet ./...`. ```bash git add internal/api/auth_handlers.go internal/api/auth_test.go internal/api/api.go git commit -m "feat(api): auth-хендлеры register/login/logout/me + session cookie" ``` --- ### Task 4: Middleware (RequireAuth + RequireProjectAccess) + IDOR-рефакторинг **Files:** - Create: `internal/api/middleware.go`, `internal/api/middleware_test.go` - Modify: `internal/store/queries/domains.sql` (`LoadDomainFull` scope), regen sqlc; `internal/store/loader.go` - Modify: `internal/service/service.go` (Check/Apply/Loader принимают projectID) - Modify: `internal/api/api.go` (подключить middleware; хендлеры check/apply берут pid из контекста), `internal/api/handlers.go`, `internal/api/tenant_handlers.go` - Modify: `cmd/server/main.go` (собрать auth+sessions+middleware) **Interfaces:** - Produces: - `func (a *API) RequireAuth(next http.Handler) http.Handler` — cookie→`Sessions.Validate`→`userID` в контекст; иначе 401 - `func (a *API) RequireProjectAccess(next http.Handler) http.Handler` — `pid` из URLParam→`GetProjectOwned(pid, userID)`→404 если не найден; кладёт `projectID` в контекст - контекст-хелперы `userIDFrom(ctx)`, `projectIDFrom(ctx)` - Изменяет: `service.Check(ctx, projectID, domainID)`, `Apply(ctx, projectID, domainID, req)`; `Loader.LoadDomain(ctx, projectID, domainID)`; `CheckApplier` — те же сигнатуры; `LoadDomainFull` — `WHERE d.id=$1 AND d.project_id=$2`. - [ ] **Step 1: Падающие тесты middleware + IDOR** `internal/api/middleware_test.go`: - `RequireAuth`: нет cookie → 401; битый токен (Validate ошибка) → 401; валидный → next вызван, `userIDFrom` установлен. - `RequireProjectAccess`: `GetProjectOwned` возвращает ошибку (чужой проект) → 404; успех → next, `projectIDFrom` установлен. - **IDOR-регресс** (в auth_test или отдельном): запрос `GET /api/v1/projects/{pidB}/domains/{did}/check` от пользователя A (сессия A, project A), где pidB — проект B → 404, `service.Check` НЕ вызван. - [ ] **Step 2: Запустить — падает** → FAIL. - [ ] **Step 3: Реализовать middleware.go** ```go package api import ( "context" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) type ctxKey string const ( ctxUserID ctxKey = "userID" ctxProjectID ctxKey = "projectID" ) func (a *API) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(sessionCookieName) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized") return } uid, err := a.Sessions.Validate(r.Context(), c.Value) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized") return } ctx := context.WithValue(r.Context(), ctxUserID, uid) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (a *API) RequireProjectAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { uid, ok := userIDFrom(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } pid, err := uuid.Parse(chi.URLParam(r, "pid")) if err != nil { writeErr(w, http.StatusBadRequest, "invalid project id") return } if _, err := a.Auth.GetProjectOwned(r.Context(), pid, uid); err != nil { writeErr(w, http.StatusNotFound, "not found") return } ctx := context.WithValue(r.Context(), ctxProjectID, pid) next.ServeHTTP(w, r.WithContext(ctx)) }) } func userIDFrom(ctx context.Context) (uuid.UUID, bool) { v, ok := ctx.Value(ctxUserID).(uuid.UUID) return v, ok } func projectIDFrom(ctx context.Context) (uuid.UUID, bool) { v, ok := ctx.Value(ctxProjectID).(uuid.UUID) return v, ok } ``` (`API.Auth` расширить методом `GetProjectOwned(ctx, projectID, userID uuid.UUID) (store.Project, error)` в интерфейсе AuthStore.) - [ ] **Step 4: Подключить middleware в роутере** `api.go`: обернуть `/api/v1/projects/{pid}` в `RequireAuth` + `RequireProjectAccess`; `/auth/me`, `/auth/logout` — в `RequireAuth`: ```go r.Route("/api/v1/auth", func(r chi.Router) { r.Post("/register", a.handleRegister) r.Post("/login", a.handleLogin) r.Group(func(r chi.Router) { r.Use(a.RequireAuth) r.Post("/logout", a.handleLogout) r.Get("/me", a.handleMe) }) }) r.Route("/api/v1/projects/{pid}", func(r chi.Router) { r.Use(a.RequireAuth) r.Use(a.RequireProjectAccess) // ... существующие domains/accounts/templates }) ``` - [ ] **Step 5: IDOR-рефакторинг check/apply** - `LoadDomainFull` в `domains.sql`: `WHERE d.id = $1 AND d.project_id = $2`; regen sqlc. - `internal/store/loader.go`: `LoadDomain(ctx, projectID, domainID uuid.UUID)` → `db.LoadDomainFullParams{ID: domainID, ProjectID: projectID}`. - `internal/service/service.go`: `Loader.LoadDomain(ctx, projectID, domainID)`; `resolve(ctx, projectID, domainID)`; `Check(ctx, projectID, domainID)`; `Apply(ctx, projectID, domainID, req)`; `SaveCheckRun` без изменений. - `internal/api/api.go` `CheckApplier`: `Check(ctx, projectID, domainID uuid.UUID)`, `Apply(ctx, projectID, domainID uuid.UUID, req service.ApplyRequest)`. - `handlers.go` `handleCheck/handleApply`: `pid := projectIDFrom(r.Context())` (гарантирован middleware), `did := uuid.Parse(URLParam "did")` → `a.Svc.Check(ctx, pid, did)`. - `tenant_handlers.go` CRUD: заменить приём `pid` из URL на `projectIDFrom(ctx)` (middleware уже провалидировал владение). Существующая scoped-логика store сохраняется. - Обновить моки в существующих api-тестах под новые сигнатуры (CheckApplier.Check/Apply c projectID); установить `projectID` в контексте запроса в тестах (обернуть в RequireProjectAccess-мок или прямой `context.WithValue`). - [ ] **Step 6: Wiring cmd/server** `main.go`: создать `sessions := auth.NewSessions(store, 720*time.Hour)`; `api.API{ Svc: svc, Store: store (TenantStore), Cipher: cipher, Reg: reg, Auth: store, Sessions: sessions }`. (Store реализует AuthStore напрямую — методы из Task 1.) - [ ] **Step 7: Проверки, коммит** Run: `go test ./... -v` (Docker) → все зелёные, включая IDOR-регресс. `go build ./...`; `go vet ./...`. ```bash git add internal/api/ internal/service/ internal/store/ cmd/server/main.go git commit -m "feat(api): RequireAuth+RequireProjectAccess middleware, IDOR-scope check/apply по projectID" ``` --- ### Task 5: Frontend — AuthContext + API-клиент под сессии **Files:** - Modify: `web/src/api/client.ts`, `web/src/api/types.ts`, `web/src/lib/config.ts` (убрать зашитый DEFAULT_PROJECT_ID), `web/src/hooks/useApi.ts` - Create: `web/src/auth/AuthContext.tsx`, `web/src/auth/AuthContext.test.tsx` - Modify: `web/src/api/client.test.ts` **Interfaces:** - Produces: - типы `User{id,email}`, `Project{id,name}`, `AuthState{user,project}` - `api.auth`: `register(email,password)`, `login(email,password)`, `logout()`, `me()` → `AuthState` - клиент ресурсов принимает `projectId` (первым аргументом) вместо зашитого; `req` использует `credentials:"include"` и при 401 бросает типизированную `UnauthorizedError` - `AuthProvider` + `useAuth()` → `{user, project, loading, login, register, logout}` - хуки в `useApi.ts` читают `project.id` из `useAuth()` и передают в client - [ ] **Step 1: Падающие тесты клиента (credentials, projectId, 401)** `client.test.ts`: `api.auth.login` шлёт POST `/api/v1/auth/login` с `credentials:"include"`; ресурс-метод `api.listDomains(projectId)` бьёт `/api/v1/projects/${projectId}/domains`; 401 → `UnauthorizedError`. Реальные ассерты на fetch mock. - [ ] **Step 2: Реализовать клиент** `config.ts`: удалить `DEFAULT_PROJECT_ID`/`API_BASE`; `const API_ROOT = "/api/v1"`. `client.ts`: `req` добавляет `credentials:"include"`; при `res.status===401` бросает `new UnauthorizedError()`. `api.auth = { register, login, logout, me }` на `${API_ROOT}/auth/*`. Ресурс-методы: `listDomains(projectId)` → `${API_ROOT}/projects/${projectId}/domains`, и т.д. (projectId первым аргументом во всех ресурс-методах). - [ ] **Step 3: AuthContext** `AuthContext.tsx`: `AuthProvider` — при монтировании `api.auth.me()` (401 → неавторизован, не ошибка); хранит `{user, project, loading}`; методы `login/register` (успех → set state), `logout` (→ clear state). `useAuth()` хук. `UnauthorizedError` из client → состояние «не авторизован». `AuthContext.test.tsx`: мок `api.auth.me` (успех → user/project в контексте; 401 → неавторизован); `login` устанавливает user; `logout` очищает. - [ ] **Step 4: Хуки под projectId из контекста** `useApi.ts`: каждый ресурс-хук берёт `const { project } = useAuth()` и передаёт `project.id` в client + включает в queryKey (`["domains", project?.id]`); `enabled: !!project`. Мутации аналогично. - [ ] **Step 5: Тесты зелёные, коммит** Run: `cd web && npm run test -- client AuthContext` → PASS; `npx tsc --noEmit`; `npm run test` (адаптировать сломанные тесты страниц под новую сигнатуру, если падают — но страницы правятся в Task 6; здесь минимально чините типы). ```bash git add web/src/api web/src/lib web/src/hooks web/src/auth git commit -m "feat(web): AuthContext + клиент под cookie-сессии, projectId из контекста" ``` - [ ] **Step 6: Commit** (см. выше) --- ### Task 6: Frontend — Login/Register + protected routes + logout **Files:** - Create: `web/src/pages/LoginPage.tsx`, `web/src/pages/RegisterPage.tsx`, `web/src/pages/LoginPage.test.tsx` - Create: `web/src/auth/ProtectedRoute.tsx`, `web/src/auth/ProtectedRoute.test.tsx` - Modify: `web/src/App.tsx` (роуты /login /register + protected), `web/src/main.tsx` (AuthProvider), `web/src/components/Layout.tsx` (logout + email) **Interfaces:** - Consumes: `useAuth` - Produces: `LoginPage`, `RegisterPage`, `ProtectedRoute` (обёртка: неавторизован → ``) **Дизайн:** используй skill frontend-design (тёмная «technical console», консистентно с существующим UI). - [ ] **Step 1: Падающий тест ProtectedRoute + Login** `ProtectedRoute.test.tsx`: неавторизован (useAuth loading=false, user=null) → редирект на /login; авторизован → рендер children. `LoginPage.test.tsx`: ввод email+пароль → submit вызывает `useAuth().login`; ошибка входа показывается (role=alert). - [ ] **Step 2: Реализовать** - `ProtectedRoute.tsx`: `const {user, loading} = useAuth(); if (loading) return spinner; if (!user) return ; return children`. - `LoginPage.tsx`/`RegisterPage.tsx`: форма (email, пароль) через field + react-hook-form/zod; submit → `login`/`register`; при успехе → `/domains` (Navigate); ошибка → `role=alert`. Ссылки между login↔register. - `main.tsx`: обернуть в ``. - `App.tsx`: роуты `/login`, `/register` (публичные, авторизованный → редирект `/domains`); всё остальное — в ``. - `Layout.tsx`: показать email пользователя + кнопка Logout (`useAuth().logout` → `/login`). - Глобально: при `UnauthorizedError` от любого запроса — AuthContext сбрасывает user (→ ProtectedRoute уводит на /login). Реализовать через QueryClient onError или в useAuth. - [ ] **Step 3: Тесты зелёные, сборка, коммит** Run: `cd web && npm run test` (все, включая обновлённые page-тесты под projectId) → PASS; `npx tsc --noEmit`; `npm run build`. ```bash git add web/src/pages web/src/auth web/src/App.tsx web/src/main.tsx web/src/components/Layout.tsx git commit -m "feat(web): Login/Register страницы, protected routes, logout" ``` --- ## Self-Review - **Spec coverage:** миграция sessions/password + store (T1), argon2id+сессии (T2), auth-хендлеры+cookie (T3), middleware RequireAuth/RequireProjectAccess + IDOR-scope (T4), фронт AuthContext+клиент под сессии (T5), Login/Register+protected+logout (T6). Все пункты spec-секции Фазы 2 покрыты. Один проект на пользователя (RegisterUser транзакцией). IDOR закрыт middleware + scope LoadDomainFull. - **Type consistency:** `store.User/Project` едины (T1→T3→T4); `auth.Sessions` (Create/Validate/Destroy) едины (T2→T3→T4); `SessionManager`/`AuthStore` интерфейсы api (T3→T4); фронт `AuthState/User/Project`, `api.auth.*`, ресурс-методы с `projectId` (T5→T6); cookie `session` HttpOnly+Secure+SameSite=Lax (T3). - **Placeholders:** нет кода-плейсхолдера. Пометки «реализатор» — точки сверки с sqlc-генерацией (nullable password_hash) и адаптации существующих тестов/страниц под новые сигнатуры; образцы кода приведены. ## Проверка (end-to-end) 1. `go test ./... -v` (Docker) — все Go-пакеты зелёные, включая IDOR-регресс (A не видит проект B → 404). 2. `cd web && npm run test && npx tsc --noEmit && npm run build` — фронт зелёный. 3. `make web && go run ./cmd/server` (Postgres + env): открыть `/` → редирект `/login`; зарегистрироваться → создаётся user+project, редирект на `/domains`; выйти → `/login`; войти снова; проверить, что запрос к чужому `pid` (подмена в URL) → 404; secret учётки и password_hash не появляются в ответах.