204 lines
5.6 KiB
Go
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 ®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
|
|
}
|