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
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:
1 parent
b6def39e29
commit
ed4a492dba
37 files changed
+5826
-49
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user