1. CORS Configuration Validation - Prevents allowCredentials(true) with ["*"] origins

2. OAuth2/SAML2 Redirect Security - Validates Referer against CORS whitelist, prevents JWT leakage
3. JWT in HttpOnly Cookies - Moved JWT from URL fragments to secure HttpOnly cookies
4. Refresh Token Infrastructure - Complete implementation with rotation and revocation
5. V2 Flag Removal - Removed from application.properties, AppConfig, and JwtService
This commit is contained in:
DarioGii
2025-10-24 14:15:43 +01:00
parent b901a66466
commit 6337fbd30d
10 changed files with 738 additions and 57 deletions

View File

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

View File

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

View File

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

View File

@@ -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<String, Object> 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
*

View File

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

View File

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

View File

@@ -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<RefreshToken, Long> {
/**
* Find a refresh token by its hash
*
* @param tokenHash SHA-256 hash of the token
* @return Optional containing the refresh token if found
*/
Optional<RefreshToken> findByTokenHash(String tokenHash);
/**
* Find all refresh tokens for a specific user
*
* @param userId User ID
* @return List of refresh tokens
*/
List<RefreshToken> 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<RefreshToken> 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);
}

View File

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

View File

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

View File

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