diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 3bcc48715..c3027358e 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -49,14 +49,6 @@ public class AppConfig { @Value("${server.port:8080}") private String serverPort; - @Value("${v2}") - public boolean v2Enabled; - - @Bean - public boolean v2Enabled() { - return v2Enabled; - } - /* Commented out Thymeleaf template engine bean - to be removed when frontend migration is complete @Bean @ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true") diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 4dba70300..5a4559fdc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,14 +1,18 @@ package stirling.software.SPDF.config; +import jakarta.annotation.PostConstruct; + import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +@Slf4j @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { @@ -16,6 +20,36 @@ public class WebMvcConfig implements WebMvcConfigurer { private final EndpointInterceptor endpointInterceptor; private final ApplicationProperties applicationProperties; + /** + * Validates CORS configuration on application startup to prevent runtime errors + * Spring will reject allowCredentials(true) + allowedOrigins("*") at runtime + * This validation provides a clear error message during startup instead + */ + @PostConstruct + public void validateCorsConfiguration() { + if (applicationProperties.getSystem() != null + && applicationProperties.getSystem().getCorsAllowedOrigins() != null + && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + + var allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins(); + + // Check if wildcard "*" is used with credentials + if (allowedOrigins.contains("*")) { + String errorMessage = + "INVALID CORS CONFIGURATION: Cannot use allowedOrigins=[\"*\"] with allowCredentials=true.\n" + + "This configuration is rejected by Spring Security at runtime.\n" + + "Please specify exact origins in system.corsAllowedOrigins (e.g., [\"http://localhost:3000\", \"https://example.com\"])\n" + + "or remove credentials support by modifying WebMvcConfig."; + log.error(errorMessage); + throw new IllegalStateException(errorMessage); + } + + log.info( + "CORS configuration validated successfully. Allowed origins: {}", + allowedOrigins); + } + } + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(endpointInterceptor); diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 18e1f4f8a..62067b3b3 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -58,6 +58,3 @@ spring.main.allow-bean-definition-overriding=true # Set up a consistent temporary directory location java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} - -# V2 features -v2=true diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 0dd8ee4bf..67c120a92 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -26,6 +26,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.RefreshTokenService; import stirling.software.proprietary.security.service.UserService; /** REST API Controller for authentication operations. */ @@ -38,19 +39,23 @@ public class AuthController { private final UserService userService; private final JwtServiceInterface jwtService; + private final RefreshTokenService refreshTokenService; private final CustomUserDetailsService userDetailsService; /** * Login endpoint - replaces Supabase signInWithPassword * * @param request Login credentials (email/username and password) + * @param servletRequest HTTP request for extracting IP and user agent * @param response HTTP response to set JWT cookie * @return User and session information */ @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/login") public ResponseEntity login( - @RequestBody UsernameAndPass request, HttpServletResponse response) { + @RequestBody UsernameAndPass request, + HttpServletRequest servletRequest, + HttpServletResponse response) { try { // Validate input parameters if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { @@ -91,6 +96,15 @@ public class AuthController { String token = jwtService.generateToken(user.getUsername(), claims); + // Generate refresh token for token rotation + String refreshToken = refreshTokenService.generateRefreshToken(user.getId(), servletRequest); + + // Set JWT as HttpOnly cookie for security + setJwtCookie(response, token); + + // Set refresh token as HttpOnly cookie + setRefreshTokenCookie(response, refreshToken); + log.info("Login successful for user: {}", request.getUsername()); return ResponseEntity.ok( @@ -144,15 +158,28 @@ public class AuthController { } /** - * Logout endpoint + * Logout endpoint - revokes all refresh tokens and clears cookies * - * @param response HTTP response + * @param response HTTP response to clear cookies * @return Success message */ @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/logout") public ResponseEntity logout(HttpServletResponse response) { try { + // Get current user from security context + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null && authentication.getPrincipal() instanceof User user) { + // Revoke all refresh tokens for this user + int revokedCount = refreshTokenService.revokeAllTokensForUser(user.getId()); + log.info("Revoked {} refresh token(s) for user: {}", revokedCount, user.getUsername()); + } + + // Clear cookies + clearAuthCookies(response); + + // Clear security context SecurityContextHolder.clearContext(); log.debug("User logged out successfully"); @@ -167,38 +194,70 @@ public class AuthController { } /** - * Refresh token + * Refresh token endpoint - validates refresh token and issues new access token + * Implements token rotation for security: revokes old refresh token and issues new one * - * @param request HTTP request containing current JWT cookie - * @param response HTTP response to set new JWT cookie + * @param request HTTP request containing refresh token cookie + * @param response HTTP response to set new cookies * @return New token information */ @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/refresh") public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { try { - String token = jwtService.extractToken(request); + // Extract refresh token from cookie + String refreshToken = extractRefreshTokenFromCookie(request); - if (token == null) { + if (refreshToken == null || refreshToken.isEmpty()) { + log.debug("Token refresh failed: no refresh token in cookie"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("error", "No token found")); + .body(Map.of("error", "No refresh token found")); } - jwtService.validateToken(token); - String username = jwtService.extractUsername(token); + // Validate refresh token + var refreshTokenOpt = refreshTokenService.validateRefreshToken(refreshToken); - UserDetails userDetails = userDetailsService.loadUserByUsername(username); - User user = (User) userDetails; + if (refreshTokenOpt.isEmpty()) { + log.debug("Token refresh failed: invalid or expired refresh token"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid or expired refresh token")); + } + var refreshTokenEntity = refreshTokenOpt.get(); + Long userId = refreshTokenEntity.getUserId(); + + // Load user + User user = userService.findById(userId).orElse(null); + if (user == null) { + log.warn("Token refresh failed: user not found for ID: {}", userId); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "User not found")); + } + + if (!user.isEnabled()) { + log.warn("Token refresh failed: user disabled: {}", user.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "User account is disabled")); + } + + // Generate new access token Map claims = new HashMap<>(); claims.put("authType", user.getAuthenticationType()); claims.put("role", user.getRolesAsString()); - String newToken = jwtService.generateToken(username, claims); + String newAccessToken = jwtService.generateToken(user.getUsername(), claims); - log.debug("Token refreshed for user: {}", username); + // Rotate refresh token for security (revoke old, issue new) + String newRefreshToken = + refreshTokenService.rotateRefreshToken(refreshToken, userId, request); - return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600)); + // Set new cookies + setJwtCookie(response, newAccessToken); + setRefreshTokenCookie(response, newRefreshToken); + + log.info("Token refreshed successfully for user: {}", user.getUsername()); + + return ResponseEntity.ok(Map.of("access_token", newAccessToken, "expires_in", 3600)); } catch (Exception e) { log.error("Token refresh error", e); @@ -207,6 +266,84 @@ public class AuthController { } } + /** + * Extracts refresh token from HTTP cookie + * + * @param request HTTP request + * @return Refresh token or null if not found + */ + private String extractRefreshTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() == null) { + return null; + } + + for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { + if ("stirling_refresh_token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + + return null; + } + + /** + * Sets JWT as an HttpOnly cookie for security + * Prevents XSS attacks by making token inaccessible to JavaScript + * + * @param response HTTP response to set cookie + * @param jwt JWT token to store + */ + private void setJwtCookie(HttpServletResponse response, String jwt) { + jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("stirling_jwt", jwt); + cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) + cookie.setSecure(true); // Only send over HTTPS (set to false for local dev if needed) + cookie.setPath("/"); // Cookie available for entire app + cookie.setMaxAge(3600); // 1 hour (matches JWT expiration) + cookie.setAttribute("SameSite", "Lax"); // CSRF protection + response.addCookie(cookie); + } + + /** + * Sets refresh token as an HttpOnly cookie for security + * + * @param response HTTP response to set cookie + * @param refreshToken Refresh token to store + */ + private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + jakarta.servlet.http.Cookie cookie = + new jakarta.servlet.http.Cookie("stirling_refresh_token", refreshToken); + cookie.setHttpOnly(true); // Prevent JavaScript access + cookie.setSecure(true); // Only send over HTTPS + cookie.setPath("/"); // Cookie available for entire app + cookie.setMaxAge(7 * 24 * 3600); // 7 days (matches refresh token expiration) + cookie.setAttribute("SameSite", "Lax"); // CSRF protection + response.addCookie(cookie); + } + + /** + * Clears authentication cookies (used on logout) + * + * @param response HTTP response + */ + private void clearAuthCookies(HttpServletResponse response) { + // Clear access token cookie + jakarta.servlet.http.Cookie jwtCookie = new jakarta.servlet.http.Cookie("stirling_jwt", ""); + jwtCookie.setHttpOnly(true); + jwtCookie.setSecure(true); + jwtCookie.setPath("/"); + jwtCookie.setMaxAge(0); // Delete immediately + response.addCookie(jwtCookie); + + // Clear refresh token cookie + jakarta.servlet.http.Cookie refreshCookie = + new jakarta.servlet.http.Cookie("stirling_refresh_token", ""); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); // Delete immediately + response.addCookie(refreshCookie); + } + /** * Helper method to build user response object * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java new file mode 100644 index 000000000..bc4fb93a8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/RefreshToken.java @@ -0,0 +1,82 @@ +package stirling.software.proprietary.security.model; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Refresh Token entity for implementing secure token rotation + * Refresh tokens are long-lived tokens that can be used to obtain new access tokens + * This prevents stolen access tokens from being kept alive indefinitely + */ +@Entity +@Table( + name = "refresh_tokens", + indexes = { + @Index(name = "idx_user_id", columnList = "user_id"), + @Index(name = "idx_token_hash", columnList = "token_hash"), + @Index(name = "idx_expires_at", columnList = "expires_at") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** User ID this refresh token belongs to */ + @Column(name = "user_id", nullable = false) + private Long userId; + + /** SHA-256 hash of the refresh token (never store tokens in plaintext) */ + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + /** When this refresh token expires */ + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + /** When this refresh token was created */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** Whether this refresh token has been revoked (for logout/security events) */ + @Column(name = "revoked", nullable = false) + @Builder.Default + private boolean revoked = false; + + /** IP address from which the token was issued (optional, for audit trail) */ + @Column(name = "issued_ip", length = 45) + private String issuedIp; + + /** User agent from which the token was issued (optional, for audit trail) */ + @Column(name = "user_agent", length = 255) + private String userAgent; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public boolean isValid() { + return !revoked && !isExpired(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index fe5fd5bcc..588cde253 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; @@ -30,6 +31,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +@Slf4j @RequiredArgsConstructor public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @@ -38,6 +40,7 @@ public class CustomOAuth2AuthenticationSuccessHandler private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; private final JwtServiceInterface jwtService; + private final ApplicationProperties applicationProperties; @Override public void onAuthenticationSuccess( @@ -114,8 +117,11 @@ public class CustomOAuth2AuthenticationSuccessHandler jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.OAUTH2)); - // Build context-aware redirect URL based on the original request - String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt); + // Set JWT as HttpOnly cookie for security + setJwtCookie(response, jwt, contextPath); + + // Build context-aware redirect URL (without JWT in URL) + String redirectUrl = buildContextAwareRedirectUrl(request, contextPath); response.sendRedirect(redirectUrl); } else { @@ -141,17 +147,51 @@ public class CustomOAuth2AuthenticationSuccessHandler return null; } + /** + * Sets JWT as an HttpOnly cookie for security + * Prevents XSS attacks by making token inaccessible to JavaScript + * + * @param response HTTP response to set cookie + * @param jwt JWT token to store + * @param contextPath Application context path for cookie path + */ + private void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) { + jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("stirling_jwt", jwt); + cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) + cookie.setSecure(true); // Only send over HTTPS (set to false for local dev if needed) + cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app + cookie.setMaxAge(3600); // 1 hour (matches JWT expiration) + cookie.setAttribute("SameSite", "Lax"); // CSRF protection + response.addCookie(cookie); + } + + /** + * Validates if the origin is in the CORS whitelist + * + * @param origin Origin to validate + * @return true if origin is whitelisted or no whitelist configured + */ + private boolean isOriginWhitelisted(String origin) { + if (applicationProperties.getSystem() == null + || applicationProperties.getSystem().getCorsAllowedOrigins() == null + || applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + // No whitelist configured - only trust request origin + return false; + } + + return applicationProperties.getSystem().getCorsAllowedOrigins().contains(origin); + } + /** * Builds a context-aware redirect URL based on the request's origin + * Validates Referer against CORS whitelist to prevent token leakage to third parties * * @param request The HTTP request * @param contextPath The application context path - * @param jwt The JWT token to include * @return The appropriate redirect URL */ - private String buildContextAwareRedirectUrl( - HttpServletRequest request, String contextPath, String jwt) { - // Try to get the origin from the Referer header first + private String buildContextAwareRedirectUrl(HttpServletRequest request, String contextPath) { + // Try to get the origin from the Referer header String referer = request.getHeader("Referer"); if (referer != null && !referer.isEmpty()) { try { @@ -162,13 +202,25 @@ public class CustomOAuth2AuthenticationSuccessHandler && refererUrl.getPort() != 443) { origin += ":" + refererUrl.getPort(); } - return origin + "/auth/callback#access_token=" + jwt; + + // SECURITY: Only trust Referer if it's in the CORS whitelist + // This prevents redirecting with JWT to untrusted domains (e.g., IdP domain) + if (isOriginWhitelisted(origin)) { + log.debug( + "Using whitelisted Referer origin for redirect: {}", + origin); + return origin + "/auth/callback"; + } else { + log.warn( + "Referer origin {} not in CORS whitelist, falling back to request origin", + origin); + } } catch (java.net.MalformedURLException e) { - // Fall back to other methods if referer is malformed + log.warn("Malformed Referer URL, falling back to request origin: {}", referer); } } - // Fall back to building from request host/port + // Fall back to building from request host/port (always safe) String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); @@ -182,6 +234,7 @@ public class CustomOAuth2AuthenticationSuccessHandler origin.append(":").append(serverPort); } - return origin.toString() + "/auth/callback#access_token=" + jwt; + log.debug("Using request origin for redirect: {}", origin); + return origin + "/auth/callback"; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/RefreshTokenRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/RefreshTokenRepository.java new file mode 100644 index 000000000..eb14fcc58 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/RefreshTokenRepository.java @@ -0,0 +1,90 @@ +package stirling.software.proprietary.security.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.proprietary.security.model.RefreshToken; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + /** + * Find a refresh token by its hash + * + * @param tokenHash SHA-256 hash of the token + * @return Optional containing the refresh token if found + */ + Optional findByTokenHash(String tokenHash); + + /** + * Find all refresh tokens for a specific user + * + * @param userId User ID + * @return List of refresh tokens + */ + List findByUserId(Long userId); + + /** + * Find all valid (non-revoked, non-expired) refresh tokens for a user + * + * @param userId User ID + * @param now Current timestamp + * @return List of valid refresh tokens + */ + @Query( + "SELECT r FROM RefreshToken r WHERE r.userId = :userId AND r.revoked = false AND r.expiresAt > :now") + List findValidTokensByUserId( + @Param("userId") Long userId, @Param("now") LocalDateTime now); + + /** + * Revoke all refresh tokens for a specific user (used on logout or security events) + * + * @param userId User ID + * @return Number of tokens revoked + */ + @Modifying + @Transactional + @Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.userId = :userId") + int revokeAllByUserId(@Param("userId") Long userId); + + /** + * Revoke a specific refresh token by its hash + * + * @param tokenHash SHA-256 hash of the token + * @return Number of tokens revoked (0 or 1) + */ + @Modifying + @Transactional + @Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.tokenHash = :tokenHash") + int revokeByTokenHash(@Param("tokenHash") String tokenHash); + + /** + * Delete all expired refresh tokens (cleanup job) + * + * @param now Current timestamp + * @return Number of tokens deleted + */ + @Modifying + @Transactional + @Query("DELETE FROM RefreshToken r WHERE r.expiresAt < :now") + int deleteExpiredTokens(@Param("now") LocalDateTime now); + + /** + * Count valid tokens for a user + * + * @param userId User ID + * @param now Current timestamp + * @return Count of valid tokens + */ + @Query( + "SELECT COUNT(r) FROM RefreshToken r WHERE r.userId = :userId AND r.revoked = false AND r.expiresAt > :now") + long countValidTokensByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 14bbd83d4..47dc9d7af 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -37,6 +37,7 @@ public class CustomSaml2AuthenticationSuccessHandler private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; private final JwtServiceInterface jwtService; + private final ApplicationProperties applicationProperties; @Override public void onAuthenticationSuccess( @@ -142,9 +143,11 @@ public class CustomSaml2AuthenticationSuccessHandler authentication, Map.of("authType", AuthenticationType.SAML2)); - // Build context-aware redirect URL based on the original request - String redirectUrl = - buildContextAwareRedirectUrl(request, contextPath, jwt); + // Set JWT as HttpOnly cookie for security + setJwtCookie(response, jwt, contextPath); + + // Build context-aware redirect URL (without JWT in URL) + String redirectUrl = buildContextAwareRedirectUrl(request, contextPath); response.sendRedirect(redirectUrl); } else { @@ -164,17 +167,51 @@ public class CustomSaml2AuthenticationSuccessHandler } } + /** + * Sets JWT as an HttpOnly cookie for security + * Prevents XSS attacks by making token inaccessible to JavaScript + * + * @param response HTTP response to set cookie + * @param jwt JWT token to store + * @param contextPath Application context path for cookie path + */ + private void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) { + jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("stirling_jwt", jwt); + cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection) + cookie.setSecure(true); // Only send over HTTPS (set to false for local dev if needed) + cookie.setPath(contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app + cookie.setMaxAge(3600); // 1 hour (matches JWT expiration) + cookie.setAttribute("SameSite", "Lax"); // CSRF protection + response.addCookie(cookie); + } + + /** + * Validates if the origin is in the CORS whitelist + * + * @param origin Origin to validate + * @return true if origin is whitelisted or no whitelist configured + */ + private boolean isOriginWhitelisted(String origin) { + if (applicationProperties.getSystem() == null + || applicationProperties.getSystem().getCorsAllowedOrigins() == null + || applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + // No whitelist configured - only trust request origin + return false; + } + + return applicationProperties.getSystem().getCorsAllowedOrigins().contains(origin); + } + /** * Builds a context-aware redirect URL based on the request's origin + * Validates Referer against CORS whitelist to prevent token leakage to third parties * * @param request The HTTP request * @param contextPath The application context path - * @param jwt The JWT token to include * @return The appropriate redirect URL */ - private String buildContextAwareRedirectUrl( - HttpServletRequest request, String contextPath, String jwt) { - // Try to get the origin from the Referer header first + private String buildContextAwareRedirectUrl(HttpServletRequest request, String contextPath) { + // Try to get the origin from the Referer header String referer = request.getHeader("Referer"); if (referer != null && !referer.isEmpty()) { try { @@ -185,14 +222,25 @@ public class CustomSaml2AuthenticationSuccessHandler && refererUrl.getPort() != 443) { origin += ":" + refererUrl.getPort(); } - return origin + "/auth/callback#access_token=" + jwt; + + // SECURITY: Only trust Referer if it's in the CORS whitelist + // This prevents redirecting with JWT to untrusted domains (e.g., IdP domain) + if (isOriginWhitelisted(origin)) { + log.debug( + "Using whitelisted Referer origin for redirect: {}", + origin); + return origin + "/auth/callback"; + } else { + log.warn( + "Referer origin {} not in CORS whitelist, falling back to request origin", + origin); + } } catch (java.net.MalformedURLException e) { - log.debug( - "Malformed referer URL: {}, falling back to request-based origin", referer); + log.warn("Malformed Referer URL, falling back to request origin: {}", referer); } } - // Fall back to building from request host/port + // Fall back to building from request host/port (always safe) String scheme = request.getScheme(); String serverName = request.getServerName(); int serverPort = request.getServerPort(); @@ -206,6 +254,7 @@ public class CustomSaml2AuthenticationSuccessHandler origin.append(":").append(serverPort); } - return origin + "/auth/callback#access_token=" + jwt; + log.debug("Using request origin for redirect: {}", origin); + return origin + "/auth/callback"; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index a65c79665..b6c6e2e23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -41,14 +41,9 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; - private final KeyPersistenceServiceInterface keyPersistenceService; - private final boolean v2Enabled; - + private final @Autowired - public JwtService( - @Qualifier("v2Enabled") boolean v2Enabled, - KeyPersistenceServiceInterface keyPersistenceService) { - this.v2Enabled = v2Enabled; + public JwtService(KeyPersistenceServiceInterface keyPersistenceService) { this.keyPersistenceService = keyPersistenceService; log.info("JwtService initialized"); } @@ -266,7 +261,7 @@ public class JwtService implements JwtServiceInterface { @Override public boolean isJwtEnabled() { - return v2Enabled; + return true; // JWT is always enabled now (V2 is the default) } private String extractKeyId(String token) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java new file mode 100644 index 000000000..24dcca614 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshTokenService.java @@ -0,0 +1,252 @@ +package stirling.software.proprietary.security.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.RefreshToken; +import stirling.software.proprietary.security.repository.RefreshTokenRepository; + +/** + * Service for managing refresh tokens + * Implements secure token generation, validation, and revocation + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final SecureRandom secureRandom = new SecureRandom(); + + /** Refresh token validity: 7 days */ + private static final long REFRESH_TOKEN_VALIDITY_DAYS = 7; + + /** Refresh token length in bytes (before base64 encoding) */ + private static final int TOKEN_LENGTH = 32; + + /** + * Generates a new refresh token for a user + * + * @param userId User ID + * @param request HTTP request for audit trail (IP, user agent) + * @return The generated refresh token (plaintext - only shown once) + */ + @Transactional + public String generateRefreshToken(Long userId, HttpServletRequest request) { + // Generate cryptographically secure random token + byte[] tokenBytes = new byte[TOKEN_LENGTH]; + secureRandom.nextBytes(tokenBytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + + // Hash the token for storage (never store plaintext tokens) + String tokenHash = hashToken(token); + + // Build refresh token entity + RefreshToken refreshToken = + RefreshToken.builder() + .userId(userId) + .tokenHash(tokenHash) + .expiresAt(LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS)) + .issuedIp(extractIpAddress(request)) + .userAgent(extractUserAgent(request)) + .revoked(false) + .build(); + + refreshTokenRepository.save(refreshToken); + + log.debug("Generated new refresh token for user ID: {}", userId); + return token; + } + + /** + * Validates a refresh token + * + * @param token The plaintext refresh token + * @return Optional containing the RefreshToken entity if valid + */ + public Optional validateRefreshToken(String token) { + if (token == null || token.isEmpty()) { + log.debug("Refresh token validation failed: token is null or empty"); + return Optional.empty(); + } + + try { + String tokenHash = hashToken(token); + Optional refreshTokenOpt = + refreshTokenRepository.findByTokenHash(tokenHash); + + if (refreshTokenOpt.isEmpty()) { + log.debug("Refresh token validation failed: token not found"); + return Optional.empty(); + } + + RefreshToken refreshToken = refreshTokenOpt.get(); + + if (!refreshToken.isValid()) { + log.debug( + "Refresh token validation failed: token revoked or expired (userId: {})", + refreshToken.getUserId()); + return Optional.empty(); + } + + log.debug("Refresh token validated successfully for user ID: {}", refreshToken.getUserId()); + return Optional.of(refreshToken); + + } catch (Exception e) { + log.error("Error validating refresh token", e); + return Optional.empty(); + } + } + + /** + * Revokes a specific refresh token + * + * @param token The plaintext refresh token + * @return true if revoked successfully + */ + @Transactional + public boolean revokeRefreshToken(String token) { + try { + String tokenHash = hashToken(token); + int revoked = refreshTokenRepository.revokeByTokenHash(tokenHash); + log.debug("Revoked {} refresh token(s)", revoked); + return revoked > 0; + } catch (Exception e) { + log.error("Error revoking refresh token", e); + return false; + } + } + + /** + * Revokes all refresh tokens for a user (used on logout or security events) + * + * @param userId User ID + * @return Number of tokens revoked + */ + @Transactional + public int revokeAllTokensForUser(Long userId) { + int revoked = refreshTokenRepository.revokeAllByUserId(userId); + log.info("Revoked {} refresh token(s) for user ID: {}", revoked, userId); + return revoked; + } + + /** + * Rotates a refresh token (revokes old, generates new) + * Best practice for security: rotate tokens on each refresh + * + * @param oldToken The old refresh token to revoke + * @param userId User ID + * @param request HTTP request for audit trail + * @return New refresh token + */ + @Transactional + public String rotateRefreshToken(String oldToken, Long userId, HttpServletRequest request) { + // Revoke the old token + revokeRefreshToken(oldToken); + + // Generate and return a new token + return generateRefreshToken(userId, request); + } + + /** + * Cleans up expired refresh tokens (should be called periodically) + * + * @return Number of tokens deleted + */ + @Transactional + public int cleanupExpiredTokens() { + int deleted = refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now()); + if (deleted > 0) { + log.info("Cleaned up {} expired refresh tokens", deleted); + } + return deleted; + } + + /** + * Hashes a token using SHA-256 + * + * @param token Plaintext token + * @return Hex-encoded hash + */ + private String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + /** + * Converts byte array to hex string + */ + private String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + + /** + * Extracts IP address from request + */ + private String extractIpAddress(HttpServletRequest request) { + if (request == null) { + return null; + } + + // Check for forwarded IP (behind proxy) + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty()) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty()) { + ip = request.getRemoteAddr(); + } + + // Handle multiple IPs (take first one) + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + + // Truncate to fit column size (45 chars for IPv6) + if (ip != null && ip.length() > 45) { + ip = ip.substring(0, 45); + } + + return ip; + } + + /** + * Extracts user agent from request + */ + private String extractUserAgent(HttpServletRequest request) { + if (request == null) { + return null; + } + + String userAgent = request.getHeader("User-Agent"); + + // Truncate to fit column size (255 chars) + if (userAgent != null && userAgent.length() > 255) { + userAgent = userAgent.substring(0, 255); + } + + return userAgent; + } +}