Files
dns-autoresolver/docs/superpowers/plans/2026-07-03-phase2-auth.md
T
2026-07-03 19:37:27 +07:00

34 KiB
Raw Blame History

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/binexport 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: чужой/несуществующий pid404 (не 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:

-- +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:

-- 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:

-- 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:

-- 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 generateinternal/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):

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 ./....

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:

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
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:

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
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 если не подтянут.

git add internal/auth/ go.mod go.sum
git commit -m "feat(auth): argon2id пароли + session store (sha256 токена)"

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 — HashPasswordRegisterUserSessions.CreatesetSessionCookie → JSON {user,project}. login — GetUserByEmail (ошибка → 401), VerifyPassword (false → 401), Sessions.Create → cookie. logout — прочитать cookie, Destroy, clearSessionCookie. Cookie:

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; роуты:

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 ./....

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.ValidateuserID в контекст; иначе 401
    • func (a *API) RequireProjectAccess(next http.Handler) http.Handlerpid из 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 — те же сигнатуры; LoadDomainFullWHERE 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

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:

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 ./....

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; здесь минимально чините типы).

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.

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 не появляются в ответах.