package authelia import ( "crypto/rand" "encoding/base64" "fmt" "os" "sort" "golang.org/x/crypto/argon2" "gopkg.in/yaml.v3" "github.com/wild-cloud/wild-central/internal/storage" ) // User represents an Authelia user account type User struct { Username string `json:"username" yaml:"-"` Displayname string `json:"displayname" yaml:"displayname"` Password string `json:"password,omitempty" yaml:"password"` // argon2id hash (cleartext on create, never returned) Email string `json:"email,omitempty" yaml:"email,omitempty"` Groups []string `json:"groups,omitempty" yaml:"groups,omitempty"` Disabled bool `json:"disabled,omitempty" yaml:"disabled,omitempty"` } // UserUpdate holds partial updates for a user type UserUpdate struct { Displayname *string `json:"displayname,omitempty"` Password *string `json:"password,omitempty"` // cleartext, will be hashed Email *string `json:"email,omitempty"` Groups []string `json:"groups,omitempty"` Disabled *bool `json:"disabled,omitempty"` } // usersDB is the top-level structure of Authelia's users_database.yml type usersDB struct { Users map[string]*userEntry `yaml:"users"` } type userEntry struct { Displayname string `yaml:"displayname"` Password string `yaml:"password"` Email string `yaml:"email,omitempty"` Groups []string `yaml:"groups,omitempty"` Disabled bool `yaml:"disabled,omitempty"` } // ListUsers returns all users from the database (passwords omitted) func (m *Manager) ListUsers() ([]User, error) { db, err := m.readUsersDB() if err != nil { return nil, err } var users []User for name, entry := range db.Users { users = append(users, User{ Username: name, Displayname: entry.Displayname, Email: entry.Email, Groups: entry.Groups, Disabled: entry.Disabled, }) } sort.Slice(users, func(i, j int) bool { return users[i].Username < users[j].Username }) return users, nil } // CreateUser adds a new user with an argon2id-hashed password func (m *Manager) CreateUser(user User) error { if user.Username == "" { return fmt.Errorf("username is required") } if user.Password == "" { return fmt.Errorf("password is required") } lockPath := m.usersDBPath() + ".lock" return storage.WithLock(lockPath, func() error { db, err := m.readUsersDB() if err != nil { return err } if _, exists := db.Users[user.Username]; exists { return fmt.Errorf("user %q already exists", user.Username) } hash, err := hashPassword(user.Password) if err != nil { return fmt.Errorf("hashing password: %w", err) } db.Users[user.Username] = &userEntry{ Displayname: user.Displayname, Password: hash, Email: user.Email, Groups: user.Groups, Disabled: user.Disabled, } return m.writeUsersDB(db) }) } // UpdateUser applies partial updates to an existing user func (m *Manager) UpdateUser(username string, updates UserUpdate) error { lockPath := m.usersDBPath() + ".lock" return storage.WithLock(lockPath, func() error { db, err := m.readUsersDB() if err != nil { return err } entry, exists := db.Users[username] if !exists { return fmt.Errorf("user %q not found", username) } if updates.Displayname != nil { entry.Displayname = *updates.Displayname } if updates.Email != nil { entry.Email = *updates.Email } if updates.Disabled != nil { entry.Disabled = *updates.Disabled } if updates.Groups != nil { entry.Groups = updates.Groups } if updates.Password != nil { hash, err := hashPassword(*updates.Password) if err != nil { return fmt.Errorf("hashing password: %w", err) } entry.Password = hash } return m.writeUsersDB(db) }) } // DeleteUser removes a user from the database func (m *Manager) DeleteUser(username string) error { lockPath := m.usersDBPath() + ".lock" return storage.WithLock(lockPath, func() error { db, err := m.readUsersDB() if err != nil { return err } if _, exists := db.Users[username]; !exists { return fmt.Errorf("user %q not found", username) } delete(db.Users, username) return m.writeUsersDB(db) }) } // UserCount returns the number of users in the database func (m *Manager) UserCount() int { db, err := m.readUsersDB() if err != nil { return 0 } return len(db.Users) } // EnsureUsersDB creates an empty users database file if one doesn't exist func (m *Manager) EnsureUsersDB() error { if _, err := os.Stat(m.usersDBPath()); err == nil { return nil } db := &usersDB{Users: map[string]*userEntry{}} return m.writeUsersDB(db) } func (m *Manager) readUsersDB() (*usersDB, error) { data, err := os.ReadFile(m.usersDBPath()) if err != nil { if os.IsNotExist(err) { return &usersDB{Users: map[string]*userEntry{}}, nil } return nil, fmt.Errorf("reading users db: %w", err) } db := &usersDB{Users: map[string]*userEntry{}} if err := yaml.Unmarshal(data, db); err != nil { return nil, fmt.Errorf("parsing users db: %w", err) } if db.Users == nil { db.Users = map[string]*userEntry{} } return db, nil } func (m *Manager) writeUsersDB(db *usersDB) error { data, err := yaml.Marshal(db) if err != nil { return fmt.Errorf("marshaling users db: %w", err) } tmpPath := m.usersDBPath() + ".tmp" if err := os.WriteFile(tmpPath, data, 0600); err != nil { return fmt.Errorf("writing temp users db: %w", err) } if err := os.Rename(tmpPath, m.usersDBPath()); err != nil { os.Remove(tmpPath) return fmt.Errorf("installing users db: %w", err) } return nil } // hashPassword creates an argon2id hash in Authelia's expected format. // Parameters tuned for Raspberry Pi: memory=64MB, iterations=3, parallelism=2. func hashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("generating salt: %w", err) } const ( memory = 64 * 1024 // 64MB iterations = 3 parallel = 2 keyLen = 32 ) hash := argon2.IDKey([]byte(password), salt, iterations, memory, uint8(parallel), keyLen) // Authelia expects: $argon2id$v=19$m=65536,t=3,p=2$$ saltB64 := base64.RawStdEncoding.EncodeToString(salt) hashB64 := base64.RawStdEncoding.EncodeToString(hash) return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, iterations, parallel, saltB64, hashB64), nil }