Files
pipu-music/services/api/internal/auth/handler.go
T
mrleo1nidandClaude Opus 5.5 ed4a492dba
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
feat(api): sign-in, refresh rotation and bootstrap admin (M1)
- 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>
2026-10-20 06:12:25 +03:00

265 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}