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.
This commit is contained in:
242
internal/authelia/users.go
Normal file
242
internal/authelia/users.go
Normal file
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user