Fixed JWT auth flow

Adding tests
This commit is contained in:
Dario Ghunney Ware 2025-07-04 18:46:32 +01:00
parent 49a041adfe
commit f8a34dbbba
8 changed files with 76 additions and 61 deletions

3
.gitignore vendored
View File

@ -200,3 +200,6 @@ id_ed25519.pub
# node_modules
node_modules/
# Claude
CLAUDE.md

View File

@ -284,7 +284,6 @@ public class ApplicationProperties {
@Data
public static class JWT {
private Boolean enabled = false;
@ToString.Exclude private String secretKey;
private Long expiration = 3600000L; // Default 1 hour in milliseconds
private String algorithm = "HS256"; // Default HMAC algorithm
private String issuer = "Stirling-PDF"; // Default issuer
@ -292,12 +291,7 @@ public class ApplicationProperties {
private Long refreshTokenExpiration = 86400000L; // Default 24 hours
public boolean isSettingsValid() {
return enabled != null
&& enabled
&& secretKey != null
&& !secretKey.trim().isEmpty()
&& expiration != null
&& expiration > 0;
return enabled != null && enabled && expiration != null && expiration > 0;
}
}
}

View File

@ -11,14 +11,14 @@
#############################################################################################################
security:
enableLogin: false # set to 'true' to enable login
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
enableLogin: true # set to 'true' to enable login
csrfDisabled: true # set to 'true' to disable CSRF protection (not recommended for production)
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
initialLogin:
username: '' # initial username for the first login
password: '' # initial password for the first login
username: 'admin' # initial username for the first login
password: 'stirling' # initial password for the first login
oauth2:
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
client:
@ -61,12 +61,9 @@ security:
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
jwt:
enabled: true # set to 'true' to enable JWT authentication
secretKey: 'Uz4BgfMySCz2Uplhp1x9ij19vVV2bXYktROtrlw3CC0=' # secret
expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms)
algorithm: HS256 # JWT signing algorithm. Default is HS256
issuer: Stirling-PDF # Issuer of the JWT token. Default is 'Stirling-PDF'
refreshTokenEnabled: false # Set to 'true' to enable refresh tokens
refreshTokenExpiration: 86400000 # Expiration time for refresh tokens in milliseconds. Default is 1 day (86400000 ms)
premium:
key: 00000000-0000-0000-0000-000000000000

View File

@ -53,11 +53,11 @@ public class CustomAuthenticationSuccessHandler
// Generate JWT token if JWT authentication is enabled
boolean jwtEnabled = jwtService.isJwtEnabled();
if (jwtService != null && jwtEnabled) {
if (jwtEnabled) {
try {
String jwt = jwtService.generateToken(authentication);
jwtService.addTokenToResponse(response, jwt);
log.debug("JWT token generated and added to response for user: {}", userName);
log.debug("JWT generated for user: {}", userName);
} catch (Exception e) {
log.error("Failed to generate JWT token for user: {}", userName, e);
}

View File

@ -53,17 +53,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
// Clear JWT token if JWT authentication is enabled
if (jwtService != null && jwtService.isJwtEnabled()) {
try {
jwtService.clearTokenFromResponse(response);
log.debug("JWT token cleared from response during logout");
} catch (Exception e) {
log.error("Failed to clear JWT token during logout", e);
// Continue with normal logout flow even if JWT clearing fails
}
}
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
@ -82,6 +71,11 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
authentication.getClass().getSimpleName());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else if (jwtService.isJwtEnabled()) {
// Clear JWT token if JWT authentication is enabled
jwtService.clearTokenFromResponse(response);
log.debug("Cleared JWT from response");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Redirect to login page after logout
String path = checkForErrors(request);

View File

@ -38,6 +38,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.JWTAuthenticationEntryPoint;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.FirstLoginFilter;
@ -74,6 +75,7 @@ public class SecurityConfiguration {
private final UserAuthenticationFilter userAuthenticationFilter;
private final JWTAuthenticationFilter jwtAuthenticationFilter;
private final JWTServiceInterface jwtService;
private final JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final FirstLoginFilter firstLoginFilter;
private final SessionPersistentRegistry sessionRegistry;
@ -93,6 +95,7 @@ public class SecurityConfiguration {
UserAuthenticationFilter userAuthenticationFilter,
JWTAuthenticationFilter jwtAuthenticationFilter,
JWTServiceInterface jwtService,
JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
FirstLoginFilter firstLoginFilter,
SessionPersistentRegistry sessionRegistry,
@ -110,6 +113,7 @@ public class SecurityConfiguration {
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.firstLoginFilter = firstLoginFilter;
this.sessionRegistry = sessionRegistry;
@ -136,16 +140,19 @@ public class SecurityConfiguration {
if (loginEnabledValue) {
if (jwtEnabled && jwtAuthenticationFilter != null) {
http.addFilterBefore(
jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// .addFilterAfter(
// jwtAuthenticationFilter,
// userAuthenticationFilter.getClass());
jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(
exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
jwtAuthenticationEntryPoint));
} else {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
userAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(userAuthenticationFilter, firstLoginFilter.getClass());
}
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitingFilter(), firstLoginFilter.getClass());
http.addFilterAfter(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
@ -198,7 +205,6 @@ public class SecurityConfiguration {
});
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
// Configure logout behavior based on JWT setting
http.logout(
logout ->
logout.logoutRequestMatcher(
@ -211,24 +217,21 @@ public class SecurityConfiguration {
.invalidateHttpSession(true)
.deleteCookies(
"JSESSIONID", "remember-me", "STIRLING_JWT_TOKEN"));
// Only configure remember-me if JWT is not enabled (stateless) todo: check if remember-me can be used with JWT
if (!jwtEnabled) {
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds( // 14 days
14 * 24 * 60 * 60)
.userDetailsService( // Your existing UserDetailsService
userDetailsService)
.useSecureCookie( // Enable secure cookie
true)
.rememberMeParameter( // Form parameter name
"remember-me")
.rememberMeCookieName( // Cookie name
"remember-me")
.alwaysRemember(false));
}
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds( // 14 days
14 * 24 * 60 * 60)
.userDetailsService( // Your existing UserDetailsService
userDetailsService)
.useSecureCookie( // Enable secure cookie
true)
.rememberMeParameter( // Form parameter name
"remember-me")
.rememberMeCookieName( // Cookie name
"remember-me")
.alwaysRemember(false));
http.authorizeHttpRequests(
authz ->
authz.requestMatchers(
@ -253,6 +256,7 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/css/")
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status");
})
@ -343,7 +347,6 @@ public class SecurityConfiguration {
return http.build();
}
// todo: check if this is needed
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {

View File

@ -117,18 +117,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
return;
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter()
.write(
"Authentication required. Please provide a X-API-KEY in request"
+ " header.\n"
+ "This is found in Settings -> Account Settings -> API Key\n"
+ "Alternatively you can disable authentication if this is"
+ " unexpected");
return;
"""
Authentication required. Please provide a X-API-KEY in request\
header.
This is found in Settings -> Account Settings -> API Key
Alternatively you can disable authentication if this is\
unexpected""");
}
return;
}
// Check if the authenticated user is disabled and invalidate their session if so

View File

@ -9,7 +9,9 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.JWTServiceInterface;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@ -17,6 +19,10 @@ class CustomLogoutSuccessHandlerTest {
@Mock private ApplicationProperties.Security securityProperties;
@Mock private AppConfig appConfig;
@Mock private JWTServiceInterface jwtService;
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@Test
@ -26,6 +32,7 @@ class CustomLogoutSuccessHandlerTest {
String logoutPath = "logout=true";
when(response.isCommitted()).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@ -34,6 +41,23 @@ class CustomLogoutSuccessHandlerTest {
verify(response).sendRedirect(logoutPath);
}
@Test
void testSuccessfulLogoutViaJWT() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(logoutPath);
verify(jwtService).clearTokenFromResponse(response);
}
@Test
void testSuccessfulLogoutViaOAuth2() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);