feat(auth): add safe Argon2id local user registry
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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)
|
||||
explicitLogoutAll := user.AuthRevision == old.AuthRevision+1
|
||||
if user.AuthRevision != old.AuthRevision && !explicitLogoutAll {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
if securityChanged && !explicitLogoutAll {
|
||||
if old.AuthRevision == ^uint64(0) {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
user.AuthRevision = old.AuthRevision + 1
|
||||
}
|
||||
if !securityChanged && !explicitLogoutAll {
|
||||
user.AuthRevision = old.AuthRevision
|
||||
}
|
||||
}
|
||||
return validateRegistry(*after)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user