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 }