bd82fe5509
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
708 lines
34 KiB
Markdown
708 lines
34 KiB
Markdown
# 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 не появляются в ответах.
|