diff --git a/development/oidc/container-compose.yml b/development/oidc/container-compose.yml index c257e85..9cc3b56 100644 --- a/development/oidc/container-compose.yml +++ b/development/oidc/container-compose.yml @@ -229,6 +229,9 @@ services: - OIDC_SESSION_MAX_AGE=7200 - OIDC_USERNAME_CLAIM=preferred_username - OIDC_SKIP_VERIFY=true + # Optional: Skip login page and redirect directly to OIDC provider (default: false) + # When set to true, users are immediately redirected to the OIDC provider without showing the login page + #- OIDC_SKIP_LOGINPAGE=true # Optional: Logout URL #- OIDC_LOGOUT_URL=${KEYCLOAK_URL}/realms/master/protocol/openid-connect/logout diff --git a/docker-compose-allinone.example.yml b/docker-compose-allinone.example.yml index 0b9680b..57052e1 100644 --- a/docker-compose-allinone.example.yml +++ b/docker-compose-allinone.example.yml @@ -100,6 +100,9 @@ services: # 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 + # Optional: Skip login page and redirect directly to OIDC provider (default: false) + # When set to true, users are immediately redirected to the OIDC provider without showing the login page + # - OIDC_SKIP_LOGINPAGE=true volumes: # Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 58e2f62..8a02ad8 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -81,6 +81,9 @@ services: # 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 + # Optional: Skip login page and redirect directly to OIDC provider (default: false) + # When set to true, users are immediately redirected to the OIDC provider without showing the login page + # - OIDC_SKIP_LOGINPAGE=true volumes: # Required for fail2ban-ui: Stores SQLite database, application settings, and SSH keys of the fail2ban-ui container diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 73212dd..fdd1640 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -141,6 +141,14 @@ func IsEnabled() bool { return oidcClient != nil && oidcClient.Config != nil && oidcClient.Config.Enabled } +// GetConfig returns the OIDC configuration +func GetConfig() *config.OIDCConfig { + if oidcClient == nil { + return nil + } + return oidcClient.Config +} + // GetAuthURL generates the authorization URL for OIDC login func (c *OIDCClient) GetAuthURL(state string) string { return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline) diff --git a/internal/config/settings.go b/internal/config/settings.go index 1e881df..e2e4f01 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -101,6 +101,7 @@ type OIDCConfig struct { 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) + SkipLoginPage bool `json:"skipLoginPage"` // Skip login page and redirect directly to OIDC provider (default: false) } type AdvancedActionsConfig struct { @@ -1518,6 +1519,10 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { } } + // Skip login page option (default: false) + skipLoginPageEnv := os.Getenv("OIDC_SKIP_LOGINPAGE") + config.SkipLoginPage = skipLoginPageEnv == "true" || skipLoginPageEnv == "1" + config.SessionSecret = os.Getenv("OIDC_SESSION_SECRET") if config.SessionSecret == "" { // Generate a random session secret diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index a840e92..eb8b806 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1126,10 +1126,22 @@ func renderIndexPage(c *gin.Context) { // Default is enabled (false means enabled, true means disabled) disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1" + // Check if OIDC is enabled and skip login page setting + oidcEnabled := auth.IsEnabled() + skipLoginPage := false + if oidcEnabled { + oidcConfig := auth.GetConfig() + if oidcConfig != nil { + skipLoginPage = oidcConfig.SkipLoginPage + } + } + c.HTML(http.StatusOK, "index.html", gin.H{ "timestamp": time.Now().Format(time.RFC1123), "version": time.Now().Unix(), "disableExternalIP": disableExternalIP, + "oidcEnabled": oidcEnabled, + "skipLoginPage": skipLoginPage, }) } @@ -3018,6 +3030,7 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { // LoginHandler shows the login page or initiates the OIDC login flow // If action=redirect query parameter is present, redirects to OIDC provider +// If OIDC_SKIP_LOGINPAGE is true, redirects directly to OIDC provider // Otherwise, renders the login page func LoginHandler(c *gin.Context) { oidcClient := auth.GetOIDCClient() @@ -3026,6 +3039,40 @@ func LoginHandler(c *gin.Context) { return } + // Check if skip login page is enabled + oidcConfig := auth.GetConfig() + if oidcConfig != nil && oidcConfig.SkipLoginPage { + // Skip login page - redirect directly to OIDC provider + // 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 + } + // Check if this is a redirect action (triggered by clicking the login button) if c.Query("action") == "redirect" { // Generate state parameter for CSRF protection @@ -3205,11 +3252,18 @@ func AuthStatusHandler(c *gin.Context) { return } + oidcConfig := auth.GetConfig() + skipLoginPage := false + if oidcConfig != nil { + skipLoginPage = oidcConfig.SkipLoginPage + } + session, err := auth.GetSession(c.Request) if err != nil { c.JSON(http.StatusOK, gin.H{ "enabled": true, "authenticated": false, + "skipLoginPage": skipLoginPage, }) return } @@ -3217,6 +3271,7 @@ func AuthStatusHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "enabled": true, "authenticated": true, + "skipLoginPage": skipLoginPage, "user": gin.H{ "id": session.UserID, "email": session.Email, diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index c6e1c8f..4fafcd2 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -33,6 +33,35 @@ z-index: 1; } +/* Both login page and main content are hidden by default */ +#loginPage.hidden { + display: none !important; +} + +#mainContent.hidden { + display: none !important; +} + +nav.hidden { + display: none !important; +} + +#footer.hidden { + display: none !important; +} + +/* Hide login page when OIDC is not enabled (additional safety) */ +body[data-oidc-enabled="false"] #loginPage { + display: none !important; + visibility: hidden !important; +} + +/* Hide login page when skipLoginPage is enabled (additional safety) */ +body[data-skip-login-page="true"] #loginPage { + display: none !important; + visibility: hidden !important; +} + /* Ensure login page is visible when shown */ body:has(#loginPage:not(.hidden)) { background-color: #f3f4f6; diff --git a/pkg/web/static/js/auth.js b/pkg/web/static/js/auth.js index 4d4fd8c..c036b92 100644 --- a/pkg/web/static/js/auth.js +++ b/pkg/web/static/js/auth.js @@ -7,15 +7,30 @@ let currentUser = null; // Check authentication status on page load async function checkAuthStatus() { - // Immediately hide main content to prevent flash + // Both login page and main content are hidden by default + // We'll show the appropriate one based on authentication status const mainContent = document.getElementById('mainContent'); const nav = document.querySelector('nav'); + const loginPage = document.getElementById('loginPage'); + const footer = document.getElementById('footer'); + + // Ensure all are hidden initially to prevent flash + if (loginPage) { + loginPage.classList.add('hidden'); + loginPage.style.display = 'none'; + } if (mainContent) { + mainContent.classList.add('hidden'); mainContent.style.display = 'none'; } if (nav) { + nav.classList.add('hidden'); nav.style.display = 'none'; } + if (footer) { + footer.classList.add('hidden'); + footer.style.display = 'none'; + } try { const response = await fetch('/auth/status', { @@ -29,25 +44,42 @@ async function checkAuthStatus() { const data = await response.json(); authEnabled = data.enabled || false; isAuthenticated = data.authenticated || false; + const skipLoginPageFlag = data.skipLoginPage || false; if (authEnabled) { if (isAuthenticated && data.user) { + // Authenticated: show main content, hide login page currentUser = data.user; showAuthenticatedUI(); } else { - showLoginPage(); + // Not authenticated + if (skipLoginPageFlag) { + // Skip login page: redirect directly to OIDC provider + window.location.href = '/auth/login'; + return { enabled: authEnabled, authenticated: false, user: null }; + } else { + // Show login page, hide main content + showLoginPage(); + } } } else { - // OIDC not enabled, show main content + // OIDC not enabled: show main content, hide login page 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(); + // On error, check OIDC status from data attributes + const oidcEnabled = document.body.getAttribute('data-oidc-enabled') === 'true'; + const skipLoginPage = document.body.getAttribute('data-skip-login-page') === 'true'; + + if (oidcEnabled) { + if (skipLoginPage) { + window.location.href = '/auth/login'; + } else { + showLoginPage(); + } } else { showMainContent(); } @@ -126,8 +158,9 @@ function showLoginPage() { const loginPage = document.getElementById('loginPage'); const mainContent = document.getElementById('mainContent'); const nav = document.querySelector('nav'); + const footer = document.getElementById('footer'); - // Hide main content and nav immediately + // Hide main content, nav, and footer if (mainContent) { mainContent.style.display = 'none'; mainContent.classList.add('hidden'); @@ -136,6 +169,10 @@ function showLoginPage() { nav.style.display = 'none'; nav.classList.add('hidden'); } + if (footer) { + footer.style.display = 'none'; + footer.classList.add('hidden'); + } // Show login page if (loginPage) { @@ -149,22 +186,27 @@ function showMainContent() { const loginPage = document.getElementById('loginPage'); const mainContent = document.getElementById('mainContent'); const nav = document.querySelector('nav'); + const footer = document.getElementById('footer'); - // Hide login page immediately + // Hide login page if (loginPage) { loginPage.style.display = 'none'; loginPage.classList.add('hidden'); } - // Show main content and nav + // Show main content, nav, and footer if (mainContent) { - mainContent.style.display = ''; + mainContent.style.display = 'block'; mainContent.classList.remove('hidden'); } if (nav) { - nav.style.display = ''; + nav.style.display = 'block'; nav.classList.remove('hidden'); } + if (footer) { + footer.style.display = 'block'; + footer.classList.remove('hidden'); + } } // Toggle user menu dropdown diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 128486f..1716c06 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -40,7 +40,7 @@ - +
@@ -129,8 +129,8 @@ - -
+ +