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

215
README.md
View File

@@ -97,15 +97,20 @@ Modern enterprises face increasing security challenges with generally distribute
- **Email Templates**: Currently features a Modern and classic email design (more to come)
- **Alert Aggregation**: This feature is planned for the SIEM-modul
### 🔐 Enterprise Security
### 🔐 Enterprise Security & Authentication
**Hardened for Production Environments**
- **OIDC Authentication**: Optional OpenID Connect authentication supporting Keycloak, Authentik, and Pocket-ID
- Secure session management with encrypted cookies (AES-GCM)
- Automatic logout with provider integration
- CSRF protection via state parameters
- Configurable session timeouts
- Automatic Keycloak client configuration for development environment
- **SELinux Support**: Full compatibility with SELinux-enabled systems and pre-created custom policies
- **Container Security**: Secure containerized deployment with proper best-practisies
- **Container Security**: Secure containerized deployment with proper best-practices
- **Least Privilege**: Only minimal permissions are used using FACLs and special sudo-rules
- **Audit Logging**: Comprehensive logging for compliance and forensics also in the future planned to ingest into a Elastic SIEM
- **Encrypted Communications Only**: Secure data transmission for all remote operations will be enforced
- **Audit Logging**: Comprehensive logging for compliance and forensics (planned: Elastic SIEM integration)
### 🌐 Internationalization
@@ -117,8 +122,10 @@ Modern enterprises face increasing security challenges with generally distribute
### 📱 Modern User Experience
- **Responsive Design**: Full functionality also on mobile devices
- **Progressive Web App**: Works also in a no-internet / offline and restricted environment with local CSS/JS builds only
- **Login Interface**: Modern, clean authentication UI with OIDC integration
- **Progressive Web App**: Works in offline/restricted environments with local CSS/JS builds only
- **Fast Performance**: Go-based backend with minimal resource footprint
- **Real-Time Updates**: WebSocket-based live event streaming for UI-actions and changes
---
@@ -327,6 +334,74 @@ podman run -d \
swissmakers/fail2ban-ui:latest
```
**OIDC Authentication Configuration (Optional)**
Enable OIDC authentication by setting the required environment variables. This protects the web UI with your identity provider. The logout flow automatically redirects back to the login page after successful provider logout.
**Basic Configuration:**
```bash
podman run -d \
--name fail2ban-ui \
--network=host \
-e OIDC_ENABLED=true \
-e OIDC_PROVIDER=keycloak \
-e OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm \
-e OIDC_CLIENT_ID=fail2ban-ui \
-e OIDC_CLIENT_SECRET=your-client-secret \
-e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback \
-v /opt/podman-fail2ban-ui:/config:Z \
-v /etc/fail2ban:/etc/fail2ban:Z \
-v /var/log:/var/log:ro \
-v /var/run/fail2ban:/var/run/fail2ban \
swissmakers/fail2ban-ui:latest
```
**Note:** The logout URL is automatically constructed for all supported providers. For Keycloak, ensure the post-logout redirect URI is configured in your client settings (see [Security Notes](#-security-notes) for details).
**Provider-Specific Examples:**
**Keycloak:**
```bash
-e OIDC_ENABLED=true \
-e OIDC_PROVIDER=keycloak \
-e OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm \
-e OIDC_CLIENT_ID=fail2ban-ui \
-e OIDC_CLIENT_SECRET=your-client-secret \
-e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
# OIDC_LOGOUT_URL is optional - automatically constructed if not set
# Ensure "Valid post logout redirect URIs" in Keycloak includes: https://fail2ban-ui.example.com/auth/login
```
**Authentik:**
```bash
-e OIDC_ENABLED=true \
-e OIDC_PROVIDER=authentik \
-e OIDC_ISSUER_URL=https://authentik.example.com/application/o/your-client-slug/ \
-e OIDC_CLIENT_ID=fail2ban-ui \
-e OIDC_CLIENT_SECRET=your-client-secret \
-e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
```
**Pocket-ID:**
```bash
-e OIDC_ENABLED=true \
-e OIDC_PROVIDER=pocketid \
-e OIDC_ISSUER_URL=https://pocket-id.example.com \
-e OIDC_CLIENT_ID=fail2ban-ui-client \
-e OIDC_CLIENT_SECRET=your-secret \
-e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
```
**Advanced Options:**
```bash
-e OIDC_SCOPES=openid,profile,email,groups \
-e OIDC_SESSION_MAX_AGE=7200 \
-e OIDC_USERNAME_CLAIM=preferred_username \
-e OIDC_SESSION_SECRET=your-32-byte-secret
```
**Note:** If `OIDC_SESSION_SECRET` is not provided, a random secret will be generated on startup. For production, it's recommended to set a fixed secret.
Access the web interface at `http://localhost:3080`.
**Disable External IP Lookup** (Privacy)
@@ -377,6 +452,22 @@ go build -o fail2ban-ui ./cmd/server/main.go
**📖 [Complete Systemd Setup Guide](./deployment/systemd/README.md)**
**OIDC Authentication for Systemd Deployment:**
When running as a systemd service, set OIDC environment variables in the systemd service file:
```ini
[Service]
Environment="OIDC_ENABLED=true"
Environment="OIDC_PROVIDER=keycloak"
Environment="OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm"
Environment="OIDC_CLIENT_ID=fail2ban-ui"
Environment="OIDC_CLIENT_SECRET=your-client-secret"
Environment="OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback"
```
See the [Security Notes](#-security-notes) section for complete OIDC configuration details.
### First Launch
1. **Access the Web Interface**
@@ -565,6 +656,83 @@ sudo setfacl -dRm u:sa_fail2ban:rwX /etc/fail2ban
#### Authentication and Authorization
##### OIDC Authentication (Optional)
Fail2ban UI supports optional OIDC (OpenID Connect) authentication via environment variables. When enabled, all routes are protected and users must authenticate through the configured OIDC provider. The authentication flow includes automatic logout handling and redirects back to the login page.
**Supported Providers:**
- **Keycloak**: Enterprise identity and access management (recommended)
- **Authentik**: Full-featured identity provider with OIDC support
- **Pocket-ID**: Modern identity provider with passkey support
**Development Setup:**
For local development and testing, a complete OIDC environment is available in `development/oidc/` with automatic Keycloak client configuration. See [Development Documentation](./development/oidc/README.md) for details.
**Required Environment Variables (when OIDC enabled):**
```bash
OIDC_ENABLED=true
OIDC_PROVIDER=keycloak|authentik|pocketid
OIDC_ISSUER_URL=https://auth.example.com
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
```
**Optional Environment Variables:**
```bash
OIDC_SCOPES=openid,profile,email # Default: openid,profile,email
OIDC_SESSION_SECRET=your-secret-key # Auto-generated if not provided
OIDC_SESSION_MAX_AGE=3600 # Session timeout in seconds (default: 3600)
OIDC_USERNAME_CLAIM=preferred_username # Claim to use as username (default: preferred_username)
OIDC_LOGOUT_URL=https://auth.example.com/logout # Provider logout URL (optional, auto-constructed if not set)
OIDC_CLIENT_SECRET_FILE=/path/to/secret-file # Path to client secret file (for auto-configuration)
OIDC_SKIP_VERIFY=false # Skip TLS verification (dev only, default: false)
```
**Configuration Examples:**
**Keycloak:**
```bash
OIDC_ENABLED=true
OIDC_PROVIDER=keycloak
OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm
OIDC_CLIENT_ID=fail2ban-ui
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
# OIDC_LOGOUT_URL is optional - automatically constructed if not set
# For Keycloak, ensure "Valid post logout redirect URIs" includes: https://fail2ban-ui.example.com/auth/login
```
**Authentik:**
```bash
OIDC_ENABLED=true
OIDC_PROVIDER=authentik
OIDC_ISSUER_URL=https://authentik.example.com/application/o/your-client-slug/
OIDC_CLIENT_ID=fail2ban-ui
OIDC_CLIENT_SECRET=your-client-secret
OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
```
**Pocket-ID:**
```bash
OIDC_ENABLED=true
OIDC_PROVIDER=pocketid
OIDC_ISSUER_URL=https://pocket-id.example.com
OIDC_CLIENT_ID=fail2ban-ui-client
OIDC_CLIENT_SECRET=your-secret
OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
```
**Security Notes:**
- Session cookies are encrypted using AES-GCM
- Sessions are httpOnly and secure (automatically detects HTTPS/HTTP context)
- CSRF protection via state parameter in OAuth flow
- Session timeout is configurable (default: 1 hour)
- Automatic logout URL construction for all supported providers
- When OIDC is disabled, the application works without authentication (backward compatible)
- Callback endpoints (`/api/ban`, `/api/unban`) remain accessible without OIDC authentication (protected by callback secret)
**Other Security Practices:**
- **SSH Key Management**: Use strong SSH keys like 4096-bit RSA or even better Ed25519. When using RSA no smaller bit size please.
- **Service Accounts**: Use dedicated service accounts, not personal accounts
- **Sudoers Configuration**: Minimal sudo permissions, no passwordless full sudo
@@ -677,6 +845,13 @@ Fail2Ban UI provides a RESTful API for programmatic access:
- `GET /api/filters` - List available filters
- `POST /api/filters/test` - Test filter against log lines
**Authentication (OIDC):**
- `GET /auth/login` - Show login page or redirect to OIDC provider
- `GET /auth/callback` - OIDC callback handler
- `GET /auth/logout` - Logout and redirect to provider logout
- `GET /auth/status` - Get authentication status
- `GET /auth/user` - Get current user information
**Service Control:**
- `POST /api/fail2ban/restart` - Restart Fail2Ban service
@@ -720,6 +895,36 @@ journalctl -u fail2ban-ui.service -f
3. Add remote server via SSH or API agent
4. Verify server connection status
#### OIDC Authentication Issues
**Symptoms:** Cannot login, redirected to provider but authentication fails
**Solution / Check:**
1. **Verify OIDC Configuration:**
```bash
# Check environment variables
podman exec fail2ban-ui env | grep OIDC
```
2. **Check Provider Connectivity:**
- Verify `OIDC_ISSUER_URL` is accessible
- Check that issuer URL matches provider's discovery document
- For Keycloak: Ensure realm exists and is enabled
3. **Verify Client Configuration:**
- Client ID and secret must match provider configuration
- Redirect URI must exactly match: `{your-url}/auth/callback`
- For Keycloak: Ensure "Valid post logout redirect URIs" includes `{your-url}/auth/login`
4. **Check Logs:**
```bash
podman logs fail2ban-ui | grep -i oidc
```
5. **Development Environment:**
- See [Development OIDC Setup](./development/oidc/README.md) for complete setup guide
- Automatic Keycloak client configuration available in development environment
#### SSH Connection Issues
**Symptoms:** Cannot connect to remote server

View File

@@ -26,6 +26,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/swissmakers/fail2ban-ui/internal/auth"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
"github.com/swissmakers/fail2ban-ui/internal/storage"
@@ -49,6 +50,23 @@ func main() {
log.Fatalf("failed to initialise fail2ban connectors: %v", err)
}
// Initialize OIDC authentication if enabled
oidcConfig, err := config.GetOIDCConfigFromEnv()
if err != nil {
log.Fatalf("failed to load OIDC configuration: %v", err)
}
if oidcConfig != nil && oidcConfig.Enabled {
// Initialize session secret
if err := auth.InitializeSessionSecret(oidcConfig.SessionSecret); err != nil {
log.Fatalf("failed to initialize session secret: %v", err)
}
// Initialize OIDC client
if _, err := auth.InitializeOIDC(oidcConfig); err != nil {
log.Fatalf("failed to initialize OIDC: %v", err)
}
log.Println("OIDC authentication enabled")
}
// Set Gin mode based on the debug flag in settings.
if settings.Debug {
gin.SetMode(gin.DebugMode)

View File

@@ -84,12 +84,16 @@ podman compose up -d
**Important:**
- Without setting these, redirect URIs will use `localhost` which won't work from remote browsers
- After changing these values, you may need to recreate the Keycloak client:
- After changing these values, you may need to recreate the Keycloak client to update redirect URIs:
```bash
podman compose down
rm -rf config/keycloak-client-secret
podman compose up -d
```
Or manually update the client in Keycloak admin console:
- Go to Clients → fail2ban-ui (name of the client)
- Update "Valid redirect URIs" and "Valid post logout redirect URIs"
- Save
## Setup Instructions
@@ -126,9 +130,12 @@ The `keycloak-init` container will:
- Wait for Keycloak to be ready
- Automatically create the `fail2ban-ui` OIDC client
- Configure redirect URIs and web origins
- Configure post-logout redirect URI (for proper logout flow)
- Save the client secret to `/config/keycloak-client-secret`
- Fail2ban-ui will automatically read the secret from this file
**Note:** If you update `PUBLIC_FRONTEND_URL` after the client has been created, you may need to delete the existing client and let `keycloak-init` recreate it, or manually update the client in Keycloak's admin console to include the new post-logout redirect URI.
**If you see "Client not found" error:**
This means the `keycloak-init` container hasn't run yet or failed. To fix:

View File

@@ -13,6 +13,7 @@ CLIENT_SECRET="${CLIENT_SECRET:-}"
# Use PUBLIC_FRONTEND_URL if provided, otherwise default to localhost
PUBLIC_FRONTEND_URL="${PUBLIC_FRONTEND_URL:-http://localhost:3080}"
REDIRECT_URI="${REDIRECT_URI:-${PUBLIC_FRONTEND_URL}/auth/callback}"
POST_LOGOUT_REDIRECT_URI="${POST_LOGOUT_REDIRECT_URI:-${PUBLIC_FRONTEND_URL}/auth/login}"
WEB_ORIGIN="${WEB_ORIGIN:-${PUBLIC_FRONTEND_URL}}"
# Extract host and port from KEYCLOAK_URL for health check
@@ -83,6 +84,9 @@ if [ -n "$EXISTING_CLIENT" ]; then
\"clientAuthenticatorType\": \"client-secret\",
\"redirectUris\": [\"${REDIRECT_URI}\"],
\"webOrigins\": [\"${WEB_ORIGIN}\"],
\"attributes\": {
\"post.logout.redirect.uris\": \"${POST_LOGOUT_REDIRECT_URI}\"
},
\"protocol\": \"openid-connect\",
\"publicClient\": false,
\"standardFlowEnabled\": true,
@@ -103,6 +107,9 @@ else
\"clientAuthenticatorType\": \"client-secret\",
\"redirectUris\": [\"${REDIRECT_URI}\"],
\"webOrigins\": [\"${WEB_ORIGIN}\"],
\"attributes\": {
\"post.logout.redirect.uris\": \"${POST_LOGOUT_REDIRECT_URI}\"
},
\"protocol\": \"openid-connect\",
\"publicClient\": false,
\"standardFlowEnabled\": true,
@@ -148,6 +155,7 @@ echo "Client ID: ${CLIENT_ID}"
echo "Client Secret: ${CLIENT_SECRET}"
echo "Realm: ${REALM}"
echo "Redirect URI: ${REDIRECT_URI}"
echo "Post Logout Redirect URI: ${POST_LOGOUT_REDIRECT_URI}"
echo "=========================================="
# Save secret to shared volume for fail2ban-ui to read

View File

@@ -36,13 +36,71 @@ services:
privileged: true # needed because the fail2ban-ui container needs to modify the fail2ban config owned by root inside the linuxserver-fail2ban container
network_mode: host
environment:
# Optional: Change this to use a different port for the web interface (defaults is 8080)
# ============================================
# Basic Configuration
# ============================================
# Optional: Change this to use a different port for the web interface (default: 8080)
- PORT=3080
# Optional: Bind to a specific IP address (default: 0.0.0.0)
# This is useful when running with host networking to prevent exposing
# the web UI to unprotected networks. Set to a specific IP (e.g., 127.0.0.1
# or a specific interface IP) to restrict access.
# - BIND_ADDRESS=127.0.0.1
# ============================================
# Privacy Settings
# ============================================
# Optional: Disable external IP lookup for privacy (default: false).
# When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made.
# - DISABLE_EXTERNAL_IP_LOOKUP=true
# ============================================
# OIDC Authentication (Optional)
# ============================================
# Enable OIDC authentication to protect the web UI
# - OIDC_ENABLED=true
# OIDC Provider: keycloak, authentik, or pocketid
# - OIDC_PROVIDER=keycloak
# OIDC Issuer URL (required when OIDC_ENABLED=true)
# Examples:
# Keycloak: https://keycloak.example.com/realms/your-realm
# Authentik: https://authentik.example.com/application/o/your-client-slug/
# Pocket-ID: https://pocket-id.example.com
# - OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm
# OIDC Client ID (required when OIDC_ENABLED=true)
# - OIDC_CLIENT_ID=fail2ban-ui
# OIDC Client Secret (required when OIDC_ENABLED=true)
# For Keycloak auto-configuration (development only), use:
# - OIDC_CLIENT_SECRET=auto-configured
# - OIDC_CLIENT_SECRET_FILE=/config/keycloak-client-secret
# Default for production:
# - OIDC_CLIENT_SECRET=your-client-secret
# OIDC Redirect URL (required when OIDC_ENABLED=true)
# This must match the redirect URI configured in your OIDC provider
# - OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
# Optional: OIDC Scopes (default: openid,profile,email)
# Comma-separated list of scopes to request
# - OIDC_SCOPES=openid,profile,email,groups
# Optional: Session timeout in seconds (default: 3600 = 1 hour)
# - OIDC_SESSION_MAX_AGE=7200
# Optional: Session secret for cookie encryption
# If not provided, a random secret will be generated on startup.
# For production, it's recommended to set a fixed secret (32 bytes, base64-encoded)
# - OIDC_SESSION_SECRET=your-32-byte-base64-encoded-secret
# Optional: Skip TLS verification (dev only, default: false)
# Only use in development environments!
# - OIDC_SKIP_VERIFY=true
# Optional: Username claim (default: preferred_username)
# The claim to use as the username (e.g., email, preferred_username, sub)
# - OIDC_USERNAME_CLAIM=preferred_username
# Optional: Provider logout URL
# If not set, the logout URL will be auto-constructed using the standard OIDC logout endpoint: {issuer}/protocol/openid-connect/logout
# Examples:
# Keycloak: https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
# Authentik: https://authentik.example.com/application/o/your-client-slug/protocol/openid-connect/logout
# Pocket-ID: https://pocket-id.example.com/protocol/openid-connect/logout
# - OIDC_LOGOUT_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
volumes:
# Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container
- ./config:/config:Z

View File

@@ -17,13 +17,71 @@ services:
network_mode: host
environment:
# Optional: Change this to use a different port for the web interface (defaults is 8080)
# ============================================
# Basic Configuration
# ============================================
# Optional: Change this to use a different port for the web interface (default: 8080)
- PORT=8080
# Optional: Bind to a specific IP address (default: 0.0.0.0)
# This is useful when running with host networking to prevent exposing
# the web UI to unprotected networks. Set to a specific IP (e.g., 127.0.0.1
# or a specific interface IP) to restrict access.
# - BIND_ADDRESS=127.0.0.1
# ============================================
# Privacy Settings
# ============================================
# Optional: Disable external IP lookup for privacy (default: false).
# When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made.
# - DISABLE_EXTERNAL_IP_LOOKUP=true
# ============================================
# OIDC Authentication (Optional)
# ============================================
# Enable OIDC authentication to protect the web UI
# - OIDC_ENABLED=true
# OIDC Provider: keycloak, authentik, or pocketid
# - OIDC_PROVIDER=keycloak
# OIDC Issuer URL (required when OIDC_ENABLED=true)
# Examples:
# Keycloak: https://keycloak.example.com/realms/your-realm
# Authentik: https://authentik.example.com/application/o/your-client-slug/
# Pocket-ID: https://pocket-id.example.com
# - OIDC_ISSUER_URL=https://keycloak.example.com/realms/your-realm
# OIDC Client ID (required when OIDC_ENABLED=true)
# - OIDC_CLIENT_ID=fail2ban-ui
# OIDC Client Secret (required when OIDC_ENABLED=true)
# For Keycloak auto-configuration (development only), use:
# - OIDC_CLIENT_SECRET=auto-configured
# - OIDC_CLIENT_SECRET_FILE=/config/keycloak-client-secret
# Default for production:
# - OIDC_CLIENT_SECRET=your-client-secret
# OIDC Redirect URL (required when OIDC_ENABLED=true)
# This must match the redirect URI configured in your OIDC provider
# - OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
# Optional: OIDC Scopes (default: openid,profile,email)
# Comma-separated list of scopes to request
# - OIDC_SCOPES=openid,profile,email,groups
# Optional: Session timeout in seconds (default: 3600 = 1 hour)
# - OIDC_SESSION_MAX_AGE=7200
# Optional: Session secret for cookie encryption
# If not provided, a random secret will be generated on startup.
# For production, it's recommended to set a fixed secret (32 bytes, base64-encoded)
# - OIDC_SESSION_SECRET=your-32-byte-base64-encoded-secret
# Optional: Skip TLS verification (dev only, default: false)
# Only use in development environments!
# - OIDC_SKIP_VERIFY=true
# Optional: Username claim (default: preferred_username)
# The claim to use as the username (e.g., email, preferred_username, sub)
# - OIDC_USERNAME_CLAIM=preferred_username
# Optional: Provider logout URL
# If not set, the logout URL will be auto-constructed using the standard OIDC logout endpoint: {issuer}/protocol/openid-connect/logout
# Examples:
# Keycloak: https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
# Authentik: https://authentik.example.com/application/o/your-client-slug/protocol/openid-connect/logout
# Pocket-ID: https://pocket-id.example.com/protocol/openid-connect/logout
# - OIDC_LOGOUT_URL=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/logout
volumes:
# Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container
- /opt/podman-fail2ban-ui:/config:Z

9
go.mod
View File

@@ -5,10 +5,15 @@ go 1.24.0
toolchain go1.24.6
require (
github.com/coreos/go-oidc/v3 v3.16.0
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.26.0
github.com/gorilla/websocket v1.5.3
github.com/likexian/whois v1.15.6
github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.33.0
golang.org/x/oauth2 v0.28.0
golang.org/x/text v0.32.0
modernc.org/sqlite v1.33.1
)
@@ -19,16 +24,15 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/likexian/whois v1.15.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -40,7 +44,6 @@ require (
golang.org/x/arch v0.13.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect

27
go.sum
View File

@@ -6,6 +6,8 @@ github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -17,6 +19,8 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -27,8 +31,8 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
@@ -46,6 +50,8 @@ github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/likexian/gokit v0.25.15 h1:QjospM1eXhdMMHwZRpMKKAHY/Wig9wgcREmLtf9NslY=
github.com/likexian/gokit v0.25.15/go.mod h1:S2QisdsxLEHWeD/XI0QMVeggp+jbxYqUxMvSBil7MRg=
github.com/likexian/whois v1.15.6 h1:hizngFHJTNQDlhwhU+FEGyPGxy8bRnf25gHDNrSB4Ag=
github.com/likexian/whois v1.15.6/go.mod h1:vx3kt3sZ4mx4XFgpaNp3GXQCZQIzAoyrUAkRtJwoM2I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -85,30 +91,23 @@ golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

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"
}

99
pkg/web/auth.go Normal file
View File

@@ -0,0 +1,99 @@
// 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 web
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/swissmakers/fail2ban-ui/internal/auth"
)
// AuthMiddleware protects routes requiring authentication
// If OIDC is enabled, validates session and redirects to login if not authenticated
// If OIDC is disabled, allows all requests
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Check if OIDC is enabled
if !auth.IsEnabled() {
// OIDC not enabled, allow request
c.Next()
return
}
// Check if this is a public route
path := c.Request.URL.Path
if isPublicRoute(path) {
c.Next()
return
}
// Validate session
session, err := auth.GetSession(c.Request)
if err != nil {
// No valid session, redirect to login
if isAPIRequest(c) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
// For HTML requests, redirect to login
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
return
}
// Store session in context for handlers to access
c.Set("session", session)
c.Set("userID", session.UserID)
c.Set("userEmail", session.Email)
c.Set("userName", session.Name)
c.Set("username", session.Username)
c.Next()
}
}
// isPublicRoute checks if the path is a public route that doesn't require authentication
func isPublicRoute(path string) bool {
publicRoutes := []string{
"/auth/login",
"/auth/callback",
"/auth/logout",
"/auth/status",
"/api/ban",
"/api/unban",
"/api/ws",
"/static/",
"/locales/",
}
for _, route := range publicRoutes {
if strings.HasPrefix(path, route) {
return true
}
}
return false
}
// isAPIRequest checks if the request is an API request (JSON expected)
func isAPIRequest(c *gin.Context) bool {
accept := c.GetHeader("Accept")
return strings.Contains(accept, "application/json") || strings.HasPrefix(c.Request.URL.Path, "/api/")
}

View File

@@ -19,8 +19,10 @@ package web
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -30,6 +32,7 @@ import (
"net"
"net/http"
"net/smtp"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -42,6 +45,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/oschwald/maxminddb-golang"
"github.com/swissmakers/fail2ban-ui/internal/auth"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
"github.com/swissmakers/fail2ban-ui/internal/integrations"
@@ -1116,8 +1120,8 @@ func shouldAlertForCountry(country string, alertCountries []string) bool {
return false
}
// IndexHandler serves the HTML page
func IndexHandler(c *gin.Context) {
// renderIndexPage renders the index.html template with common data
func renderIndexPage(c *gin.Context) {
// Check if external IP lookup is disabled via environment variable
// Default is enabled (false means enabled, true means disabled)
disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1"
@@ -3007,3 +3011,241 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
}
return nil, nil
}
// *******************************************************************
// * OIDC Authentication Handlers *
// *******************************************************************
// LoginHandler shows the login page or initiates the OIDC login flow
// If action=redirect query parameter is present, redirects to OIDC provider
// Otherwise, renders the login page
func LoginHandler(c *gin.Context) {
oidcClient := auth.GetOIDCClient()
if oidcClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"})
return
}
// Check if this is a redirect action (triggered by clicking the login button)
if c.Query("action") == "redirect" {
// Generate state parameter for CSRF protection
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"})
return
}
state := base64.URLEncoding.EncodeToString(stateBytes)
// Determine if we're using HTTPS
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
// Store state in session cookie for validation
stateCookie := &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/",
MaxAge: 600, // 10 minutes
HttpOnly: true,
Secure: isSecure, // Only secure over HTTPS
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(c.Writer, stateCookie)
config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure)
// Get authorization URL and redirect
authURL := oidcClient.GetAuthURL(state)
c.Redirect(http.StatusFound, authURL)
return
}
// Otherwise, render the login page (index.html)
// The JavaScript will handle showing the login page and redirecting when button is clicked
renderIndexPage(c)
}
// CallbackHandler handles the OIDC callback after user authentication
func CallbackHandler(c *gin.Context) {
oidcClient := auth.GetOIDCClient()
if oidcClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"})
return
}
// Get state from cookie
stateCookie, err := c.Cookie("oidc_state")
if err != nil {
config.DebugLog("Failed to get state cookie: %v", err)
config.DebugLog("Request cookies: %v", c.Request.Cookies())
config.DebugLog("Request URL: %s", c.Request.URL.String())
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing state parameter", "details": err.Error()})
return
}
// Determine if we're using HTTPS
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
// Clear state cookie
http.SetCookie(c.Writer, &http.Cookie{
Name: "oidc_state",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecure, // Only secure over HTTPS
SameSite: http.SameSiteLaxMode,
})
// Verify state parameter
returnedState := c.Query("state")
if returnedState != stateCookie {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state parameter"})
return
}
// Get authorization code
code := c.Query("code")
if code == "" {
errorDesc := c.Query("error_description")
if errorDesc != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC authentication failed: " + errorDesc})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing authorization code"})
}
return
}
// Exchange code for tokens
token, err := oidcClient.ExchangeCode(c.Request.Context(), code)
if err != nil {
config.DebugLog("Failed to exchange code for token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange authorization code"})
return
}
// Verify token and extract user info
userInfo, err := oidcClient.VerifyToken(c.Request.Context(), token)
if err != nil {
config.DebugLog("Failed to verify token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify authentication token"})
return
}
// Create session
if err := auth.CreateSession(c.Writer, c.Request, userInfo, oidcClient.Config.SessionMaxAge); err != nil {
config.DebugLog("Failed to create session: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
config.DebugLog("User authenticated: %s (%s)", userInfo.Username, userInfo.Email)
// Redirect to main page
c.Redirect(http.StatusFound, "/")
}
// LogoutHandler clears the session and optionally redirects to provider logout
func LogoutHandler(c *gin.Context) {
oidcClient := auth.GetOIDCClient()
// Clear session first
auth.DeleteSession(c.Writer, c.Request)
// If provider logout URL is configured, redirect there
// Auto-construct logout URL for standard OIDC providers if not explicitly set
if oidcClient != nil {
logoutURL := oidcClient.Config.LogoutURL
if logoutURL == "" && oidcClient.Config.IssuerURL != "" {
// Auto-construct standard OIDC logout URL for Keycloak, Authentik, and Pocket-ID
issuerURL := oidcClient.Config.IssuerURL
redirectURI := oidcClient.Config.RedirectURL
// Extract base URL from redirect URI for logout redirect (remove /auth/callback)
if strings.Contains(redirectURI, "/auth/callback") {
redirectURI = strings.TrimSuffix(redirectURI, "/auth/callback")
}
// Redirect to login page after logout
redirectURI = redirectURI + "/auth/login"
// URL encode the redirect_uri parameter
redirectURIEncoded := url.QueryEscape(redirectURI)
clientIDEncoded := url.QueryEscape(oidcClient.Config.ClientID)
// Provider-specific logout URL construction
switch oidcClient.Config.Provider {
case "keycloak":
// Keycloak requires client_id when using post_logout_redirect_uri
// Format: {issuer}/protocol/openid-connect/logout?post_logout_redirect_uri={redirect}&client_id={client_id}
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?post_logout_redirect_uri=%s&client_id=%s", issuerURL, redirectURIEncoded, clientIDEncoded)
case "authentik", "pocketid":
// Standard OIDC format for Authentik and Pocket-ID
// Format: {issuer}/protocol/openid-connect/logout?redirect_uri={redirect}
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded)
default:
// Fallback to standard OIDC format
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded)
}
}
if logoutURL != "" {
config.DebugLog("Redirecting to provider logout: %s", logoutURL)
c.Redirect(http.StatusFound, logoutURL)
return
}
}
// Otherwise, redirect to login page
c.Redirect(http.StatusFound, "/auth/login")
}
// AuthStatusHandler returns the current authentication status
func AuthStatusHandler(c *gin.Context) {
if !auth.IsEnabled() {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"authenticated": false,
})
return
}
session, err := auth.GetSession(c.Request)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"authenticated": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"authenticated": true,
"user": gin.H{
"id": session.UserID,
"email": session.Email,
"name": session.Name,
"username": session.Username,
},
})
}
// UserInfoHandler returns the current user information
func UserInfoHandler(c *gin.Context) {
if !auth.IsEnabled() {
c.JSON(http.StatusOK, gin.H{"authenticated": false})
return
}
session, err := auth.GetSession(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
c.JSON(http.StatusOK, gin.H{
"authenticated": true,
"user": gin.H{
"id": session.UserID,
"email": session.Email,
"name": session.Name,
"username": session.Username,
},
})
}

View File

@@ -25,8 +25,21 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
// Set the global WebSocket hub
SetWebSocketHub(hub)
// Public authentication routes (no auth required)
authRoutes := r.Group("/auth")
{
authRoutes.GET("/login", LoginHandler)
authRoutes.GET("/callback", CallbackHandler)
authRoutes.GET("/logout", LogoutHandler)
authRoutes.GET("/status", AuthStatusHandler)
authRoutes.GET("/user", UserInfoHandler)
}
// Apply authentication middleware to all routes
r.Use(AuthMiddleware())
// Render the dashboard
r.GET("/", IndexHandler)
r.GET("/", renderIndexPage)
api := r.Group("/api")
{

View File

@@ -21,6 +21,277 @@
opacity: 1;
}
/* Login Page Styling */
#loginPage {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
padding: 3rem 1rem;
position: relative;
z-index: 1;
}
/* Ensure login page is visible when shown */
body:has(#loginPage:not(.hidden)) {
background-color: #f3f4f6;
overflow: hidden;
}
#loginPage .max-w-md {
max-width: 28rem;
width: 100%;
}
#loginPage .bg-white {
background-color: #ffffff;
}
#loginPage .rounded-lg {
border-radius: 0.5rem;
}
#loginPage .shadow-lg {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
#loginPage .p-8 {
padding: 2rem;
}
#loginPage .mb-8 {
margin-bottom: 2rem;
}
#loginPage .mb-4 {
margin-bottom: 1rem;
}
#loginPage .mb-6 {
margin-bottom: 1.5rem;
}
#loginPage .mb-2 {
margin-bottom: 0.5rem;
}
#loginPage .mr-2 {
margin-right: 0.5rem;
}
#loginPage .mr-3 {
margin-right: 0.75rem;
}
#loginPage .ml-3 {
margin-left: 0.75rem;
}
#loginPage .pt-6 {
padding-top: 1.5rem;
}
#loginPage .py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
#loginPage .py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
#loginPage .px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
#loginPage .h-16 {
height: 4rem;
}
#loginPage .w-16 {
width: 4rem;
}
#loginPage .h-10 {
height: 2.5rem;
}
#loginPage .w-10 {
width: 2.5rem;
}
#loginPage .h-5 {
height: 1.25rem;
}
#loginPage .w-5 {
width: 1.25rem;
}
#loginPage .rounded-full {
border-radius: 9999px;
}
#loginPage .bg-blue-600 {
background-color: #2563eb;
}
#loginPage .text-white {
color: #ffffff;
}
#loginPage .text-gray-900 {
color: #111827;
}
#loginPage .text-gray-600 {
color: #4b5563;
}
#loginPage .text-gray-500 {
color: #6b7280;
}
#loginPage .text-red-700 {
color: #b91c1c;
}
#loginPage .text-red-400 {
color: #f87171;
}
#loginPage .text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
#loginPage .text-base {
font-size: 1rem;
line-height: 1.5rem;
}
#loginPage .text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
#loginPage .text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
#loginPage .font-bold {
font-weight: 700;
}
#loginPage .font-medium {
font-weight: 500;
}
#loginPage .border {
border-width: 1px;
}
#loginPage .border-l-4 {
border-left-width: 4px;
}
#loginPage .border-t {
border-top-width: 1px;
}
#loginPage .border-gray-200 {
border-color: #e5e7eb;
}
#loginPage .border-red-400 {
border-color: #f87171;
}
#loginPage .border-transparent {
border-color: transparent;
}
#loginPage .bg-red-50 {
background-color: #fef2f2;
}
#loginPage .hover\:bg-blue-700:hover {
background-color: #1d4ed8;
}
#loginPage .focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
#loginPage .focus\:ring-2:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
#loginPage .focus\:ring-offset-2:focus {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px rgba(59, 130, 246, 0.5);
}
#loginPage .focus\:ring-blue-500:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
#loginPage .transition-colors {
transition-property: background-color, border-color, color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
#loginPage .animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#loginPage .flex {
display: flex;
}
#loginPage .inline-flex {
display: inline-flex;
}
#loginPage .items-center {
align-items: center;
}
#loginPage .justify-center {
justify-content: center;
}
#loginPage .text-center {
text-align: center;
}
#loginPage .mx-auto {
margin-left: auto;
margin-right: auto;
}
#loginPage .w-full {
width: 100%;
}
#loginPage .hidden {
display: none;
}
/* Restart banner */
#restartBanner {
display: none;
@@ -296,6 +567,32 @@ mark {
}
/* Mobile responsive adjustments */
/* Custom breakpoint at 830px for menu collapse */
/* This overrides Tailwind's default md: breakpoint (768px) to collapse at 830px instead */
@media (max-width: 830px) {
/* Hide desktop menu navigation at 830px */
nav .hidden.md\:block {
display: none !important;
}
/* Show burger menu button at 830px */
nav > div > div > div.md\:hidden:not(#mobileMenu) {
display: block !important;
}
/* Allow mobile menu to be shown at 830px (override md:hidden) */
/* The menu visibility is controlled by JavaScript via the 'hidden' class */
/* When hidden class is NOT present, show the menu */
nav #mobileMenu:not(.hidden) {
display: block !important;
}
/* When mobile menu has 'hidden' class, hide it (JavaScript control takes precedence) */
nav #mobileMenu.hidden {
display: none !important;
}
}
@media (max-width: 768px) {
#backendStatus {
padding: 0.125rem 0.375rem;

View File

@@ -17,3 +17,27 @@ function serverHeaders(headers) {
return headers;
}
// Auth-aware fetch wrapper that handles 401/403 responses
function authFetch(url, options) {
options = options || {};
// Ensure Accept header for API requests
if (!options.headers) {
options.headers = {};
}
if (!options.headers['Accept']) {
options.headers['Accept'] = 'application/json';
}
return fetch(url, options).then(function(response) {
// Handle authentication errors
if (response.status === 401 || response.status === 403) {
if (typeof handleAuthError === 'function') {
handleAuthError(response);
}
// Return a rejected promise to stop the chain
return Promise.reject(new Error('Authentication required'));
}
return response;
});
}

247
pkg/web/static/js/auth.js Normal file
View File

@@ -0,0 +1,247 @@
// Authentication functions for Fail2ban UI
"use strict";
let authEnabled = false;
let isAuthenticated = false;
let currentUser = null;
// Check authentication status on page load
async function checkAuthStatus() {
// Immediately hide main content to prevent flash
const mainContent = document.getElementById('mainContent');
const nav = document.querySelector('nav');
if (mainContent) {
mainContent.style.display = 'none';
}
if (nav) {
nav.style.display = 'none';
}
try {
const response = await fetch('/auth/status', {
headers: serverHeaders()
});
if (!response.ok) {
throw new Error('Failed to check auth status');
}
const data = await response.json();
authEnabled = data.enabled || false;
isAuthenticated = data.authenticated || false;
if (authEnabled) {
if (isAuthenticated && data.user) {
currentUser = data.user;
showAuthenticatedUI();
} else {
showLoginPage();
}
} else {
// OIDC not enabled, show main content
showMainContent();
}
return { enabled: authEnabled, authenticated: isAuthenticated, user: currentUser };
} catch (error) {
console.error('Error checking auth status:', error);
// If auth check fails and we're on a protected route, show login
if (authEnabled) {
showLoginPage();
} else {
showMainContent();
}
return { enabled: false, authenticated: false, user: null };
}
}
// Get current user info
async function getUserInfo() {
try {
const response = await fetch('/auth/user', {
headers: serverHeaders()
});
if (!response.ok) {
if (response.status === 401) {
isAuthenticated = false;
currentUser = null;
showLoginPage();
return null;
}
throw new Error('Failed to get user info');
}
const data = await response.json();
if (data.authenticated && data.user) {
currentUser = data.user;
isAuthenticated = true;
return data.user;
}
return null;
} catch (error) {
console.error('Error getting user info:', error);
return null;
}
}
// Handle login - redirect to login endpoint with action parameter
function handleLogin() {
const loginLoading = document.getElementById('loginLoading');
const loginError = document.getElementById('loginError');
const loginErrorText = document.getElementById('loginErrorText');
const loginButton = event?.target?.closest('button');
// Show loading state
if (loginLoading) loginLoading.classList.remove('hidden');
if (loginButton) {
loginButton.disabled = true;
loginButton.classList.add('opacity-75', 'cursor-not-allowed');
}
// Hide error if shown
if (loginError) {
loginError.classList.add('hidden');
if (loginErrorText) loginErrorText.textContent = '';
}
// Redirect to login endpoint with action=redirect to trigger OIDC redirect
window.location.href = '/auth/login?action=redirect';
}
// Handle logout - use direct redirect instead of fetch to avoid CORS issues
function handleLogout() {
// Clear local state
isAuthenticated = false;
currentUser = null;
// Direct redirect to logout endpoint (server will handle redirect to provider)
// Using window.location.href instead of fetch to avoid CORS issues with redirects
window.location.href = '/auth/logout';
}
// Show login page
function showLoginPage() {
const loginPage = document.getElementById('loginPage');
const mainContent = document.getElementById('mainContent');
const nav = document.querySelector('nav');
// Hide main content and nav immediately
if (mainContent) {
mainContent.style.display = 'none';
mainContent.classList.add('hidden');
}
if (nav) {
nav.style.display = 'none';
nav.classList.add('hidden');
}
// Show login page
if (loginPage) {
loginPage.style.display = 'flex';
loginPage.classList.remove('hidden');
}
}
// Show main content (when authenticated or OIDC disabled)
function showMainContent() {
const loginPage = document.getElementById('loginPage');
const mainContent = document.getElementById('mainContent');
const nav = document.querySelector('nav');
// Hide login page immediately
if (loginPage) {
loginPage.style.display = 'none';
loginPage.classList.add('hidden');
}
// Show main content and nav
if (mainContent) {
mainContent.style.display = '';
mainContent.classList.remove('hidden');
}
if (nav) {
nav.style.display = '';
nav.classList.remove('hidden');
}
}
// Toggle user menu dropdown
function toggleUserMenu() {
const dropdown = document.getElementById('userMenuDropdown');
if (dropdown) {
dropdown.classList.toggle('hidden');
}
}
// Close user menu when clicking outside
document.addEventListener('click', function(event) {
const userMenuButton = document.getElementById('userMenuButton');
const userMenuDropdown = document.getElementById('userMenuDropdown');
if (userMenuButton && userMenuDropdown &&
!userMenuButton.contains(event.target) &&
!userMenuDropdown.contains(event.target)) {
userMenuDropdown.classList.add('hidden');
}
});
// Show authenticated UI (update header with user info)
function showAuthenticatedUI() {
showMainContent();
const userInfoContainer = document.getElementById('userInfoContainer');
const userDisplayName = document.getElementById('userDisplayName');
const userMenuDisplayName = document.getElementById('userMenuDisplayName');
const userMenuEmail = document.getElementById('userMenuEmail');
const mobileUserInfoContainer = document.getElementById('mobileUserInfoContainer');
const mobileUserDisplayName = document.getElementById('mobileUserDisplayName');
const mobileUserEmail = document.getElementById('mobileUserEmail');
if (userInfoContainer && currentUser) {
userInfoContainer.classList.remove('hidden');
const displayName = currentUser.name || currentUser.username || currentUser.email;
if (userDisplayName) {
userDisplayName.textContent = displayName;
}
if (userMenuDisplayName) {
userMenuDisplayName.textContent = displayName;
}
if (userMenuEmail && currentUser.email) {
userMenuEmail.textContent = currentUser.email;
}
}
// Update mobile menu
if (mobileUserInfoContainer && currentUser) {
mobileUserInfoContainer.classList.remove('hidden');
const displayName = currentUser.name || currentUser.username || currentUser.email;
if (mobileUserDisplayName) {
mobileUserDisplayName.textContent = displayName;
}
if (mobileUserEmail && currentUser.email) {
mobileUserEmail.textContent = currentUser.email;
}
}
}
// Handle 401/403 responses from API
function handleAuthError(response) {
if (response.status === 401 || response.status === 403) {
if (authEnabled) {
isAuthenticated = false;
currentUser = null;
showLoginPage();
return true;
}
}
return false;
}

View File

@@ -3,6 +3,29 @@
window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
// Check authentication status first (if auth.js is loaded)
if (typeof checkAuthStatus === 'function') {
checkAuthStatus().then(function(authStatus) {
// Only proceed with initialization if authenticated or OIDC disabled
if (!authStatus.enabled || authStatus.authenticated) {
initializeApp();
} else {
// Not authenticated, login page will be shown by checkAuthStatus
showLoading(false);
}
}).catch(function(err) {
console.error('Auth check failed:', err);
// Proceed with initialization anyway (fallback)
initializeApp();
});
} else {
// Auth.js not loaded, proceed normally
initializeApp();
}
});
function initializeApp() {
// Only display external IP if the element exists (not disabled via template variable)
if (document.getElementById('external-ip')) {
displayExternalIP();
@@ -148,4 +171,4 @@ window.addEventListener('DOMContentLoaded', function() {
advancedIntegrationSelect.addEventListener('change', updateAdvancedIntegrationFields);
}
});
});
}

View File

@@ -60,7 +60,7 @@
<!-- ******************************************************************* -->
<!-- Navigation START -->
<!-- ******************************************************************* -->
<nav class="bg-blue-600 text-white shadow-lg">
<nav class="hidden bg-blue-600 text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
@@ -80,6 +80,25 @@
<div id="clockDisplay" class="ml-4 text-sm font-mono">
<span id="clockTime">--:--:--</span>
</div>
<!-- User info and logout (shown when authenticated) -->
<div id="userInfoContainer" class="hidden ml-4 flex items-center gap-3 border-l border-blue-500 pl-4">
<div class="relative">
<button id="userMenuButton" onclick="toggleUserMenu()" class="flex items-center gap-2 px-3 py-2 rounded text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none">
<span id="userDisplayName" class="font-medium"></span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- User dropdown menu -->
<div id="userMenuDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
<div class="px-4 py-2 border-b border-gray-200">
<div class="text-sm font-medium text-gray-900" id="userMenuDisplayName"></div>
<div class="text-xs text-gray-500" id="userMenuEmail"></div>
</div>
<button onclick="handleLogout()" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors" data-i18n="auth.logout">Logout</button>
</div>
</div>
</div>
</div>
</div>
<div class="md:hidden">
@@ -97,14 +116,79 @@
<a href="#" onclick="showSection('dashboardSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
<a href="#" onclick="showSection('filterSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
<a href="#" onclick="showSection('settingsSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
<!-- User info and logout in mobile menu (shown when authenticated) -->
<div id="mobileUserInfoContainer" class="hidden border-t border-blue-500 mt-2 pt-2">
<div class="px-3 py-2">
<div class="text-sm font-medium" id="mobileUserDisplayName"></div>
<div class="text-xs text-blue-200" id="mobileUserEmail"></div>
</div>
<button onclick="handleLogout()" class="w-full text-left block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="auth.logout">Logout</button>
</div>
</div>
</div>
</nav>
<!-- ************************ Navigation END *************************** -->
<!-- Login Page (shown when not authenticated) -->
<div id="loginPage" class="min-h-screen flex items-center justify-center bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full">
<!-- Login Card -->
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-blue-600 mb-4">
<svg class="h-10 w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h2 class="text-3xl font-bold text-gray-900 mb-2" data-i18n="auth.login_title">Sign in to Fail2ban UI</h2>
<p class="text-sm text-gray-600" data-i18n="auth.login_description">Please authenticate to access the management interface</p>
</div>
<!-- Error Message -->
<div id="loginError" class="hidden bg-red-50 border-l-4 border-red-400 text-red-700 px-4 py-3 rounded mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium" id="loginErrorText"></p>
</div>
</div>
</div>
<!-- Login Button -->
<div class="mb-6">
<button type="button" onclick="handleLogin()" class="w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
<span data-i18n="auth.login_button">Sign in with OIDC</span>
</button>
<!-- Loading State -->
<div id="loginLoading" class="hidden text-center py-4">
<div class="inline-flex items-center">
<div class="h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-3"></div>
<p class="text-sm text-gray-600 font-medium" data-i18n="auth.logging_in">Redirecting to login...</p>
</div>
</div>
</div>
<!-- Footer Info -->
<div class="pt-6 border-t border-gray-200">
<p class="text-xs text-center text-gray-500">
Secure authentication via OpenID Connect
</p>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<main id="mainContent" class="hidden max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- ******************************************************************* -->
<!-- Dashboard Page START -->
<!-- ******************************************************************* -->
@@ -1397,6 +1481,7 @@
<script src="/static/js/websocket.js?v={{.version}}"></script>
<script src="/static/js/header.js?v={{.version}}"></script>
<script src="/static/js/lotr.js?v={{.version}}"></script>
<script src="/static/js/auth.js?v={{.version}}"></script>
<script src="/static/js/init.js?v={{.version}}"></script>
</body>
</html>