Add optional OIDC authentication with Keycloak, Authentik, and Pocket-ID support

This commit is contained in:
2026-01-19 22:09:54 +01:00
parent 62ab6dede3
commit d64eb3db95
25 changed files with 2028 additions and 37 deletions

238
internal/auth/oidc.go Normal file
View File

@@ -0,0 +1,238 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/swissmakers/fail2ban-ui/internal/config"
"golang.org/x/oauth2"
)
// OIDCClient holds the OIDC provider, verifier, and OAuth2 configuration
type OIDCClient struct {
Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
OAuth2Config *oauth2.Config
Config *config.OIDCConfig
}
// UserInfo represents the authenticated user information
type UserInfo struct {
ID string
Email string
Name string
Username string
}
var (
oidcClient *OIDCClient
)
// contextWithSkipVerify returns a context with an HTTP client that skips TLS verification if enabled
func contextWithSkipVerify(ctx context.Context, skipVerify bool) context.Context {
if !skipVerify {
return ctx
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
return oidc.ClientContext(ctx, client)
}
// InitializeOIDC sets up the OIDC client from configuration
func InitializeOIDC(cfg *config.OIDCConfig) (*OIDCClient, error) {
if cfg == nil || !cfg.Enabled {
return nil, nil
}
// Retry OIDC provider discovery with exponential backoff
// This handles cases where the provider isn't ready yet (e.g., Keycloak starting up)
maxRetries := 10
retryDelay := 2 * time.Second
var provider *oidc.Provider
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
// Create context with timeout for each attempt
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx = contextWithSkipVerify(ctx, cfg.SkipVerify)
// Try to discover OIDC provider
provider, err = oidc.NewProvider(ctx, cfg.IssuerURL)
cancel()
if err == nil {
// Success - provider discovered
break
}
// Log retry attempt (but don't fail yet)
config.DebugLog("OIDC provider discovery attempt %d/%d failed: %v, retrying in %v...", attempt+1, maxRetries, err, retryDelay)
if attempt < maxRetries-1 {
time.Sleep(retryDelay)
// Exponential backoff: increase delay for each retry
retryDelay = time.Duration(float64(retryDelay) * 1.5)
if retryDelay > 10*time.Second {
retryDelay = 10 * time.Second
}
}
}
if err != nil {
return nil, fmt.Errorf("failed to discover OIDC provider after %d attempts: %w", maxRetries, err)
}
// Create OAuth2 configuration
oauth2Config := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: cfg.Scopes,
}
// Create ID token verifier
verifier := provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
})
oidcClient = &OIDCClient{
Provider: provider,
Verifier: verifier,
OAuth2Config: oauth2Config,
Config: cfg,
}
config.DebugLog("OIDC authentication initialized with provider: %s, issuer: %s", cfg.Provider, cfg.IssuerURL)
return oidcClient, nil
}
// GetOIDCClient returns the initialized OIDC client
func GetOIDCClient() *OIDCClient {
return oidcClient
}
// IsEnabled returns whether OIDC is enabled
func IsEnabled() bool {
return oidcClient != nil && oidcClient.Config != nil && oidcClient.Config.Enabled
}
// GetAuthURL generates the authorization URL for OIDC login
func (c *OIDCClient) GetAuthURL(state string) string {
return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
// ExchangeCode exchanges the authorization code for tokens
func (c *OIDCClient) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
if c.OAuth2Config == nil {
return nil, fmt.Errorf("OIDC client not properly initialized")
}
ctx = contextWithSkipVerify(ctx, c.Config.SkipVerify)
token, err := c.OAuth2Config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
}
return token, nil
}
// VerifyToken verifies the ID token and extracts user information
func (c *OIDCClient) VerifyToken(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, fmt.Errorf("no id_token in token response")
}
ctx = contextWithSkipVerify(ctx, c.Config.SkipVerify)
// Verify the ID token
idToken, err := c.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
// Extract claims
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to extract claims: %w", err)
}
userInfo := &UserInfo{
ID: claims.Subject,
Email: claims.Email,
Name: claims.Name,
}
// Determine username based on configured claim
switch c.Config.UsernameClaim {
case "email":
userInfo.Username = claims.Email
case "preferred_username":
userInfo.Username = claims.PreferredUsername
if userInfo.Username == "" {
userInfo.Username = claims.Email // Fallback to email
}
default:
// Try to get the claim value dynamically
var claimValue interface{}
if err := idToken.Claims(&map[string]interface{}{
c.Config.UsernameClaim: &claimValue,
}); err == nil {
if str, ok := claimValue.(string); ok {
userInfo.Username = str
}
}
if userInfo.Username == "" {
userInfo.Username = claims.PreferredUsername
if userInfo.Username == "" {
userInfo.Username = claims.Email
}
}
}
// Fallback name construction
if userInfo.Name == "" {
if claims.GivenName != "" || claims.FamilyName != "" {
userInfo.Name = fmt.Sprintf("%s %s", claims.GivenName, claims.FamilyName)
userInfo.Name = strings.TrimSpace(userInfo.Name)
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
}
return userInfo, nil
}

206
internal/auth/session.go Normal file
View File

@@ -0,0 +1,206 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
sessionCookieName = "fail2ban_ui_session"
sessionKeyLength = 32 // AES-256
)
// Session represents a user session
type Session struct {
UserID string `json:"userID"`
Email string `json:"email"`
Name string `json:"name"`
Username string `json:"username"`
ExpiresAt time.Time `json:"expiresAt"`
}
var sessionSecret []byte
// InitializeSessionSecret initializes the session encryption secret
func InitializeSessionSecret(secret string) error {
if secret == "" {
return fmt.Errorf("session secret cannot be empty")
}
// Decode base64 secret or use directly if not base64
decoded, err := base64.URLEncoding.DecodeString(secret)
if err != nil {
// Not base64, use as-is (but ensure it's 32 bytes for AES-256)
if len(secret) < sessionKeyLength {
return fmt.Errorf("session secret must be at least %d bytes", sessionKeyLength)
}
// Use first 32 bytes
sessionSecret = []byte(secret[:sessionKeyLength])
} else {
if len(decoded) < sessionKeyLength {
return fmt.Errorf("decoded session secret must be at least %d bytes", sessionKeyLength)
}
sessionSecret = decoded[:sessionKeyLength]
}
return nil
}
// CreateSession creates a new encrypted session cookie
func CreateSession(w http.ResponseWriter, r *http.Request, userInfo *UserInfo, maxAge int) error {
session := &Session{
UserID: userInfo.ID,
Email: userInfo.Email,
Name: userInfo.Name,
Username: userInfo.Username,
ExpiresAt: time.Now().Add(time.Duration(maxAge) * time.Second),
}
// Serialize session to JSON
sessionData, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
// Encrypt session data
encrypted, err := encrypt(sessionData)
if err != nil {
return fmt.Errorf("failed to encrypt session: %w", err)
}
// Determine if we're using HTTPS
isSecure := r != nil && (r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https")
// Create secure cookie
cookie := &http.Cookie{
Name: sessionCookieName,
Value: encrypted,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: isSecure, // Only secure over HTTPS
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
return nil
}
// GetSession retrieves and validates a session from the cookie
func GetSession(r *http.Request) (*Session, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, fmt.Errorf("no session cookie: %w", err)
}
// Decrypt session data
decrypted, err := decrypt(cookie.Value)
if err != nil {
return nil, fmt.Errorf("failed to decrypt session: %w", err)
}
// Deserialize session
var session Session
if err := json.Unmarshal(decrypted, &session); err != nil {
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
}
// Check if session is expired
if time.Now().After(session.ExpiresAt) {
return nil, fmt.Errorf("session expired")
}
return &session, nil
}
// DeleteSession clears the session cookie
func DeleteSession(w http.ResponseWriter, r *http.Request) {
// Determine if we're using HTTPS
isSecure := r != nil && (r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https")
cookie := &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecure, // Only secure over HTTPS
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
}
// encrypt encrypts data using AES-GCM
func encrypt(plaintext []byte) (string, error) {
block, err := aes.NewCipher(sessionSecret)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.URLEncoding.EncodeToString(ciphertext), nil
}
// decrypt decrypts data using AES-GCM
func decrypt(ciphertext string) ([]byte, error) {
data, err := base64.URLEncoding.DecodeString(ciphertext)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(sessionSecret)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}

View File

@@ -87,6 +87,22 @@ type AppSettings struct {
ConsoleOutput bool `json:"consoleOutput"` // Enable console output in web UI (default: false)
}
// OIDCConfig holds OIDC authentication configuration
type OIDCConfig struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"` // keycloak, authentik, pocketid
IssuerURL string `json:"issuerURL"`
ClientID string `json:"clientID"`
ClientSecret string `json:"clientSecret"`
RedirectURL string `json:"redirectURL"`
Scopes []string `json:"scopes"` // Default: ["openid", "profile", "email"]
SessionSecret string `json:"sessionSecret"` // For session encryption
SessionMaxAge int `json:"sessionMaxAge"` // Session timeout in seconds
SkipVerify bool `json:"skipVerify"` // Skip TLS verification (dev only)
UsernameClaim string `json:"usernameClaim"` // Claim to use as username
LogoutURL string `json:"logoutURL"` // Provider logout URL (optional)
}
type AdvancedActionsConfig struct {
Enabled bool `json:"enabled"`
Threshold int `json:"threshold"`
@@ -1428,6 +1444,103 @@ func GetBindAddressFromEnv() (string, bool) {
return "0.0.0.0", false
}
// GetOIDCConfigFromEnv reads OIDC configuration from environment variables
// Returns nil if OIDC is not enabled
func GetOIDCConfigFromEnv() (*OIDCConfig, error) {
enabled := os.Getenv("OIDC_ENABLED")
if enabled != "true" && enabled != "1" {
return nil, nil // OIDC not enabled
}
config := &OIDCConfig{
Enabled: true,
}
// Required fields
config.Provider = os.Getenv("OIDC_PROVIDER")
if config.Provider == "" {
return nil, fmt.Errorf("OIDC_PROVIDER environment variable is required when OIDC_ENABLED=true")
}
if config.Provider != "keycloak" && config.Provider != "authentik" && config.Provider != "pocketid" {
return nil, fmt.Errorf("OIDC_PROVIDER must be one of: keycloak, authentik, pocketid")
}
config.IssuerURL = os.Getenv("OIDC_ISSUER_URL")
if config.IssuerURL == "" {
return nil, fmt.Errorf("OIDC_ISSUER_URL environment variable is required when OIDC_ENABLED=true")
}
config.ClientID = os.Getenv("OIDC_CLIENT_ID")
if config.ClientID == "" {
return nil, fmt.Errorf("OIDC_CLIENT_ID environment variable is required when OIDC_ENABLED=true")
}
config.ClientSecret = os.Getenv("OIDC_CLIENT_SECRET")
// If client secret is "auto-configured", try to read from file
// This is primarily used for Keycloak's automatic client setup in development
if config.ClientSecret == "auto-configured" {
secretFile := os.Getenv("OIDC_CLIENT_SECRET_FILE")
if secretFile == "" {
secretFile = "/config/keycloak-client-secret" // Default path for Keycloak auto-configuration
}
if secretBytes, err := os.ReadFile(secretFile); err == nil {
config.ClientSecret = strings.TrimSpace(string(secretBytes))
} else {
return nil, fmt.Errorf("OIDC_CLIENT_SECRET is set to 'auto-configured' but could not read from file %s: %w", secretFile, err)
}
}
if config.ClientSecret == "" {
return nil, fmt.Errorf("OIDC_CLIENT_SECRET environment variable is required when OIDC_ENABLED=true")
}
config.RedirectURL = os.Getenv("OIDC_REDIRECT_URL")
if config.RedirectURL == "" {
return nil, fmt.Errorf("OIDC_REDIRECT_URL environment variable is required when OIDC_ENABLED=true")
}
// Optional fields with defaults
scopesEnv := os.Getenv("OIDC_SCOPES")
if scopesEnv != "" {
config.Scopes = strings.Split(scopesEnv, ",")
for i := range config.Scopes {
config.Scopes[i] = strings.TrimSpace(config.Scopes[i])
}
} else {
config.Scopes = []string{"openid", "profile", "email"}
}
// Set default session max age
config.SessionMaxAge = 3600 // Default: 1 hour
sessionMaxAgeEnv := os.Getenv("OIDC_SESSION_MAX_AGE")
if sessionMaxAgeEnv != "" {
if maxAge, err := strconv.Atoi(sessionMaxAgeEnv); err == nil && maxAge > 0 {
config.SessionMaxAge = maxAge
}
}
config.SessionSecret = os.Getenv("OIDC_SESSION_SECRET")
if config.SessionSecret == "" {
// Generate a random session secret
secretBytes := make([]byte, 32)
if _, err := rand.Read(secretBytes); err != nil {
return nil, fmt.Errorf("failed to generate session secret: %w", err)
}
config.SessionSecret = base64.URLEncoding.EncodeToString(secretBytes)
}
skipVerifyEnv := os.Getenv("OIDC_SKIP_VERIFY")
config.SkipVerify = (skipVerifyEnv == "true" || skipVerifyEnv == "1")
config.UsernameClaim = os.Getenv("OIDC_USERNAME_CLAIM")
if config.UsernameClaim == "" {
config.UsernameClaim = "preferred_username" // Default claim
}
config.LogoutURL = os.Getenv("OIDC_LOGOUT_URL") // Optional
return config, nil
}
func GetSettings() AppSettings {
settingsLock.RLock()
defer settingsLock.RUnlock()

View File

@@ -368,6 +368,14 @@
"toast.ban.title": "Neue Blockierung aufgetreten",
"toast.ban.action": "gesperrt in",
"toast.unban.title": "IP entsperrt",
"toast.unban.action": "entsperrt von"
"toast.unban.action": "entsperrt von",
"auth.login_title": "Bei Fail2ban UI anmelden",
"auth.login_description": "Bitte authentifizieren Sie sich, um auf die Verwaltungsoberfläche zuzugreifen",
"auth.login_button": "Mit OIDC anmelden",
"auth.logging_in": "Weiterleitung zur Anmeldung...",
"auth.logout": "Abmelden",
"auth.user_info": "Benutzerinformationen",
"auth.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"auth.login_required": "Authentifizierung erforderlich"
}

View File

@@ -368,6 +368,14 @@
"toast.ban.title": "Neui Blockierig ufträte",
"toast.ban.action": "gsperrt i",
"toast.unban.title": "IP entsperrt",
"toast.unban.action": "entsperrt vo"
"toast.unban.action": "entsperrt vo",
"auth.login_title": "Bi Fail2ban UI amäudä",
"auth.login_description": "Bitte log di ih, zum uf d Verwaltigsoberflächi zuezgriifä",
"auth.login_button": "Mit OIDC amäudä",
"auth.logging_in": "Wiiterleitig zur Amäudig...",
"auth.logout": "Abmäudä",
"auth.user_info": "Benutzerinformationä",
"auth.session_expired": "Ihri Sitzig isch abglaufä. Bitte mäudä di erneut a.",
"auth.login_required": "Authentifizierig erforderlich"
}

View File

@@ -368,6 +368,14 @@
"toast.ban.title": "New block occurred",
"toast.ban.action": "banned in",
"toast.unban.title": "IP unblocked",
"toast.unban.action": "unblocked from"
"toast.unban.action": "unblocked from",
"auth.login_title": "Sign in to Fail2ban UI",
"auth.login_description": "Please authenticate to access the management interface",
"auth.login_button": "Sign in with OIDC",
"auth.logging_in": "Redirecting to login...",
"auth.logout": "Logout",
"auth.user_info": "User Information",
"auth.session_expired": "Your session has expired. Please log in again.",
"auth.login_required": "Authentication required"
}

View File

@@ -368,5 +368,13 @@
"toast.ban.title": "Nuevo bloqueo ocurrido",
"toast.ban.action": "bloqueado en",
"toast.unban.title": "IP desbloqueada",
"toast.unban.action": "desbloqueada de"
"toast.unban.action": "desbloqueada de",
"auth.login_title": "Iniciar sesión en Fail2ban UI",
"auth.login_description": "Por favor, autentíquese para acceder a la interfaz de gestión",
"auth.login_button": "Iniciar sesión con OIDC",
"auth.logging_in": "Redirigiendo al inicio de sesión...",
"auth.logout": "Cerrar sesión",
"auth.user_info": "Información del usuario",
"auth.session_expired": "Su sesión ha expirado. Por favor, inicie sesión nuevamente.",
"auth.login_required": "Autenticación requerida"
}

View File

@@ -368,5 +368,13 @@
"toast.ban.title": "Nouveau blocage survenu",
"toast.ban.action": "banni dans",
"toast.unban.title": "IP débloquée",
"toast.unban.action": "débloquée de"
"toast.unban.action": "débloquée de",
"auth.login_title": "Se connecter à Fail2ban UI",
"auth.login_description": "Veuillez vous authentifier pour accéder à l'interface de gestion",
"auth.login_button": "Se connecter avec OIDC",
"auth.logging_in": "Redirection vers la connexion...",
"auth.logout": "Déconnexion",
"auth.user_info": "Informations utilisateur",
"auth.session_expired": "Votre session a expiré. Veuillez vous reconnecter.",
"auth.login_required": "Authentification requise"
}

View File

@@ -368,5 +368,13 @@
"toast.ban.title": "Nuovo blocco verificato",
"toast.ban.action": "bannato in",
"toast.unban.title": "IP sbloccato",
"toast.unban.action": "sbloccato da"
"toast.unban.action": "sbloccato da",
"auth.login_title": "Accedi a Fail2ban UI",
"auth.login_description": "Si prega di autenticarsi per accedere all'interfaccia di gestione",
"auth.login_button": "Accedi con OIDC",
"auth.logging_in": "Reindirizzamento al login...",
"auth.logout": "Esci",
"auth.user_info": "Informazioni utente",
"auth.session_expired": "La tua sessione è scaduta. Si prega di accedere nuovamente.",
"auth.login_required": "Autenticazione richiesta"
}