This commit is contained in:
DarioGii
2025-10-24 17:39:44 +01:00
parent 6337fbd30d
commit cb14b592e1
15 changed files with 391 additions and 276 deletions
+1
View File
@@ -43,6 +43,7 @@ dependencies {
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
api("org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.7")
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
api 'org.springframework.boot:spring-boot-starter-jetty'
api 'org.springframework.boot:spring-boot-starter-security'
@@ -59,6 +59,9 @@ public class CustomAuthenticationSuccessHandler
authentication, Map.of("authType", AuthenticationType.WEB));
log.debug("JWT generated for user: {}", userName);
// Set JWT as HttpOnly cookie for security
jwtService.setJwtCookie(response, jwt, request.getContextPath());
getRedirectStrategy().sendRedirect(request, response, "/");
} else {
// Get the saved request
@@ -68,7 +68,7 @@ public class SecurityConfiguration {
private final boolean loginEnabledValue;
private final boolean runningProOrHigher;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final JwtServiceInterface jwtService;
@@ -88,7 +88,7 @@ public class SecurityConfiguration {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties.Security securityProperties,
ApplicationProperties applicationProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
@@ -105,7 +105,7 @@ public class SecurityConfiguration {
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.securityProperties = securityProperties;
this.applicationProperties = applicationProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
@@ -125,24 +125,19 @@ public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class);
.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class)
.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
if (v2Enabled) {
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
}
if (!securityProperties.getCsrfDisabled()) {
if (!applicationProperties.getSecurity().getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
@@ -185,21 +180,12 @@ public class SecurityConfiguration {
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS);
} else {
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true");
}
});
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
sessionManagement ->
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.authenticationProvider(daoAuthenticationProvider())
.requestCache(
requestCache -> requestCache.requestCache(new NullRequestCache()));
http.logout(
logout ->
logout.logoutRequestMatcher(
@@ -207,7 +193,9 @@ public class SecurityConfiguration {
.matcher("/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
applicationProperties.getSecurity(),
appConfig,
jwtService))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
@@ -275,7 +263,7 @@ public class SecurityConfiguration {
.anyRequest()
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
if (applicationProperties.getSecurity().isUserPass()) {
http.formLogin(
formLogin ->
formLogin
@@ -291,45 +279,40 @@ public class SecurityConfiguration {
.permitAll());
}
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
if (applicationProperties.getSecurity().isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
// v2: Don't set loginPage, let default OAuth2 flow handle it
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll();
});
oauth2 ->
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
applicationProperties,
loginAttemptService,
userService,
jwtService))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
applicationProperties
.getSecurity()
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) {
OpenSaml4AuthenticationProvider authenticationProvider =
new OpenSaml4AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(
@@ -345,8 +328,8 @@ public class SecurityConfiguration {
new ProviderManager(authenticationProvider))
.successHandler(
new CustomSaml2AuthenticationSuccessHandler(
applicationProperties,
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService))
.failureHandler(
@@ -391,6 +374,6 @@ public class SecurityConfiguration {
userService,
userDetailsService,
jwtAuthenticationEntryPoint,
securityProperties);
applicationProperties.getSecurity());
}
}
@@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import jakarta.servlet.http.Cookie;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -97,7 +98,8 @@ public class AuthController {
String token = jwtService.generateToken(user.getUsername(), claims);
// Generate refresh token for token rotation
String refreshToken = refreshTokenService.generateRefreshToken(user.getId(), servletRequest);
String refreshToken =
refreshTokenService.generateRefreshToken(user.getId(), servletRequest);
// Set JWT as HttpOnly cookie for security
setJwtCookie(response, token);
@@ -173,7 +175,10 @@ public class AuthController {
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());
log.info(
"Revoked {} refresh token(s) for user: {}",
revokedCount,
user.getUsername());
}
// Clear cookies
@@ -194,12 +199,12 @@ public class AuthController {
}
/**
* Refresh token endpoint - validates refresh token and issues new access 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 refresh token cookie
* @param response HTTP response to set new cookies
* @return New token information
* @param request HTTP request
* @param response HTTP response
* @return the refreshed token
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/refresh")
@@ -214,7 +219,6 @@ public class AuthController {
.body(Map.of("error", "No refresh token found"));
}
// Validate refresh token
var refreshTokenOpt = refreshTokenService.validateRefreshToken(refreshToken);
if (refreshTokenOpt.isEmpty()) {
@@ -226,8 +230,8 @@ public class AuthController {
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)
@@ -277,7 +281,7 @@ public class AuthController {
return null;
}
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
for (Cookie cookie : request.getCookies()) {
if ("stirling_refresh_token".equals(cookie.getName())) {
return cookie.getValue();
}
@@ -287,20 +291,14 @@ public class AuthController {
}
/**
* Sets JWT as an HttpOnly cookie for security
* Prevents XSS attacks by making token inaccessible to JavaScript
* 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);
jwtService.setJwtCookie(response, jwt, "");
}
/**
@@ -310,14 +308,7 @@ public class AuthController {
* @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);
jwtService.setRefreshTokenCookie(response, refreshToken, "", 7 * 24 * 3600);
}
/**
@@ -326,22 +317,8 @@ public class AuthController {
* @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);
jwtService.removeJwtCookie(response, "");
jwtService.removeRefreshTokenCookie(response, "");
}
/**
@@ -366,6 +343,21 @@ public class AuthController {
return userMap;
}
/**
* Get security configuration (including secureCookie flag)
*
* @return Security configuration
*/
@GetMapping("/config")
public ResponseEntity<Map<String, Object>> getAuthConfig() {
Map<String, Object> config = new HashMap<>();
config.put("secureCookie", jwtService.isSecureCookie());
config.put("jwtEnabled", jwtService.isJwtEnabled());
log.debug("Auth config requested: secureCookie={}", jwtService.isSecureCookie());
return ResponseEntity.ok(config);
}
// ===========================
// Request/Response DTOs
// ===========================
@@ -10,12 +10,17 @@ import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import java.time.Instant;
import java.util.HashMap;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -128,7 +133,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
authenticate(request, jwtToken, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
@@ -183,7 +188,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return true;
}
private void authenticate(HttpServletRequest request, Map<String, Object> claims)
private void authenticate(
HttpServletRequest request, String jwtToken, Map<String, Object> claims)
throws SQLException, UnsupportedProviderException {
String username = claims.get("sub").toString();
@@ -192,11 +198,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// Create a Jwt object from the token string and claims
Jwt jwt = createJwtFromClaims(jwtToken, claims);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
JwtAuthenticationToken authToken =
new JwtAuthenticationToken(jwt, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
throw new UsernameNotFoundException("User not found: " + username);
@@ -204,6 +213,37 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
/**
* Creates a Spring Security Jwt object from the token string and claims This is needed for
* JwtAuthenticationToken
*
* @param tokenValue The JWT token string
* @param claims The extracted claims from the token
* @return A Jwt object
*/
private Jwt createJwtFromClaims(String tokenValue, Map<String, Object> claims) {
// Extract standard claims
Instant issuedAt =
claims.containsKey("iat")
? Instant.ofEpochSecond(((Number) claims.get("iat")).longValue())
: Instant.now();
Instant expiresAt =
claims.containsKey("exp")
? Instant.ofEpochSecond(((Number) claims.get("exp")).longValue())
: Instant.now().plusSeconds(3600);
// Extract headers (kid if present)
Map<String, Object> headers = new HashMap<>();
if (claims.containsKey("kid")) {
headers.put("kid", claims.get("kid"));
}
headers.put("alg", "RS256");
// Create and return the Jwt object
return new Jwt(tokenValue, issuedAt, expiresAt, headers, claims);
}
private void processUserAuthenticationType(Map<String, Object> claims, String username)
throws SQLException, UnsupportedProviderException {
AuthenticationType authenticationType =
@@ -19,9 +19,9 @@ 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
* 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(
@@ -42,33 +42,26 @@ public class RefreshToken {
@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;
@@ -36,11 +36,10 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private final ApplicationProperties applicationProperties;
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
private final JwtServiceInterface jwtService;
private final ApplicationProperties applicationProperties;
@Override
public void onAuthenticationSuccess(
@@ -69,6 +68,9 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {
ApplicationProperties.Security.OAUTH2 oauth2Properties =
applicationProperties.getSecurity().getOauth2();
if (loginAttemptService.isBlocked(username)) {
if (session != null) {
session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST");
@@ -118,7 +120,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Set JWT as HttpOnly cookie for security
setJwtCookie(response, jwt, contextPath);
jwtService.setJwtCookie(response, jwt, contextPath);
// Build context-aware redirect URL (without JWT in URL)
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath);
@@ -147,24 +149,6 @@ 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
*
@@ -172,6 +156,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
* @return true if origin is whitelisted or no whitelist configured
*/
private boolean isOriginWhitelisted(String origin) {
ApplicationProperties applicationProperties = new ApplicationProperties();
if (applicationProperties.getSystem() == null
|| applicationProperties.getSystem().getCorsAllowedOrigins() == null
|| applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
@@ -183,8 +168,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
}
/**
* Builds a context-aware redirect URL based on the request's origin
* Validates Referer against CORS whitelist to prevent token leakage to third parties
* 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
@@ -206,9 +191,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
// 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);
log.debug("Using whitelisted Referer origin for redirect: {}", origin);
return origin + "/auth/callback";
} else {
log.warn(
@@ -33,11 +33,10 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomSaml2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private final ApplicationProperties applicationProperties;
private LoginAttemptService loginAttemptService;
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
private final JwtServiceInterface jwtService;
private final ApplicationProperties applicationProperties;
@Override
public void onAuthenticationSuccess(
@@ -73,7 +72,7 @@ public class CustomSaml2AuthenticationSuccessHandler
} else {
log.debug(
"Processing SAML2 authentication with autoCreateUser: {}",
saml2Properties.getAutoCreateUser());
applicationProperties.getSecurity().getSaml2().getAutoCreateUser());
if (loginAttemptService.isBlocked(username)) {
log.debug("User {} is blocked due to too many login attempts", username);
@@ -101,7 +100,7 @@ public class CustomSaml2AuthenticationSuccessHandler
if (userExists
&& hasPassword
&& (!isSSOUser || !isSAML2User)
&& saml2Properties.getAutoCreateUser()) {
&& applicationProperties.getSecurity().getSaml2().getAutoCreateUser()) {
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
username);
@@ -111,7 +110,11 @@ public class CustomSaml2AuthenticationSuccessHandler
}
try {
if (!userExists || saml2Properties.getBlockRegistration()) {
if (!userExists
|| applicationProperties
.getSecurity()
.getSaml2()
.getBlockRegistration()) {
log.debug("Registration blocked for new user: {}", username);
response.sendRedirect(
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
@@ -132,7 +135,7 @@ public class CustomSaml2AuthenticationSuccessHandler
username,
ssoProviderId,
ssoProvider,
saml2Properties.getAutoCreateUser(),
applicationProperties.getSecurity().getSaml2().getAutoCreateUser(),
SAML2);
log.debug("Successfully processed authentication for user: {}", username);
@@ -144,7 +147,7 @@ public class CustomSaml2AuthenticationSuccessHandler
Map.of("authType", AuthenticationType.SAML2));
// Set JWT as HttpOnly cookie for security
setJwtCookie(response, jwt, contextPath);
jwtService.setJwtCookie(response, jwt, contextPath);
// Build context-aware redirect URL (without JWT in URL)
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath);
@@ -167,24 +170,6 @@ 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
*
@@ -203,8 +188,8 @@ public class CustomSaml2AuthenticationSuccessHandler
}
/**
* Builds a context-aware redirect URL based on the request's origin
* Validates Referer against CORS whitelist to prevent token leakage to third parties
* 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
@@ -226,9 +211,7 @@ public class CustomSaml2AuthenticationSuccessHandler
// 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);
log.debug("Using whitelisted Referer origin for redirect: {}", origin);
return origin + "/auth/callback";
} else {
log.warn(
@@ -13,7 +13,7 @@ import java.util.Optional;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
@@ -26,7 +26,9 @@ import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -40,8 +42,14 @@ public class JwtService implements JwtServiceInterface {
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 3600000;
private static final String JWT_COOKIE_NAME = "stirling_jwt";
private static final String REFRESH_TOKEN_COOKIE_NAME = "stirling_refresh";
private final KeyPersistenceServiceInterface keyPersistenceService;
@Value("${security.jwt.secureCookie:true}")
private boolean secureCookie;
private final
@Autowired
public JwtService(KeyPersistenceServiceInterface keyPersistenceService) {
this.keyPersistenceService = keyPersistenceService;
@@ -285,4 +293,97 @@ public class JwtService implements JwtServiceInterface {
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
*/
public void setJwtCookie(HttpServletResponse response, String jwt, String contextPath) {
Cookie cookie = new Cookie(JWT_COOKIE_NAME, jwt);
cookie.setHttpOnly(true);
cookie.setSecure(secureCookie);
cookie.setPath(contextPath.isEmpty() ? "/" : contextPath);
cookie.setMaxAge((int) (EXPIRATION / 1000));
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
log.debug(
"JWT cookie set with secure={}, maxAge={}, path={}",
secureCookie,
(EXPIRATION / 1000),
contextPath.isEmpty() ? "/" : contextPath);
}
/**
* Sets refresh token as an HttpOnly cookie for security.
*
* @param response HTTP response to set cookie
* @param refreshToken Refresh token to store
* @param contextPath Application context path for cookie path
* @param maxAge Maximum age in seconds
*/
public void setRefreshTokenCookie(
HttpServletResponse response, String refreshToken, String contextPath, int maxAge) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
cookie.setHttpOnly(true); // Prevent JavaScript access (XSS protection)
cookie.setSecure(secureCookie); // Only send over HTTPS (configurable for local dev)
cookie.setPath(
contextPath.isEmpty() ? "/" : contextPath); // Cookie available for entire app
cookie.setMaxAge(maxAge);
cookie.setAttribute("SameSite", "Lax"); // CSRF protection
response.addCookie(cookie);
log.debug(
"Refresh token cookie set with secure={}, maxAge={}, path={}",
secureCookie,
maxAge,
contextPath.isEmpty() ? "/" : contextPath);
}
/**
* Removes JWT cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
public void removeJwtCookie(HttpServletResponse response, String contextPath) {
Cookie cookie = new Cookie(JWT_COOKIE_NAME, "");
cookie.setHttpOnly(true);
cookie.setSecure(secureCookie);
cookie.setPath(contextPath.isEmpty() ? "/" : contextPath);
cookie.setMaxAge(0); // Expire immediately
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
log.debug("JWT cookie removed");
}
/**
* Removes refresh token cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
public void removeRefreshTokenCookie(HttpServletResponse response, String contextPath) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, "");
cookie.setHttpOnly(true);
cookie.setSecure(secureCookie);
cookie.setPath(contextPath.isEmpty() ? "/" : contextPath);
cookie.setMaxAge(0); // Expire immediately
cookie.setAttribute("SameSite", "Lax");
response.addCookie(cookie);
log.debug("Refresh token cookie removed");
}
/**
* Gets the configured secureCookie flag.
*
* @return true if cookies should be set with Secure flag, false otherwise
*/
public boolean isSecureCookie() {
return secureCookie;
}
}
@@ -5,6 +5,7 @@ import java.util.Map;
import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public interface JwtServiceInterface {
@@ -71,4 +72,48 @@ public interface JwtServiceInterface {
* @return true if JWT is enabled, false otherwise
*/
boolean isJwtEnabled();
/**
* 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
*/
void setJwtCookie(HttpServletResponse response, String jwt, String contextPath);
/**
* Sets refresh token as an HttpOnly cookie for security.
*
* @param response HTTP response to set cookie
* @param refreshToken Refresh token to store
* @param contextPath Application context path for cookie path
* @param maxAge Maximum age in seconds
*/
void setRefreshTokenCookie(
HttpServletResponse response, String refreshToken, String contextPath, int maxAge);
/**
* Removes JWT cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
void removeJwtCookie(HttpServletResponse response, String contextPath);
/**
* Removes refresh token cookie from the response (used for logout).
*
* @param response HTTP response to remove cookie
* @param contextPath Application context path for cookie path
*/
void removeRefreshTokenCookie(HttpServletResponse response, String contextPath);
/**
* Gets the configured secureCookie flag.
*
* @return true if cookies should be set with Secure flag, false otherwise
*/
boolean isSecureCookie();
}
@@ -8,6 +8,7 @@ import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,8 +21,8 @@ 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
* Service for managing refresh tokens. Implements secure token generation, validation, and
* revocation
*/
@Slf4j
@Service
@@ -31,10 +32,9 @@ 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;
@Value("${security.jwt.refreshTokenDays:7}")
private long refreshTokenValidityDays;
/** Refresh token length in bytes (before base64 encoding) */
private static final int TOKEN_LENGTH = 32;
/**
@@ -46,12 +46,11 @@ public class RefreshTokenService {
*/
@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)
// Hash the token for storage
String tokenHash = hashToken(token);
// Build refresh token entity
@@ -59,7 +58,7 @@ public class RefreshTokenService {
RefreshToken.builder()
.userId(userId)
.tokenHash(tokenHash)
.expiresAt(LocalDateTime.now().plusDays(REFRESH_TOKEN_VALIDITY_DAYS))
.expiresAt(LocalDateTime.now().plusDays(refreshTokenValidityDays))
.issuedIp(extractIpAddress(request))
.userAgent(extractUserAgent(request))
.revoked(false)
@@ -102,7 +101,9 @@ public class RefreshTokenService {
return Optional.empty();
}
log.debug("Refresh token validated successfully for user ID: {}", refreshToken.getUserId());
log.debug(
"Refresh token validated successfully for user ID: {}",
refreshToken.getUserId());
return Optional.of(refreshToken);
} catch (Exception e) {
@@ -144,8 +145,8 @@ public class RefreshTokenService {
}
/**
* Rotates a refresh token (revokes old, generates new)
* Best practice for security: rotate tokens on each refresh
* 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
@@ -185,15 +186,14 @@ public class RefreshTokenService {
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
*/
/** Converts byte array to hex string */
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
@@ -202,9 +202,7 @@ public class RefreshTokenService {
return result.toString();
}
/**
* Extracts IP address from request
*/
/** Extracts IP address from request */
private String extractIpAddress(HttpServletRequest request) {
if (request == null) {
return null;
@@ -232,9 +230,7 @@ public class RefreshTokenService {
return ip;
}
/**
* Extracts user agent from request
*/
/** Extracts user agent from request */
private String extractUserAgent(HttpServletRequest request) {
if (request == null) {
return null;
@@ -378,6 +378,10 @@ public class UserService implements UserServiceInterface {
}
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
@@ -64,7 +64,7 @@ class JwtServiceTest {
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
jwtService = new JwtService(true, keystoreService);
jwtService = new JwtService(keystoreService);
}
@Test
+54 -54
View File
@@ -2,9 +2,10 @@
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - Uses HttpOnly cookies for JWT storage (automatic secure storage)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
* - Refresh tokens stored in separate HttpOnly cookie
*/
// Auth types
@@ -44,17 +45,52 @@ export type AuthChangeEvent =
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
export interface AuthConfig {
secureCookie: boolean;
jwtEnabled: boolean;
}
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
private authConfig: AuthConfig | null = null;
constructor() {
// Load auth config
this.loadAuthConfig();
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Load authentication configuration from backend
*/
private async loadAuthConfig() {
try {
const response = await fetch('/api/v1/auth/config', {
credentials: 'include',
});
if (response.ok) {
this.authConfig = await response.json();
console.debug('[SpringAuth] Auth config loaded:', this.authConfig);
} else {
console.warn('[SpringAuth] Failed to load auth config');
}
} catch (error) {
console.error('[SpringAuth] Error loading auth config:', error);
}
}
/**
* Get authentication configuration
*/
getAuthConfig(): AuthConfig | null {
return this.authConfig;
}
/**
* Helper to get CSRF token from cookie
*/
@@ -71,38 +107,26 @@ class SpringAuthClient {
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
* JWT is stored in HttpOnly cookie (automatic with credentials: 'include')
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
const token = localStorage.getItem('stirling_jwt');
if (!token) {
console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
// Verify with backend
// Verify with backend (JWT automatically sent via HttpOnly cookie)
const response = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
credentials: 'include', // Include cookies
});
if (!response.ok) {
// Token invalid or expired - clear it
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
}
const data = await response.json();
// Create session object
// Create session object (no access_token in JS - it's in HttpOnly cookie)
const session: Session = {
user: data.user,
access_token: token,
access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie)
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
@@ -111,8 +135,6 @@ class SpringAuthClient {
return { data: { session }, error: null };
} catch (error) {
console.error('[SpringAuth] getSession error:', error);
// Clear potentially invalid token
localStorage.removeItem('stirling_jwt');
return {
data: { session: null },
error: { message: error instanceof Error ? error.message : 'Unknown error' },
@@ -144,15 +166,13 @@ class SpringAuthClient {
}
const data = await response.json();
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[SpringAuth] JWT stored in localStorage');
// JWT is now stored in HttpOnly cookie by backend (no localStorage needed)
console.log('[SpringAuth] JWT stored in HttpOnly cookie by backend');
const session: Session = {
user: data.user,
access_token: token,
access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie)
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
@@ -233,13 +253,11 @@ class SpringAuthClient {
}
/**
* Sign out
* Sign out - revokes refresh tokens and clears HttpOnly cookies
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
// Clear JWT from localStorage immediately
localStorage.removeItem('stirling_jwt');
console.log('[SpringAuth] JWT removed from localStorage');
console.log('[SpringAuth] Signing out, clearing HttpOnly cookies');
const csrfToken = this.getCsrfToken();
const headers: HeadersInit = {};
@@ -248,7 +266,7 @@ class SpringAuthClient {
headers['X-XSRF-TOKEN'] = csrfToken;
}
// Notify backend (optional - mainly for session cleanup)
// Notify backend to revoke refresh tokens and clear cookies
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
@@ -261,8 +279,6 @@ class SpringAuthClient {
return { error: null };
} catch (error) {
console.error('[SpringAuth] signOut error:', error);
// Still remove token even if backend call fails
localStorage.removeItem('stirling_jwt');
return {
error: { message: error instanceof Error ? error.message : 'Sign out failed' },
};
@@ -270,50 +286,35 @@ class SpringAuthClient {
}
/**
* Refresh session token
* Refresh session token using refresh token (automatically sent via HttpOnly cookie)
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
const currentToken = localStorage.getItem('stirling_jwt');
if (!currentToken) {
return { data: { session: null }, error: { message: 'No token to refresh' } };
}
// Refresh token is automatically sent via HttpOnly cookie
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentToken}`,
},
credentials: 'include',
});
if (!response.ok) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: { message: 'Token refresh failed' } };
}
const refreshData = await response.json();
const newToken = refreshData.access_token;
// Store new token
localStorage.setItem('stirling_jwt', newToken);
// Backend sets new access token and refresh token in HttpOnly cookies
// Get updated user info
const userResponse = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${newToken}`,
},
credentials: 'include',
});
if (!userResponse.ok) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: { message: 'Failed to get user info' } };
}
const userData = await userResponse.json();
const session: Session = {
user: userData.user,
access_token: newToken,
access_token: '', // Not accessible to JavaScript (stored in HttpOnly cookie)
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
@@ -324,7 +325,6 @@ class SpringAuthClient {
return { data: { session }, error: null };
} catch (error) {
console.error('[SpringAuth] refreshSession error:', error);
localStorage.removeItem('stirling_jwt');
return {
data: { session: null },
error: { message: error instanceof Error ? error.message : 'Refresh failed' },
+9 -18
View File
@@ -6,8 +6,8 @@ import { useAuth } from '../auth/UseSession'
* OAuth Callback Handler
*
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
* We extract it, store in localStorage, and redirect to the home page.
* The JWT is now stored in an HttpOnly cookie by the Spring backend (secure, no localStorage).
* We just need to verify the authentication and redirect to the home page.
*/
export default function AuthCallback() {
const navigate = useNavigate()
@@ -18,30 +18,21 @@ export default function AuthCallback() {
try {
console.log('[AuthCallback] Handling OAuth callback...')
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1) // Remove '#'
const params = new URLSearchParams(hash)
const token = params.get('access_token')
// JWT is now stored in HttpOnly cookie by backend - just refresh session to verify
const result = await refreshSession()
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment')
if (result.error || !result.data.session) {
console.error('[AuthCallback] Authentication verification failed:', result.error)
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
state: { error: 'OAuth login failed - authentication could not be verified.' }
})
return
}
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token)
console.log('[AuthCallback] JWT stored in localStorage')
console.log('[AuthCallback] Authentication verified, redirecting to home')
// Refresh session to load user info into state
await refreshSession()
console.log('[AuthCallback] Session refreshed, redirecting to home')
// Clear the hash from URL and redirect to home page
// Redirect to home page
navigate('/', { replace: true })
} catch (error) {
console.error('[AuthCallback] Error:', error)