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

+162
View File
@@ -0,0 +1,162 @@
package auth
import (
"context"
"io"
"log/slog"
"net"
"net/http"
"strings"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
type ctxKey int
const (
principalKey ctxKey = iota
requestKey
)
// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт:
// адрес клиента и cookie, которые нужно выставить в ответе.
type requestInfo struct {
ip string
cookies []*http.Cookie
}
// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов).
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
// WithPrincipal кладёт пользователя в контекст (для тестов других модулей).
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, principalKey, p)
}
func clientIP(ctx context.Context) string {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
return ri.ip
}
return ""
}
func setCookie(ctx context.Context, c *http.Cookie) {
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
ri.cookies = append(ri.cookies, c)
}
}
// Публичные эндпоинты /api/v1 — без access-токена.
var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
var (
credentialsChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/bootstrap-credentials": true,
"POST /api/v1/auth/logout": true,
}
passwordChangeRoutes = map[string]bool{
"GET /api/v1/me": true,
"POST /api/v1/me/password": true,
"POST /api/v1/auth/logout": true,
}
)
// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей,
// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md).
func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ri := &requestInfo{ip: hostOnly(r.RemoteAddr)}
ctx := context.WithValue(r.Context(), requestKey, ri)
w = &cookieWriter{ResponseWriter: w, ri: ri}
route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/")
if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 {
// Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen
// декодирует необязательное тело всё равно и отвечает 400 на пустое.
r.Body = io.NopCloser(strings.NewReader("{}"))
r.ContentLength = 2
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] {
next.ServeHTTP(w, r.WithContext(ctx))
return
}
token, ok := bearer(r.Header.Get("Authorization"))
if !ok {
httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required"))
return
}
p, err := svc.Authenticate(ctx, token)
if err != nil {
httpx.WriteError(log, w, r, err)
return
}
switch {
case p.MustChangeCredentials && !credentialsChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first"))
return
case p.MustChangePassword && !passwordChangeRoutes[route]:
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
})
}
}
func bearer(h string) (string, bool) {
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
t := strings.TrimSpace(h[len(prefix):])
return t, t != ""
}
func hostOnly(addr string) string {
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа.
type cookieWriter struct {
http.ResponseWriter
ri *requestInfo
wrote bool
}
func (w *cookieWriter) flush() {
if w.wrote {
return
}
w.wrote = true
for _, c := range w.ri.cookies {
http.SetCookie(w.ResponseWriter, c)
}
}
func (w *cookieWriter) WriteHeader(code int) {
w.flush()
w.ResponseWriter.WriteHeader(code)
}
func (w *cookieWriter) Write(b []byte) (int, error) {
w.flush()
return w.ResponseWriter.Write(b)
}
// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer.
func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }