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>
147 lines
4.8 KiB
Go
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
|
|
}
|