From 537eed17142d299113bc82f0f653e23b7a52890c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:18:43 +0000 Subject: [PATCH] Backend: - Make /api/v1/proprietary/ui-data/login endpoint public - Fix enableLogin to check both config flag AND proprietary module availability - Add enableLogin field to login endpoint response Frontend: - Dynamically show/hide SSO providers based on backend configuration - Redirect to home when login is disabled (anonymous mode) - Suppress 401 authentication errors on auth pages - Fix carousel image reset on input typing (memoize component) - Remove forgot password and signup links from login page - Conditionally render email form and dividers based on SSO availability Other: - Add .dockerignore for faster Docker builds - Configure nginx to run as non-root user - Bump version to 2.0.0 --- .dockerignore | 74 ++++++++++++ .../software/common/util/RequestUriUtils.java | 3 + .../controller/api/misc/ConfigController.java | 10 +- .../api/ProprietaryUIDataController.java | 5 + .../filter/UserAuthenticationFilter.java | 3 +- build.gradle | 2 +- docker/unified/nginx.conf | 3 + .../src/core/services/httpErrorHandler.ts | 15 ++- .../components/shared/LoginRightCarousel.tsx | 6 +- frontend/src/proprietary/routes/Login.tsx | 108 +++++++++++------- .../proprietary/routes/login/OAuthButtons.tsx | 52 ++++++--- 11 files changed, 221 insertions(+), 60 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..54b49fd80 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,74 @@ +# Node modules and build artifacts +node_modules +frontend/node_modules +frontend/dist +frontend/build +frontend/.vite +frontend/.tauri + +# Gradle build artifacts +.gradle +build +bin +target +out + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.iml +*.iws +*.ipr + +# Logs +*.log +logs + +# Environment files +.env +.env.* +!.env.example + +# OS files +.DS_Store +Thumbs.db + +# Java compiled files +*.class +*.jar +*.war +*.ear + +# Test reports +test-results +coverage + +# Docker +docker-compose.override.yml +.dockerignore + +# Temporary files +tmp +temp +*.tmp +*.swp +*~ + +# Runtime database and config files (locked by running app) +app/core/configs/** +stirling/** +stirling-pdf-DB*.mv.db +stirling-pdf-DB*.trace.db + +# Documentation +*.md +!README.md +docs + +# CI/CD +.github +.gitlab-ci.yml diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 23d369bf3..a0d7f3610 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -75,6 +75,9 @@ public class RequestUriUtils { || trimmedUri.startsWith("/api/v1/auth/login") || trimmedUri.startsWith("/api/v1/auth/refresh") || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith( + "/api/v1/proprietary/ui-data/login") // Login page config (SSO providers + + // enableLogin) || trimmedUri.startsWith("/v1/api-docs") || trimmedUri.startsWith("/api/v1/invite/validate") || trimmedUri.startsWith("/api/v1/invite/accept") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index b578d7c42..42ee1c9a7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; +import lombok.extern.slf4j.Slf4j; + import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; @@ -19,6 +21,7 @@ import stirling.software.common.service.UserServiceInterface; @ConfigApi @Hidden +@Slf4j public class ConfigController { private final ApplicationProperties applicationProperties; @@ -60,7 +63,12 @@ public class ConfigController { configData.put("languages", applicationProperties.getUi().getLanguages()); // Security settings - configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin()); + // enableLogin requires both the config flag AND proprietary features to be loaded + // If userService is null, proprietary module isn't loaded + // (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false) + boolean enableLogin = + applicationProperties.getSecurity().getEnableLogin() && userService != null; + configData.put("enableLogin", enableLogin); // Mail settings - check both SMTP enabled AND invites enabled boolean smtpEnabled = applicationProperties.getMail().isEnabled(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index ed9106f2f..a5e0b8a0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -116,6 +116,10 @@ public class ProprietaryUIDataController { LoginData data = new LoginData(); Map providerList = new HashMap<>(); Security securityProps = applicationProperties.getSecurity(); + + // Add enableLogin flag so frontend doesn't need to call /app-config + data.setEnableLogin(securityProps.getEnableLogin()); + OAUTH2 oauth = securityProps.getOauth2(); if (oauth != null && oauth.getEnabled()) { @@ -448,6 +452,7 @@ public class ProprietaryUIDataController { @Data public static class LoginData { + private Boolean enableLogin; private Map providerList; private String loginMethod; private boolean altLogin; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index cdf7bdd0d..9d1dbc96f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { || trimmedUri.startsWith("/saml2") || trimmedUri.startsWith("/api/v1/auth/login") || trimmedUri.startsWith("/api/v1/auth/refresh") - || trimmedUri.startsWith("/api/v1/auth/logout"); + || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith("/api/v1/proprietary/ui-data/login"); } private enum UserLoginType { diff --git a/build.gradle b/build.gradle index 9acb8c9f0..2451aa45c 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '1.4.0' + version = '2.0.0' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/docker/unified/nginx.conf b/docker/unified/nginx.conf index 77ee17f89..1e47b8619 100644 --- a/docker/unified/nginx.conf +++ b/docker/unified/nginx.conf @@ -1,3 +1,6 @@ +# Run nginx as non-root user +pid /tmp/nginx.pid; + events { worker_connections 1024; } diff --git a/frontend/src/core/services/httpErrorHandler.ts b/frontend/src/core/services/httpErrorHandler.ts index fa5160adf..0640b480c 100644 --- a/frontend/src/core/services/httpErrorHandler.ts +++ b/frontend/src/core/services/httpErrorHandler.ts @@ -92,6 +92,20 @@ export async function handleHttpError(error: any): Promise { if (error?.config?.suppressErrorToast === true) { return false; // Don't show global toast, but continue rejection } + + // Suppress "Authentication required" 401 errors on auth pages + const status: number | undefined = error?.response?.status; + if (status === 401) { + const pathname = window.location.pathname; + const isAuthPage = pathname.includes('/login') || + pathname.includes('/signup') || + pathname.includes('/auth/') || + pathname.includes('/invite/'); + if (isAuthPage) { + console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname); + return true; // Suppress toast + } + } // Compute title/body (friendly) from the error object const { title, body } = extractAxiosErrorMessage(error); @@ -112,7 +126,6 @@ export async function handleHttpError(error: any): Promise { // 2) Generic-vs-special dedupe by endpoint const url: string | undefined = error?.config?.url; - const status: number | undefined = error?.response?.status; const now = Date.now(); const isSpecial = status === 422 || diff --git a/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx b/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx index 00b5dac98..f7157a43e 100644 --- a/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx +++ b/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { BASE_PATH } from '@app/constants/app'; type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number } -export default function LoginRightCarousel({ +function LoginRightCarousel({ imageSlides = [], showBackground = true, initialSeconds = 5, @@ -157,3 +157,5 @@ export default function LoginRightCarousel({ ); } + +export default memo(LoginRightCarousel); diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 855793a93..ef03496b3 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -10,7 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout'; import LoginHeader from '@app/routes/login/LoginHeader'; import ErrorMessage from '@app/routes/login/ErrorMessage'; import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'; -import OAuthButtons from '@app/routes/login/OAuthButtons'; +import OAuthButtons, { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig } from '@app/routes/login/OAuthButtons'; import DividerWithText from '@app/components/shared/DividerWithText'; import LoggedInState from '@app/routes/login/LoggedInState'; import { BASE_PATH } from '@app/constants/app'; @@ -26,6 +26,53 @@ export default function Login() { const [showEmailForm, setShowEmailForm] = useState(false); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [enabledProviders, setEnabledProviders] = useState([]); + const [hasSSOProviders, setHasSSOProviders] = useState(false); + const [enableLogin, setEnableLogin] = useState(null); + + // Fetch enabled SSO providers and login config from backend + useEffect(() => { + const fetchProviders = async () => { + try { + const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`); + if (response.ok) { + const data = await response.json(); + + // Check if login is disabled - if so, redirect to home + if (data.enableLogin === false) { + console.debug('[Login] Login disabled, redirecting to home'); + navigate('/'); + return; + } + + setEnableLogin(data.enableLogin ?? true); + + // Extract provider IDs from the providerList map + // The keys are like "/oauth2/authorization/google" - extract the last part + const providerIds = Object.keys(data.providerList || {}) + .map(key => key.split('/').pop()) + .filter(id => id); + setEnabledProviders(providerIds); + } + } catch (err) { + console.error('[Login] Failed to fetch enabled providers:', err); + } + }; + fetchProviders(); + }, [navigate]); + + // Update hasSSOProviders and showEmailForm when enabledProviders changes + useEffect(() => { + // In debug mode, check if any providers exist in the config + const hasProviders = DEBUG_SHOW_ALL_PROVIDERS + ? Object.keys(oauthProviderConfig).length > 0 + : enabledProviders.length > 0; + setHasSSOProviders(hasProviders); + // If no SSO providers, show email form by default + if (!hasProviders) { + setShowEmailForm(true); + } + }, [enabledProviders]); // Handle query params (email prefill and success messages) useEffect(() => { @@ -160,25 +207,31 @@ export default function Login() { onProviderClick={signInWithProvider} isSubmitting={isSigningIn} layout="vertical" + enabledProviders={enabledProviders} /> - {/* Divider between OAuth and Email */} - + {/* Divider between OAuth and Email - only show if SSO is available */} + {hasSSOProviders && ( + + )} - {/* Sign in with email button (primary color to match signup CTA) */} -
- -
+ {/* Sign in with email button - only show if SSO providers exist */} + {hasSSOProviders && !showEmailForm && ( +
+ +
+ )} + {/* Email form - show by default if no SSO, or when button clicked */} {showEmailForm && ( -
+
)} - {showEmailForm && ( -
- -
- )} - - {/* Divider then signup link */} - - -
- -
- ); } diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.tsx index e7fcb1b06..b0cd4a89e 100644 --- a/frontend/src/proprietary/routes/login/OAuthButtons.tsx +++ b/frontend/src/proprietary/routes/login/OAuthButtons.tsx @@ -1,30 +1,54 @@ import { useTranslation } from 'react-i18next'; import { BASE_PATH } from '@app/constants/app'; -// OAuth provider configuration -const oauthProviders = [ - { id: 'google', label: 'Google', file: 'google.svg', isDisabled: false }, - { id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false }, - { id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true }, - { id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true } -]; +// Debug flag to show all providers for UI testing +// Set to true to see all SSO options regardless of backend configuration +export const DEBUG_SHOW_ALL_PROVIDERS = false; + +// OAuth provider configuration - maps provider ID to display info +export const oauthProviderConfig = { + google: { label: 'Google', file: 'google.svg' }, + github: { label: 'GitHub', file: 'github.svg' }, + apple: { label: 'Apple', file: 'apple.svg' }, + azure: { label: 'Microsoft', file: 'microsoft.svg' }, + // microsoft and azure are the same, keycloak and oidc need their own icons + // These are commented out from debug view since they need proper icons or backend doesn't use them + // keycloak: { label: 'Keycloak', file: 'keycloak.svg' }, + // oidc: { label: 'OIDC', file: 'oidc.svg' } +}; interface OAuthButtonsProps { - onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void + onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc') => void isSubmitting: boolean layout?: 'vertical' | 'grid' | 'icons' + enabledProviders?: string[] // List of enabled provider IDs from backend } -export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) { +export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) { const { t } = useTranslation(); - // Filter out disabled providers - don't show them at all - const enabledProviders = oauthProviders.filter(p => !p.isDisabled); + // Debug mode: show all providers for UI testing + const providersToShow = DEBUG_SHOW_ALL_PROVIDERS + ? Object.keys(oauthProviderConfig) + : enabledProviders; + + // Filter to only show enabled providers from backend + const providers = providersToShow + .filter(id => id in oauthProviderConfig) + .map(id => ({ + id, + ...oauthProviderConfig[id as keyof typeof oauthProviderConfig] + })); + + // If no providers are enabled, don't render anything + if (providers.length === 0) { + return null; + } if (layout === 'icons') { return (
- {enabledProviders.map((p) => ( + {providers.map((p) => (