From bd82fe5509dc0e68086d62e9dfa366d2dcbd103d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 19:37:27 +0700 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=A4=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=202=20(=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-03-phase2-auth.md | 707 ++++++++++++++++++ 1 file changed, 707 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-phase2-auth.md diff --git a/docs/superpowers/plans/2026-07-03-phase2-auth.md b/docs/superpowers/plans/2026-07-03-phase2-auth.md new file mode 100644 index 0000000..58b4c0b --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-phase2-auth.md @@ -0,0 +1,707 @@ +# 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 не появляются в ответах.