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:
86
internal/authelia/TODO.md
Normal file
86
internal/authelia/TODO.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Authelia Packaging TODO
|
||||
|
||||
These steps must be added to the postinst script (`wild-cloud/dist/debian/DEBIAN/postinst`)
|
||||
when packaging Wild Central for distribution.
|
||||
|
||||
## Install Authelia
|
||||
|
||||
```bash
|
||||
if ! command -v authelia &>/dev/null; then
|
||||
curl -fsSL https://apt.authelia.com/organization/signing.asc | \
|
||||
gpg --dearmor -o /usr/share/keyrings/authelia-archive-keyring.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/authelia-archive-keyring.gpg] \
|
||||
https://apt.authelia.com/integration/apt/repo/stable/debian debian main" \
|
||||
> /etc/apt/sources.list.d/authelia.list
|
||||
apt-get update -qq
|
||||
apt-get install -y authelia
|
||||
fi
|
||||
```
|
||||
|
||||
## Install HAProxy Lua scripts
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/haproxy/lua
|
||||
curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua \
|
||||
https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua
|
||||
curl -fsSL -o /etc/haproxy/lua/haproxy-lua-http.lua \
|
||||
https://raw.githubusercontent.com/haproxytech/haproxy-lua-http/master/http.lua
|
||||
apt-get install -y lua-json
|
||||
```
|
||||
|
||||
## Systemd service override
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/systemd/system/authelia.service.d
|
||||
cat > /etc/systemd/system/authelia.service.d/wild-central.conf << EOF
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/authelia --config /var/lib/wild-central/authelia/configuration.yml
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
```
|
||||
|
||||
## Data directory
|
||||
|
||||
```bash
|
||||
mkdir -p /var/lib/wild-central/authelia
|
||||
chown wildcloud:wildcloud /var/lib/wild-central/authelia
|
||||
chmod 700 /var/lib/wild-central/authelia
|
||||
```
|
||||
|
||||
## Polkit rule (manages all Wild Central services, not just Authelia)
|
||||
|
||||
```bash
|
||||
cat > /etc/polkit-1/rules.d/50-wild-central.rules << 'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
subject.isInGroup("wildcloud")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Certificate Issues TODO
|
||||
|
||||
Problems discovered during Authelia integration that need fixing in the cert management system.
|
||||
|
||||
## `hasCertForDomain` doesn't verify cert actually covers the domain
|
||||
|
||||
`hasCertForDomain()` in `helpers.go` checks if a PEM file exists at the expected path (e.g. `payne.io.pem` for wildcard coverage of `auth.payne.io`), but never verifies the cert's Subject/SAN actually matches. We had a `*.payne.io.pem` file that was actually a cert for `central.payne.io` — it passed the existence check but served the wrong cert at runtime, causing TLS errors.
|
||||
|
||||
**Fix:** Parse the cert and verify the SAN covers the requested domain, or at minimum check for `CN=*.domain` when relying on a wildcard.
|
||||
|
||||
## Cert provisioning deploy hook can produce misnamed files
|
||||
|
||||
The certbot deploy hook builds an HAProxy PEM from `/etc/letsencrypt/live/{cert-name}/`. The cert name doesn't always match the domain (e.g. certbot may name a `*.payne.io` wildcard cert as `payne.io`). If the PEM filename doesn't match what `HAProxyCertPath()` expects, the cert won't be found or will be confused with a different cert.
|
||||
|
||||
**Fix:** The deploy hook or `BuildHAProxyCert` should name the PEM based on the cert's actual SAN, not just the certbot cert name.
|
||||
|
||||
## UI has no "re-provision" option when cert exists but is wrong
|
||||
|
||||
The Certificates UI considers a cert valid if the PEM file exists and is non-empty. There's no way to re-provision if the cert covers the wrong domain or is otherwise invalid. The user had to manually delete the bad PEM to trigger re-provisioning.
|
||||
|
||||
**Fix:** Add a "Re-provision" or "Renew" action per domain in the Certificates UI, and show what domain(s) the cert actually covers (parsed from SAN).
|
||||
222
internal/authelia/config.go
Normal file
222
internal/authelia/config.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ConfigOpts holds parameters for generating Authelia's configuration.yml
|
||||
type ConfigOpts struct {
|
||||
Domain string // auth portal domain (e.g. auth.payne.io)
|
||||
JWTSecret string // identity validation JWT secret
|
||||
SessionSecret string // session cookie secret
|
||||
StorageEncKey string // storage encryption key (>=20 chars)
|
||||
OIDCHMACSecret string // OIDC HMAC secret
|
||||
DefaultPolicy string // "one_factor" or "two_factor"
|
||||
SessionDomain string // cookie domain for SSO (e.g. payne.io) — forward-auth only works for subdomains of this
|
||||
DataDir string // authelia data directory path
|
||||
ListenAddr string // listen address (default: 127.0.0.1:9091)
|
||||
OIDCClients []OIDCClient // registered OIDC clients
|
||||
}
|
||||
|
||||
// GenerateConfig builds Authelia's configuration.yml and writes it atomically.
|
||||
func (m *Manager) GenerateConfig(opts ConfigOpts) error {
|
||||
if err := m.EnsureDataDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.ListenAddr == "" {
|
||||
opts.ListenAddr = "127.0.0.1:9091"
|
||||
}
|
||||
if opts.DefaultPolicy == "" {
|
||||
opts.DefaultPolicy = "one_factor"
|
||||
}
|
||||
if opts.DataDir == "" {
|
||||
opts.DataDir = m.dataDir
|
||||
}
|
||||
|
||||
cfg := m.buildConfig(opts)
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling authelia config: %w", err)
|
||||
}
|
||||
|
||||
header := "# Authelia Configuration\n# Managed by Wild Central — do not edit manually\n---\n"
|
||||
content := header + string(data)
|
||||
|
||||
tmpPath := m.configPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("writing temp config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, m.configPath()); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("installing config: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia config written", "component", "authelia", "path", m.configPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureJWKS generates an RSA key pair for OIDC token signing if one doesn't exist.
|
||||
func (m *Manager) EnsureJWKS() error {
|
||||
if _, err := os.Stat(m.jwksPath()); err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating RSA key: %w", err)
|
||||
}
|
||||
|
||||
keyBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||
pemBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyBytes}
|
||||
pemData := pem.EncodeToMemory(pemBlock)
|
||||
|
||||
if err := os.WriteFile(m.jwksPath(), pemData, 0600); err != nil {
|
||||
return fmt.Errorf("writing JWKS key: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("authelia JWKS key pair generated", "component", "authelia", "path", m.jwksPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildConfig constructs the Authelia config as a nested map for YAML marshaling.
|
||||
func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
|
||||
cfg := map[string]any{
|
||||
"theme": "auto",
|
||||
|
||||
"server": map[string]any{
|
||||
"address": "tcp://" + opts.ListenAddr + "/",
|
||||
},
|
||||
|
||||
"log": map[string]any{
|
||||
"level": "info",
|
||||
},
|
||||
|
||||
"totp": map[string]any{
|
||||
"issuer": "Wild Cloud",
|
||||
},
|
||||
|
||||
"authentication_backend": map[string]any{
|
||||
"file": map[string]any{
|
||||
"path": m.usersDBPath(),
|
||||
"password": map[string]any{
|
||||
"algorithm": "argon2id",
|
||||
"iterations": 3,
|
||||
"memory": 65536,
|
||||
"parallelism": 2,
|
||||
"key_length": 32,
|
||||
"salt_length": 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"access_control": map[string]any{
|
||||
"default_policy": opts.DefaultPolicy,
|
||||
},
|
||||
|
||||
"session": map[string]any{
|
||||
"name": "wild_central_session",
|
||||
"secret": opts.SessionSecret,
|
||||
"cookies": []map[string]any{
|
||||
{
|
||||
"domain": opts.SessionDomain,
|
||||
"authelia_url": "https://" + opts.Domain,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"storage": map[string]any{
|
||||
"encryption_key": opts.StorageEncKey,
|
||||
"local": map[string]any{
|
||||
"path": m.sqlitePath(),
|
||||
},
|
||||
},
|
||||
|
||||
"notifier": map[string]any{
|
||||
"filesystem": map[string]any{
|
||||
"filename": m.notificationPath(),
|
||||
},
|
||||
},
|
||||
|
||||
"identity_validation": map[string]any{
|
||||
"reset_password": map[string]any{
|
||||
"jwt_secret": opts.JWTSecret,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// OIDC provider configuration — only emitted when clients exist.
|
||||
// Authelia requires at least one client when the OIDC section is present.
|
||||
if opts.OIDCHMACSecret != "" && len(opts.OIDCClients) > 0 {
|
||||
oidcCfg := map[string]any{
|
||||
"hmac_secret": opts.OIDCHMACSecret,
|
||||
}
|
||||
|
||||
// Inline the JWKS private key directly (avoids needing template filters)
|
||||
if jwksData, err := os.ReadFile(m.jwksPath()); err == nil {
|
||||
oidcCfg["jwks"] = []map[string]any{
|
||||
{
|
||||
"key_id": "wild-central",
|
||||
"algorithm": "RS256",
|
||||
"use": "sig",
|
||||
"key": string(jwksData),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var clients []map[string]any
|
||||
for _, c := range opts.OIDCClients {
|
||||
client := map[string]any{
|
||||
"client_id": c.ID,
|
||||
"client_name": c.Name,
|
||||
"client_secret": c.Secret, // already hashed
|
||||
"redirect_uris": c.RedirectURIs,
|
||||
"scopes": c.Scopes,
|
||||
"authorization_policy": c.Policy,
|
||||
}
|
||||
if c.ConsentMode != "" {
|
||||
client["consent_mode"] = c.ConsentMode
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
oidcCfg["clients"] = clients
|
||||
}
|
||||
|
||||
cfg["identity_providers"] = map[string]any{
|
||||
"oidc": oidcCfg,
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GenerateSecret creates a cryptographically random hex string of the given byte length.
|
||||
func GenerateSecret(byteLen int) (string, error) {
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generating random bytes: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
|
||||
// SessionDomainFromAuthDomain extracts the parent domain for session cookie scoping.
|
||||
// "auth.payne.io" -> "payne.io", "auth.sub.example.com" -> "sub.example.com"
|
||||
func SessionDomainFromAuthDomain(authDomain string) string {
|
||||
parts := strings.SplitN(authDomain, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return authDomain
|
||||
}
|
||||
156
internal/authelia/manager.go
Normal file
156
internal/authelia/manager.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Package authelia manages the Authelia authentication service.
|
||||
// Authelia provides centralized authentication for network services via
|
||||
// HAProxy forward-auth and acts as an OIDC provider for apps with native support.
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status represents the current state of the Authelia service
|
||||
type Status struct {
|
||||
Active bool `json:"active"`
|
||||
Version string `json:"version,omitempty"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
LastRestart time.Time `json:"lastRestart,omitempty"`
|
||||
}
|
||||
|
||||
// Manager handles Authelia configuration and service lifecycle
|
||||
type Manager struct {
|
||||
dataDir string // e.g. /var/lib/wild-central/authelia
|
||||
}
|
||||
|
||||
// NewManager creates a new Authelia manager
|
||||
func NewManager(dataDir string) *Manager {
|
||||
return &Manager{dataDir: dataDir}
|
||||
}
|
||||
|
||||
// EnsureDataDir creates the Authelia data directory if it doesn't exist
|
||||
func (m *Manager) EnsureDataDir() error {
|
||||
if err := os.MkdirAll(m.dataDir, 0700); err != nil {
|
||||
return fmt.Errorf("creating authelia data dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsInstalled checks if the authelia binary exists on PATH
|
||||
func (m *Manager) IsInstalled() bool {
|
||||
_, err := exec.LookPath("authelia")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
const luaScriptPath = "/etc/haproxy/lua/haproxy-auth-request.lua"
|
||||
|
||||
// HasLuaScript checks if the HAProxy auth-request Lua script is installed
|
||||
func (m *Manager) HasLuaScript() bool {
|
||||
info, err := os.Stat(luaScriptPath)
|
||||
return err == nil && info.Size() > 0
|
||||
}
|
||||
|
||||
// GetStatus returns the current Authelia service status
|
||||
func (m *Manager) GetStatus() (*Status, error) {
|
||||
status := &Status{}
|
||||
|
||||
cmd := exec.Command("systemctl", "is-active", "--quiet", "authelia")
|
||||
status.Active = cmd.Run() == nil
|
||||
|
||||
if status.Active {
|
||||
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=MainPID").Output(); err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), "=")
|
||||
if len(parts) == 2 {
|
||||
if pid, err := strconv.Atoi(parts[1]); err == nil {
|
||||
status.PID = pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("systemctl", "show", "authelia.service", "--property=ActiveEnterTimestamp").Output(); err == nil {
|
||||
parts := strings.Split(strings.TrimSpace(string(out)), "=")
|
||||
if len(parts) == 2 {
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
|
||||
status.LastRestart = t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("authelia", "--version").Output(); err == nil {
|
||||
s := string(out)
|
||||
if i := strings.Index(s, "version "); i >= 0 {
|
||||
v := s[i+8:]
|
||||
if j := strings.IndexByte(v, ' '); j > 0 {
|
||||
v = v[:j]
|
||||
}
|
||||
status.Version = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RestartService restarts the Authelia systemd service
|
||||
func (m *Manager) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "restart", "authelia.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output))
|
||||
}
|
||||
slog.Info("authelia service restarted", "component", "authelia")
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopService stops the Authelia systemd service
|
||||
func (m *Manager) StopService() error {
|
||||
cmd := exec.Command("systemctl", "stop", "authelia.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop authelia: %w (output: %s)", err, string(output))
|
||||
}
|
||||
slog.Info("authelia service stopped", "component", "authelia")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadConfig reads the current Authelia configuration file
|
||||
func (m *Manager) ReadConfig() (string, error) {
|
||||
data, err := os.ReadFile(m.configPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading authelia config: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DataDir returns the Authelia data directory path
|
||||
func (m *Manager) DataDir() string {
|
||||
return m.dataDir
|
||||
}
|
||||
|
||||
func (m *Manager) configPath() string {
|
||||
return filepath.Join(m.dataDir, "configuration.yml")
|
||||
}
|
||||
|
||||
func (m *Manager) usersDBPath() string {
|
||||
return filepath.Join(m.dataDir, "users_database.yml")
|
||||
}
|
||||
|
||||
func (m *Manager) sqlitePath() string {
|
||||
return filepath.Join(m.dataDir, "db.sqlite3")
|
||||
}
|
||||
|
||||
func (m *Manager) jwksPath() string {
|
||||
return filepath.Join(m.dataDir, "jwks.pem")
|
||||
}
|
||||
|
||||
func (m *Manager) notificationPath() string {
|
||||
return filepath.Join(m.dataDir, "notification.txt")
|
||||
}
|
||||
|
||||
func (m *Manager) oidcClientsPath() string {
|
||||
return filepath.Join(m.dataDir, "oidc_clients.yml")
|
||||
}
|
||||
352
internal/authelia/manager_test.go
Normal file
352
internal/authelia/manager_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGenerateConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
opts := ConfigOpts{
|
||||
Domain: "auth.payne.io",
|
||||
JWTSecret: "test-jwt-secret",
|
||||
SessionSecret: "test-session-secret",
|
||||
StorageEncKey: "test-storage-encryption-key-min-20",
|
||||
OIDCHMACSecret: "test-oidc-hmac-secret",
|
||||
DefaultPolicy: "one_factor",
|
||||
SessionDomain: "payne.io",
|
||||
DataDir: dir,
|
||||
ListenAddr: "127.0.0.1:9091",
|
||||
}
|
||||
|
||||
if err := m.GenerateConfig(opts); err != nil {
|
||||
t.Fatalf("GenerateConfig: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
configPath := filepath.Join(dir, "configuration.yml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading config: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Check key values are present
|
||||
checks := []string{
|
||||
"tcp://127.0.0.1:9091/",
|
||||
"one_factor",
|
||||
"payne.io",
|
||||
"auth.payne.io",
|
||||
"Wild Cloud",
|
||||
"argon2id",
|
||||
"test-session-secret",
|
||||
"test-storage-encryption-key-min-20",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(content, check) {
|
||||
t.Errorf("config missing expected value: %q", check)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it's valid YAML
|
||||
var parsed map[string]any
|
||||
if err := yaml.Unmarshal(data, &parsed); err != nil {
|
||||
t.Errorf("config is not valid YAML: %v", err)
|
||||
}
|
||||
|
||||
// Check file permissions
|
||||
info, _ := os.Stat(configPath)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("config permissions = %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigWithOIDCClients(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
opts := ConfigOpts{
|
||||
Domain: "auth.payne.io",
|
||||
JWTSecret: "jwt",
|
||||
SessionSecret: "session",
|
||||
StorageEncKey: "storage-enc-key-20chars",
|
||||
OIDCHMACSecret: "oidc-hmac",
|
||||
SessionDomain: "payne.io",
|
||||
OIDCClients: []OIDCClient{
|
||||
{
|
||||
ID: "gitea",
|
||||
Name: "Gitea",
|
||||
Secret: "$pbkdf2-sha512$hashed",
|
||||
RedirectURIs: []string{"https://git.payne.io/user/oauth2/authelia/callback"},
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
Policy: "one_factor",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.GenerateConfig(opts); err != nil {
|
||||
t.Fatalf("GenerateConfig: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "gitea") {
|
||||
t.Error("config should contain OIDC client 'gitea'")
|
||||
}
|
||||
if !strings.Contains(content, "git.payne.io") {
|
||||
t.Error("config should contain redirect URI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
// Minimal opts — should use defaults
|
||||
opts := ConfigOpts{
|
||||
Domain: "auth.example.com",
|
||||
JWTSecret: "jwt",
|
||||
SessionSecret: "session",
|
||||
StorageEncKey: "enc-key-twenty-chars",
|
||||
SessionDomain: "example.com",
|
||||
}
|
||||
|
||||
if err := m.GenerateConfig(opts); err != nil {
|
||||
t.Fatalf("GenerateConfig: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "configuration.yml"))
|
||||
content := string(data)
|
||||
|
||||
// Should default to one_factor
|
||||
if !strings.Contains(content, "one_factor") {
|
||||
t.Error("should default to one_factor policy")
|
||||
}
|
||||
// Should default to 127.0.0.1:9091
|
||||
if !strings.Contains(content, "127.0.0.1:9091") {
|
||||
t.Error("should default to 127.0.0.1:9091")
|
||||
}
|
||||
// Without OIDCHMACSecret, should not have identity_providers
|
||||
if strings.Contains(content, "identity_providers") {
|
||||
t.Error("should not include OIDC section without HMAC secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
// List empty
|
||||
users, err := m.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers empty: %v", err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Errorf("expected 0 users, got %d", len(users))
|
||||
}
|
||||
|
||||
// Create
|
||||
err = m.CreateUser(User{
|
||||
Username: "admin",
|
||||
Displayname: "Admin User",
|
||||
Password: "supersecret",
|
||||
Email: "admin@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
// List after create
|
||||
users, err = m.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", len(users))
|
||||
}
|
||||
if users[0].Username != "admin" {
|
||||
t.Errorf("username = %q, want %q", users[0].Username, "admin")
|
||||
}
|
||||
if users[0].Displayname != "Admin User" {
|
||||
t.Errorf("displayname = %q, want %q", users[0].Displayname, "Admin User")
|
||||
}
|
||||
if users[0].Email != "admin@example.com" {
|
||||
t.Errorf("email = %q, want %q", users[0].Email, "admin@example.com")
|
||||
}
|
||||
// Password should be omitted in listing
|
||||
if users[0].Password != "" {
|
||||
t.Error("password should be empty in listing")
|
||||
}
|
||||
|
||||
// Duplicate create should fail
|
||||
err = m.CreateUser(User{Username: "admin", Password: "x", Displayname: "dup"})
|
||||
if err == nil {
|
||||
t.Error("duplicate create should fail")
|
||||
}
|
||||
|
||||
// Update
|
||||
newName := "Super Admin"
|
||||
err = m.UpdateUser("admin", UserUpdate{Displayname: &newName})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
users, _ = m.ListUsers()
|
||||
if users[0].Displayname != "Super Admin" {
|
||||
t.Errorf("after update, displayname = %q, want %q", users[0].Displayname, "Super Admin")
|
||||
}
|
||||
|
||||
// Update nonexistent user
|
||||
err = m.UpdateUser("nobody", UserUpdate{Displayname: &newName})
|
||||
if err == nil {
|
||||
t.Error("update nonexistent should fail")
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = m.DeleteUser("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
users, _ = m.ListUsers()
|
||||
if len(users) != 0 {
|
||||
t.Errorf("after delete, expected 0 users, got %d", len(users))
|
||||
}
|
||||
|
||||
// Delete nonexistent
|
||||
err = m.DeleteUser("admin")
|
||||
if err == nil {
|
||||
t.Error("delete nonexistent should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserValidation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if err := m.CreateUser(User{Password: "x", Displayname: "y"}); err == nil {
|
||||
t.Error("empty username should fail")
|
||||
}
|
||||
if err := m.CreateUser(User{Username: "x", Displayname: "y"}); err == nil {
|
||||
t.Error("empty password should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
hash, err := hashPassword("testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("hashPassword: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
|
||||
t.Errorf("hash should start with $argon2id$v=19$, got %q", hash[:30])
|
||||
}
|
||||
|
||||
// Two hashes of the same password should differ (different salt)
|
||||
hash2, _ := hashPassword("testpassword")
|
||||
if hash == hash2 {
|
||||
t.Error("two hashes of the same password should differ (random salt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if m.UserCount() != 0 {
|
||||
t.Error("empty db should have 0 users")
|
||||
}
|
||||
|
||||
m.CreateUser(User{Username: "a", Password: "p", Displayname: "A"})
|
||||
m.CreateUser(User{Username: "b", Password: "p", Displayname: "B"})
|
||||
|
||||
if m.UserCount() != 2 {
|
||||
t.Errorf("expected 2 users, got %d", m.UserCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUsersDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if err := m.EnsureUsersDB(); err != nil {
|
||||
t.Fatalf("EnsureUsersDB: %v", err)
|
||||
}
|
||||
|
||||
// File should exist
|
||||
if _, err := os.Stat(m.usersDBPath()); err != nil {
|
||||
t.Errorf("users db file should exist: %v", err)
|
||||
}
|
||||
|
||||
// Should be idempotent
|
||||
if err := m.EnsureUsersDB(); err != nil {
|
||||
t.Fatalf("EnsureUsersDB (second call): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureJWKS(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := NewManager(dir)
|
||||
|
||||
if err := m.EnsureJWKS(); err != nil {
|
||||
t.Fatalf("EnsureJWKS: %v", err)
|
||||
}
|
||||
|
||||
// File should exist
|
||||
data, err := os.ReadFile(m.jwksPath())
|
||||
if err != nil {
|
||||
t.Fatalf("reading JWKS: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "RSA PRIVATE KEY") {
|
||||
t.Error("JWKS file should contain RSA private key")
|
||||
}
|
||||
|
||||
// Should be idempotent (doesn't regenerate)
|
||||
if err := m.EnsureJWKS(); err != nil {
|
||||
t.Fatalf("EnsureJWKS (second call): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSecret(t *testing.T) {
|
||||
s1, err := GenerateSecret(32)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret: %v", err)
|
||||
}
|
||||
if len(s1) != 64 { // 32 bytes = 64 hex chars
|
||||
t.Errorf("secret length = %d, want 64", len(s1))
|
||||
}
|
||||
|
||||
s2, _ := GenerateSecret(32)
|
||||
if s1 == s2 {
|
||||
t.Error("two generated secrets should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDomainFromAuthDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"auth.payne.io", "payne.io"},
|
||||
{"auth.sub.example.com", "sub.example.com"},
|
||||
{"localhost", "localhost"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := SessionDomainFromAuthDomain(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("SessionDomainFromAuthDomain(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled(t *testing.T) {
|
||||
m := NewManager(t.TempDir())
|
||||
// Just verify it doesn't panic — the result depends on whether authelia is installed
|
||||
_ = m.IsInstalled()
|
||||
}
|
||||
219
internal/authelia/oidc.go
Normal file
219
internal/authelia/oidc.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package authelia
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
)
|
||||
|
||||
// OIDCClient represents a registered OIDC client application
|
||||
type OIDCClient struct {
|
||||
ID string `yaml:"client_id" json:"clientId"`
|
||||
Name string `yaml:"client_name" json:"clientName"`
|
||||
Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response
|
||||
RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"`
|
||||
Scopes []string `yaml:"scopes" json:"scopes"`
|
||||
Policy string `yaml:"authorization_policy" json:"authorizationPolicy"`
|
||||
ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCClientUpdate holds partial updates for an OIDC client
|
||||
type OIDCClientUpdate struct {
|
||||
Name *string `json:"clientName,omitempty"`
|
||||
RedirectURIs []string `json:"redirectUris,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Policy *string `json:"authorizationPolicy,omitempty"`
|
||||
ConsentMode *string `json:"consentMode,omitempty"`
|
||||
}
|
||||
|
||||
// oidcClientsFile is the top-level structure of the OIDC clients file
|
||||
type oidcClientsFile struct {
|
||||
Clients []OIDCClient `yaml:"clients"`
|
||||
}
|
||||
|
||||
// ListOIDCClients returns all registered OIDC clients (secrets redacted)
|
||||
func (m *Manager) ListOIDCClients() ([]OIDCClient, error) {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Redact secrets for listing
|
||||
for i := range clients {
|
||||
clients[i].Secret = ""
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// ReadOIDCClients returns all clients with secrets (for config generation)
|
||||
func (m *Manager) ReadOIDCClients() ([]OIDCClient, error) {
|
||||
return m.readOIDCClients()
|
||||
}
|
||||
|
||||
// AddOIDCClient adds a new OIDC client. Returns the cleartext secret (shown once).
|
||||
func (m *Manager) AddOIDCClient(client OIDCClient) (cleartextSecret string, err error) {
|
||||
if client.ID == "" {
|
||||
return "", fmt.Errorf("client_id is required")
|
||||
}
|
||||
if client.Name == "" {
|
||||
return "", fmt.Errorf("client_name is required")
|
||||
}
|
||||
if len(client.RedirectURIs) == 0 {
|
||||
return "", fmt.Errorf("at least one redirect_uri is required")
|
||||
}
|
||||
|
||||
lockPath := m.configPath() + ".lock"
|
||||
err = storage.WithLock(lockPath, func() error {
|
||||
clients, readErr := m.readOIDCClients()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
if c.ID == client.ID {
|
||||
return fmt.Errorf("client %q already exists", client.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate client secret
|
||||
secret, genErr := GenerateSecret(32)
|
||||
if genErr != nil {
|
||||
return fmt.Errorf("generating client secret: %w", genErr)
|
||||
}
|
||||
cleartextSecret = secret
|
||||
|
||||
// Hash for storage
|
||||
client.Secret = hashClientSecret(secret)
|
||||
|
||||
// Default scopes
|
||||
if len(client.Scopes) == 0 {
|
||||
client.Scopes = []string{"openid", "profile", "email", "groups"}
|
||||
}
|
||||
if client.Policy == "" {
|
||||
client.Policy = "one_factor"
|
||||
}
|
||||
|
||||
return m.writeOIDCClients(append(clients, client))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateOIDCClient applies partial updates to an OIDC client
|
||||
func (m *Manager) UpdateOIDCClient(clientID string, updates OIDCClientUpdate) error {
|
||||
lockPath := m.configPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, c := range clients {
|
||||
if c.ID == clientID {
|
||||
if updates.Name != nil {
|
||||
clients[i].Name = *updates.Name
|
||||
}
|
||||
if updates.RedirectURIs != nil {
|
||||
clients[i].RedirectURIs = updates.RedirectURIs
|
||||
}
|
||||
if updates.Scopes != nil {
|
||||
clients[i].Scopes = updates.Scopes
|
||||
}
|
||||
if updates.Policy != nil {
|
||||
clients[i].Policy = *updates.Policy
|
||||
}
|
||||
if updates.ConsentMode != nil {
|
||||
clients[i].ConsentMode = *updates.ConsentMode
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("client %q not found", clientID)
|
||||
}
|
||||
|
||||
return m.writeOIDCClients(clients)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteOIDCClient removes an OIDC client by ID
|
||||
func (m *Manager) DeleteOIDCClient(clientID string) error {
|
||||
lockPath := m.configPath() + ".lock"
|
||||
return storage.WithLock(lockPath, func() error {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filtered []OIDCClient
|
||||
found := false
|
||||
for _, c := range clients {
|
||||
if c.ID == clientID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("client %q not found", clientID)
|
||||
}
|
||||
|
||||
return m.writeOIDCClients(filtered)
|
||||
})
|
||||
}
|
||||
|
||||
// OIDCClientCount returns the number of registered OIDC clients
|
||||
func (m *Manager) OIDCClientCount() int {
|
||||
clients, err := m.readOIDCClients()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(clients)
|
||||
}
|
||||
|
||||
// readOIDCClients reads OIDC clients from the dedicated clients file
|
||||
func (m *Manager) readOIDCClients() ([]OIDCClient, error) {
|
||||
data, err := os.ReadFile(m.oidcClientsPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading oidc clients: %w", err)
|
||||
}
|
||||
|
||||
var f oidcClientsFile
|
||||
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parsing oidc clients: %w", err)
|
||||
}
|
||||
|
||||
return f.Clients, nil
|
||||
}
|
||||
|
||||
// writeOIDCClients writes the OIDC clients to the dedicated clients file
|
||||
func (m *Manager) writeOIDCClients(clients []OIDCClient) error {
|
||||
f := oidcClientsFile{Clients: clients}
|
||||
data, err := yaml.Marshal(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling oidc clients: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := m.oidcClientsPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("writing temp oidc clients: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.oidcClientsPath()); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("installing oidc clients: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashClientSecret wraps the secret with $plaintext$ prefix.
|
||||
// Authelia accepts this format and handles hashing internally.
|
||||
func hashClientSecret(secret string) string {
|
||||
return "$plaintext$" + secret
|
||||
}
|
||||
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