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
@@ -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 }
|
||||
Reference in new issue
Block a user