mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 18:45:21 +00:00
Fixed JWT auth flow
Adding tests
This commit is contained in:
parent
98fb801247
commit
460f2d9ade
3
.gitignore
vendored
3
.gitignore
vendored
@ -200,3 +200,6 @@ id_ed25519.pub
|
|||||||
|
|
||||||
# node_modules
|
# node_modules
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
CLAUDE.md
|
||||||
|
@ -306,7 +306,6 @@ public class ApplicationProperties {
|
|||||||
@Data
|
@Data
|
||||||
public static class JWT {
|
public static class JWT {
|
||||||
private Boolean enabled = false;
|
private Boolean enabled = false;
|
||||||
@ToString.Exclude private String secretKey;
|
|
||||||
private Long expiration = 3600000L; // Default 1 hour in milliseconds
|
private Long expiration = 3600000L; // Default 1 hour in milliseconds
|
||||||
private String algorithm = "HS256"; // Default HMAC algorithm
|
private String algorithm = "HS256"; // Default HMAC algorithm
|
||||||
private String issuer = "Stirling-PDF"; // Default issuer
|
private String issuer = "Stirling-PDF"; // Default issuer
|
||||||
@ -314,12 +313,7 @@ public class ApplicationProperties {
|
|||||||
private Long refreshTokenExpiration = 86400000L; // Default 24 hours
|
private Long refreshTokenExpiration = 86400000L; // Default 24 hours
|
||||||
|
|
||||||
public boolean isSettingsValid() {
|
public boolean isSettingsValid() {
|
||||||
return enabled != null
|
return enabled != null && enabled && expiration != null && expiration > 0;
|
||||||
&& enabled
|
|
||||||
&& secretKey != null
|
|
||||||
&& !secretKey.trim().isEmpty()
|
|
||||||
&& expiration != null
|
|
||||||
&& expiration > 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,14 +11,14 @@
|
|||||||
#############################################################################################################
|
#############################################################################################################
|
||||||
|
|
||||||
security:
|
security:
|
||||||
enableLogin: false # set to 'true' to enable login
|
enableLogin: true # set to 'true' to enable login
|
||||||
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
|
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
|
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
|
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)
|
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:
|
initialLogin:
|
||||||
username: '' # initial username for the first login
|
username: 'admin' # initial username for the first login
|
||||||
password: '' # initial password for the first login
|
password: 'stirling' # initial password for the first login
|
||||||
oauth2:
|
oauth2:
|
||||||
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
|
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
|
||||||
client:
|
client:
|
||||||
@ -61,12 +61,9 @@ security:
|
|||||||
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
|
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
|
||||||
jwt:
|
jwt:
|
||||||
enabled: true # set to 'true' to enable JWT authentication
|
enabled: true # set to 'true' to enable JWT authentication
|
||||||
secretKey: 'Uz4BgfMySCz2Uplhp1x9ij19vVV2bXYktROtrlw3CC0=' # secret
|
|
||||||
expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms)
|
expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms)
|
||||||
algorithm: HS256 # JWT signing algorithm. Default is HS256
|
algorithm: HS256 # JWT signing algorithm. Default is HS256
|
||||||
issuer: Stirling-PDF # Issuer of the JWT token. Default is 'Stirling-PDF'
|
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:
|
premium:
|
||||||
key: 00000000-0000-0000-0000-000000000000
|
key: 00000000-0000-0000-0000-000000000000
|
||||||
|
@ -53,11 +53,11 @@ public class CustomAuthenticationSuccessHandler
|
|||||||
|
|
||||||
// Generate JWT token if JWT authentication is enabled
|
// Generate JWT token if JWT authentication is enabled
|
||||||
boolean jwtEnabled = jwtService.isJwtEnabled();
|
boolean jwtEnabled = jwtService.isJwtEnabled();
|
||||||
if (jwtService != null && jwtEnabled) {
|
if (jwtEnabled) {
|
||||||
try {
|
try {
|
||||||
String jwt = jwtService.generateToken(authentication);
|
String jwt = jwtService.generateToken(authentication);
|
||||||
jwtService.addTokenToResponse(response, jwt);
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to generate JWT token for user: {}", userName, e);
|
log.error("Failed to generate JWT token for user: {}", userName, e);
|
||||||
}
|
}
|
||||||
|
@ -53,17 +53,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
|||||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||||
throws IOException {
|
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 (!response.isCommitted()) {
|
||||||
if (authentication != null) {
|
if (authentication != null) {
|
||||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||||
@ -82,6 +71,11 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
|||||||
authentication.getClass().getSimpleName());
|
authentication.getClass().getSimpleName());
|
||||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
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 {
|
} else {
|
||||||
// Redirect to login page after logout
|
// Redirect to login page after logout
|
||||||
String path = checkForErrors(request);
|
String path = checkForErrors(request);
|
||||||
|
@ -38,6 +38,7 @@ import stirling.software.common.model.ApplicationProperties;
|
|||||||
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
||||||
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
|
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
|
||||||
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
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.JPATokenRepositoryImpl;
|
||||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||||
import stirling.software.proprietary.security.filter.FirstLoginFilter;
|
import stirling.software.proprietary.security.filter.FirstLoginFilter;
|
||||||
@ -74,6 +75,7 @@ public class SecurityConfiguration {
|
|||||||
private final UserAuthenticationFilter userAuthenticationFilter;
|
private final UserAuthenticationFilter userAuthenticationFilter;
|
||||||
private final JWTAuthenticationFilter jwtAuthenticationFilter;
|
private final JWTAuthenticationFilter jwtAuthenticationFilter;
|
||||||
private final JWTServiceInterface jwtService;
|
private final JWTServiceInterface jwtService;
|
||||||
|
private final JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||||
private final LoginAttemptService loginAttemptService;
|
private final LoginAttemptService loginAttemptService;
|
||||||
private final FirstLoginFilter firstLoginFilter;
|
private final FirstLoginFilter firstLoginFilter;
|
||||||
private final SessionPersistentRegistry sessionRegistry;
|
private final SessionPersistentRegistry sessionRegistry;
|
||||||
@ -93,6 +95,7 @@ public class SecurityConfiguration {
|
|||||||
UserAuthenticationFilter userAuthenticationFilter,
|
UserAuthenticationFilter userAuthenticationFilter,
|
||||||
JWTAuthenticationFilter jwtAuthenticationFilter,
|
JWTAuthenticationFilter jwtAuthenticationFilter,
|
||||||
JWTServiceInterface jwtService,
|
JWTServiceInterface jwtService,
|
||||||
|
JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||||
LoginAttemptService loginAttemptService,
|
LoginAttemptService loginAttemptService,
|
||||||
FirstLoginFilter firstLoginFilter,
|
FirstLoginFilter firstLoginFilter,
|
||||||
SessionPersistentRegistry sessionRegistry,
|
SessionPersistentRegistry sessionRegistry,
|
||||||
@ -110,6 +113,7 @@ public class SecurityConfiguration {
|
|||||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
|
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
|
||||||
this.loginAttemptService = loginAttemptService;
|
this.loginAttemptService = loginAttemptService;
|
||||||
this.firstLoginFilter = firstLoginFilter;
|
this.firstLoginFilter = firstLoginFilter;
|
||||||
this.sessionRegistry = sessionRegistry;
|
this.sessionRegistry = sessionRegistry;
|
||||||
@ -136,16 +140,19 @@ public class SecurityConfiguration {
|
|||||||
if (loginEnabledValue) {
|
if (loginEnabledValue) {
|
||||||
if (jwtEnabled && jwtAuthenticationFilter != null) {
|
if (jwtEnabled && jwtAuthenticationFilter != null) {
|
||||||
http.addFilterBefore(
|
http.addFilterBefore(
|
||||||
jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
// .addFilterAfter(
|
.exceptionHandling(
|
||||||
// jwtAuthenticationFilter,
|
exceptionHandling ->
|
||||||
// userAuthenticationFilter.getClass());
|
exceptionHandling.authenticationEntryPoint(
|
||||||
|
jwtAuthenticationEntryPoint));
|
||||||
} else {
|
} else {
|
||||||
http.addFilterBefore(
|
http.addFilterBefore(
|
||||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
userAuthenticationFilter,
|
||||||
|
UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterBefore(userAuthenticationFilter, firstLoginFilter.getClass());
|
||||||
}
|
}
|
||||||
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class)
|
http.addFilterAfter(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||||
.addFilterAfter(rateLimitingFilter(), firstLoginFilter.getClass());
|
|
||||||
if (!securityProperties.getCsrfDisabled()) {
|
if (!securityProperties.getCsrfDisabled()) {
|
||||||
CookieCsrfTokenRepository cookieRepo =
|
CookieCsrfTokenRepository cookieRepo =
|
||||||
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||||
@ -198,7 +205,6 @@ public class SecurityConfiguration {
|
|||||||
});
|
});
|
||||||
http.authenticationProvider(daoAuthenticationProvider());
|
http.authenticationProvider(daoAuthenticationProvider());
|
||||||
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
|
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
|
||||||
// Configure logout behavior based on JWT setting
|
|
||||||
http.logout(
|
http.logout(
|
||||||
logout ->
|
logout ->
|
||||||
logout.logoutRequestMatcher(
|
logout.logoutRequestMatcher(
|
||||||
@ -211,24 +217,21 @@ public class SecurityConfiguration {
|
|||||||
.invalidateHttpSession(true)
|
.invalidateHttpSession(true)
|
||||||
.deleteCookies(
|
.deleteCookies(
|
||||||
"JSESSIONID", "remember-me", "STIRLING_JWT_TOKEN"));
|
"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
|
http.rememberMe(
|
||||||
if (!jwtEnabled) {
|
rememberMeConfigurer -> // Use the configurator directly
|
||||||
http.rememberMe(
|
rememberMeConfigurer
|
||||||
rememberMeConfigurer -> // Use the configurator directly
|
.tokenRepository(persistentTokenRepository())
|
||||||
rememberMeConfigurer
|
.tokenValiditySeconds( // 14 days
|
||||||
.tokenRepository(persistentTokenRepository())
|
14 * 24 * 60 * 60)
|
||||||
.tokenValiditySeconds( // 14 days
|
.userDetailsService( // Your existing UserDetailsService
|
||||||
14 * 24 * 60 * 60)
|
userDetailsService)
|
||||||
.userDetailsService( // Your existing UserDetailsService
|
.useSecureCookie( // Enable secure cookie
|
||||||
userDetailsService)
|
true)
|
||||||
.useSecureCookie( // Enable secure cookie
|
.rememberMeParameter( // Form parameter name
|
||||||
true)
|
"remember-me")
|
||||||
.rememberMeParameter( // Form parameter name
|
.rememberMeCookieName( // Cookie name
|
||||||
"remember-me")
|
"remember-me")
|
||||||
.rememberMeCookieName( // Cookie name
|
.alwaysRemember(false));
|
||||||
"remember-me")
|
|
||||||
.alwaysRemember(false));
|
|
||||||
}
|
|
||||||
http.authorizeHttpRequests(
|
http.authorizeHttpRequests(
|
||||||
authz ->
|
authz ->
|
||||||
authz.requestMatchers(
|
authz.requestMatchers(
|
||||||
@ -253,6 +256,7 @@ public class SecurityConfiguration {
|
|||||||
|| trimmedUri.startsWith("/css/")
|
|| trimmedUri.startsWith("/css/")
|
||||||
|| trimmedUri.startsWith("/fonts/")
|
|| trimmedUri.startsWith("/fonts/")
|
||||||
|| trimmedUri.startsWith("/js/")
|
|| trimmedUri.startsWith("/js/")
|
||||||
|
|| trimmedUri.startsWith("/favicon")
|
||||||
|| trimmedUri.startsWith(
|
|| trimmedUri.startsWith(
|
||||||
"/api/v1/info/status");
|
"/api/v1/info/status");
|
||||||
})
|
})
|
||||||
@ -343,7 +347,6 @@ public class SecurityConfiguration {
|
|||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: check if this is needed
|
|
||||||
@Bean
|
@Bean
|
||||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
@ -117,18 +117,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
|
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) {
|
||||||
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
response.getWriter()
|
response.getWriter()
|
||||||
.write(
|
.write(
|
||||||
"Authentication required. Please provide a X-API-KEY in request"
|
"""
|
||||||
+ " header.\n"
|
Authentication required. Please provide a X-API-KEY in request\
|
||||||
+ "This is found in Settings -> Account Settings -> API Key\n"
|
header.
|
||||||
+ "Alternatively you can disable authentication if this is"
|
This is found in Settings -> Account Settings -> API Key
|
||||||
+ " unexpected");
|
Alternatively you can disable authentication if this is\
|
||||||
return;
|
unexpected""");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the authenticated user is disabled and invalidate their session if so
|
// Check if the authenticated user is disabled and invalidate their session if so
|
||||||
|
@ -9,7 +9,9 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
|
import stirling.software.common.configuration.AppConfig;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.proprietary.security.service.JWTServiceInterface;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@ -17,6 +19,10 @@ class CustomLogoutSuccessHandlerTest {
|
|||||||
|
|
||||||
@Mock private ApplicationProperties.Security securityProperties;
|
@Mock private ApplicationProperties.Security securityProperties;
|
||||||
|
|
||||||
|
@Mock private AppConfig appConfig;
|
||||||
|
|
||||||
|
@Mock private JWTServiceInterface jwtService;
|
||||||
|
|
||||||
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -26,6 +32,7 @@ class CustomLogoutSuccessHandlerTest {
|
|||||||
String logoutPath = "logout=true";
|
String logoutPath = "logout=true";
|
||||||
|
|
||||||
when(response.isCommitted()).thenReturn(false);
|
when(response.isCommitted()).thenReturn(false);
|
||||||
|
when(jwtService.isJwtEnabled()).thenReturn(false);
|
||||||
when(request.getContextPath()).thenReturn("");
|
when(request.getContextPath()).thenReturn("");
|
||||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||||
|
|
||||||
@ -34,6 +41,23 @@ class CustomLogoutSuccessHandlerTest {
|
|||||||
verify(response).sendRedirect(logoutPath);
|
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
|
@Test
|
||||||
void testSuccessfulLogoutViaOAuth2() throws IOException {
|
void testSuccessfulLogoutViaOAuth2() throws IOException {
|
||||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user