feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s

- users, refresh_tokens and audit_log schema (migration 00002)
- argon2id passwords with rehash on login, password policy, lockout
  with exponential back-off and a per-IP rate limit in Redis
- Ed25519 JWT access tokens with kid-based key rotation; rotating
  refresh tokens with reuse detection (cookie for web, body for Android)
- bootstrap admin on first start; must_change_credentials and
  must_change_password gate every other endpoint
- /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password,
  /me/bootstrap-credentials, /me/sessions; server-info reports
  bootstrap_required
- role policy from docs/auth-and-roles.md with table tests
- problem+json errors with field errors; ru/en translations of the codes
- install.sh and update.sh generate JWT_PRIVATE_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:12:25 +03:00
1 parent b6def39e29
commit ed4a492dba
37 files changed
+5826 -49

No files matched your search

+58 -1
View File
@@ -3,13 +3,17 @@ package main
import (
"context"
"crypto/ed25519"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
@@ -56,16 +60,69 @@ func run() error {
rdb := redis.NewClient(redisOpts)
defer func() { _ = rdb.Close() }()
authSvc, err := newAuthService(cfg, pool, rdb, log)
if err != nil {
return err
}
if err := authSvc.Bootstrap(ctx); err != nil {
return err
}
sys := system.NewHandler(system.Info{
Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
BootstrapRequired: authSvc.BootstrapRequired,
}, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
})
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
Log: log,
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
}
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
var (
key ed25519.PrivateKey
err error
)
if cfg.JWTPrivateKey == "" {
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
key, err = auth.GenerateKey()
} else {
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
}
if err != nil {
return nil, err
}
var previous []ed25519.PrivateKey
for _, s := range cfg.JWTPreviousKeys {
if s == "" {
continue
}
k, err := auth.ParsePrivateKey(s)
if err != nil {
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
}
previous = append(previous, k)
}
clock := auth.SystemClock{}
acfg := auth.DefaultConfig()
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
acfg.SupportedLocales = cfg.SupportedLocales
return auth.NewService(
auth.NewPgStore(pool),
auth.NewHasher(auth.DefaultArgon2Params),
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
auth.NewRedisLimiter(rdb),
clock, auth.UUIDv7{}, log, acfg,
)
}