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:
2
go.mod
2
go.mod
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/nats-io/nats-server/v2 v2.14.3
|
github.com/nats-io/nats-server/v2 v2.14.3
|
||||||
github.com/nats-io/nats.go v1.52.0
|
github.com/nats-io/nats.go v1.52.0
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,5 @@ require (
|
|||||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
golang.org/x/crypto v0.53.0 // indirect
|
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/crowdsec"
|
"github.com/wild-cloud/wild-central/internal/crowdsec"
|
||||||
"github.com/wild-cloud/wild-central/internal/ddns"
|
"github.com/wild-cloud/wild-central/internal/ddns"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
||||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
@@ -45,6 +47,9 @@ type API struct {
|
|||||||
crowdsec *crowdsec.Manager
|
crowdsec *crowdsec.Manager
|
||||||
vpn *wireguard.Manager
|
vpn *wireguard.Manager
|
||||||
certbot *certbot.Manager
|
certbot *certbot.Manager
|
||||||
|
authelia *authelia.Manager // Authelia authentication service manager
|
||||||
|
dnsFilter *dnsfilter.Manager // DNS filtering manager
|
||||||
|
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
|
||||||
domains *domains.Manager // Domain registration manager
|
domains *domains.Manager // Domain registration manager
|
||||||
sseManager *sse.Manager // SSE manager for real-time events
|
sseManager *sse.Manager // SSE manager for real-time events
|
||||||
port int // Running API port (for config-driven routes)
|
port int // Running API port (for config-driven routes)
|
||||||
@@ -72,10 +77,18 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
|||||||
crowdsec: crowdsec.NewManager(),
|
crowdsec: crowdsec.NewManager(),
|
||||||
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
||||||
certbot: certbot.NewManager(""),
|
certbot: certbot.NewManager(""),
|
||||||
|
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
|
||||||
|
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
|
||||||
domains: domains.NewManager(dataDir),
|
domains: domains.NewManager(dataDir),
|
||||||
sseManager: sseManager,
|
sseManager: sseManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The compiled blocklist must be readable by dnsmasq which runs as its
|
||||||
|
// own user. Use /var/lib/wild-central/dns-filter/ (world-readable) rather
|
||||||
|
// than the data dir which may be under a user home with 0750 permissions.
|
||||||
|
filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter")
|
||||||
|
api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf"))
|
||||||
|
|
||||||
// Wire up domain registration reconciliation: when domains change,
|
// Wire up domain registration reconciliation: when domains change,
|
||||||
// regenerate dnsmasq DNS entries and HAProxy routes.
|
// regenerate dnsmasq DNS entries and HAProxy routes.
|
||||||
api.domains.SetReconcileFn(api.reconcileNetworking)
|
api.domains.SetReconcileFn(api.reconcileNetworking)
|
||||||
@@ -110,6 +123,30 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
|
|||||||
api.ddns.Start(ctx, api.ddnsParams)
|
api.ddns.Start(ctx, api.ddnsParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StartDNSFilter launches the DNS filter background goroutine if enabled.
|
||||||
|
func (api *API) StartDNSFilter(ctx gocontext.Context) {
|
||||||
|
api.dnsFilterRunner = dnsfilter.NewRunner(api.dnsFilter, func() {
|
||||||
|
if err := api.dnsmasq.ReloadService(); err != nil {
|
||||||
|
slog.Warn("dns-filter: failed to reload dnsmasq after update", "error", err)
|
||||||
|
}
|
||||||
|
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
|
||||||
|
})
|
||||||
|
|
||||||
|
state, err := config.LoadState(api.statePath())
|
||||||
|
if err != nil || !state.Cloud.DNSFilter.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = api.dnsFilter.EnsureDataDir()
|
||||||
|
|
||||||
|
interval := state.Cloud.DNSFilter.IntervalHours
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
api.dnsFilterRunner.Start(ctx, interval)
|
||||||
|
}
|
||||||
|
|
||||||
// ddnsParams returns the current DDNS parameters derived from runtime state.
|
// ddnsParams returns the current DDNS parameters derived from runtime state.
|
||||||
// Called by the DDNS runner on each tick — always returns fresh values.
|
// Called by the DDNS runner on each tick — always returns fresh values.
|
||||||
func (api *API) ddnsParams() ddns.Params {
|
func (api *API) ddnsParams() ddns.Params {
|
||||||
@@ -257,6 +294,35 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
|||||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
|
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
|
r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST")
|
||||||
|
|
||||||
|
// Authelia authentication management
|
||||||
|
r.HandleFunc("/api/v1/authelia/status", api.AutheliaStatus).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/authelia/config", api.AutheliaGetConfig).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/authelia/config", api.AutheliaUpdateConfig).Methods("PUT")
|
||||||
|
r.HandleFunc("/api/v1/authelia/restart", api.AutheliaRestart).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/authelia/users", api.AutheliaListUsers).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/authelia/users", api.AutheliaCreateUser).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaUpdateUser).Methods("PUT")
|
||||||
|
r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaDeleteUser).Methods("DELETE")
|
||||||
|
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaListOIDCClients).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaCreateOIDCClient).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaUpdateOIDCClient).Methods("PUT")
|
||||||
|
r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaDeleteOIDCClient).Methods("DELETE")
|
||||||
|
|
||||||
|
// DNS Filtering
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/status", api.DNSFilterStatus).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterGetConfig).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterUpdateConfig).Methods("PUT")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterGetLists).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterAddList).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/lists/upload", api.DNSFilterUploadList).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/lists/suggested", api.DNSFilterGetSuggested).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/lists/{id}", api.DNSFilterRemoveList).Methods("DELETE")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/lists/{id}/toggle", api.DNSFilterToggleList).Methods("PUT")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterGetCustomEntries).Methods("GET")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterSetCustomEntry).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/custom/{domain:.+}", api.DNSFilterRemoveCustomEntry).Methods("DELETE")
|
||||||
|
r.HandleFunc("/api/v1/dns-filter/update", api.DNSFilterTriggerUpdate).Methods("POST")
|
||||||
|
|
||||||
// Domain registration — domain is the unique key
|
// Domain registration — domain is the unique key
|
||||||
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
|
r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
|
r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST")
|
||||||
@@ -386,6 +452,17 @@ func getDaemonStatus() map[string]map[string]any {
|
|||||||
return firstWord(out)
|
return firstWord(out)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"authelia": {
|
||||||
|
unit: "authelia.service",
|
||||||
|
verCmd: []string{"authelia", "--version"},
|
||||||
|
verParse: func(out string) string {
|
||||||
|
// "authelia version 4.x.x" or just "4.x.x"
|
||||||
|
if after, found := strings.CutPrefix(out, "authelia version "); found {
|
||||||
|
return firstWord(after)
|
||||||
|
}
|
||||||
|
return firstWord(out)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]map[string]any, len(daemons)+1)
|
result := make(map[string]map[string]any, len(daemons)+1)
|
||||||
|
|||||||
442
internal/api/v1/handlers_authelia.go
Normal file
442
internal/api/v1/handlers_authelia.go
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if api.authelia.UserCount() > 0 {
|
||||||
|
if err := api.authelia.RestartService(); err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start Authelia: %v", err))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger reconciliation to update HAProxy
|
||||||
|
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)
|
||||||
|
|
||||||
|
opts := authelia.ConfigOpts{
|
||||||
|
Domain: state.Cloud.Authelia.Domain,
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
SessionSecret: sessionSecret,
|
||||||
|
StorageEncKey: storageEncKey,
|
||||||
|
OIDCHMACSecret: oidcHmac,
|
||||||
|
DefaultPolicy: defaultPolicy,
|
||||||
|
SessionDomain: sessionDomain,
|
||||||
|
OIDCClients: clients,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wild-cloud/wild-central/internal/authelia"
|
||||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
"github.com/wild-cloud/wild-central/internal/sse"
|
"github.com/wild-cloud/wild-central/internal/sse"
|
||||||
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
|
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
|
||||||
@@ -96,13 +98,18 @@ func (api *API) reconcileNetworking() {
|
|||||||
case domains.BackendHTTP:
|
case domains.BackendHTTP:
|
||||||
var routeBackends []haproxy.HTTPRouteBackend
|
var routeBackends []haproxy.HTTPRouteBackend
|
||||||
for _, r := range dom.EffectiveRoutes() {
|
for _, r := range dom.EffectiveRoutes() {
|
||||||
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
|
rb := haproxy.HTTPRouteBackend{
|
||||||
Paths: r.Paths,
|
Paths: r.Paths,
|
||||||
Backend: r.Backend.Address,
|
Backend: r.Backend.Address,
|
||||||
HealthPath: r.Backend.Health,
|
HealthPath: r.Backend.Health,
|
||||||
Headers: r.Headers,
|
Headers: r.Headers,
|
||||||
IPAllow: r.IPAllow,
|
IPAllow: r.IPAllow,
|
||||||
})
|
}
|
||||||
|
if dom.Auth != nil && dom.Auth.Enabled {
|
||||||
|
rb.AuthEnabled = true
|
||||||
|
rb.AuthPolicy = dom.Auth.Policy
|
||||||
|
}
|
||||||
|
routeBackends = append(routeBackends, rb)
|
||||||
}
|
}
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: dom.DomainName,
|
Name: dom.DomainName,
|
||||||
@@ -143,6 +150,22 @@ func (api *API) reconcileNetworking() {
|
|||||||
HTTPRoutes: activeHTTPRoutes,
|
HTTPRoutes: activeHTTPRoutes,
|
||||||
CertsDir: certsDir,
|
CertsDir: certsDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate Authelia forward-auth settings if enabled
|
||||||
|
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
|
||||||
|
genOpts.AuthEnabled = true
|
||||||
|
genOpts.AuthBackend = "127.0.0.1:9091"
|
||||||
|
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
|
||||||
|
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
|
||||||
|
|
||||||
|
// Regenerate Authelia config so session cookie domains stay in sync
|
||||||
|
// with the set of auth-protected domains
|
||||||
|
if err := api.generateAutheliaConfig(globalCfg); err != nil {
|
||||||
|
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
|
||||||
|
} else if api.authelia.UserCount() > 0 {
|
||||||
|
_ = api.authelia.RestartService()
|
||||||
|
}
|
||||||
|
}
|
||||||
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
|
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
|
||||||
|
|
||||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
||||||
@@ -224,6 +247,16 @@ func (api *API) reconcileNetworking() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set addn-hosts path for DNS filtering
|
||||||
|
if globalCfg.Cloud.DNSFilter.Enabled {
|
||||||
|
hostsPath := api.dnsFilter.HostsFilePath()
|
||||||
|
if storage.FileExists(hostsPath) {
|
||||||
|
api.dnsmasq.SetFilterConfPath(hostsPath)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
api.dnsmasq.SetFilterConfPath("")
|
||||||
|
}
|
||||||
|
|
||||||
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
||||||
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -359,6 +392,25 @@ func (api *API) broadcastHaproxyEvent(eventType string, message string) {
|
|||||||
api.sseManager.Broadcast(event)
|
api.sseManager.Broadcast(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// broadcastAutheliaEvent broadcasts SSE events for Authelia status changes
|
||||||
|
func (api *API) broadcastAutheliaEvent(eventType string, message string) {
|
||||||
|
if api.sseManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := &sse.Event{
|
||||||
|
ID: fmt.Sprintf("authelia-%d", time.Now().UnixNano()),
|
||||||
|
Type: eventType,
|
||||||
|
InstanceName: "global",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Data: map[string]any{
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
api.sseManager.Broadcast(event)
|
||||||
|
}
|
||||||
|
|
||||||
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
|
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
|
||||||
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
||||||
if api.sseManager == nil {
|
if api.sseManager == nil {
|
||||||
@@ -382,3 +434,22 @@ func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
|
|||||||
|
|
||||||
api.sseManager.Broadcast(event)
|
api.sseManager.Broadcast(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// broadcastDNSFilterEvent broadcasts SSE events for DNS filter changes
|
||||||
|
func (api *API) broadcastDNSFilterEvent(eventType string, message string) {
|
||||||
|
if api.sseManager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event := &sse.Event{
|
||||||
|
ID: fmt.Sprintf("dns-filter-%d", time.Now().UnixNano()),
|
||||||
|
Type: eventType,
|
||||||
|
InstanceName: "global",
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Data: map[string]any{
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
api.sseManager.Broadcast(event)
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -114,6 +114,15 @@ type State struct {
|
|||||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||||
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
|
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
|
||||||
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
|
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
|
||||||
|
Authelia struct {
|
||||||
|
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||||
|
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io)
|
||||||
|
DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor
|
||||||
|
} `yaml:"authelia,omitempty" json:"authelia,omitempty"`
|
||||||
|
DNSFilter struct {
|
||||||
|
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||||
|
IntervalHours int `yaml:"intervalHours,omitempty" json:"intervalHours,omitempty"` // update interval, default 24
|
||||||
|
} `yaml:"dnsFilter,omitempty" json:"dnsFilter,omitempty"`
|
||||||
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
|
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ type Route struct {
|
|||||||
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
|
IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthConfig describes forward-auth protection for an L7 HTTP domain.
|
||||||
|
// Only applicable to domains with backend type "http" (L7 TLS termination).
|
||||||
|
type AuthConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||||
|
Policy string `yaml:"policy,omitempty" json:"policy,omitempty"` // one_factor, two_factor, bypass
|
||||||
|
}
|
||||||
|
|
||||||
// Domain represents a registered domain. The Domain field is the unique key.
|
// Domain represents a registered domain. The Domain field is the unique key.
|
||||||
//
|
//
|
||||||
// Simple domains use Backend directly. Multi-backend domains use Routes
|
// Simple domains use Backend directly. Multi-backend domains use Routes
|
||||||
@@ -69,6 +76,7 @@ type Domain struct {
|
|||||||
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain
|
||||||
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
|
Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only
|
||||||
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
|
TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate
|
||||||
|
Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only)
|
||||||
|
|
||||||
// Multi-backend path routing. When present, Backend is ignored.
|
// Multi-backend path routing. When present, Backend is ignored.
|
||||||
// Each route maps path prefixes to a specific backend with its own L7 options.
|
// Each route maps path prefixes to a specific backend with its own L7 options.
|
||||||
@@ -354,6 +362,21 @@ func (m *Manager) Update(domain string, updates map[string]any) error {
|
|||||||
if tls, ok := updates["tls"].(string); ok {
|
if tls, ok := updates["tls"].(string); ok {
|
||||||
dom.TLS = TLSMode(tls)
|
dom.TLS = TLSMode(tls)
|
||||||
}
|
}
|
||||||
|
if auth, ok := updates["auth"].(map[string]any); ok {
|
||||||
|
if dom.Auth == nil {
|
||||||
|
dom.Auth = &AuthConfig{}
|
||||||
|
}
|
||||||
|
if enabled, ok := auth["enabled"].(bool); ok {
|
||||||
|
dom.Auth.Enabled = enabled
|
||||||
|
}
|
||||||
|
if policy, ok := auth["policy"].(string); ok {
|
||||||
|
dom.Auth.Policy = policy
|
||||||
|
}
|
||||||
|
// Clear auth if disabled and no policy
|
||||||
|
if !dom.Auth.Enabled && dom.Auth.Policy == "" {
|
||||||
|
dom.Auth = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return m.Register(*dom)
|
return m.Register(*dom)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ type HTTPRouteBackend struct {
|
|||||||
HealthPath string // optional health check path
|
HealthPath string // optional health check path
|
||||||
Headers *domains.HeaderConfig // per-route headers
|
Headers *domains.HeaderConfig // per-route headers
|
||||||
IPAllow []string // per-route CIDR whitelist
|
IPAllow []string // per-route CIDR whitelist
|
||||||
|
AuthEnabled bool // forward-auth protection via Authelia
|
||||||
|
AuthPolicy string // one_factor, two_factor (for access control)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPRoute represents an L7 HTTP reverse proxy route.
|
// HTTPRoute represents an L7 HTTP reverse proxy route.
|
||||||
@@ -82,6 +84,10 @@ func (m *Manager) GetConfigPath() string {
|
|||||||
type GenerateOpts struct {
|
type GenerateOpts struct {
|
||||||
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central)
|
||||||
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
CertsDir string // directory containing per-domain PEM files for L7 TLS
|
||||||
|
AuthEnabled bool // global Authelia forward-auth toggle
|
||||||
|
AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091")
|
||||||
|
AuthDomain string // Authelia login portal domain (excluded from protection)
|
||||||
|
AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
// GenerateWithOpts creates a complete HAProxy configuration with full options.
|
||||||
@@ -97,7 +103,13 @@ global
|
|||||||
stats timeout 30s
|
stats timeout 30s
|
||||||
maxconn 50000
|
maxconn 50000
|
||||||
daemon
|
daemon
|
||||||
|
`)
|
||||||
|
if opts.AuthEnabled {
|
||||||
|
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
|
||||||
|
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(`
|
||||||
defaults
|
defaults
|
||||||
log global
|
log global
|
||||||
mode tcp
|
mode tcp
|
||||||
@@ -284,7 +296,48 @@ frontend http_in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authelia forward-auth directives for protected routes
|
||||||
|
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||||
|
authDomainACL := ""
|
||||||
|
if opts.AuthDomain != "" {
|
||||||
|
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, httpRoute := range opts.HTTPRoutes {
|
||||||
|
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||||
|
|
||||||
|
// Skip auth portal domain (would cause infinite redirect)
|
||||||
|
if hostACL == authDomainACL {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip domains outside the auth session scope — forward-auth
|
||||||
|
// requires shared cookies, which only work within the same
|
||||||
|
// parent domain. Other domains should use OIDC instead.
|
||||||
|
if opts.AuthSessionDomain != "" &&
|
||||||
|
httpRoute.Domain != opts.AuthSessionDomain &&
|
||||||
|
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any route on this domain has auth enabled
|
||||||
|
hasAuth := false
|
||||||
|
for _, rb := range httpRoute.Routes {
|
||||||
|
if rb.AuthEnabled {
|
||||||
|
hasAuth = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAuth {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&sb, " # auth: %s\n", httpRoute.Domain)
|
||||||
|
fmt.Fprintf(&sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL)
|
||||||
|
fmt.Fprintf(&sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL)
|
||||||
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Routing rules: path-specific first, then catch-all
|
// Routing rules: path-specific first, then catch-all
|
||||||
for _, httpRoute := range opts.HTTPRoutes {
|
for _, httpRoute := range opts.HTTPRoutes {
|
||||||
@@ -333,6 +386,15 @@ frontend http_in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authelia backend for forward-auth
|
||||||
|
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||||
|
sb.WriteString("# Authelia forward-auth backend\n")
|
||||||
|
sb.WriteString("backend be_authelia\n")
|
||||||
|
sb.WriteString(" mode http\n")
|
||||||
|
fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
for _, route := range custom {
|
for _, route := range custom {
|
||||||
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
|
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
|
||||||
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
||||||
|
|||||||
@@ -833,3 +833,213 @@ line3
|
|||||||
t.Errorf("expected 0 broken services when no markers, got %d", len(broken))
|
t.Errorf("expected 0 broken services when no markers, got %d", len(broken))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Authelia forward-auth tests ---
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_AuthLuaLoad(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
AuthEnabled: true,
|
||||||
|
AuthBackend: "127.0.0.1:9091",
|
||||||
|
AuthDomain: "auth.payne.io",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(cfg, "lua-load /etc/haproxy/lua/haproxy-auth-request.lua") {
|
||||||
|
t.Error("config should contain lua-load directive when auth is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_NoAuthLuaLoadWhenDisabled(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
AuthEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Contains(cfg, "lua-load") {
|
||||||
|
t.Error("config should NOT contain lua-load when auth is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_AuthBackend(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
httpRoutes := []HTTPRoute{
|
||||||
|
{
|
||||||
|
Name: "app.payne.io",
|
||||||
|
Domain: "app.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/tmp/certs/",
|
||||||
|
AuthEnabled: true,
|
||||||
|
AuthBackend: "127.0.0.1:9091",
|
||||||
|
AuthDomain: "auth.payne.io",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(cfg, "backend be_authelia") {
|
||||||
|
t.Error("config should contain Authelia backend")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "server s0 127.0.0.1:9091") {
|
||||||
|
t.Error("config should contain Authelia server address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_AuthInterceptDirective(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
httpRoutes := []HTTPRoute{
|
||||||
|
{
|
||||||
|
Name: "app.payne.io",
|
||||||
|
Domain: "app.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/tmp/certs/",
|
||||||
|
AuthEnabled: true,
|
||||||
|
AuthBackend: "127.0.0.1:9091",
|
||||||
|
AuthDomain: "auth.payne.io",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(cfg, "lua.auth-request be_authelia") {
|
||||||
|
t.Error("config should contain auth-intercept directive for protected route")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "auth.payne.io/?rd=") {
|
||||||
|
t.Error("config should contain redirect to auth portal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_AuthDomainExempt(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
httpRoutes := []HTTPRoute{
|
||||||
|
{
|
||||||
|
Name: "auth.payne.io",
|
||||||
|
Domain: "auth.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{Backend: "127.0.0.1:9091", AuthEnabled: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "app.payne.io",
|
||||||
|
Domain: "app.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/tmp/certs/",
|
||||||
|
AuthEnabled: true,
|
||||||
|
AuthBackend: "127.0.0.1:9091",
|
||||||
|
AuthDomain: "auth.payne.io",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count auth-intercept directives — should be 1 (for app), not 2 (auth domain exempt)
|
||||||
|
count := strings.Count(cfg, "lua.auth-request")
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("expected 1 auth-intercept directive (auth domain exempt), got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The auth comment should only appear for app.payne.io
|
||||||
|
if !strings.Contains(cfg, "# auth: app.payne.io") {
|
||||||
|
t.Error("should have auth comment for app.payne.io")
|
||||||
|
}
|
||||||
|
if strings.Contains(cfg, "# auth: auth.payne.io") {
|
||||||
|
t.Error("should NOT have auth comment for auth.payne.io (exempt)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_NoAuthForUnprotectedRoutes(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
httpRoutes := []HTTPRoute{
|
||||||
|
{
|
||||||
|
Name: "public.payne.io",
|
||||||
|
Domain: "public.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{Backend: "127.0.0.1:8080", AuthEnabled: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/tmp/certs/",
|
||||||
|
AuthEnabled: true,
|
||||||
|
AuthBackend: "127.0.0.1:9091",
|
||||||
|
AuthDomain: "auth.payne.io",
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Contains(cfg, "lua.auth-request") {
|
||||||
|
t.Error("should NOT contain auth-intercept for unprotected routes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_AuthDisabledNoDirectives(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
httpRoutes := []HTTPRoute{
|
||||||
|
{
|
||||||
|
Name: "app.payne.io",
|
||||||
|
Domain: "app.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{Backend: "127.0.0.1:8080", AuthEnabled: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/tmp/certs/",
|
||||||
|
AuthEnabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Contains(cfg, "lua.auth-request") {
|
||||||
|
t.Error("should NOT contain auth directives when auth is globally disabled")
|
||||||
|
}
|
||||||
|
if strings.Contains(cfg, "be_authelia") {
|
||||||
|
t.Error("should NOT contain authelia backend when auth is globally disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithOpts_AuthWithHeaders(t *testing.T) {
|
||||||
|
m := NewManager("")
|
||||||
|
httpRoutes := []HTTPRoute{
|
||||||
|
{
|
||||||
|
Name: "app.payne.io",
|
||||||
|
Domain: "app.payne.io",
|
||||||
|
Routes: []HTTPRouteBackend{
|
||||||
|
{
|
||||||
|
Backend: "127.0.0.1:8080",
|
||||||
|
AuthEnabled: true,
|
||||||
|
Headers: &domains.HeaderConfig{
|
||||||
|
Request: map[string]string{"X-Custom": "value"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.GenerateWithOpts(nil, nil, GenerateOpts{
|
||||||
|
HTTPRoutes: httpRoutes,
|
||||||
|
CertsDir: "/tmp/certs/",
|
||||||
|
AuthEnabled: true,
|
||||||
|
AuthBackend: "127.0.0.1:9091",
|
||||||
|
AuthDomain: "auth.payne.io",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should have both auth and headers
|
||||||
|
if !strings.Contains(cfg, "lua.auth-request") {
|
||||||
|
t.Error("should contain auth-intercept")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg, "X-Custom") {
|
||||||
|
t.Error("should still contain custom headers alongside auth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, Network, ChevronRight } from 'lucide-react';
|
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -89,10 +89,12 @@ export function AppSidebar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const centralItems = [
|
const centralItems = [
|
||||||
|
{ to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const },
|
||||||
{ to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
|
{ to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
|
||||||
{ to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
|
{ to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
|
||||||
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||||
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
|
||||||
|
{ to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const advancedItems = [
|
const advancedItems = [
|
||||||
@@ -101,6 +103,7 @@ export function AppSidebar() {
|
|||||||
{ to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
{ to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
|
||||||
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
|
||||||
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
|
||||||
|
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
633
web/src/components/AutheliaComponent.tsx
Normal file
633
web/src/components/AutheliaComponent.tsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { KeyRound, BookOpen, CheckCircle, AlertCircle, Plus, Trash2, Loader2, RotateCw, ExternalLink, Copy, X, UserPlus, Globe, Pencil } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Label } from './ui/label';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import { useAuthelia } from '../hooks/useAuthelia';
|
||||||
|
import { domainsApi } from '../services/api';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function AutheliaComponent() {
|
||||||
|
const {
|
||||||
|
status, isLoadingStatus,
|
||||||
|
users, isLoadingUsers,
|
||||||
|
oidcClients, isLoadingClients,
|
||||||
|
updateConfig, restart,
|
||||||
|
createUser, deleteUser,
|
||||||
|
createOIDCClient, updateOIDCClient, deleteOIDCClient,
|
||||||
|
} = useAuthelia();
|
||||||
|
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false);
|
||||||
|
const [showAddClient, setShowAddClient] = useState(false);
|
||||||
|
const [newClientSecret, setNewClientSecret] = useState<string | null>(null);
|
||||||
|
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const showSuccess = (msg: string) => {
|
||||||
|
setSuccessMsg(msg);
|
||||||
|
setErrorMsg(null);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setErrorMsg(msg);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingStatus) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<KeyRound className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Authentication</h2>
|
||||||
|
<p className="text-muted-foreground">Centralized authentication for network services via Authelia</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{successMsg && (
|
||||||
|
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription className="text-green-700 dark:text-green-300">{successMsg}</AlertDescription>
|
||||||
|
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setSuccessMsg(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{errorMsg && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{errorMsg}</AlertDescription>
|
||||||
|
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setErrorMsg(null)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Educational Card */}
|
||||||
|
<Card className="bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
|
||||||
|
<CardContent className="p-4 flex items-start gap-3">
|
||||||
|
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-sm text-cyan-800 dark:text-cyan-200">
|
||||||
|
<p className="font-medium mb-1">Centralized Authentication for Your Network</p>
|
||||||
|
<p>Authelia provides two ways to protect services: <strong>Forward-auth</strong> intercepts requests at HAProxy for apps without native login. <strong>OIDC</strong> lets apps like Gitea and Grafana use Authelia as their identity provider. Both share one user database and login portal.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<ConfigCard
|
||||||
|
status={status}
|
||||||
|
onUpdate={updateConfig}
|
||||||
|
showSuccess={showSuccess}
|
||||||
|
showError={showError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Service Status (only if enabled) */}
|
||||||
|
{status?.enabled && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Service Status</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={status.active ? 'success' : 'destructive'} className="gap-1">
|
||||||
|
{status.active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
||||||
|
{status.active ? 'Running' : 'Stopped'}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1" onClick={() => restart.mutate(undefined, {
|
||||||
|
onSuccess: () => showSuccess('Authelia restarted'),
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to restart'),
|
||||||
|
})} disabled={restart.isPending}>
|
||||||
|
{restart.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
{status.version && <div><span className="text-muted-foreground">Version</span><div className="font-mono mt-0.5">{status.version}</div></div>}
|
||||||
|
<div><span className="text-muted-foreground">Users</span><div className="font-mono mt-0.5">{status.userCount}</div></div>
|
||||||
|
<div><span className="text-muted-foreground">OIDC Clients</span><div className="font-mono mt-0.5">{status.clientCount}</div></div>
|
||||||
|
{status.domain && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Login Portal</span>
|
||||||
|
<div className="font-mono mt-0.5">
|
||||||
|
<a href={`https://${status.domain}`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1">
|
||||||
|
{status.domain}<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Management */}
|
||||||
|
{status?.enabled && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Users</CardTitle>
|
||||||
|
<Button size="sm" className="gap-1" onClick={() => setShowAddUser(!showAddUser)}>
|
||||||
|
{showAddUser ? <X className="h-3 w-3" /> : <UserPlus className="h-3 w-3" />}
|
||||||
|
{showAddUser ? 'Cancel' : 'Add User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
{showAddUser && (
|
||||||
|
<AddUserForm
|
||||||
|
onSubmit={(data) => {
|
||||||
|
createUser.mutate(data, {
|
||||||
|
onSuccess: () => { showSuccess(`User "${data.username}" created`); setShowAddUser(false); },
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create user'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isSubmitting={createUser.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoadingUsers ? (
|
||||||
|
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No users yet. Add a user to get started.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.username} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{user.displayname || user.username}</span>
|
||||||
|
<span className="text-muted-foreground text-sm ml-2">({user.username})</span>
|
||||||
|
{user.email && <span className="text-muted-foreground text-xs ml-2">{user.email}</span>}
|
||||||
|
</div>
|
||||||
|
{user.disabled && <Badge variant="secondary">Disabled</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete user "${user.username}"?`)) {
|
||||||
|
deleteUser.mutate(user.username, {
|
||||||
|
onSuccess: () => showSuccess(`User "${user.username}" deleted`),
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete user'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OIDC Clients */}
|
||||||
|
{status?.enabled && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">OIDC Clients</CardTitle>
|
||||||
|
<Button size="sm" className="gap-1" onClick={() => { setShowAddClient(!showAddClient); setNewClientSecret(null); }}>
|
||||||
|
{showAddClient ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||||
|
{showAddClient ? 'Cancel' : 'Add Client'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
{newClientSecret && (
|
||||||
|
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||||
|
<AlertDescription className="text-amber-800 dark:text-amber-200">
|
||||||
|
<p className="font-medium mb-1">Client secret — save this now, it won't be shown again:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="bg-muted px-2 py-1 rounded text-xs font-mono break-all">{newClientSecret}</code>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => navigator.clipboard.writeText(newClientSecret)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{showAddClient && (
|
||||||
|
<AddOIDCClientForm
|
||||||
|
onSubmit={(data) => {
|
||||||
|
createOIDCClient.mutate(data, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setNewClientSecret(result.clientSecret);
|
||||||
|
setShowAddClient(false);
|
||||||
|
showSuccess(`OIDC client "${data.clientId}" created`);
|
||||||
|
},
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create client'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
isSubmitting={createOIDCClient.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoadingClients ? (
|
||||||
|
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
|
||||||
|
) : oidcClients.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">No OIDC clients. Apps with native SSO support (Gitea, Grafana, etc.) need a client registration here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{oidcClients.map((client) => (
|
||||||
|
editingClientId === client.clientId ? (
|
||||||
|
<EditOIDCClientForm
|
||||||
|
key={client.clientId}
|
||||||
|
client={client}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateOIDCClient.mutate({ clientId: client.clientId, ...data }, {
|
||||||
|
onSuccess: () => { showSuccess(`Client "${client.clientName}" updated`); setEditingClientId(null); },
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update client'),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingClientId(null)}
|
||||||
|
isSubmitting={updateOIDCClient.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={client.clientId} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-sm">{client.clientName}</span>
|
||||||
|
<span className="text-muted-foreground text-xs ml-2 font-mono">{client.clientId}</span>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
|
||||||
|
{client.redirectUris.join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{client.authorizationPolicy}</Badge>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditingClientId(client.clientId)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete OIDC client "${client.clientName}"?`)) {
|
||||||
|
deleteOIDCClient.mutate(client.clientId, {
|
||||||
|
onSuccess: () => showSuccess(`Client "${client.clientName}" deleted`),
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete client'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Protected Domains */}
|
||||||
|
{status?.enabled && <ProtectedDomainsCard authDomain={status.domain} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sub-components ---
|
||||||
|
|
||||||
|
function ConfigCard({ status, onUpdate, showSuccess, showError }: {
|
||||||
|
status: AutheliaStatus | undefined;
|
||||||
|
onUpdate: ReturnType<typeof useAuthelia>['updateConfig'];
|
||||||
|
showSuccess: (msg: string) => void;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const { register, handleSubmit, watch, setValue } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
domain: status?.domain ?? '',
|
||||||
|
defaultPolicy: status?.defaultPolicy || 'one_factor',
|
||||||
|
},
|
||||||
|
values: status ? {
|
||||||
|
domain: status.domain ?? '',
|
||||||
|
defaultPolicy: status.defaultPolicy || 'one_factor',
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabled = status?.enabled ?? false;
|
||||||
|
const isUpdating = onUpdate.isPending;
|
||||||
|
|
||||||
|
const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => {
|
||||||
|
onUpdate.mutate(data, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditing(false);
|
||||||
|
showSuccess('Configuration updated');
|
||||||
|
},
|
||||||
|
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update config'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Configuration</CardTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="authelia-enabled" className="text-sm">Enabled</Label>
|
||||||
|
<Switch
|
||||||
|
id="authelia-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!checked || status?.domain) {
|
||||||
|
doUpdate({ enabled: checked });
|
||||||
|
} else {
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUpdating || (!status?.installed && !enabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{!status?.installed && !enabled && (
|
||||||
|
<p className="text-sm text-muted-foreground">Authelia is not installed. Install it first: <code className="bg-muted px-1 rounded">apt install authelia</code></p>
|
||||||
|
)}
|
||||||
|
{(enabled || editing) && (
|
||||||
|
<form onSubmit={handleSubmit((data) => doUpdate({ ...data, enabled: true }))} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="auth-domain">Auth Portal Domain</Label>
|
||||||
|
<Input {...register('domain', { required: 'Required' })} id="auth-domain" placeholder="auth.example.com" className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="default-policy">Default Policy</Label>
|
||||||
|
<Select value={watch('defaultPolicy')} onValueChange={(v) => setValue('defaultPolicy', v)}>
|
||||||
|
<SelectTrigger className="mt-1" id="default-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one_factor">One Factor (password only)</SelectItem>
|
||||||
|
<SelectItem value="two_factor">Two Factor (password + TOTP)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={isUpdating}>
|
||||||
|
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Enable & Save
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(false)} disabled={isUpdating}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{enabled && !editing && (
|
||||||
|
<Button type="submit" size="sm" disabled={isUpdating}>
|
||||||
|
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUserForm({ onSubmit, isSubmitting }: {
|
||||||
|
onSubmit: (data: { username: string; displayname: string; password: string; email?: string }) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||||
|
defaultValues: { username: '', displayname: '', password: '', email: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-username">Username</Label>
|
||||||
|
<Input {...register('username', { required: 'Required' })} id="new-username" className="mt-1" />
|
||||||
|
{errors.username && <p className="text-sm text-red-600 mt-1">{errors.username.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-displayname">Display Name</Label>
|
||||||
|
<Input {...register('displayname', { required: 'Required' })} id="new-displayname" className="mt-1" />
|
||||||
|
{errors.displayname && <p className="text-sm text-red-600 mt-1">{errors.displayname.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-email">Email</Label>
|
||||||
|
<Input {...register('email')} id="new-email" type="email" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="new-password">Password</Label>
|
||||||
|
<Input {...register('password', { required: 'Required', minLength: { value: 8, message: 'Min 8 characters' } })} id="new-password" type="password" className="mt-1" />
|
||||||
|
{errors.password && <p className="text-sm text-red-600 mt-1">{errors.password.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddOIDCClientForm({ onSubmit, isSubmitting }: {
|
||||||
|
onSubmit: (data: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy?: string }) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({
|
||||||
|
defaultValues: { clientId: '', clientName: '', redirectUri: '', authorizationPolicy: 'one_factor' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit((data) => {
|
||||||
|
onSubmit({
|
||||||
|
clientId: data.clientId,
|
||||||
|
clientName: data.clientName,
|
||||||
|
redirectUris: [data.redirectUri],
|
||||||
|
authorizationPolicy: data.authorizationPolicy,
|
||||||
|
});
|
||||||
|
})} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="client-id">Client ID</Label>
|
||||||
|
<Input {...register('clientId', { required: 'Required' })} id="client-id" placeholder="gitea" className="mt-1 font-mono" />
|
||||||
|
{errors.clientId && <p className="text-sm text-red-600 mt-1">{errors.clientId.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="client-name">Client Name</Label>
|
||||||
|
<Input {...register('clientName', { required: 'Required' })} id="client-name" placeholder="Gitea" className="mt-1" />
|
||||||
|
{errors.clientName && <p className="text-sm text-red-600 mt-1">{errors.clientName.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="redirect-uri">Redirect URI</Label>
|
||||||
|
<Input {...register('redirectUri', { required: 'Required' })} id="redirect-uri" placeholder="https://git.example.com/user/oauth2/authelia/callback" className="mt-1 font-mono text-sm" />
|
||||||
|
{errors.redirectUri && <p className="text-sm text-red-600 mt-1">{errors.redirectUri.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="client-policy">Authorization Policy</Label>
|
||||||
|
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
|
||||||
|
<SelectTrigger className="mt-1" id="client-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one_factor">One Factor</SelectItem>
|
||||||
|
<SelectItem value="two_factor">Two Factor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Create Client
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: {
|
||||||
|
client: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy: string };
|
||||||
|
onSubmit: (data: { clientName?: string; redirectUris?: string[]; authorizationPolicy?: string }) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}) {
|
||||||
|
const { register, handleSubmit, watch, setValue } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
clientName: client.clientName,
|
||||||
|
redirectUri: client.redirectUris[0] ?? '',
|
||||||
|
authorizationPolicy: client.authorizationPolicy || 'one_factor',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit((data) => {
|
||||||
|
onSubmit({
|
||||||
|
clientName: data.clientName,
|
||||||
|
redirectUris: [data.redirectUri],
|
||||||
|
authorizationPolicy: data.authorizationPolicy,
|
||||||
|
});
|
||||||
|
})} className="p-3 bg-muted/30 rounded-md space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Client ID</Label>
|
||||||
|
<Input value={client.clientId} disabled className="mt-1 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-client-name">Client Name</Label>
|
||||||
|
<Input {...register('clientName')} id="edit-client-name" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-redirect-uri">Redirect URI</Label>
|
||||||
|
<Input {...register('redirectUri')} id="edit-redirect-uri" className="mt-1 font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-client-policy">Authorization Policy</Label>
|
||||||
|
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
|
||||||
|
<SelectTrigger className="mt-1" id="edit-client-policy">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one_factor">One Factor</SelectItem>
|
||||||
|
<SelectItem value="two_factor">Two Factor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isSubmitting}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutheliaStatus {
|
||||||
|
active: boolean;
|
||||||
|
version?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
domain: string;
|
||||||
|
defaultPolicy: string;
|
||||||
|
userCount: number;
|
||||||
|
clientCount: number;
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedDomainsCard({ authDomain }: { authDomain: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const domainsQuery = useQuery({
|
||||||
|
queryKey: ['domains'],
|
||||||
|
queryFn: () => domainsApi.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleAuthMutation = useMutation({
|
||||||
|
mutationFn: async ({ domain, enabled }: { domain: string; enabled: boolean }) => {
|
||||||
|
return apiClient.patch(`/api/v1/domains/${domain}`, { auth: { enabled, policy: 'one_factor' } });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['domains'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpDomains = (domainsQuery.data?.domains ?? []).filter(
|
||||||
|
(d) => d.backend.type === 'http' && d.source !== 'authelia' && d.source !== 'central' && d.domain !== authDomain
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-base">Protected Domains</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-xs">Forward-Auth</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Toggle forward-auth protection for L7 HTTP services. Apps with native OIDC support (Gitea, Grafana, etc.) should use an OIDC client above instead.
|
||||||
|
</p>
|
||||||
|
{httpDomains.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4 flex items-center justify-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
No L7 HTTP domains registered yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{httpDomains.map((domain) => {
|
||||||
|
const authEnabled = (domain as any).auth?.enabled ?? false;
|
||||||
|
return (
|
||||||
|
<div key={domain.domain} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-sm">{domain.domain}</span>
|
||||||
|
<span className="text-muted-foreground text-xs ml-2">({domain.source})</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={authEnabled}
|
||||||
|
onCheckedChange={(checked) => toggleAuthMutation.mutate({ domain: domain.domain, enabled: checked })}
|
||||||
|
disabled={toggleAuthMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import apiClient for the PATCH call in ProtectedDomainsCard
|
||||||
|
import { apiClient } from '../services/api/client';
|
||||||
@@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert';
|
|||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
|
||||||
BookOpen, Globe, Router, RotateCw, Key,
|
BookOpen, Globe, Router, RotateCw, Key, KeyRound,
|
||||||
Shield, Eye, ArrowLeftRight, Lock,
|
Shield, Eye, ArrowLeftRight, Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCloudflare } from '../hooks/useCloudflare';
|
import { useCloudflare } from '../hooks/useCloudflare';
|
||||||
@@ -22,6 +22,7 @@ const SERVICES = [
|
|||||||
{ key: 'nftables', label: 'Firewall', icon: Shield },
|
{ key: 'nftables', label: 'Firewall', icon: Shield },
|
||||||
{ key: 'wireguard', label: 'VPN', icon: Lock },
|
{ key: 'wireguard', label: 'VPN', icon: Lock },
|
||||||
{ key: 'crowdsec', label: 'CrowdSec', icon: Eye },
|
{ key: 'crowdsec', label: 'CrowdSec', icon: Eye },
|
||||||
|
{ key: 'authelia', label: 'Auth', icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatUptime(totalSeconds: number): string {
|
function formatUptime(totalSeconds: number): string {
|
||||||
|
|||||||
41
web/src/components/advanced/AutheliaSubsystem.tsx
Normal file
41
web/src/components/advanced/AutheliaSubsystem.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
import { autheliaApi } from '../../services/api/authelia';
|
||||||
|
import { SubsystemPage } from '../SubsystemPage';
|
||||||
|
|
||||||
|
export function AutheliaSubsystem() {
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'config', 'raw'],
|
||||||
|
queryFn: () => autheliaApi.getConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: () => autheliaApi.restart(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMsg('Authelia restarted');
|
||||||
|
setErrorMsg(null);
|
||||||
|
setTimeout(() => setSuccessMsg(null), 5000);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setErrorMsg(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubsystemPage
|
||||||
|
title="Authelia"
|
||||||
|
description="Authentication and identity provider"
|
||||||
|
icon={KeyRound}
|
||||||
|
daemonKey="authelia"
|
||||||
|
configContent={configQuery.data?.content}
|
||||||
|
isLoadingConfig={configQuery.isLoading}
|
||||||
|
onRestart={() => restartMutation.mutate()}
|
||||||
|
isRestarting={restartMutation.isPending}
|
||||||
|
restartLabel="Restart Authelia"
|
||||||
|
successMessage={successMsg}
|
||||||
|
errorMessage={errorMsg}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export { DnsmasqSubsystem } from './DnsmasqSubsystem';
|
|||||||
export { NftablesSubsystem } from './NftablesSubsystem';
|
export { NftablesSubsystem } from './NftablesSubsystem';
|
||||||
export { WireguardSubsystem } from './WireguardSubsystem';
|
export { WireguardSubsystem } from './WireguardSubsystem';
|
||||||
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
|
||||||
|
export { AutheliaSubsystem } from './AutheliaSubsystem';
|
||||||
|
|||||||
90
web/src/hooks/useAuthelia.ts
Normal file
90
web/src/hooks/useAuthelia.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { autheliaApi } from '../services/api/authelia';
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, CreateOIDCClientRequest, UpdateOIDCClientRequest } from '../services/api/authelia';
|
||||||
|
|
||||||
|
export function useAuthelia() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'status'],
|
||||||
|
queryFn: () => autheliaApi.getStatus(),
|
||||||
|
staleTime: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'users'],
|
||||||
|
queryFn: () => autheliaApi.listUsers(),
|
||||||
|
enabled: !!statusQuery.data?.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcClientsQuery = useQuery({
|
||||||
|
queryKey: ['authelia', 'oidc', 'clients'],
|
||||||
|
queryFn: () => autheliaApi.listOIDCClients(),
|
||||||
|
enabled: !!statusQuery.data?.active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['authelia'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) =>
|
||||||
|
autheliaApi.updateConfig(data),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartMutation = useMutation({
|
||||||
|
mutationFn: () => autheliaApi.restart(),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUserMutation = useMutation({
|
||||||
|
mutationFn: (req: CreateUserRequest) => autheliaApi.createUser(req),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = useMutation({
|
||||||
|
mutationFn: ({ username, ...req }: UpdateUserRequest & { username: string }) =>
|
||||||
|
autheliaApi.updateUser(username, req),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'users'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteUserMutation = useMutation({
|
||||||
|
mutationFn: (username: string) => autheliaApi.deleteUser(username),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOIDCClientMutation = useMutation({
|
||||||
|
mutationFn: (req: CreateOIDCClientRequest) => autheliaApi.createOIDCClient(req),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOIDCClientMutation = useMutation({
|
||||||
|
mutationFn: ({ clientId, ...req }: UpdateOIDCClientRequest & { clientId: string }) =>
|
||||||
|
autheliaApi.updateOIDCClient(clientId, req),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteOIDCClientMutation = useMutation({
|
||||||
|
mutationFn: (clientId: string) => autheliaApi.deleteOIDCClient(clientId),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: statusQuery.data,
|
||||||
|
isLoadingStatus: statusQuery.isLoading,
|
||||||
|
users: usersQuery.data?.users ?? [],
|
||||||
|
isLoadingUsers: usersQuery.isLoading,
|
||||||
|
oidcClients: oidcClientsQuery.data?.clients ?? [],
|
||||||
|
isLoadingClients: oidcClientsQuery.isLoading,
|
||||||
|
updateConfig: updateConfigMutation,
|
||||||
|
restart: restartMutation,
|
||||||
|
createUser: createUserMutation,
|
||||||
|
updateUser: updateUserMutation,
|
||||||
|
deleteUser: deleteUserMutation,
|
||||||
|
createOIDCClient: createOIDCClientMutation,
|
||||||
|
updateOIDCClient: updateOIDCClientMutation,
|
||||||
|
deleteOIDCClient: deleteOIDCClientMutation,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
web/src/router/pages/AutheliaPage.tsx
Normal file
10
web/src/router/pages/AutheliaPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { AutheliaComponent } from '../../components/AutheliaComponent';
|
||||||
|
|
||||||
|
export function AutheliaPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AutheliaComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/router/pages/advanced/AutheliaPage.tsx
Normal file
10
web/src/router/pages/advanced/AutheliaPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../../components';
|
||||||
|
import { AutheliaSubsystem } from '../../../components/advanced';
|
||||||
|
|
||||||
|
export function AutheliaAdvancedPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AutheliaSubsystem />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export { DnsmasqPage } from './DnsmasqPage';
|
|||||||
export { NftablesPage } from './NftablesPage';
|
export { NftablesPage } from './NftablesPage';
|
||||||
export { WireguardPage } from './WireguardPage';
|
export { WireguardPage } from './WireguardPage';
|
||||||
export { CrowdsecPage } from './CrowdsecPage';
|
export { CrowdsecPage } from './CrowdsecPage';
|
||||||
|
export { AutheliaAdvancedPage } from './AutheliaPage';
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import { FirewallPage } from './pages/FirewallPage';
|
|||||||
import { VpnPage } from './pages/VpnPage';
|
import { VpnPage } from './pages/VpnPage';
|
||||||
import { CrowdSecPage } from './pages/CrowdSecPage';
|
import { CrowdSecPage } from './pages/CrowdSecPage';
|
||||||
import { DhcpPage } from './pages/DhcpPage';
|
import { DhcpPage } from './pages/DhcpPage';
|
||||||
|
import { DnsFilterPage } from './pages/DnsFilterPage';
|
||||||
|
import { AutheliaPage } from './pages/AutheliaPage';
|
||||||
import {
|
import {
|
||||||
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
|
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
|
||||||
CrowdsecPage as CrowdsecAdvancedPage,
|
CrowdsecPage as CrowdsecAdvancedPage,
|
||||||
|
AutheliaAdvancedPage,
|
||||||
} from './pages/advanced';
|
} from './pages/advanced';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
@@ -22,12 +25,15 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'firewall', element: <FirewallPage /> },
|
{ path: 'firewall', element: <FirewallPage /> },
|
||||||
{ path: 'vpn', element: <VpnPage /> },
|
{ path: 'vpn', element: <VpnPage /> },
|
||||||
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
{ path: 'crowdsec', element: <CrowdSecPage /> },
|
||||||
|
{ path: 'auth', element: <AutheliaPage /> },
|
||||||
{ path: 'dhcp', element: <DhcpPage /> },
|
{ path: 'dhcp', element: <DhcpPage /> },
|
||||||
|
{ path: 'dns-filter', element: <DnsFilterPage /> },
|
||||||
{ path: 'advanced/haproxy', element: <HaproxyPage /> },
|
{ path: 'advanced/haproxy', element: <HaproxyPage /> },
|
||||||
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
|
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
|
||||||
{ path: 'advanced/nftables', element: <NftablesPage /> },
|
{ path: 'advanced/nftables', element: <NftablesPage /> },
|
||||||
{ path: 'advanced/wireguard', element: <WireguardPage /> },
|
{ path: 'advanced/wireguard', element: <WireguardPage /> },
|
||||||
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
|
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
|
||||||
|
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
111
web/src/services/api/authelia.ts
Normal file
111
web/src/services/api/authelia.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
|
export interface AutheliaStatus {
|
||||||
|
active: boolean;
|
||||||
|
version?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
domain: string;
|
||||||
|
defaultPolicy: string;
|
||||||
|
userCount: number;
|
||||||
|
clientCount: number;
|
||||||
|
installed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutheliaUser {
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
email?: string;
|
||||||
|
groups?: string[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
username: string;
|
||||||
|
displayname: string;
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
groups?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
displayname?: string;
|
||||||
|
password?: string;
|
||||||
|
email?: string;
|
||||||
|
groups?: string[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OIDCClient {
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
scopes: string[];
|
||||||
|
authorizationPolicy: string;
|
||||||
|
consentMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOIDCClientRequest {
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
scopes?: string[];
|
||||||
|
authorizationPolicy?: string;
|
||||||
|
consentMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOIDCClientRequest {
|
||||||
|
clientName?: string;
|
||||||
|
redirectUris?: string[];
|
||||||
|
scopes?: string[];
|
||||||
|
authorizationPolicy?: string;
|
||||||
|
consentMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
|
||||||
|
export const autheliaApi = {
|
||||||
|
// Status & Config
|
||||||
|
async getStatus(): Promise<AutheliaStatus> {
|
||||||
|
return apiClient.get('/api/v1/authelia/status');
|
||||||
|
},
|
||||||
|
async getConfig(): Promise<{ content: string }> {
|
||||||
|
return apiClient.get('/api/v1/authelia/config');
|
||||||
|
},
|
||||||
|
async updateConfig(data: { enabled?: boolean; domain?: string; defaultPolicy?: string }): Promise<{ message: string }> {
|
||||||
|
return apiClient.put('/api/v1/authelia/config', data);
|
||||||
|
},
|
||||||
|
async restart(): Promise<{ message: string }> {
|
||||||
|
return apiClient.post('/api/v1/authelia/restart');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Users
|
||||||
|
async listUsers(): Promise<{ users: AutheliaUser[] }> {
|
||||||
|
return apiClient.get('/api/v1/authelia/users');
|
||||||
|
},
|
||||||
|
async createUser(req: CreateUserRequest): Promise<{ message: string }> {
|
||||||
|
return apiClient.post('/api/v1/authelia/users', req);
|
||||||
|
},
|
||||||
|
async updateUser(username: string, req: UpdateUserRequest): Promise<{ message: string }> {
|
||||||
|
return apiClient.put(`/api/v1/authelia/users/${username}`, req);
|
||||||
|
},
|
||||||
|
async deleteUser(username: string): Promise<{ message: string }> {
|
||||||
|
return apiClient.delete(`/api/v1/authelia/users/${username}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// OIDC Clients
|
||||||
|
async listOIDCClients(): Promise<{ clients: OIDCClient[] }> {
|
||||||
|
return apiClient.get('/api/v1/authelia/oidc/clients');
|
||||||
|
},
|
||||||
|
async createOIDCClient(req: CreateOIDCClientRequest): Promise<{ clientId: string; clientSecret: string; message: string }> {
|
||||||
|
return apiClient.post('/api/v1/authelia/oidc/clients', req);
|
||||||
|
},
|
||||||
|
async updateOIDCClient(clientId: string, req: UpdateOIDCClientRequest): Promise<{ message: string }> {
|
||||||
|
return apiClient.put(`/api/v1/authelia/oidc/clients/${clientId}`, req);
|
||||||
|
},
|
||||||
|
async deleteOIDCClient(clientId: string): Promise<{ message: string }> {
|
||||||
|
return apiClient.delete(`/api/v1/authelia/oidc/clients/${clientId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,3 +11,7 @@ export { cloudflareApi } from './cloudflare';
|
|||||||
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
|
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
|
||||||
export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
|
export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
|
||||||
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';
|
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';
|
||||||
|
export { autheliaApi } from './authelia';
|
||||||
|
export type { AutheliaStatus, AutheliaUser, OIDCClient } from './authelia';
|
||||||
|
export { dnsFilterApi } from './dnsfilter';
|
||||||
|
export type { DNSFilterStats, DNSFilterConfig, Blocklist, CustomEntry } from './dnsfilter';
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface RegisteredDomain {
|
|||||||
public: boolean;
|
public: boolean;
|
||||||
tls?: string;
|
tls?: string;
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
|
auth?: {
|
||||||
|
enabled: boolean;
|
||||||
|
policy?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const domainsApi = {
|
export const domainsApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user