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