package authconfig import ( "crypto/rand" "encoding/hex" "regexp" "strings" "unicode" "unicode/utf8" ) var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$`) var uuidV4Pattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) func newUser(username, displayName, passwordHash string, roles []Role) (User, error) { id, err := randomUUIDv4() if err != nil { return User{}, errInvalidAuthenticationConfig } user := User{ ID: id, Username: username, DisplayName: displayName, PasswordHash: passwordHash, Roles: append([]Role(nil), roles...), Enabled: true, AuthRevision: 1, } if err := validateUser(user); err != nil { return User{}, errInvalidAuthenticationConfig } return user, nil } func randomUUIDv4() (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return "", err } bytes[6] = bytes[6]&0x0f | 0x40 bytes[8] = bytes[8]&0x3f | 0x80 encoded := hex.EncodeToString(bytes) return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil } func validateRegistry(registry Registry) error { if registry.Version != 1 || len(registry.Users) == 0 { return errInvalidAuthenticationConfig } ids := make(map[string]struct{}, len(registry.Users)) usernames := make(map[string]struct{}, len(registry.Users)) enabledAdmin := false for _, user := range registry.Users { if err := validateUser(user); err != nil { return errInvalidAuthenticationConfig } if _, exists := ids[user.ID]; exists { return errInvalidAuthenticationConfig } ids[user.ID] = struct{}{} normalized := normalizeUsername(user.Username) if _, exists := usernames[normalized]; exists { return errInvalidAuthenticationConfig } usernames[normalized] = struct{}{} if user.Enabled && hasRole(user.Roles, RoleAdmin) { enabledAdmin = true } } if !enabledAdmin { return errInvalidAuthenticationConfig } return nil } func validateUser(user User) error { if !uuidV4Pattern.MatchString(user.ID) || !usernamePattern.MatchString(user.Username) || !utf8.ValidString(user.DisplayName) || containsControl(user.DisplayName) || user.AuthRevision == 0 { return errInvalidAuthenticationConfig } if err := validatePasswordHash(user.PasswordHash); err != nil { return errInvalidAuthenticationConfig } if len(user.Roles) == 0 { return errInvalidAuthenticationConfig } roles := make(map[Role]struct{}, len(user.Roles)) for _, role := range user.Roles { if role != RoleUser && role != RoleAdmin { return errInvalidAuthenticationConfig } if _, exists := roles[role]; exists { return errInvalidAuthenticationConfig } roles[role] = struct{}{} } return nil } func containsControl(value string) bool { for _, character := range value { if unicode.IsControl(character) { return true } } return false } func normalizeUsername(username string) string { var normalized strings.Builder normalized.Grow(len(username)) for index := 0; index < len(username); index++ { character := username[index] if character >= 'A' && character <= 'Z' { character += 'a' - 'A' } normalized.WriteByte(character) } return normalized.String() } func hasRole(roles []Role, wanted Role) bool { for _, role := range roles { if role == wanted { return true } } return false } // FindByUsername uses the configured ASCII case-insensitive lookup while returning the original // display spelling in the persisted record. func (registry *Registry) FindByUsername(username string) *User { normalized := normalizeUsername(username) for index := range registry.Users { if normalizeUsername(registry.Users[index].Username) == normalized { return ®istry.Users[index] } } return nil } func cloneRegistry(registry Registry) Registry { clone := Registry{Version: registry.Version, Users: make([]User, len(registry.Users))} for index, user := range registry.Users { clone.Users[index] = user clone.Users[index].Roles = append([]Role(nil), user.Roles...) } return clone } func applyMutationInvariants(before Registry, after *Registry) error { previous := make(map[string]User, len(before.Users)) for _, user := range before.Users { previous[user.ID] = user } for index := range after.Users { user := &after.Users[index] old, exists := previous[user.ID] if !exists { if user.AuthRevision != 1 { return errInvalidAuthenticationConfig } continue } if user.Username != old.Username || user.AuthRevision < old.AuthRevision { return errInvalidAuthenticationConfig } securityChanged := user.PasswordHash != old.PasswordHash || user.Enabled != old.Enabled || !sameRoles(user.Roles, old.Roles) revision, err := reconciledAuthRevision(old.AuthRevision, user.AuthRevision, securityChanged) if err != nil { return errInvalidAuthenticationConfig } user.AuthRevision = revision } return validateRegistry(*after) } // reconciledAuthRevision is the single authority for local-user security revisions. Security // changes get one bump; a caller may also request exactly one explicit logout-all bump. func reconciledAuthRevision(previous, requested uint64, securityChanged bool) (uint64, error) { if requested != previous && (previous == ^uint64(0) || requested != previous+1) { return 0, errInvalidAuthenticationConfig } if securityChanged && requested == previous { if previous == ^uint64(0) { return 0, errInvalidAuthenticationConfig } return previous + 1, nil } return requested, nil } func sameRoles(left, right []Role) bool { if len(left) != len(right) { return false } for _, role := range left { if !hasRole(right, role) { return false } } return true }