Files
ThothII/tools/tht/internal/authconfig/users.go
T

204 lines
5.6 KiB
Go

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 &registry.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
}