Files
wild-central/internal/authelia/users.go
Paul Payne d796a79f79 Add Authelia as centralized authentication and OIDC provider
Authelia runs as a managed native service on Wild Central, providing
two integration patterns for network services:

- Forward-auth via HAProxy for apps without native SSO (Lua auth-request
  script intercepts requests, redirects unauthenticated users to login portal)
- OIDC provider for apps with native support (Gitea, Grafana, etc.)

Backend: new internal/authelia/ package with service manager, config
generation, file-based user management (argon2id), and OIDC client
management. API endpoints for status, config, users CRUD, and OIDC
clients CRUD.

HAProxy: config generator extended with lua-load, auth-request
directives, and Authelia backend. Auth directives scoped to session
cookie domain — only subdomains of the auth portal's parent are
eligible for forward-auth.

Frontend: Authentication page with enable/disable, user management,
OIDC client management (add/edit/delete), and protected domains
toggles. Advanced subsystem page for raw config view. Dashboard
service card and sidebar entries.
2026-07-12 11:47:37 +00:00

243 lines
6.3 KiB
Go

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$<base64-salt>$<base64-hash>
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
}