Fixed login refresh, added logout

This commit is contained in:
Dario Ghunney Ware
2025-10-16 17:46:23 +01:00
parent a969f13715
commit 38a97dc3e3
12 changed files with 216 additions and 221 deletions

View File

@@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=false
v2=true

View File

@@ -139,11 +139,15 @@ public class SecurityConfiguration {
.exceptionHandling(
exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
jwtAuthenticationEntryPoint));
jwtAuthenticationEntryPoint))
.addFilterAfter(
userAuthenticationFilter,
JwtAuthenticationFilter.class); // Run AFTER JWT filter
} else {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
http.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
@@ -245,6 +249,7 @@ public class SecurityConfiguration {
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
@@ -293,33 +298,39 @@ public class SecurityConfiguration {
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 ->
oauth2.loginPage("/oauth2")
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties,
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
oauth2 -> {
// v2: Don't set loginPage, let default OAuth2 flow handle it
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties,
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll();
});
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {

View File

@@ -75,28 +75,49 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow auth endpoints to pass through without JWT
// Allow specific auth endpoints to pass through without JWT
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Skip redirect for auth endpoints (they'll handle their own auth checks)
if (!requestURI.startsWith(contextPath + "/login")
&& !requestURI.startsWith(contextPath + "/api/v1/auth")) {
response.sendRedirect("/login");
return;
}
// Public auth endpoints that don't require JWT
boolean isPublicAuthEndpoint =
requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/signup")
|| requestURI.startsWith(contextPath + "/auth/")
|| requestURI.startsWith(contextPath + "/oauth2")
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh");
// For auth endpoints without JWT, continue to the endpoint
// (it will return 401 if needed)
if (requestURI.startsWith(contextPath + "/api/v1/auth")) {
if (!isPublicAuthEndpoint) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null
&& acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
return;
}
// For public auth endpoints without JWT, continue to the endpoint
filterChain.doFilter(request, response);
return;
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
log.warn("JWT validation failed: {}", e.getMessage());
jwtService.clearToken(response);
handleAuthenticationFailure(request, response, e);
return;
@@ -104,9 +125,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(

View File

@@ -239,6 +239,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/register",
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/site.webmanifest"
};

View File

@@ -72,12 +72,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
throw new LockedException(
"Your account has been locked due to too many failed login attempts.");
}
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
jwtService.addToken(response, jwt);
}
if (userService.isUserDisabled(username)) {
getRedirectStrategy()
.sendRedirect(request, response, "/logout?userIsDisabled=true");
@@ -102,7 +96,19 @@ public class CustomOAuth2AuthenticationSuccessHandler
userService.processSSOPostLogin(
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
}
response.sendRedirect(contextPath + "/");
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
jwtService.addToken(response, jwt);
// Redirect to auth callback for v2 (React will handle final routing)
response.sendRedirect(contextPath + "/auth/callback");
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
}
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
}

View File

@@ -122,7 +122,14 @@ public class CustomSaml2AuthenticationSuccessHandler
log.debug("Successfully processed authentication for user: {}", username);
generateJwt(response, authentication);
response.sendRedirect(contextPath + "/");
// Redirect to auth callback for v2 (React will handle final routing)
if (jwtService.isJwtEnabled()) {
response.sendRedirect(contextPath + "/auth/callback");
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
}
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.debug(
"Invalid username detected for user: {}, redirecting to logout",

View File

@@ -44,11 +44,11 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
public class JwtService implements JwtServiceInterface {
private static final String JWT_COOKIE_NAME = "stirling_jwt";
private static final String ISSUER = "Stirling PDF";
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 3600000;
@Value("${stirling.security.jwt.secureCookie:true}")
private boolean secureCookie;
@Value("${stirling.security.jwt.secureCookie:false}")
private boolean secureCookie = false; // Hardcoded to false for HTTP development
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
@@ -59,6 +59,7 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized with secureCookie={}", secureCookie);
}
@Override
@@ -260,24 +261,37 @@ public class JwtService implements JwtServiceInterface {
@Override
public String extractToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
// First, try to extract from Authorization header (Bearer token)
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
log.debug("JWT token extracted from Authorization header");
return token;
}
// Fall back to cookie-based authentication
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
log.debug("JWT token extracted from cookie");
return cookie.getValue();
}
}
}
log.debug("No JWT token found in Authorization header or cookies");
return null;
}
@Override
public void addToken(HttpServletResponse response, String token) {
log.info("Setting JWT cookie with secureCookie={}", secureCookie);
ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
.httpOnly(true)
.httpOnly(
false) // Set to false for V2 to allow JavaScript to read token for
// Authorization header
.secure(secureCookie)
.sameSite("Lax") // Changed from Strict to Lax for cross-port dev
// compatibility
@@ -287,23 +301,28 @@ public class JwtService implements JwtServiceInterface {
// compatibility
.build();
response.addHeader("Set-Cookie", cookie.toString());
String cookieString = cookie.toString();
log.info("Set-Cookie header: {}", cookieString);
response.addHeader("Set-Cookie", cookieString);
}
@Override
public void clearToken(HttpServletResponse response) {
log.info("Clearing JWT cookie");
ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, "")
.httpOnly(true)
.httpOnly(false) // Must match addToken settings
.secure(secureCookie)
.sameSite("None")
.sameSite("Lax") // Must match addToken settings
.maxAge(0)
.path("/")
.domain("localhost") // Set domain to localhost for cross-port dev
// compatibility
.build();
response.addHeader("Set-Cookie", cookie.toString());
String cookieString = cookie.toString();
log.info("Clear-Cookie header: {}", cookieString);
response.addHeader("Set-Cookie", cookieString);
}
@Override

View File

@@ -19,7 +19,6 @@ import OnboardingTour from "./components/onboarding/OnboardingTour";
import { AuthProvider } from "./auth/UseSession";
import Landing from "./routes/Landing";
import Login from "./routes/Login";
import Signup from "./routes/Signup";
import AuthCallback from "./routes/AuthCallback";
// Import global styles
@@ -59,7 +58,6 @@ export default function App() {
<Routes>
{/* Auth routes - no FileContext or other providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
{/* Main app routes - wrapped with all providers */}

View File

@@ -62,6 +62,22 @@ class SpringAuthClient {
this.startSessionMonitoring();
}
/**
* Helper to get JWT token from cookie and add to fetch headers
*/
private getAuthHeaders(): HeadersInit {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'stirling_jwt') {
return {
'Authorization': `Bearer ${value}`,
};
}
}
return {};
}
/**
* Get current session
* Note: JWT is stored in HttpOnly cookie, so we can't read it directly
@@ -69,13 +85,18 @@ class SpringAuthClient {
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Verify with backend
// Verify with backend - add Authorization header from cookie
const authHeaders = this.getAuthHeaders();
const response = await fetch('/api/v1/auth/me', {
credentials: 'include', // Include cookies
headers: {
...authHeaders,
},
});
if (!response.ok) {
// Not authenticated
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
}
@@ -89,6 +110,7 @@ class SpringAuthClient {
expires_at: Date.now() + 3600 * 1000,
};
console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error) {
console.error('[SpringAuth] getSession error:', error);
@@ -190,9 +212,11 @@ class SpringAuthClient {
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
// Redirect to Spring OAuth2 endpoint
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
const redirectUrl = `/oauth2/authorization/${params.provider}`;
window.location.href = redirectUrl;
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {

View File

@@ -1,9 +1,13 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
import { useAuth } from '../../../../auth/UseSession';
import { useNavigate } from 'react-router-dom';
const Overview: React.FC = () => {
const { config, loading, error } = useAppConfig();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
@@ -54,6 +58,15 @@ const Overview: React.FC = () => {
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
};
if (loading) {
return (
<Stack align="center" py="md">
@@ -74,10 +87,24 @@ const Overview: React.FC = () => {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email}
</Text>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
</Button>
)}
</div>
</div>
{config && (

View File

@@ -1,152 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { springAuth } from '../auth/springAuthClient'
import { useAuth } from '../auth/UseSession'
import { useTranslation } from 'react-i18next'
import { useDocumentMeta } from '../hooks/useDocumentMeta'
import { BASE_PATH } from '../constants/app'
import AuthLayout from './authShared/AuthLayout'
// Import signup components
import LoginHeader from './login/LoginHeader'
import ErrorMessage from './login/ErrorMessage'
import OAuthButtons from './login/OAuthButtons'
import DividerWithText from '../components/shared/DividerWithText'
import NavigationLink from './login/NavigationLink'
import SignupForm from './signup/SignupForm'
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation'
export default function Signup() {
const navigate = useNavigate()
const { session, loading } = useAuth()
const { t } = useTranslation()
const [isSigningUp, setIsSigningUp] = useState(false)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [agree, setAgree] = useState(true)
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t('signup.title', 'Create an account')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
const { validateSignupForm } = useSignupFormValidation()
const handleSignUp = async () => {
const validation = validateSignupForm(email, password, confirmPassword, name)
if (!validation.isValid) {
setError(validation.error)
setFieldErrors(validation.fieldErrors || {})
return
}
try {
setIsSigningUp(true)
setError(null)
setFieldErrors({})
console.log('[Signup] Creating account for:', email)
const { user, error } = await springAuth.signUp({
email: email.trim(),
password: password,
options: {
data: { full_name: name }
}
})
if (error) {
console.error('[Signup] Sign up error:', error)
setError(error.message)
} else if (user) {
console.log('[Signup] Account created successfully')
// Show success message
alert(t('signup.accountCreatedSuccessfully') || 'Account created successfully! Please log in.')
// Redirect to login
setTimeout(() => navigate('/login'), 1000)
}
} catch (err) {
console.error('[Signup] Unexpected error:', err)
setError(err instanceof Error ? err.message : (t('signup.unexpectedError', { message: 'Unknown error' }) || 'An unexpected error occurred'))
} finally {
setIsSigningUp(false)
}
}
const handleProviderSignIn = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
try {
setIsSigningUp(true)
setError(null)
console.log(`[Signup] Signing up with ${provider}`)
const { error } = await springAuth.signInWithOAuth({
provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
})
if (error) {
setError(error.message)
}
} catch (err) {
setError(err instanceof Error ? err.message : (t('signup.unexpectedError', { message: 'Unknown error' }) || 'An unexpected error occurred'))
} finally {
setIsSigningUp(false)
}
}
return (
<AuthLayout>
<LoginHeader title={t('signup.title') || 'Create an account'} subtitle={t('signup.subtitle')} />
<ErrorMessage error={error} />
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
<div style={{ margin: '0.5rem 0' }}>
<DividerWithText text={t('signup.or', "or")} respondsToDarkMode={false} opacity={0.4} />
</div>
<div style={{ marginBottom: '0.5rem' }}>
<OAuthButtons
onProviderClick={handleProviderSignIn}
isSubmitting={isSigningUp}
layout="icons"
/>
</div>
<div style={{ marginBottom: '0.5rem', textAlign: 'center' }}>
<NavigationLink
onClick={() => navigate('/login')}
text={t('signup.alreadyHaveAccount') || 'Already have an account? Sign in'}
isDisabled={isSigningUp}
/>
</div>
</AuthLayout>
)
}

View File

@@ -8,6 +8,37 @@ const apiClient = axios.create({
responseType: 'json',
});
// Helper function to get JWT token from cookies
function getJwtTokenFromCookie(): string | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'stirling_jwt') {
return value;
}
}
return null;
}
// ---------- Install request interceptor to add JWT token ----------
apiClient.interceptors.request.use(
(config) => {
// Get JWT token from cookie
const jwtToken = getJwtTokenFromCookie();
// If token exists and Authorization header is not already set, add it
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from cookie to Authorization header');
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// ---------- Install error interceptor ----------
apiClient.interceptors.response.use(
(response) => response,