Fixed JWT auth flow

Adding tests
This commit is contained in:
Dario Ghunney Ware 2025-07-04 18:46:32 +01:00
parent 65856e283f
commit 13e985657f
13 changed files with 168 additions and 377 deletions

3
.gitignore vendored
View File

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

View File

@ -284,7 +284,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
@ -292,12 +291,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;
} }
} }
} }

View File

@ -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);
} }

View File

@ -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);

View File

@ -0,0 +1,23 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized: " + authException.getMessage());
}
}

View File

@ -1,153 +0,0 @@
package stirling.software.proprietary.security.configuration;
import java.security.SecureRandom;
import java.util.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
@Component
public class JWTConfigurationValidator {
@Autowired private ApplicationProperties applicationProperties;
@EventListener(ApplicationReadyEvent.class)
public void validateJWTConfiguration() {
if (!isJwtEnabled()) {
log.debug("JWT authentication is disabled");
return;
}
log.info("Validating JWT configuration...");
ApplicationProperties.Security.JWT jwtConfig = applicationProperties.getSecurity().getJwt();
// Validate basic configuration
if (!jwtConfig.isSettingsValid()) {
log.error("JWT configuration is invalid. Please check your settings.yml file.");
log.error("Required fields: enabled=true, secretKey (Base64 encoded), expiration > 0");
throw new IllegalStateException("Invalid JWT configuration");
}
// Validate secret key length and format
validateSecretKey(jwtConfig.getSecretKey());
// Validate expiration time
validateExpiration(jwtConfig.getExpiration());
// Validate algorithm
validateAlgorithm(jwtConfig.getAlgorithm());
log.info("JWT configuration validated successfully");
log.info("JWT algorithm: {}", jwtConfig.getAlgorithm());
log.info(
"JWT expiration: {} ms ({} minutes)",
jwtConfig.getExpiration(),
jwtConfig.getExpiration() / 60000);
log.info("JWT issuer: {}", jwtConfig.getIssuer());
}
private void validateSecretKey(String secretKey) {
if (secretKey == null || secretKey.trim().isEmpty()) {
log.error("JWT secret key is not configured");
throw new IllegalStateException("JWT secret key is required when JWT is enabled");
}
try {
byte[] decodedKey = Base64.getDecoder().decode(secretKey);
// For HMAC-SHA256, minimum key length should be 32 bytes (256 bits)
if (decodedKey.length < 32) {
log.warn(
"JWT secret key is shorter than recommended 256 bits. Current length: {} bits",
decodedKey.length * 8);
log.warn("Consider using a longer key for better security");
} else {
log.debug("JWT secret key length: {} bits", decodedKey.length * 8);
}
} catch (IllegalArgumentException e) {
log.error("JWT secret key is not a valid Base64 encoded string");
log.error("Generate a valid key using: openssl rand -base64 32");
throw new IllegalStateException("Invalid JWT secret key format", e);
}
}
private void validateExpiration(Long expiration) {
if (expiration == null || expiration <= 0) {
log.error("JWT expiration time must be positive. Current value: {}", expiration);
throw new IllegalStateException("Invalid JWT expiration time");
}
// Warn if expiration is too short (less than 5 minutes)
if (expiration < 300000) { // 5 minutes in milliseconds
log.warn(
"JWT expiration time is very short: {} ms. Consider using a longer expiration time for better user experience.",
expiration);
}
// Warn if expiration is too long (more than 24 hours)
if (expiration > 86400000) { // 24 hours in milliseconds
log.warn(
"JWT expiration time is very long: {} ms. Consider using a shorter expiration time for better security.",
expiration);
}
}
private void validateAlgorithm(String algorithm) {
if (algorithm == null || algorithm.trim().isEmpty()) {
log.warn("JWT algorithm is not specified, defaulting to HS256");
return;
}
switch (algorithm.toUpperCase()) {
case "HS256", "HS384", "HS512" -> {
log.debug("Using HMAC algorithm: {}", algorithm);
}
case "RS256", "RS384", "RS512" -> {
log.debug("Using RSA algorithm: {}", algorithm);
log.warn(
"RSA algorithms are configured but current implementation uses HMAC. Consider implementing RSA support for production use.");
}
default -> {
log.warn("Unsupported JWT algorithm: {}. Falling back to HS256", algorithm);
}
}
}
/**
* Generate a secure random Base64 encoded secret key for JWT This method is useful for
* generating initial secret keys
*/
public static String generateSecretKey() {
SecureRandom secureRandom = new SecureRandom();
byte[] key = new byte[32]; // 256 bits
secureRandom.nextBytes(key);
return Base64.getEncoder().encodeToString(key);
}
private boolean isJwtEnabled() {
return applicationProperties != null
&& applicationProperties.getSecurity() != null
&& applicationProperties.getSecurity().isJwtActive();
}
/** Provides helpful information for JWT configuration troubleshooting */
public void logConfigurationHelp() {
log.info("JWT Configuration Help:");
log.info("1. Enable JWT: Set jwt.enabled=true in settings.yml");
log.info("2. Generate secret key: openssl rand -base64 32");
log.info("3. Set expiration: jwt.expiration=3600000 (1 hour in milliseconds)");
log.info("4. Example generated secret key: {}", generateSecretKey());
log.info("5. Recommended expiration times:");
log.info(" - Short sessions: 900000 (15 minutes)");
log.info(" - Medium sessions: 3600000 (1 hour)");
log.info(" - Long sessions: 14400000 (4 hours)");
}
}

View File

@ -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 {

View File

@ -7,6 +7,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@ -18,6 +19,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JWTServiceInterface; import stirling.software.proprietary.security.service.JWTServiceInterface;
@ -43,51 +45,33 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
if (shouldNotFilter(request)) {
try { filterChain.doFilter(request, response);
if (shouldNotFilter(request)) {
filterChain.doFilter(request, response);
return;
}
String jwtToken = jwtService.extractTokenFromRequest(request);
if (jwtToken == null) {
sendUnauthorizedResponse(response, "JWT token is missing");
return;
}
if (!jwtService.validateToken(jwtToken)) {
sendUnauthorizedResponse(response, "JWT token is invalid or expired");
return;
}
String username = jwtService.extractUsername(jwtToken);
Authentication authentication = createAuthToken(request, username);
String jwt = jwtService.generateToken(authentication);
jwtService.addTokenToResponse(response, jwt);
} catch (Exception e) {
log.error(
"JWT authentication failed for request: {} {}",
request.getMethod(),
request.getRequestURI(),
e);
// Determine specific error message based on exception type
String errorMessage = "JWT authentication failed";
if (e.getMessage() != null && e.getMessage().contains("expired")) {
errorMessage = "JWT token has expired";
} else if (e.getMessage() != null && e.getMessage().contains("signature")) {
errorMessage = "JWT token signature is invalid";
} else if (e.getMessage() != null && e.getMessage().contains("malformed")) {
errorMessage = "JWT token is malformed";
}
sendUnauthorizedResponse(response, errorMessage);
return; return;
} }
String jwtToken = jwtService.extractTokenFromRequest(request);
if (jwtToken == null) {
// Special handling for root path - redirect to login instead of 401
if ("/".equals(request.getRequestURI())
&& "GET".equalsIgnoreCase(request.getMethod())) {
response.sendRedirect("/login");
return;
}
throw new AuthenticationFailureException("JWT is missing from request");
}
if (!jwtService.validateToken(jwtToken)) {
throw new AuthenticationFailureException("JWT is invalid or expired");
}
String tokenUsername = jwtService.extractUsername(jwtToken);
Authentication authentication = createAuthToken(request, tokenUsername);
String jwt = jwtService.generateToken(authentication);
jwtService.addTokenToResponse(response, jwt);
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
@ -101,25 +85,29 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
userDetails, null, userDetails.getAuthorities()); userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Set authentication in SecurityContext
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
log.debug("JWT authentication successful for user: {}", username); log.debug("JWT authentication successful for user: {}", username);
return SecurityContextHolder.getContext().getAuthentication(); } else {
throw new UsernameNotFoundException("User not found: " + username);
} }
} }
return null; return SecurityContextHolder.getContext().getAuthentication();
} }
@Override @Override
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI(); String uri = request.getRequestURI();
String contextPath = request.getContextPath(); String method = request.getMethod();
// Always allow login POST requests to be processed
if ("/login".equals(uri) && "POST".equalsIgnoreCase(method)) {
return true;
}
String[] permitAllPatterns = { String[] permitAllPatterns = {
"/",
"/login", "/login",
"/register", "/register",
"/error", "/error",
@ -131,7 +119,8 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
"/pdfjs/", "/pdfjs/",
"/pdfjs-legacy/", "/pdfjs-legacy/",
"/api/v1/info/status", "/api/v1/info/status",
"/site.webmanifest" "/site.webmanifest",
"/favicon"
}; };
for (String pattern : permitAllPatterns) { for (String pattern : permitAllPatterns) {
@ -145,25 +134,4 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
return false; return false;
} }
private void sendUnauthorizedResponse(HttpServletResponse response, String message)
throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
String jsonResponse =
String.format(
"""
{
"error": "Unauthorized",
"mesaage": %s,
"status": 401
}
""",
message);
response.getWriter().write(jsonResponse);
response.getWriter().flush();
}
} }

View File

@ -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

View File

@ -0,0 +1,9 @@
package stirling.software.proprietary.security.model.exception;
import org.springframework.security.core.AuthenticationException;
public class AuthenticationFailureException extends AuthenticationException {
public AuthenticationFailureException(String message) {
super(message);
}
}

View File

@ -1,16 +1,11 @@
package stirling.software.proprietary.security.service; package stirling.software.proprietary.security.service;
import static org.apache.commons.lang3.StringUtils.*; import java.security.KeyPair;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import javax.crypto.SecretKey;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -22,10 +17,8 @@ import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException; import io.jsonwebtoken.security.SignatureException;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@ -39,79 +32,18 @@ import stirling.software.common.model.ApplicationProperties;
@ConditionalOnBooleanProperty("security.jwt.enabled") @ConditionalOnBooleanProperty("security.jwt.enabled")
public class JWTService implements JWTServiceInterface { public class JWTService implements JWTServiceInterface {
private static final String JWT_COOKIE_NAME = "STIRLING_JWT_TOKEN"; private static final String JWT_COOKIE_NAME = "STIRLING_JWT";
private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
private final ApplicationProperties.Security securityProperties; private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties.Security.JWT jwtProperties;
private final KeyPair keyPair;
private SecretKey signingKey; public JWTService(ApplicationProperties.Security securityProperties) {
this.securityProperties = securityProperties;
public JWTService(ApplicationProperties applicationProperties) { this.jwtProperties = securityProperties.getJwt();
this.securityProperties = applicationProperties.getSecurity(); keyPair = Jwts.SIG.RS256.keyPair().build();
}
@PostConstruct
public void init() {
if (isJwtEnabled()) {
try {
initializeSigningKey();
log.info("JWT service initialized successfully");
} catch (Exception e) {
log.error(
"Failed to initialize JWT service. JWT authentication will be disabled.",
e);
throw new RuntimeException("JWT service initialization failed", e);
}
} else {
log.debug("JWT authentication is disabled");
}
}
private void initializeSigningKey() {
try {
ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt();
String secretKey = jwtProperties.getSecretKey();
if (isBlank(secretKey)) {
log.warn(
"JWT secret key is not configured. Generating a temporary key for this session.");
secretKey = generateTemporaryKey();
}
switch (jwtProperties.getAlgorithm()) {
case "HS256" ->
this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey));
case "RS256" -> // RSA256 algorithm requires a 2048-bit key. Should load RSA key
// pairs configuration
// this.signingKey =
// Jwts.SIG.RS256.keyPair().build().getPrivate()
log.info("Using RSA algorithm: RS256");
default -> {
log.warn(
"Unsupported JWT algorithm: {}. Using default algorithm.",
jwtProperties.getAlgorithm());
this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey));
}
}
log.info("JWT service initialized with algorithm: {}", jwtProperties.getAlgorithm());
} catch (Exception e) {
log.error("Failed to initialize JWT signing key", e);
throw new RuntimeException("JWT service initialization failed", e);
}
}
private String generateTemporaryKey() {
try {
// Generate a secure random key for HMAC-SHA256
SecureRandom secureRandom = new SecureRandom();
byte[] key = new byte[32]; // 256 bits
secureRandom.nextBytes(key);
return Base64.getEncoder().encodeToString(key);
} catch (Exception e) {
throw new RuntimeException("Failed to generate temporary JWT key", e);
}
} }
@Override @Override
@ -126,15 +58,13 @@ public class JWTService implements JWTServiceInterface {
throw new IllegalStateException("JWT is not enabled"); throw new IllegalStateException("JWT is not enabled");
} }
ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt();
return Jwts.builder() return Jwts.builder()
.claims(claims) .claims(claims)
.subject(username) .subject(username)
.issuer(jwtProperties.getIssuer()) .issuer(jwtProperties.getIssuer())
.issuedAt(new Date()) .issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration())) .expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
.signWith(signingKey) .signWith(keyPair.getPrivate(), Jwts.SIG.RS256)
.compact(); .compact();
} }
@ -145,19 +75,20 @@ public class JWTService implements JWTServiceInterface {
} }
try { try {
Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token); Jwts.parser().verifyWith(keyPair.getPublic()).build().parseSignedClaims(token);
return true; return true;
} catch (SignatureException e) { } catch (SignatureException e) {
log.debug("Invalid JWT signature: {}", e.getMessage()); log.warn("Invalid JWT signature: {}", e.getMessage());
} catch (MalformedJwtException e) { } catch (MalformedJwtException e) {
log.debug("Invalid JWT token: {}", e.getMessage()); log.warn("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) { } catch (ExpiredJwtException e) {
log.debug("JWT token is expired: {}", e.getMessage()); log.warn("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) { } catch (UnsupportedJwtException e) {
log.debug("JWT token is unsupported: {}", e.getMessage()); log.warn("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.debug("JWT claims string is empty: {}", e.getMessage()); log.warn("JWT claims string is empty: {}", e.getMessage());
} }
return false; return false;
} }
@ -191,18 +122,21 @@ public class JWTService implements JWTServiceInterface {
throw new IllegalStateException("JWT is not enabled"); throw new IllegalStateException("JWT is not enabled");
} }
return Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token).getPayload(); return Jwts.parser()
.verifyWith(keyPair.getPublic())
.build()
.parseSignedClaims(token)
.getPayload();
} }
@Override @Override
public String extractTokenFromRequest(HttpServletRequest request) { public String extractTokenFromRequest(HttpServletRequest request) {
// First, try to get token from Authorization header
String authHeader = request.getHeader(AUTHORIZATION_HEADER); String authHeader = request.getHeader(AUTHORIZATION_HEADER);
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length()); return authHeader.substring(BEARER_PREFIX.length());
} }
// Fallback to cookie
Cookie[] cookies = request.getCookies(); Cookie[] cookies = request.getCookies();
if (cookies != null) { if (cookies != null) {
for (Cookie cookie : cookies) { for (Cookie cookie : cookies) {
@ -217,11 +151,8 @@ public class JWTService implements JWTServiceInterface {
@Override @Override
public void addTokenToResponse(HttpServletResponse response, String token) { public void addTokenToResponse(HttpServletResponse response, String token) {
ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt();
// Add to Authorization header
response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + token); response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + token);
// Add as HTTP-only secure cookie
ResponseCookie cookie = ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, token) ResponseCookie.from(JWT_COOKIE_NAME, token)
.httpOnly(true) .httpOnly(true)
@ -252,8 +183,6 @@ public class JWTService implements JWTServiceInterface {
@Override @Override
public boolean isJwtEnabled() { public boolean isJwtEnabled() {
ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt();
return securityProperties.isJwtActive() return securityProperties.isJwtActive()
&& jwtProperties != null && jwtProperties != null
&& jwtProperties.isSettingsValid(); && jwtProperties.isSettingsValid();

View File

@ -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);

View File

@ -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