mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
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:
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user