Files
pipu-music/services/api/internal/auth/validate.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

147 lines
4.8 KiB
Go

package auth
import (
_ "embed"
"regexp"
"strings"
"unicode/utf8"
"pipu/api/internal/platform/apperr"
)
// Коды ошибок валидации (переводятся клиентом, packages/i18n).
const (
CodePasswordTooShort = "validation.password_too_short"
CodePasswordTooLong = "validation.password_too_long"
CodePasswordSameAsUsername = "validation.password_same_as_username"
CodePasswordTooCommon = "validation.password_too_common"
CodePasswordUnchanged = "validation.password_unchanged"
CodeUsernameInvalid = "validation.username_invalid"
CodeLocaleUnsupported = "validation.locale_unsupported"
CodeDisplayNameTooLong = "validation.display_name_too_long"
CodeUIPrefsInvalid = "validation.ui_prefs_invalid"
)
// Пределы длины. Верхний предел пароля защищает argon2 от гигантских входов.
const (
MinPasswordUser = 8
MinPasswordPrivileged = 12
MaxPassword = 256
MaxDisplayName = 64
)
var usernameRe = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`)
// NormalizeUsername приводит логин к нижнему регистру без пробелов по краям.
func NormalizeUsername(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// ValidateUsername проверяет нормализованный логин: 3..32 символа [a-z0-9._-].
func ValidateUsername(field, username string) error {
if !usernameRe.MatchString(username) {
return apperr.Validation(field, CodeUsernameInvalid, "username must be 3-32 chars of [a-z0-9._-]",
map[string]any{"min": 3, "max": 32})
}
return nil
}
// MinPasswordLen — минимальная длина пароля для роли.
func MinPasswordLen(r Role) int {
if r == RoleUser {
return MinPasswordUser
}
return MinPasswordPrivileged
}
// ValidatePassword проверяет новый пароль по политике: длина по роли, не совпадает с логином, не из списка частых.
func ValidatePassword(field, password, username string, role Role) error {
n := utf8.RuneCountInString(password)
if minLen := MinPasswordLen(role); n < minLen {
return apperr.Validation(field, CodePasswordTooShort, "password is too short", map[string]any{"min": minLen})
}
if n > MaxPassword {
return apperr.Validation(field, CodePasswordTooLong, "password is too long", map[string]any{"max": MaxPassword})
}
lower := strings.ToLower(password)
if username != "" && lower == strings.ToLower(username) {
return apperr.Validation(field, CodePasswordSameAsUsername, "password equals username", nil)
}
if _, ok := commonPasswords[lower]; ok {
return apperr.Validation(field, CodePasswordTooCommon, "password is too common", nil)
}
return nil
}
//go:embed common_passwords.txt
var commonPasswordsTxt string
var commonPasswords = func() map[string]struct{} {
m := make(map[string]struct{})
for line := range strings.SplitSeq(commonPasswordsTxt, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
m[strings.ToLower(line)] = struct{}{}
}
}
return m
}()
var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"},
}
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
func ValidateUIPrefs(p UIPrefs) error {
check := func(name string, v *string) error {
if v == nil {
return nil
}
for _, allowed := range uiPrefEnums[name] {
if *v == allowed {
return nil
}
}
return apperr.Validation("ui_prefs."+name, CodeUIPrefsInvalid, "unknown value", nil)
}
for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
} {
if err := check(name, v); err != nil {
return err
}
}
if p.CrossfadeS != nil && (*p.CrossfadeS < 0 || *p.CrossfadeS > 6) {
return apperr.Validation("ui_prefs.crossfade_s", CodeUIPrefsInvalid, "crossfade must be 0..6",
map[string]any{"min": 0, "max": 6})
}
return nil
}
// MergeUIPrefs накладывает заданные поля patch на base.
func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.Theme != nil {
base.Theme = patch.Theme
}
if patch.WaveVariant != nil {
base.WaveVariant = patch.WaveVariant
}
if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer
}
if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer
}
if patch.Ambient != nil {
base.Ambient = patch.Ambient
}
if patch.CrossfadeS != nil {
base.CrossfadeS = patch.CrossfadeS
}
if patch.Normalize != nil {
base.Normalize = patch.Normalize
}
return base
}