mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
wip
This commit is contained in:
@@ -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'
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+50
-67
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+34
-42
@@ -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
|
||||
// ===========================
|
||||
|
||||
+46
-6
@@ -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 =
|
||||
|
||||
+3
-10
@@ -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;
|
||||
|
||||
|
||||
+9
-26
@@ -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(
|
||||
|
||||
+13
-30
@@ -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(
|
||||
|
||||
+103
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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();
|
||||
}
|
||||
|
||||
+16
-20
@@ -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;
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user