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

+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"errors"
"net/http"
"time"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
// RefreshCookie — имя cookie с refresh-токеном веб-клиента.
const RefreshCookie = "pipu_rt"
const refreshCookiePath = "/api/v1/auth"
// Handler реализует auth- и me-части gen.StrictServerInterface.
type Handler struct {
svc *Service
cookieSecure bool
}
// NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http.
func NewHandler(svc *Service, cookieSecure bool) *Handler {
return &Handler{svc: svc, cookieSecure: cookieSecure}
}
func principal(ctx context.Context) (Principal, error) {
p, ok := PrincipalFrom(ctx)
if !ok {
return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated")
}
return p, nil
}
// Login реализует gen.StrictServerInterface.
func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) {
in := LoginInput{
Username: req.Body.Username,
Password: req.Body.Password,
ClientType: ClientType(req.Body.ClientType),
IP: clientIP(ctx),
}
if req.Body.DeviceName != nil {
in.DeviceName = *req.Body.DeviceName
}
t, err := h.svc.Login(ctx, in)
if err != nil {
return nil, err
}
return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// RefreshTokens реализует gen.StrictServerInterface.
func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) {
var token string
if req.Body != nil && req.Body.RefreshToken != nil {
token = *req.Body.RefreshToken
} else if req.Params.PipuRt != nil {
token = *req.Params.PipuRt
}
t, err := h.svc.Refresh(ctx, token)
if err != nil {
var ae *apperr.Error
// Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова.
// При гонке двух вкладок cookie уже новая, её не трогаем.
if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace {
h.clearCookie(ctx)
}
return nil, err
}
return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// Logout реализует gen.StrictServerInterface.
func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.Logout(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.Logout204Response{}, nil
}
// LogoutAll реализует gen.StrictServerInterface.
func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.LogoutAll(ctx, p); err != nil {
return nil, err
}
h.clearCookie(ctx)
return gen.LogoutAll204Response{}, nil
}
// GetMe реализует gen.StrictServerInterface.
func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
u, err := h.svc.Me(ctx, p)
if err != nil {
return nil, err
}
return gen.GetMe200JSONResponse(MeDTO(u)), nil
}
// UpdateMe реализует gen.StrictServerInterface.
func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale}
if req.Body.UiPrefs != nil {
prefs := uiPrefsFromDTO(*req.Body.UiPrefs)
patch.UIPrefs = &prefs
}
u, err := h.svc.UpdateMe(ctx, p, patch)
if err != nil {
return nil, err
}
return gen.UpdateMe200JSONResponse(MeDTO(u)), nil
}
// ChangePassword реализует gen.StrictServerInterface.
func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// SetBootstrapCredentials реализует gen.StrictServerInterface.
func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword)
if err != nil {
return nil, err
}
return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil
}
// ListMySessions реализует gen.StrictServerInterface.
func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
sessions, err := h.svc.ListSessions(ctx, p)
if err != nil {
return nil, err
}
items := make([]gen.Session, 0, len(sessions))
for _, s := range sessions {
items = append(items, gen.Session{
Id: s.ID,
DeviceName: s.DeviceName,
ClientType: gen.ClientType(s.ClientType),
CreatedAt: s.CreatedAt,
LastUsedAt: s.LastUsedAt,
Current: s.ID == p.SessionID,
})
}
return gen.ListMySessions200JSONResponse{Items: items}, nil
}
// RevokeMySession реализует gen.StrictServerInterface.
func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil {
return nil, err
}
return gen.RevokeMySession204Response{}, nil
}
// tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле.
func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens {
out := gen.AuthTokens{
AccessToken: t.Access,
TokenType: gen.Bearer,
ExpiresIn: int(t.ExpiresIn / time.Second),
User: MeDTO(t.User),
}
if t.ClientType == ClientWeb {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie,
Value: t.Refresh,
Path: refreshCookiePath,
MaxAge: int(h.svc.cfg.RefreshTTL / time.Second),
HttpOnly: true,
Secure: h.cookieSecure,
SameSite: http.SameSiteStrictMode,
})
} else {
refresh := t.Refresh
out.RefreshToken = &refresh
}
return out
}
func (h *Handler) clearCookie(ctx context.Context) {
setCookie(ctx, &http.Cookie{
Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1,
HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode,
})
}
// MeDTO переводит пользователя в схему Me.
func MeDTO(u User) gen.Me {
return gen.Me{
Id: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
CreatedAt: u.CreatedAt,
}
}
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
return UIPrefs{
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}