diff --git a/.gitignore b/.gitignore index ca949e769..5711d3dff 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,6 @@ id_ed25519.pub # node_modules node_modules/ + +# Claude +CLAUDE.md diff --git a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 53e37d89a..dddca220f 100644 --- a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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; } } } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index ea115c4ef..63bb87613 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -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); } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 2f19fedca..8249d31f5 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -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); diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/JWTAuthenticationEntryPoint.java b/proprietary/src/main/java/stirling/software/proprietary/security/JWTAuthenticationEntryPoint.java new file mode 100644 index 000000000..e1164541f --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/JWTAuthenticationEntryPoint.java @@ -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()); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/JWTConfigurationValidator.java b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/JWTConfigurationValidator.java deleted file mode 100644 index c95a6c065..000000000 --- a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/JWTConfigurationValidator.java +++ /dev/null @@ -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)"); - } -} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 5185ac1ab..8090ced3b 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -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 { diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/filter/JWTAuthenticationFilter.java b/proprietary/src/main/java/stirling/software/proprietary/security/filter/JWTAuthenticationFilter.java index c8e76da8b..9a14ce276 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/filter/JWTAuthenticationFilter.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/filter/JWTAuthenticationFilter.java @@ -7,6 +7,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -18,6 +19,7 @@ import jakarta.servlet.http.HttpServletResponse; 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.JWTServiceInterface; @@ -43,51 +45,33 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); return; } - - try { - 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); + if (shouldNotFilter(request)) { + filterChain.doFilter(request, response); 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); } @@ -101,25 +85,29 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter { userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - - // Set authentication in SecurityContext SecurityContextHolder.getContext().setAuthentication(authToken); + 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 protected boolean shouldNotFilter(HttpServletRequest request) { 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 = { - "/", "/login", "/register", "/error", @@ -131,7 +119,8 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter { "/pdfjs/", "/pdfjs-legacy/", "/api/v1/info/status", - "/site.webmanifest" + "/site.webmanifest", + "/favicon" }; for (String pattern : permitAllPatterns) { @@ -145,25 +134,4 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter { 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(); - } } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index e9addd239..971cd4859 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -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 diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java b/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java new file mode 100644 index 000000000..ec773dadf --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java @@ -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); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/service/JWTService.java b/proprietary/src/main/java/stirling/software/proprietary/security/service/JWTService.java index 8ab69c744..1d7b6144b 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/service/JWTService.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/service/JWTService.java @@ -1,16 +1,11 @@ package stirling.software.proprietary.security.service; -import static org.apache.commons.lang3.StringUtils.*; - -import java.security.SecureRandom; -import java.util.Base64; +import java.security.KeyPair; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; -import javax.crypto.SecretKey; - import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; @@ -22,10 +17,8 @@ import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; -import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; -import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -39,79 +32,18 @@ import stirling.software.common.model.ApplicationProperties; @ConditionalOnBooleanProperty("security.jwt.enabled") 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 BEARER_PREFIX = "Bearer "; private final ApplicationProperties.Security securityProperties; + private final ApplicationProperties.Security.JWT jwtProperties; + private final KeyPair keyPair; - private SecretKey signingKey; - - public JWTService(ApplicationProperties applicationProperties) { - this.securityProperties = applicationProperties.getSecurity(); - } - - @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); - } + public JWTService(ApplicationProperties.Security securityProperties) { + this.securityProperties = securityProperties; + this.jwtProperties = securityProperties.getJwt(); + keyPair = Jwts.SIG.RS256.keyPair().build(); } @Override @@ -126,15 +58,13 @@ public class JWTService implements JWTServiceInterface { throw new IllegalStateException("JWT is not enabled"); } - ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt(); - return Jwts.builder() .claims(claims) .subject(username) .issuer(jwtProperties.getIssuer()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration())) - .signWith(signingKey) + .signWith(keyPair.getPrivate(), Jwts.SIG.RS256) .compact(); } @@ -145,19 +75,20 @@ public class JWTService implements JWTServiceInterface { } try { - Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token); + Jwts.parser().verifyWith(keyPair.getPublic()).build().parseSignedClaims(token); return true; } catch (SignatureException e) { - log.debug("Invalid JWT signature: {}", e.getMessage()); + log.warn("Invalid JWT signature: {}", e.getMessage()); } catch (MalformedJwtException e) { - log.debug("Invalid JWT token: {}", e.getMessage()); + log.warn("Invalid JWT token: {}", e.getMessage()); } catch (ExpiredJwtException e) { - log.debug("JWT token is expired: {}", e.getMessage()); + log.warn("JWT token is expired: {}", e.getMessage()); } catch (UnsupportedJwtException e) { - log.debug("JWT token is unsupported: {}", e.getMessage()); + log.warn("JWT token is unsupported: {}", e.getMessage()); } catch (IllegalArgumentException e) { - log.debug("JWT claims string is empty: {}", e.getMessage()); + log.warn("JWT claims string is empty: {}", e.getMessage()); } + return false; } @@ -191,18 +122,21 @@ public class JWTService implements JWTServiceInterface { 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 public String extractTokenFromRequest(HttpServletRequest request) { - // First, try to get token from Authorization header String authHeader = request.getHeader(AUTHORIZATION_HEADER); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { return authHeader.substring(BEARER_PREFIX.length()); } - // Fallback to cookie Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { @@ -217,11 +151,8 @@ public class JWTService implements JWTServiceInterface { @Override public void addTokenToResponse(HttpServletResponse response, String token) { - ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt(); - // Add to Authorization header response.setHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + token); - // Add as HTTP-only secure cookie ResponseCookie cookie = ResponseCookie.from(JWT_COOKIE_NAME, token) .httpOnly(true) @@ -252,8 +183,6 @@ public class JWTService implements JWTServiceInterface { @Override public boolean isJwtEnabled() { - ApplicationProperties.Security.JWT jwtProperties = securityProperties.getJwt(); - return securityProperties.isJwtActive() && jwtProperties != null && jwtProperties.isSettingsValid(); diff --git a/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 5a392f369..4e4bf8552 100644 --- a/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ b/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java @@ -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); diff --git a/stirling-pdf/src/main/resources/settings.yml.template b/stirling-pdf/src/main/resources/settings.yml.template index 968734cfd..32557cd2d 100644 --- a/stirling-pdf/src/main/resources/settings.yml.template +++ b/stirling-pdf/src/main/resources/settings.yml.template @@ -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