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).
493 lines
16 KiB
Go
493 lines
16 KiB
Go
package v1
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/authelia"
|
|
"github.com/wild-cloud/wild-central/internal/config"
|
|
"github.com/wild-cloud/wild-central/internal/domains"
|
|
)
|
|
|
|
// AutheliaStatus returns the current Authelia service status
|
|
func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
|
|
status, err := api.authelia.GetStatus()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get authelia status: %v", err))
|
|
return
|
|
}
|
|
|
|
state, _ := config.LoadState(api.statePath())
|
|
enabled := false
|
|
domain := ""
|
|
defaultPolicy := ""
|
|
if state != nil {
|
|
enabled = state.Cloud.Authelia.Enabled
|
|
domain = state.Cloud.Authelia.Domain
|
|
defaultPolicy = state.Cloud.Authelia.DefaultPolicy
|
|
}
|
|
|
|
smtp := struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Sender string `json:"sender"`
|
|
}{}
|
|
if state != nil {
|
|
smtp.Host = state.Cloud.Authelia.SMTP.Host
|
|
smtp.Port = state.Cloud.Authelia.SMTP.Port
|
|
smtp.Username = state.Cloud.Authelia.SMTP.Username
|
|
smtp.Sender = state.Cloud.Authelia.SMTP.Sender
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"active": status.Active,
|
|
"version": status.Version,
|
|
"enabled": enabled,
|
|
"domain": domain,
|
|
"defaultPolicy": defaultPolicy,
|
|
"userCount": api.authelia.UserCount(),
|
|
"clientCount": api.authelia.OIDCClientCount(),
|
|
"installed": api.authelia.IsInstalled(),
|
|
"smtp": smtp,
|
|
})
|
|
}
|
|
|
|
// AutheliaGetConfig returns the raw Authelia configuration for the advanced view
|
|
func (api *API) AutheliaGetConfig(w http.ResponseWriter, r *http.Request) {
|
|
content, err := api.authelia.ReadConfig()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read authelia config: %v", err))
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"content": content})
|
|
}
|
|
|
|
// AutheliaUpdateConfig enables/disables Authelia and updates its configuration
|
|
func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Domain *string `json:"domain"`
|
|
DefaultPolicy *string `json:"defaultPolicy"`
|
|
SMTPHost *string `json:"smtpHost"`
|
|
SMTPPort *int `json:"smtpPort"`
|
|
SMTPUsername *string `json:"smtpUsername"`
|
|
SMTPSender *string `json:"smtpSender"`
|
|
SMTPPassword *string `json:"smtpPassword"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
|
return
|
|
}
|
|
|
|
state, err := config.LoadState(api.statePath())
|
|
if err != nil {
|
|
state = &config.State{}
|
|
}
|
|
|
|
if req.Domain != nil {
|
|
state.Cloud.Authelia.Domain = *req.Domain
|
|
}
|
|
if req.DefaultPolicy != nil {
|
|
state.Cloud.Authelia.DefaultPolicy = *req.DefaultPolicy
|
|
}
|
|
if req.Enabled != nil {
|
|
state.Cloud.Authelia.Enabled = *req.Enabled
|
|
}
|
|
if req.SMTPHost != nil {
|
|
state.Cloud.Authelia.SMTP.Host = *req.SMTPHost
|
|
}
|
|
if req.SMTPPort != nil {
|
|
state.Cloud.Authelia.SMTP.Port = *req.SMTPPort
|
|
}
|
|
if req.SMTPUsername != nil {
|
|
state.Cloud.Authelia.SMTP.Username = *req.SMTPUsername
|
|
}
|
|
if req.SMTPSender != nil {
|
|
state.Cloud.Authelia.SMTP.Sender = *req.SMTPSender
|
|
}
|
|
if req.SMTPPassword != nil && *req.SMTPPassword != "" {
|
|
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
|
|
}
|
|
|
|
// Enabling Authelia
|
|
if state.Cloud.Authelia.Enabled {
|
|
if !api.authelia.IsInstalled() {
|
|
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
|
|
return
|
|
}
|
|
if state.Cloud.Authelia.Domain == "" {
|
|
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
|
|
return
|
|
}
|
|
if !api.authelia.HasLuaScript() {
|
|
respondError(w, http.StatusBadRequest,
|
|
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. "+
|
|
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua "+
|
|
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
|
|
return
|
|
}
|
|
|
|
// Ensure data directory exists
|
|
if err := api.authelia.EnsureDataDir(); err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
|
|
return
|
|
}
|
|
|
|
// Auto-generate secrets if missing
|
|
if err := api.ensureAutheliaSecrets(); err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
|
|
return
|
|
}
|
|
|
|
// Generate JWKS key pair for OIDC
|
|
if err := api.authelia.EnsureJWKS(); err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
|
|
return
|
|
}
|
|
|
|
// Ensure user database exists
|
|
if err := api.authelia.EnsureUsersDB(); err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
|
|
return
|
|
}
|
|
|
|
// Generate config
|
|
if err := api.generateAutheliaConfig(state); err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
|
|
return
|
|
}
|
|
|
|
// Register auth portal domain
|
|
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
|
|
|
|
// Start service — requires at least one user
|
|
startErr := error(nil)
|
|
if api.authelia.UserCount() > 0 {
|
|
startErr = api.authelia.RestartService()
|
|
}
|
|
|
|
// Save state and reconcile regardless of whether the service started
|
|
if err := config.SaveState(state, api.statePath()); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
|
return
|
|
}
|
|
go api.reconcileNetworking()
|
|
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
|
|
|
if startErr != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Configuration saved but Authelia failed to start: %v", startErr))
|
|
return
|
|
}
|
|
} else {
|
|
// Disabling — stop service and deregister domain
|
|
_ = api.authelia.StopService()
|
|
api.deregisterAuthDomain()
|
|
|
|
if err := config.SaveState(state, api.statePath()); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
|
return
|
|
}
|
|
go api.reconcileNetworking()
|
|
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
|
}
|
|
|
|
respondMessage(w, http.StatusOK, "Authelia configuration updated")
|
|
}
|
|
|
|
// AutheliaRestart restarts the Authelia service
|
|
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
|
|
if err := api.authelia.RestartService(); err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart authelia: %v", err))
|
|
return
|
|
}
|
|
api.broadcastAutheliaEvent("authelia:restart", "Authelia service restarted")
|
|
respondMessage(w, http.StatusOK, "Authelia restarted")
|
|
}
|
|
|
|
// --- User management ---
|
|
|
|
// AutheliaListUsers returns all Authelia users (passwords omitted)
|
|
func (api *API) AutheliaListUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := api.authelia.ListUsers()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list users: %v", err))
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"users": users})
|
|
}
|
|
|
|
// AutheliaCreateUser creates a new Authelia user
|
|
func (api *API) AutheliaCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
var user authelia.User
|
|
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
|
return
|
|
}
|
|
|
|
if err := api.authelia.CreateUser(user); err != nil {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create user: %v", err))
|
|
return
|
|
}
|
|
|
|
slog.Info("authelia user created", "username", user.Username)
|
|
|
|
// If Authelia is enabled but not running (was waiting for first user), start it
|
|
state, _ := config.LoadState(api.statePath())
|
|
if state != nil && state.Cloud.Authelia.Enabled {
|
|
status, _ := api.authelia.GetStatus()
|
|
if status != nil && !status.Active {
|
|
if err := api.authelia.RestartService(); err != nil {
|
|
slog.Warn("failed to start authelia after first user", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q created", user.Username))
|
|
respondMessage(w, http.StatusCreated, fmt.Sprintf("User %q created", user.Username))
|
|
}
|
|
|
|
// AutheliaUpdateUser updates an existing Authelia user
|
|
func (api *API) AutheliaUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
username := mux.Vars(r)["username"]
|
|
|
|
var updates authelia.UserUpdate
|
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
|
return
|
|
}
|
|
|
|
if err := api.authelia.UpdateUser(username, updates); err != nil {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update user: %v", err))
|
|
return
|
|
}
|
|
|
|
slog.Info("authelia user updated", "username", username)
|
|
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q updated", username))
|
|
}
|
|
|
|
// AutheliaDeleteUser deletes an Authelia user
|
|
func (api *API) AutheliaDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
username := mux.Vars(r)["username"]
|
|
|
|
if err := api.authelia.DeleteUser(username); err != nil {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete user: %v", err))
|
|
return
|
|
}
|
|
|
|
slog.Info("authelia user deleted", "username", username)
|
|
api.broadcastAutheliaEvent("authelia:user", fmt.Sprintf("User %q deleted", username))
|
|
respondMessage(w, http.StatusOK, fmt.Sprintf("User %q deleted", username))
|
|
}
|
|
|
|
// --- OIDC client management ---
|
|
|
|
// AutheliaListOIDCClients returns all registered OIDC clients (secrets redacted)
|
|
func (api *API) AutheliaListOIDCClients(w http.ResponseWriter, r *http.Request) {
|
|
clients, err := api.authelia.ListOIDCClients()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list OIDC clients: %v", err))
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]any{"clients": clients})
|
|
}
|
|
|
|
// AutheliaCreateOIDCClient creates a new OIDC client and returns the secret once
|
|
func (api *API) AutheliaCreateOIDCClient(w http.ResponseWriter, r *http.Request) {
|
|
var client authelia.OIDCClient
|
|
if err := json.NewDecoder(r.Body).Decode(&client); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
|
return
|
|
}
|
|
|
|
secret, err := api.authelia.AddOIDCClient(client)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to create OIDC client: %v", err))
|
|
return
|
|
}
|
|
|
|
// Regenerate full config (adds OIDC section) and restart
|
|
if err := api.regenAndRestartAuthelia(); err != nil {
|
|
slog.Warn("failed to restart authelia after OIDC client create", "error", err)
|
|
}
|
|
|
|
slog.Info("authelia OIDC client created", "clientId", client.ID)
|
|
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q created", client.ID))
|
|
|
|
respondJSON(w, http.StatusCreated, map[string]any{
|
|
"clientId": client.ID,
|
|
"clientSecret": secret,
|
|
"message": "OIDC client created. Save the client secret — it will not be shown again.",
|
|
})
|
|
}
|
|
|
|
// AutheliaUpdateOIDCClient updates an OIDC client
|
|
func (api *API) AutheliaUpdateOIDCClient(w http.ResponseWriter, r *http.Request) {
|
|
clientID := mux.Vars(r)["clientId"]
|
|
|
|
var updates authelia.OIDCClientUpdate
|
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
|
respondError(w, http.StatusBadRequest, "Invalid JSON")
|
|
return
|
|
}
|
|
|
|
if err := api.authelia.UpdateOIDCClient(clientID, updates); err != nil {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to update OIDC client: %v", err))
|
|
return
|
|
}
|
|
|
|
if err := api.regenAndRestartAuthelia(); err != nil {
|
|
slog.Warn("failed to restart authelia after OIDC client update", "error", err)
|
|
}
|
|
|
|
slog.Info("authelia OIDC client updated", "clientId", clientID)
|
|
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q updated", clientID))
|
|
}
|
|
|
|
// AutheliaDeleteOIDCClient deletes an OIDC client
|
|
func (api *API) AutheliaDeleteOIDCClient(w http.ResponseWriter, r *http.Request) {
|
|
clientID := mux.Vars(r)["clientId"]
|
|
|
|
if err := api.authelia.DeleteOIDCClient(clientID); err != nil {
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to delete OIDC client: %v", err))
|
|
return
|
|
}
|
|
|
|
if err := api.regenAndRestartAuthelia(); err != nil {
|
|
slog.Warn("failed to restart authelia after OIDC client delete", "error", err)
|
|
}
|
|
|
|
slog.Info("authelia OIDC client deleted", "clientId", clientID)
|
|
api.broadcastAutheliaEvent("authelia:oidc", fmt.Sprintf("OIDC client %q deleted", clientID))
|
|
respondMessage(w, http.StatusOK, fmt.Sprintf("OIDC client %q deleted", clientID))
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
// regenAndRestartAuthelia regenerates the full Authelia config and restarts the service.
|
|
// Used after OIDC client changes to ensure the config includes the OIDC section.
|
|
func (api *API) regenAndRestartAuthelia() error {
|
|
state, err := config.LoadState(api.statePath())
|
|
if err != nil || !state.Cloud.Authelia.Enabled {
|
|
return nil
|
|
}
|
|
if err := api.generateAutheliaConfig(state); err != nil {
|
|
return fmt.Errorf("regenerating config: %w", err)
|
|
}
|
|
if api.authelia.UserCount() > 0 {
|
|
return api.authelia.RestartService()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureAutheliaSecrets generates Authelia secrets if they don't already exist
|
|
func (api *API) ensureAutheliaSecrets() error {
|
|
keys := []string{
|
|
"authelia.jwtSecret",
|
|
"authelia.sessionSecret",
|
|
"authelia.storageEncryptionKey",
|
|
"authelia.oidcHmacSecret",
|
|
}
|
|
for _, key := range keys {
|
|
existing, _ := api.secrets.GetSecret(key)
|
|
if existing != "" {
|
|
continue
|
|
}
|
|
secret, err := authelia.GenerateSecret(32)
|
|
if err != nil {
|
|
return fmt.Errorf("generating %s: %w", key, err)
|
|
}
|
|
if err := api.secrets.SetSecret(key, secret); err != nil {
|
|
return fmt.Errorf("saving %s: %w", key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateAutheliaConfig reads secrets and state, then generates the Authelia config
|
|
func (api *API) generateAutheliaConfig(state *config.State) error {
|
|
jwtSecret, _ := api.secrets.GetSecret("authelia.jwtSecret")
|
|
sessionSecret, _ := api.secrets.GetSecret("authelia.sessionSecret")
|
|
storageEncKey, _ := api.secrets.GetSecret("authelia.storageEncryptionKey")
|
|
oidcHmac, _ := api.secrets.GetSecret("authelia.oidcHmacSecret")
|
|
|
|
// Read OIDC clients from the clients file (with secrets for config generation)
|
|
clients, _ := api.authelia.ReadOIDCClients()
|
|
|
|
defaultPolicy := state.Cloud.Authelia.DefaultPolicy
|
|
if defaultPolicy == "" {
|
|
defaultPolicy = "one_factor"
|
|
}
|
|
|
|
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
|
|
|
|
smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword")
|
|
|
|
opts := authelia.ConfigOpts{
|
|
Domain: state.Cloud.Authelia.Domain,
|
|
JWTSecret: jwtSecret,
|
|
SessionSecret: sessionSecret,
|
|
StorageEncKey: storageEncKey,
|
|
OIDCHMACSecret: oidcHmac,
|
|
DefaultPolicy: defaultPolicy,
|
|
SessionDomain: sessionDomain,
|
|
OIDCClients: clients,
|
|
SMTPHost: state.Cloud.Authelia.SMTP.Host,
|
|
SMTPPort: state.Cloud.Authelia.SMTP.Port,
|
|
SMTPUsername: state.Cloud.Authelia.SMTP.Username,
|
|
SMTPSender: state.Cloud.Authelia.SMTP.Sender,
|
|
SMTPPassword: smtpPassword,
|
|
}
|
|
|
|
return api.authelia.GenerateConfig(opts)
|
|
}
|
|
|
|
// ensureAuthDomain registers the Authelia login portal as a domain
|
|
func (api *API) ensureAuthDomain(authDomain string) {
|
|
if authDomain == "" {
|
|
return
|
|
}
|
|
|
|
// Clean up stale authelia registrations
|
|
doms, _ := api.domains.List()
|
|
for _, dom := range doms {
|
|
if dom.Source == "authelia" && dom.DomainName != authDomain {
|
|
_ = api.domains.Deregister(dom.DomainName)
|
|
}
|
|
}
|
|
|
|
_ = api.domains.Register(domains.Domain{
|
|
DomainName: authDomain,
|
|
Source: "authelia",
|
|
Routes: []domains.Route{
|
|
{
|
|
Backend: domains.Backend{
|
|
Address: "127.0.0.1:9091",
|
|
Type: domains.BackendHTTP,
|
|
},
|
|
Headers: &domains.HeaderConfig{
|
|
Request: map[string]string{
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": authDomain,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
TLS: domains.TLSTerminate,
|
|
})
|
|
}
|
|
|
|
// deregisterAuthDomain removes all authelia-sourced domain registrations
|
|
func (api *API) deregisterAuthDomain() {
|
|
doms, _ := api.domains.List()
|
|
for _, dom := range doms {
|
|
if dom.Source == "authelia" {
|
|
_ = api.domains.Deregister(dom.DomainName)
|
|
}
|
|
}
|
|
}
|