Files
2026-07-03 19:37:27 +07:00

708 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 не появляются в ответах.