docs: план реализации Фазы 2 (авторизация)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 19:37:27 +07:00
parent fc5d3cdbae
commit bd82fe5509
@@ -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$<b64salt>$<b64hash>`. 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` (обёртка: неавторизован → `<Navigate to="/login">`)
**Дизайн:** используй 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 <Navigate to="/login" replace/>; return children`.
- `LoginPage.tsx`/`RegisterPage.tsx`: форма (email, пароль) через field + react-hook-form/zod; submit → `login`/`register`; при успехе → `/domains` (Navigate); ошибка → `role=alert`. Ссылки между login↔register.
- `main.tsx`: обернуть в `<AuthProvider>`.
- `App.tsx`: роуты `/login`, `/register` (публичные, авторизованный → редирект `/domains`); всё остальное — в `<ProtectedRoute>`.
- `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 не появляются в ответах.