SMTP: Add host, port, username, sender, and password fields to the Authelia configuration. Uses modern address format (submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465) with explicit TLS server name. Falls back to filesystem notifier when SMTP is not configured. Fixes: Config changes now persist before attempting service restart, so a restart failure (e.g. bad SMTP credentials) no longer prevents saving. The specific Authelia error is extracted from the journal and shown in the UI. Frontend: SMTP fields added to the Authentication config card. Form no longer continuously resets from server state while user is editing. OIDC client edit UI added (pencil icon).
258 lines
7.0 KiB
Go
258 lines
7.0 KiB
Go
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
|
|
SMTPHost string // SMTP server host (empty = use filesystem notifier)
|
|
SMTPPort int // SMTP server port (default: 587)
|
|
SMTPUsername string // SMTP login username (defaults to sender if empty)
|
|
SMTPSender string // From address
|
|
SMTPPassword string // SMTP password (from secrets)
|
|
}
|
|
|
|
// 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": buildNotifier(opts, 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
|
|
}
|
|
|
|
// buildNotifier returns the notifier config — SMTP if configured, filesystem otherwise.
|
|
func buildNotifier(opts ConfigOpts, filesystemPath string) map[string]any {
|
|
if opts.SMTPHost != "" && opts.SMTPSender != "" {
|
|
username := opts.SMTPUsername
|
|
if username == "" {
|
|
username = opts.SMTPSender
|
|
}
|
|
port := opts.SMTPPort
|
|
if port == 0 {
|
|
port = 587
|
|
}
|
|
// Use the modern address format: submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465
|
|
scheme := "submission"
|
|
if port == 465 {
|
|
scheme = "smtps"
|
|
}
|
|
smtp := map[string]any{
|
|
"address": fmt.Sprintf("%s://%s:%d", scheme, opts.SMTPHost, port),
|
|
"sender": opts.SMTPSender,
|
|
"username": username,
|
|
"password": opts.SMTPPassword,
|
|
"tls": map[string]any{
|
|
"server_name": opts.SMTPHost,
|
|
},
|
|
}
|
|
return map[string]any{"smtp": smtp}
|
|
}
|
|
return map[string]any{
|
|
"filesystem": map[string]any{
|
|
"filename": filesystemPath,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|