Add SMTP support for Authelia notifications and fix config persistence

SMTP: Add host, port, username, sender, and password fields to the
Authelia configuration. Uses modern address format (submission:// for
STARTTLS on 587, smtps:// for implicit TLS on 465) with explicit TLS
server name. Falls back to filesystem notifier when SMTP is not
configured.

Fixes: Config changes now persist before attempting service restart,
so a restart failure (e.g. bad SMTP credentials) no longer prevents
saving. The specific Authelia error is extracted from the journal and
shown in the UI.

Frontend: SMTP fields added to the Authentication config card. Form
no longer continuously resets from server state while user is editing.
OIDC client edit UI added (pencil icon).
This commit is contained in:
2026-07-12 12:42:36 +00:00
parent d796a79f79
commit 134c01fe5e
7 changed files with 224 additions and 40 deletions

View File

@@ -31,6 +31,19 @@ func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
defaultPolicy = state.Cloud.Authelia.DefaultPolicy defaultPolicy = state.Cloud.Authelia.DefaultPolicy
} }
smtp := struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Sender string `json:"sender"`
}{}
if state != nil {
smtp.Host = state.Cloud.Authelia.SMTP.Host
smtp.Port = state.Cloud.Authelia.SMTP.Port
smtp.Username = state.Cloud.Authelia.SMTP.Username
smtp.Sender = state.Cloud.Authelia.SMTP.Sender
}
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"active": status.Active, "active": status.Active,
"version": status.Version, "version": status.Version,
@@ -40,6 +53,7 @@ func (api *API) AutheliaStatus(w http.ResponseWriter, r *http.Request) {
"userCount": api.authelia.UserCount(), "userCount": api.authelia.UserCount(),
"clientCount": api.authelia.OIDCClientCount(), "clientCount": api.authelia.OIDCClientCount(),
"installed": api.authelia.IsInstalled(), "installed": api.authelia.IsInstalled(),
"smtp": smtp,
}) })
} }
@@ -59,6 +73,11 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
Domain *string `json:"domain"` Domain *string `json:"domain"`
DefaultPolicy *string `json:"defaultPolicy"` DefaultPolicy *string `json:"defaultPolicy"`
SMTPHost *string `json:"smtpHost"`
SMTPPort *int `json:"smtpPort"`
SMTPUsername *string `json:"smtpUsername"`
SMTPSender *string `json:"smtpSender"`
SMTPPassword *string `json:"smtpPassword"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON") respondError(w, http.StatusBadRequest, "Invalid JSON")
@@ -79,6 +98,21 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
if req.Enabled != nil { if req.Enabled != nil {
state.Cloud.Authelia.Enabled = *req.Enabled state.Cloud.Authelia.Enabled = *req.Enabled
} }
if req.SMTPHost != nil {
state.Cloud.Authelia.SMTP.Host = *req.SMTPHost
}
if req.SMTPPort != nil {
state.Cloud.Authelia.SMTP.Port = *req.SMTPPort
}
if req.SMTPUsername != nil {
state.Cloud.Authelia.SMTP.Username = *req.SMTPUsername
}
if req.SMTPSender != nil {
state.Cloud.Authelia.SMTP.Sender = *req.SMTPSender
}
if req.SMTPPassword != nil && *req.SMTPPassword != "" {
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
}
// Enabling Authelia // Enabling Authelia
if state.Cloud.Authelia.Enabled { if state.Cloud.Authelia.Enabled {
@@ -132,27 +166,36 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
api.ensureAuthDomain(state.Cloud.Authelia.Domain) api.ensureAuthDomain(state.Cloud.Authelia.Domain)
// Start service — requires at least one user // Start service — requires at least one user
startErr := error(nil)
if api.authelia.UserCount() > 0 { if api.authelia.UserCount() > 0 {
if err := api.authelia.RestartService(); err != nil { startErr = api.authelia.RestartService()
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to start Authelia: %v", err)) }
// Save state and reconcile regardless of whether the service started
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return return
} }
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
if startErr != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Configuration saved but Authelia failed to start: %v", startErr))
return
} }
} else { } else {
// Disabling — stop service and deregister domain // Disabling — stop service and deregister domain
_ = api.authelia.StopService() _ = api.authelia.StopService()
api.deregisterAuthDomain() api.deregisterAuthDomain()
}
if err := config.SaveState(state, api.statePath()); err != nil { if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state") respondError(w, http.StatusInternalServerError, "Failed to save state")
return return
} }
// Trigger reconciliation to update HAProxy
go api.reconcileNetworking() go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated") api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
}
respondMessage(w, http.StatusOK, "Authelia configuration updated") respondMessage(w, http.StatusOK, "Authelia configuration updated")
} }
@@ -382,6 +425,8 @@ func (api *API) generateAutheliaConfig(state *config.State) error {
sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain) sessionDomain := authelia.SessionDomainFromAuthDomain(state.Cloud.Authelia.Domain)
smtpPassword, _ := api.secrets.GetSecret("authelia.smtpPassword")
opts := authelia.ConfigOpts{ opts := authelia.ConfigOpts{
Domain: state.Cloud.Authelia.Domain, Domain: state.Cloud.Authelia.Domain,
JWTSecret: jwtSecret, JWTSecret: jwtSecret,
@@ -391,6 +436,11 @@ func (api *API) generateAutheliaConfig(state *config.State) error {
DefaultPolicy: defaultPolicy, DefaultPolicy: defaultPolicy,
SessionDomain: sessionDomain, SessionDomain: sessionDomain,
OIDCClients: clients, OIDCClients: clients,
SMTPHost: state.Cloud.Authelia.SMTP.Host,
SMTPPort: state.Cloud.Authelia.SMTP.Port,
SMTPUsername: state.Cloud.Authelia.SMTP.Username,
SMTPSender: state.Cloud.Authelia.SMTP.Sender,
SMTPPassword: smtpPassword,
} }
return api.authelia.GenerateConfig(opts) return api.authelia.GenerateConfig(opts)

View File

@@ -25,6 +25,11 @@ type ConfigOpts struct {
DataDir string // authelia data directory path DataDir string // authelia data directory path
ListenAddr string // listen address (default: 127.0.0.1:9091) ListenAddr string // listen address (default: 127.0.0.1:9091)
OIDCClients []OIDCClient // registered OIDC clients OIDCClients []OIDCClient // registered OIDC clients
SMTPHost string // SMTP server host (empty = use filesystem notifier)
SMTPPort int // SMTP server port (default: 587)
SMTPUsername string // SMTP login username (defaults to sender if empty)
SMTPSender string // From address
SMTPPassword string // SMTP password (from secrets)
} }
// GenerateConfig builds Authelia's configuration.yml and writes it atomically. // GenerateConfig builds Authelia's configuration.yml and writes it atomically.
@@ -143,11 +148,7 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
}, },
}, },
"notifier": map[string]any{ "notifier": buildNotifier(opts, m.notificationPath()),
"filesystem": map[string]any{
"filename": m.notificationPath(),
},
},
"identity_validation": map[string]any{ "identity_validation": map[string]any{
"reset_password": map[string]any{ "reset_password": map[string]any{
@@ -202,6 +203,40 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any {
return cfg return cfg
} }
// buildNotifier returns the notifier config — SMTP if configured, filesystem otherwise.
func buildNotifier(opts ConfigOpts, filesystemPath string) map[string]any {
if opts.SMTPHost != "" && opts.SMTPSender != "" {
username := opts.SMTPUsername
if username == "" {
username = opts.SMTPSender
}
port := opts.SMTPPort
if port == 0 {
port = 587
}
// Use the modern address format: submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465
scheme := "submission"
if port == 465 {
scheme = "smtps"
}
smtp := map[string]any{
"address": fmt.Sprintf("%s://%s:%d", scheme, opts.SMTPHost, port),
"sender": opts.SMTPSender,
"username": username,
"password": opts.SMTPPassword,
"tls": map[string]any{
"server_name": opts.SMTPHost,
},
}
return map[string]any{"smtp": smtp}
}
return map[string]any{
"filesystem": map[string]any{
"filename": filesystemPath,
},
}
}
// GenerateSecret creates a cryptographically random hex string of the given byte length. // GenerateSecret creates a cryptographically random hex string of the given byte length.
func GenerateSecret(byteLen int) (string, error) { func GenerateSecret(byteLen int) (string, error) {
b := make([]byte, byteLen) b := make([]byte, byteLen)

View File

@@ -95,17 +95,66 @@ func (m *Manager) GetStatus() (*Status, error) {
return status, nil return status, nil
} }
// RestartService restarts the Authelia systemd service // RestartService restarts the Authelia systemd service and verifies it stays running.
func (m *Manager) RestartService() error { func (m *Manager) RestartService() error {
cmd := exec.Command("systemctl", "restart", "authelia.service") cmd := exec.Command("systemctl", "restart", "authelia.service")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output)) return fmt.Errorf("failed to restart authelia: %w (output: %s)", err, string(output))
} }
// Give it a moment to start (or crash)
time.Sleep(2 * time.Second)
// Check if it's actually running
if exec.Command("systemctl", "is-active", "--quiet", "authelia.service").Run() != nil {
// It crashed — grab the error from the journal
reason := m.lastError()
if reason != "" {
return fmt.Errorf("authelia started but crashed: %s", reason)
}
return fmt.Errorf("authelia started but crashed (check journalctl -u authelia.service)")
}
slog.Info("authelia service restarted", "component", "authelia") slog.Info("authelia service restarted", "component", "authelia")
return nil return nil
} }
// lastError extracts the most recent error-level message from the Authelia journal.
// Authelia logs look like: time="..." level=error msg="..." error="..." provider=...
func (m *Manager) lastError() string {
cmd := exec.Command("journalctl", "-u", "authelia.service", "-n", "20", "--no-pager", "-o", "cat")
output, err := cmd.CombinedOutput()
if err != nil {
return ""
}
// Find the last line containing level=error
var lastErr string
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "level=error") {
lastErr = line
}
}
if lastErr == "" {
return ""
}
// Extract error="..." field (has the actual detail)
if i := strings.Index(lastErr, "error=\""); i >= 0 {
detail := lastErr[i+7:]
if j := strings.Index(detail, "\""); j >= 0 {
return detail[:j]
}
}
// Fall back to msg="..." field
if i := strings.Index(lastErr, "msg=\""); i >= 0 {
detail := lastErr[i+5:]
if j := strings.Index(detail, "\""); j >= 0 {
return detail[:j]
}
}
return lastErr
}
// StopService stops the Authelia systemd service // StopService stops the Authelia systemd service
func (m *Manager) StopService() error { func (m *Manager) StopService() error {
cmd := exec.Command("systemctl", "stop", "authelia.service") cmd := exec.Command("systemctl", "stop", "authelia.service")

View File

@@ -118,6 +118,12 @@ type State struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io) 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 DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor
SMTP struct {
Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com
Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587
Username string `yaml:"username,omitempty" json:"username,omitempty"` // SMTP login username
Sender string `yaml:"sender,omitempty" json:"sender,omitempty"` // From address (e.g. auth@payne.io)
} `yaml:"smtp,omitempty" json:"smtp,omitempty"`
} `yaml:"authelia,omitempty" json:"authelia,omitempty"` } `yaml:"authelia,omitempty" json:"authelia,omitempty"`
DNSFilter struct { DNSFilter struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`

View File

@@ -11,6 +11,7 @@ import { Switch } from './ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { useAuthelia } from '../hooks/useAuthelia'; import { useAuthelia } from '../hooks/useAuthelia';
import { domainsApi } from '../services/api'; import { domainsApi } from '../services/api';
import type { AutheliaStatus } from '../services/api/authelia';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function AutheliaComponent() { export function AutheliaComponent() {
@@ -312,21 +313,37 @@ function ConfigCard({ status, onUpdate, showSuccess, showError }: {
showError: (msg: string) => void; showError: (msg: string) => void;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const { register, handleSubmit, watch, setValue } = useForm({ const [initialized, setInitialized] = useState(false);
const { register, handleSubmit, watch, setValue, reset } = useForm({
defaultValues: { defaultValues: {
domain: status?.domain ?? '', domain: '',
defaultPolicy: status?.defaultPolicy || 'one_factor', defaultPolicy: 'one_factor',
smtpHost: '',
smtpPort: 587,
smtpUsername: '',
smtpSender: '',
smtpPassword: '',
}, },
values: status ? { });
// Sync form with status data once on load, not continuously
if (status && !initialized) {
reset({
domain: status.domain ?? '', domain: status.domain ?? '',
defaultPolicy: status.defaultPolicy || 'one_factor', defaultPolicy: status.defaultPolicy || 'one_factor',
} : undefined, smtpHost: status.smtp?.host ?? '',
smtpPort: status.smtp?.port || 587,
smtpUsername: status.smtp?.username ?? '',
smtpSender: status.smtp?.sender ?? '',
smtpPassword: '',
}); });
setInitialized(true);
}
const enabled = status?.enabled ?? false; const enabled = status?.enabled ?? false;
const isUpdating = onUpdate.isPending; const isUpdating = onUpdate.isPending;
const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => { const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) => {
onUpdate.mutate(data, { onUpdate.mutate(data, {
onSuccess: () => { onSuccess: () => {
setEditing(false); setEditing(false);
@@ -363,7 +380,11 @@ function ConfigCard({ status, onUpdate, showSuccess, showError }: {
<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> <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) && ( {(enabled || editing) && (
<form onSubmit={handleSubmit((data) => doUpdate({ ...data, enabled: true }))} className="space-y-3"> <form onSubmit={handleSubmit((data) => {
const update: any = { ...data, enabled: true };
if (!update.smtpPassword) delete update.smtpPassword; // don't send empty password
doUpdate(update);
})} className="space-y-3">
<div> <div>
<Label htmlFor="auth-domain">Auth Portal Domain</Label> <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" /> <Input {...register('domain', { required: 'Required' })} id="auth-domain" placeholder="auth.example.com" className="mt-1 font-mono" />
@@ -380,6 +401,34 @@ function ConfigCard({ status, onUpdate, showSuccess, showError }: {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="pt-2 border-t">
<p className="text-sm font-medium mb-2">Email Notifications (SMTP)</p>
<p className="text-xs text-muted-foreground mb-3">Required for password resets and 2FA enrollment. Without SMTP, notifications are written to a local file.</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Label htmlFor="smtp-host">SMTP Host</Label>
<Input {...register('smtpHost')} id="smtp-host" placeholder="smtp.gmail.com" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="smtp-port">Port</Label>
<Input {...register('smtpPort', { valueAsNumber: true })} id="smtp-port" type="number" placeholder="587" className="mt-1 font-mono" />
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-3">
<div>
<Label htmlFor="smtp-sender">Sender Email</Label>
<Input {...register('smtpSender')} id="smtp-sender" placeholder="auth@example.com" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="smtp-username">Username</Label>
<Input {...register('smtpUsername')} id="smtp-username" placeholder="(defaults to sender)" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="smtp-password">Password</Label>
<Input {...register('smtpPassword')} id="smtp-password" type="password" placeholder={status?.smtp?.host ? '••••••••' : ''} className="mt-1" />
</div>
</div>
</div>
{editing && ( {editing && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" size="sm" disabled={isUpdating}> <Button type="submit" size="sm" disabled={isUpdating}>
@@ -556,17 +605,6 @@ function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: {
); );
} }
interface AutheliaStatus {
active: boolean;
version?: string;
enabled: boolean;
domain: string;
defaultPolicy: string;
userCount: number;
clientCount: number;
installed: boolean;
}
function ProtectedDomainsCard({ authDomain }: { authDomain: string }) { function ProtectedDomainsCard({ authDomain }: { authDomain: string }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const domainsQuery = useQuery({ const domainsQuery = useQuery({

View File

@@ -29,7 +29,7 @@ export function useAuthelia() {
}; };
const updateConfigMutation = useMutation({ const updateConfigMutation = useMutation({
mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }) =>
autheliaApi.updateConfig(data), autheliaApi.updateConfig(data),
onSuccess: invalidateAll, onSuccess: invalidateAll,
}); });

View File

@@ -11,6 +11,12 @@ export interface AutheliaStatus {
userCount: number; userCount: number;
clientCount: number; clientCount: number;
installed: boolean; installed: boolean;
smtp: {
host: string;
port: number;
username: string;
sender: string;
};
} }
export interface AutheliaUser { export interface AutheliaUser {
@@ -74,7 +80,7 @@ export const autheliaApi = {
async getConfig(): Promise<{ content: string }> { async getConfig(): Promise<{ content: string }> {
return apiClient.get('/api/v1/authelia/config'); return apiClient.get('/api/v1/authelia/config');
}, },
async updateConfig(data: { enabled?: boolean; domain?: string; defaultPolicy?: string }): Promise<{ message: string }> { async updateConfig(data: { enabled?: boolean; domain?: string; defaultPolicy?: string; smtpHost?: string; smtpPort?: number; smtpUsername?: string; smtpSender?: string; smtpPassword?: string }): Promise<{ message: string }> {
return apiClient.put('/api/v1/authelia/config', data); return apiClient.put('/api/v1/authelia/config', data);
}, },
async restart(): Promise<{ message: string }> { async restart(): Promise<{ message: string }> {