mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Add optional OIDC authentication with Keycloak, Authentik, and Pocket-ID support
This commit is contained in:
215
README.md
215
README.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
9
go.mod
@@ -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
27
go.sum
@@ -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
238
internal/auth/oidc.go
Normal 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
206
internal/auth/session.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
99
pkg/web/auth.go
Normal 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/")
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
247
pkg/web/static/js/auth.js
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user