mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
JWT enhancements for desktop (#5742)
# Description of Changes This is temporary solution which will be enhanced in future --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
package stirling.software.common.constants;
|
||||
|
||||
/**
|
||||
* Centralized constants for JWT token management.
|
||||
*
|
||||
* <p>These defaults are used when configuration values are not explicitly set.
|
||||
*/
|
||||
public final class JwtConstants {
|
||||
|
||||
private JwtConstants() {
|
||||
throw new UnsupportedOperationException("Utility class");
|
||||
}
|
||||
|
||||
/** Default JWT access token lifetime in minutes (24 hours). */
|
||||
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
|
||||
|
||||
/** Default desktop client token lifetime in minutes (30 days). */
|
||||
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
|
||||
|
||||
/**
|
||||
* Default refresh grace period in minutes.
|
||||
*
|
||||
* <p>Allows refresh of expired tokens within this window after expiration.
|
||||
*/
|
||||
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Default allowed clock skew in seconds.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks during validation.
|
||||
*/
|
||||
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
/** Milliseconds per minute. */
|
||||
public static final long MILLIS_PER_MINUTE = 60_000L;
|
||||
|
||||
/** Seconds per minute. */
|
||||
public static final long SECONDS_PER_MINUTE = 60L;
|
||||
|
||||
/** JWT issuer identifier. */
|
||||
public static final String ISSUER = "https://stirling.com";
|
||||
|
||||
/**
|
||||
* Maximum refresh attempts allowed within the grace period window.
|
||||
*
|
||||
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
|
||||
*/
|
||||
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
@@ -393,12 +394,107 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token configuration.
|
||||
*
|
||||
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
|
||||
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
|
||||
* {@code tokenExpiryMinutes: 720} in your configuration.
|
||||
*/
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for web clients.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
|
||||
*
|
||||
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
|
||||
* defaults to 1440 minutes (24 hours).
|
||||
*/
|
||||
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for desktop clients (Tauri app).
|
||||
*
|
||||
* <p>Desktop clients are automatically detected via User-Agent header and receive
|
||||
* longer-lived tokens because they run on personal devices with OS-level encrypted
|
||||
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
|
||||
*
|
||||
* <p>This provides better UX (login once per month) while maintaining security through
|
||||
* device encryption and secure storage, matching the behavior of popular desktop apps
|
||||
* like Slack, Discord, VS Code, etc.
|
||||
*
|
||||
* <p>Default: 43200 minutes (30 days).
|
||||
*/
|
||||
private int desktopTokenExpiryMinutes = 43200;
|
||||
|
||||
/**
|
||||
* Allowed clock skew in seconds for JWT validation.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks. Tokens that are
|
||||
* slightly expired or slightly in the future (within this window) will still be
|
||||
* accepted.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
|
||||
*/
|
||||
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
|
||||
/**
|
||||
* Grace period in minutes for refreshing expired tokens.
|
||||
*
|
||||
* <p>Allows token refresh using an expired access token if the token expired within
|
||||
* this many minutes. This provides better UX by allowing users to refresh slightly
|
||||
* expired tokens without re-authentication.
|
||||
*
|
||||
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
|
||||
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
|
||||
*/
|
||||
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
|
||||
/**
|
||||
* Calculate number of days to retain old JWT signing keys.
|
||||
*
|
||||
* <p>Automatically calculated based on the longest token lifetime plus a proportional
|
||||
* safety buffer. Keys must be retained for at least as long as the tokens they signed
|
||||
* remain valid, otherwise token verification will fail.
|
||||
*
|
||||
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
|
||||
*
|
||||
* <p>The buffer includes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>10% of token lifetime (scales with token duration)
|
||||
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
|
||||
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
|
||||
* </ul>
|
||||
*
|
||||
* @return calculated key retention period in days
|
||||
*/
|
||||
public int getKeyRetentionDays() {
|
||||
final int MINUTES_PER_DAY = 1440;
|
||||
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
|
||||
|
||||
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
|
||||
|
||||
// Add 10% buffer (scales with token lifetime)
|
||||
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
|
||||
|
||||
// Add refresh grace period
|
||||
bufferMinutes += refreshGraceMinutes;
|
||||
|
||||
// Add clock skew (convert seconds to minutes, round up)
|
||||
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
|
||||
|
||||
// Total retention in minutes, convert to days (round up)
|
||||
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
|
||||
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+2
-1
@@ -426,7 +426,8 @@ public class PdfJsonFallbackFontService {
|
||||
String normalized =
|
||||
WHITESPACE_PATTERN
|
||||
.matcher(
|
||||
PATTERN.matcher(originalFontName).replaceAll("") // Remove subset prefix
|
||||
PATTERN.matcher(originalFontName)
|
||||
.replaceAll("") // Remove subset prefix
|
||||
.toLowerCase())
|
||||
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
// "timesnewroman")
|
||||
|
||||
@@ -64,7 +64,10 @@ security:
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
|
||||
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
|
||||
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
|
||||
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
|
||||
+10
-3
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
@Value("${security.jwt.keyRetentionDays}")
|
||||
private int keyRetentionDays;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
public CacheConfig(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
cacheManager.setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
|
||||
+2
-1
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
|
||||
+208
-10
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
@@ -34,12 +35,15 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.RefreshRateLimitService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@@ -55,7 +59,9 @@ public class AuthController {
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final MfaService mfaService;
|
||||
private final TotpService totpService;
|
||||
private final RefreshRateLimitService refreshRateLimitService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -171,16 +177,52 @@ public class AuthController {
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
// Detect desktop client and issue longer-lived tokens for better UX
|
||||
// Desktop apps run on personal devices with OS-level encryption (secure storage)
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest);
|
||||
String token;
|
||||
int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays();
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
|
||||
token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440,
|
||||
keyRetentionDays);
|
||||
} else {
|
||||
// Web: Use configured web expiry (default 24 hours)
|
||||
token = jwtService.generateToken(user.getUsername(), claims);
|
||||
int webExpiryMinutes =
|
||||
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
log.info(
|
||||
"Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d",
|
||||
username,
|
||||
webExpiryMinutes,
|
||||
webExpiryMinutes / 1440,
|
||||
keyRetentionDays);
|
||||
}
|
||||
|
||||
// Record successful login
|
||||
loginAttemptService.loginSucceeded(username);
|
||||
log.info("Login successful for user: {} from IP: {}", username, ip);
|
||||
log.info(
|
||||
"Login successful for user: {} from IP: {} (desktop: {})",
|
||||
username,
|
||||
ip,
|
||||
isDesktopClient);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
token,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
String username = request.getUsername();
|
||||
@@ -272,25 +314,92 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
// Generate token hash for rate limiting (avoid storing actual tokens)
|
||||
String tokenHash = generateTokenHash(token);
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
|
||||
if (!isRefreshWithinGrace(claims)) {
|
||||
log.warn("Token refresh rejected: token expired beyond configured grace window");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
// Only apply rate limiting if token is actually expired (not for valid tokens)
|
||||
// This prevents false-positive 429 errors with multiple tabs, retries, etc.
|
||||
long expMillis = extractEpochMillis(claims.get("exp"));
|
||||
boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis();
|
||||
if (isExpired
|
||||
&& !refreshRateLimitService.isRefreshAllowed(
|
||||
tokenHash, getRefreshGraceMillis())) {
|
||||
log.warn(
|
||||
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Too many refresh attempts",
|
||||
"max_attempts",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
|
||||
}
|
||||
|
||||
Object usernameClaim = claims.get("sub");
|
||||
String username = usernameClaim != null ? usernameClaim.toString() : null;
|
||||
if (username == null || username.isBlank()) {
|
||||
log.warn("Token refresh rejected: missing subject claim");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
Map<String, Object> newClaims = new HashMap<>();
|
||||
newClaims.put("authType", user.getAuthenticationType());
|
||||
newClaims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String newToken;
|
||||
if (isDesktopClient) {
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
|
||||
newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Refreshed DESKTOP token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
newToken = jwtService.generateToken(username, newClaims);
|
||||
int webExpiryMinutes =
|
||||
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
log.info(
|
||||
"Refreshed WEB token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
webExpiryMinutes,
|
||||
webExpiryMinutes / 1440);
|
||||
}
|
||||
|
||||
// Don't clear rate limit tracking - let it expire naturally after grace period
|
||||
// This prevents reusing the same expired token indefinitely
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
newToken,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("Token refresh failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -532,6 +641,95 @@ public class AuthController {
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes =
|
||||
configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds(boolean isDesktop) {
|
||||
if (isDesktop) {
|
||||
// Desktop: use configured desktop token expiry
|
||||
return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties)
|
||||
* JwtConstants.SECONDS_PER_MINUTE;
|
||||
}
|
||||
// Web: use configured web value
|
||||
return getTokenExpirySeconds();
|
||||
}
|
||||
|
||||
private boolean isRefreshWithinGrace(Map<String, Object> claims) {
|
||||
long expMillis = extractEpochMillis(claims.get("exp"));
|
||||
if (expMillis <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (expMillis >= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
long expiredForMillis = now - expMillis;
|
||||
return expiredForMillis <= getRefreshGraceMillis();
|
||||
}
|
||||
|
||||
private long getRefreshGraceMillis() {
|
||||
int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes();
|
||||
int graceMinutes =
|
||||
configuredMinutes >= 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
return graceMinutes * JwtConstants.MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long extractEpochMillis(Object claimValue) {
|
||||
if (claimValue == null) {
|
||||
return -1L;
|
||||
}
|
||||
|
||||
if (claimValue instanceof java.util.Date date) {
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
if (claimValue instanceof Number number) {
|
||||
long epochSeconds = number.longValue();
|
||||
return epochSeconds * 1000L;
|
||||
}
|
||||
|
||||
return -1L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash of the token for rate limiting purposes.
|
||||
*
|
||||
* <p>Uses SHA-256 to avoid storing actual token values in memory.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return hex-encoded SHA-256 hash of the token
|
||||
*/
|
||||
private String generateTokenHash(String token) {
|
||||
try {
|
||||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes =
|
||||
digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
// Fallback to hashCode if SHA-256 is not available (should never happen)
|
||||
log.warn("SHA-256 not available, using hashCode for token tracking", e);
|
||||
return String.valueOf(token.hashCode());
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
|
||||
+23
-3
@@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
Map<String, Object> claims = Map.of("authType", AuthenticationType.OAUTH2);
|
||||
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String jwt;
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(
|
||||
applicationProperties);
|
||||
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
// Web: Use default expiry
|
||||
jwt = jwtService.generateToken(authentication, claims);
|
||||
log.debug("Issued WEB OAuth2 token for user '{}'", username);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
+22
-4
@@ -37,6 +37,7 @@ import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
@@ -191,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
|
||||
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String jwt;
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(
|
||||
applicationProperties);
|
||||
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
// Web: Use default expiry
|
||||
jwt = jwtService.generateToken(authentication, claims);
|
||||
log.debug("Issued WEB SAML token for user '{}'", username);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
+137
-23
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
@Service
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 43200000;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Autowired
|
||||
public JwtService(
|
||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
KeyPersistenceServiceInterface keyPersistenceService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
this.securityProperties = applicationProperties.getSecurity();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
|
||||
Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuer(JwtConstants.ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + getExpirationMillis()))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
@@ -100,6 +109,40 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateToken(String username, Map<String, Object> claims, int expiryMinutes) {
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
Optional<KeyPair> keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
|
||||
|
||||
if (keyPairOpt.isEmpty()) {
|
||||
throw new RuntimeException("Unable to retrieve key pair for active key");
|
||||
}
|
||||
|
||||
KeyPair keyPair = keyPairOpt.get();
|
||||
long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
|
||||
|
||||
var builder =
|
||||
Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(JwtConstants.ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + customExpirationMillis))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
if (keyId != null) {
|
||||
builder.header().keyId(keyId);
|
||||
}
|
||||
|
||||
return builder.compact();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate token with custom expiry", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateToken(String token) throws AuthenticationFailureException {
|
||||
extractAllClaims(token);
|
||||
@@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface {
|
||||
return extractClaim(token, Claims::getSubject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractUsernameAllowExpired(String token) {
|
||||
return extractClaim(token, Claims::getSubject, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaims(String token) {
|
||||
Claims claims = extractAllClaims(token);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaimsAllowExpired(String token) {
|
||||
Claims claims = extractAllClaims(token, true);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTokenExpired(String token) {
|
||||
return extractExpiration(token).before(new Date());
|
||||
@@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
final Claims claims = extractAllClaims(token, false);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private <T> T extractClaim(
|
||||
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
|
||||
final Claims claims = extractAllClaims(token, allowExpired);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token) {
|
||||
return extractAllClaims(token, false);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token, boolean allowExpired) {
|
||||
try {
|
||||
String keyId = extractKeyId(token);
|
||||
KeyPair keyPair;
|
||||
@@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface {
|
||||
} else {
|
||||
log.debug("No key ID in token header, trying all available keys");
|
||||
// Try all available keys when no keyId is present
|
||||
return tryAllKeys(token);
|
||||
return tryAllKeys(token, allowExpired);
|
||||
}
|
||||
|
||||
return Jwts.parser()
|
||||
.verifyWith(keyPair.getPublic())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.warn("Invalid token: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("Invalid token", e);
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.warn("The token has expired: {}", e.getMessage());
|
||||
if (allowExpired) {
|
||||
log.debug(
|
||||
"Extracting claims from expired token (allowed for refresh grace period): {}",
|
||||
e.getMessage());
|
||||
return e.getClaims();
|
||||
}
|
||||
log.warn("Token validation failed - token has expired: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.warn("The token is unsupported: {}", e.getMessage());
|
||||
@@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
|
||||
private Claims tryAllKeys(String token, boolean allowExpired)
|
||||
throws AuthenticationFailureException {
|
||||
// First try the active key
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
@@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
log.debug(
|
||||
"Extracting claims from expired token (allowed for refresh grace period)");
|
||||
return e.getClaims();
|
||||
}
|
||||
log.warn("Token validation failed - token has expired");
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (SignatureException
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException activeKeyException) {
|
||||
@@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface {
|
||||
verificationKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (SignatureException
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException e) {
|
||||
@@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface {
|
||||
return v2Enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key ID from JWT header without validating the token.
|
||||
*
|
||||
* <p>Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
|
||||
* the header cannot be parsed or does not contain a key ID.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return the key ID, or null if not found or parsing fails
|
||||
*/
|
||||
private String extractKeyId(String token) {
|
||||
try {
|
||||
PublicKey signingKey =
|
||||
keyPersistenceService.decodePublicKey(
|
||||
keyPersistenceService.getActiveKey().getVerifyingKey());
|
||||
String[] tokenParts = token.split("\\.");
|
||||
if (tokenParts.length < 2) {
|
||||
log.debug(
|
||||
"Token does not have enough parts (expected at least 2, got {})",
|
||||
tokenParts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
String keyId =
|
||||
(String)
|
||||
Jwts.parser()
|
||||
.verifyWith(signingKey)
|
||||
.build()
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
.get("kid");
|
||||
return keyId;
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
|
||||
Map<String, Object> header =
|
||||
OBJECT_MAPPER.readValue(
|
||||
headerBytes, new TypeReference<Map<String, Object>>() {});
|
||||
Object keyId = header.get("kid");
|
||||
return keyId instanceof String ? (String) keyId : null;
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
|
||||
return null;
|
||||
} catch (java.io.IOException e) {
|
||||
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private long getExpirationMillis() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes =
|
||||
configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long getAllowedClockSkewSeconds() {
|
||||
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
|
||||
return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String generateToken(String username, Map<String, Object> claims);
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a specific username with custom expiry
|
||||
*
|
||||
* @param username the username for which to generate the token
|
||||
* @param claims additional claims to include in the token
|
||||
* @param expiryMinutes custom token lifetime in minutes
|
||||
* @return JWT token as a string
|
||||
*/
|
||||
String generateToken(String username, Map<String, Object> claims, int expiryMinutes);
|
||||
|
||||
/**
|
||||
* Validate a JWT token
|
||||
*
|
||||
@@ -41,6 +51,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractUsername(String token);
|
||||
|
||||
/**
|
||||
* Extract username from JWT token while allowing expired tokens. Signature and token structure
|
||||
* must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return username extracted from token
|
||||
*/
|
||||
String extractUsernameAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token
|
||||
*
|
||||
@@ -49,6 +68,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
Map<String, Object> extractClaims(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token while allowing expired tokens. Signature and token
|
||||
* structure must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return map of claims
|
||||
*/
|
||||
Map<String, Object> extractClaimsAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*
|
||||
|
||||
+191
-13
@@ -10,8 +10,10 @@ import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.interfaces.RSAPrivateCrtKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
|
||||
public static final String KEY_SUFFIX = ".key";
|
||||
public static final String PUB_KEY_SUFFIX = ".pub";
|
||||
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
private final CacheManager cacheManager;
|
||||
@@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
@PostConstruct
|
||||
public void initializeKeystore() {
|
||||
if (!isKeystoreEnabled()) {
|
||||
log.info("JWT keystore is disabled - keys will be generated in memory");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ensurePrivateKeyDirectoryExists();
|
||||
loadKeyPair();
|
||||
loadExistingKeysFromDisk();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize keystore, using in-memory generation", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadKeyPair() {
|
||||
if (activeKey == null) {
|
||||
/**
|
||||
* Load all existing JWT keys from disk into memory on startup.
|
||||
*
|
||||
* <p>This ensures tokens signed with previous keys remain valid after server restart. If no
|
||||
* keys exist on disk, generates a new keypair.
|
||||
*/
|
||||
private void loadExistingKeysFromDisk() {
|
||||
try {
|
||||
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
|
||||
|
||||
if (!Files.exists(keyDirectory)) {
|
||||
log.info("No existing keys found, generating new keypair");
|
||||
generateAndStoreKeypair();
|
||||
return;
|
||||
}
|
||||
|
||||
List<Path> keyFiles;
|
||||
try (var stream = Files.list(keyDirectory)) {
|
||||
keyFiles =
|
||||
stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
b.getFileName().compareTo(a.getFileName())) // Most
|
||||
// recent
|
||||
// first
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
if (keyFiles.isEmpty()) {
|
||||
log.info("No existing keys found in directory, generating new keypair");
|
||||
generateAndStoreKeypair();
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Loading {} existing JWT keys from disk", keyFiles.size());
|
||||
int loadedCount = 0;
|
||||
|
||||
for (Path keyFile : keyFiles) {
|
||||
try {
|
||||
String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
|
||||
|
||||
// Load private key first
|
||||
PrivateKey privateKey = loadPrivateKey(keyId);
|
||||
|
||||
// Try to load public key, or generate it from private key if missing
|
||||
// (migration)
|
||||
String encodedPublicKey;
|
||||
try {
|
||||
encodedPublicKey = loadPublicKey(keyId);
|
||||
} catch (IOException e) {
|
||||
// Public key file doesn't exist - generate it from private key (migration)
|
||||
log.info("Migrating legacy key: generating public key file for {}", keyId);
|
||||
KeyPair keyPair = reconstructKeyPair(privateKey);
|
||||
|
||||
// Save the public key file
|
||||
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
encodedPublicKey = encodePublicKey(keyPair.getPublic());
|
||||
Files.writeString(publicKeyFile, encodedPublicKey);
|
||||
publicKeyFile.toFile().setReadable(true, true);
|
||||
publicKeyFile.toFile().setWritable(true, true);
|
||||
publicKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
log.info("Successfully migrated key: {}", keyId);
|
||||
}
|
||||
|
||||
// Create verification key and add to cache
|
||||
JwtVerificationKey verifyingKey =
|
||||
new JwtVerificationKey(keyId, encodedPublicKey);
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
loadedCount++;
|
||||
|
||||
// Set the most recent key as active (first in sorted list)
|
||||
if (activeKey == null) {
|
||||
activeKey = verifyingKey;
|
||||
log.info("Set active JWT signing key: {}", keyId);
|
||||
} else {
|
||||
log.debug(
|
||||
"Loaded historical JWT key: {} (created: {})",
|
||||
keyId,
|
||||
verifyingKey.getCreatedAt());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to load key: {}, skipping. Error: {}",
|
||||
keyFile.getFileName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedCount == 0) {
|
||||
log.warn("No valid keys could be loaded from disk, generating new keypair");
|
||||
generateAndStoreKeypair();
|
||||
} else {
|
||||
log.info(
|
||||
"Successfully loaded {} JWT keys, active key: {}",
|
||||
loadedCount,
|
||||
activeKey.getKeyId());
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to load keys from disk, generating new keypair", e);
|
||||
generateAndStoreKeypair();
|
||||
}
|
||||
}
|
||||
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
KeyPair keyPair = generateRSAKeypair();
|
||||
String keyId = generateKeyId();
|
||||
|
||||
storePrivateKey(keyId, keyPair.getPrivate());
|
||||
storeKeyPair(keyId, keyPair);
|
||||
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
activeKey = verifyingKey;
|
||||
log.info("Generated and stored new JWT keypair: {}", keyId);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to generate and store keypair", e);
|
||||
}
|
||||
@@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
|
||||
Path keyFile =
|
||||
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
|
||||
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
|
||||
Files.writeString(keyFile, encodedKey);
|
||||
/**
|
||||
* Store both private and public keys to disk.
|
||||
*
|
||||
* <p>Private key stored as: keyId.key
|
||||
*
|
||||
* <p>Public key stored as: keyId.pub
|
||||
*/
|
||||
private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
|
||||
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
|
||||
|
||||
// Set read/write to only the owner
|
||||
keyFile.toFile().setReadable(true, true);
|
||||
keyFile.toFile().setWritable(true, true);
|
||||
keyFile.toFile().setExecutable(false, false);
|
||||
// Store private key
|
||||
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
|
||||
String encodedPrivateKey =
|
||||
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
|
||||
Files.writeString(privateKeyFile, encodedPrivateKey);
|
||||
|
||||
// Set read/write to only the owner (security)
|
||||
privateKeyFile.toFile().setReadable(true, true);
|
||||
privateKeyFile.toFile().setWritable(true, true);
|
||||
privateKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
// Store public key
|
||||
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
String encodedPublicKey =
|
||||
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
|
||||
Files.writeString(publicKeyFile, encodedPublicKey);
|
||||
|
||||
// Public key can be more permissive but still restrict to owner
|
||||
publicKeyFile.toFile().setReadable(true, true);
|
||||
publicKeyFile.toFile().setWritable(true, true);
|
||||
publicKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
log.debug(
|
||||
"Stored keypair to disk: {} (private: {}, public: {})",
|
||||
keyId,
|
||||
privateKeyFile.getFileName(),
|
||||
publicKeyFile.getFileName());
|
||||
}
|
||||
|
||||
private PrivateKey loadPrivateKey(String keyId)
|
||||
@@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
return keyFactory.generatePrivate(keySpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public key from disk.
|
||||
*
|
||||
* @param keyId the key identifier
|
||||
* @return Base64-encoded public key string
|
||||
* @throws IOException if the public key file is not found
|
||||
*/
|
||||
private String loadPublicKey(String keyId) throws IOException {
|
||||
Path publicKeyFile =
|
||||
Paths.get(InstallationPathConfig.getPrivateKeyPath())
|
||||
.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
|
||||
if (!Files.exists(publicKeyFile)) {
|
||||
throw new IOException("Public key not found: " + publicKeyFile);
|
||||
}
|
||||
|
||||
return Files.readString(publicKeyFile).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a KeyPair from a PrivateKey.
|
||||
*
|
||||
* <p>For RSA keys, derives the public key from the private key.
|
||||
*
|
||||
* @param privateKey the RSA private key
|
||||
* @return reconstructed KeyPair
|
||||
* @throws NoSuchAlgorithmException if RSA algorithm is not available
|
||||
* @throws InvalidKeySpecException if the key specification is invalid
|
||||
*/
|
||||
private KeyPair reconstructKeyPair(PrivateKey privateKey)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
// For RSA, we can derive the public key from the private key
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
|
||||
// Get the private key spec
|
||||
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
|
||||
|
||||
// Create public key spec from private key parameters
|
||||
RSAPublicKeySpec publicKeySpec =
|
||||
new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
|
||||
|
||||
// Generate public key
|
||||
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
|
||||
|
||||
return new KeyPair(publicKey, privateKey);
|
||||
}
|
||||
|
||||
private String encodePublicKey(PublicKey publicKey) {
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Service to rate limit token refresh attempts within the grace period.
|
||||
*
|
||||
* <p>Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens
|
||||
* are identified by a hash to avoid storing actual token values.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class RefreshRateLimitService {
|
||||
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
|
||||
@Autowired
|
||||
public RefreshRateLimitService(ApplicationProperties applicationProperties) {
|
||||
this.jwtProperties = applicationProperties.getSecurity().getJwt();
|
||||
}
|
||||
|
||||
private static class RefreshAttempt {
|
||||
private final AtomicInteger count = new AtomicInteger(0);
|
||||
private final Instant firstAttempt = Instant.now();
|
||||
|
||||
int incrementAndGet() {
|
||||
return count.incrementAndGet();
|
||||
}
|
||||
|
||||
Instant getFirstAttempt() {
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
int getCount() {
|
||||
return count.get();
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, RefreshAttempt> attempts = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Check if a refresh attempt is allowed for the given token.
|
||||
*
|
||||
* @param tokenHash hash of the token attempting refresh
|
||||
* @param graceWindowMillis the configured grace window in milliseconds
|
||||
* @return true if refresh is allowed, false if rate limit exceeded
|
||||
*/
|
||||
public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) {
|
||||
RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt());
|
||||
|
||||
int attemptCount = attempt.incrementAndGet();
|
||||
|
||||
if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) {
|
||||
log.warn(
|
||||
"Refresh rate limit exceeded for token (attempt {}). Token hash: {}",
|
||||
attemptCount,
|
||||
tokenHash.substring(0, Math.min(8, tokenHash.length())));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up if outside grace window
|
||||
Instant cutoff = Instant.now().minusMillis(graceWindowMillis);
|
||||
if (attempt.getFirstAttempt().isBefore(cutoff)) {
|
||||
attempts.remove(tokenHash);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tracking for a token after successful refresh.
|
||||
*
|
||||
* @param tokenHash hash of the refreshed token
|
||||
*/
|
||||
public void clearRefreshAttempts(String tokenHash) {
|
||||
attempts.remove(tokenHash);
|
||||
}
|
||||
|
||||
/** Clean up expired tracking entries every 5 minutes. */
|
||||
@Scheduled(fixedRate = 300000)
|
||||
public void cleanupExpiredEntries() {
|
||||
// Use configured grace period with same normalization as runtime checks
|
||||
int configuredMinutes = jwtProperties.getRefreshGraceMinutes();
|
||||
int graceMinutes =
|
||||
configuredMinutes >= 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L);
|
||||
int removed =
|
||||
attempts.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff))
|
||||
.mapToInt(
|
||||
entry -> {
|
||||
attempts.remove(entry.getKey());
|
||||
return 1;
|
||||
})
|
||||
.sum();
|
||||
|
||||
if (removed > 0) {
|
||||
log.debug("Cleaned up {} expired refresh tracking entries", removed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current tracking statistics for monitoring. */
|
||||
public Map<String, Object> getStats() {
|
||||
return Map.of(
|
||||
"tracked_tokens",
|
||||
attempts.size(),
|
||||
"max_attempts_allowed",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Utility class for detecting desktop clients and determining appropriate token expiry times.
|
||||
*
|
||||
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
|
||||
*
|
||||
* <ul>
|
||||
* <li>They run on personal devices (not shared computers)
|
||||
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
|
||||
* <li>Better UX (users expect desktop apps to stay logged in)
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
public class DesktopClientUtils {
|
||||
|
||||
private DesktopClientUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the request is from a desktop client (Tauri app).
|
||||
*
|
||||
* @param request the HTTP request
|
||||
* @return true if desktop client, false if web browser
|
||||
*/
|
||||
public static boolean isDesktopClient(HttpServletRequest request) {
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
if (userAgent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent
|
||||
// Also check for common desktop app identifiers
|
||||
String userAgentLower = userAgent.toLowerCase();
|
||||
boolean hasTauri = userAgentLower.contains("tauri");
|
||||
boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop");
|
||||
boolean hasElectron = userAgentLower.contains("electron");
|
||||
boolean isDesktop = hasTauri || hasStirling || hasElectron;
|
||||
|
||||
log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured desktop token expiry time in minutes.
|
||||
*
|
||||
* @param applicationProperties the application properties
|
||||
* @return desktop token expiry in minutes (defaults to 30 days if not configured)
|
||||
*/
|
||||
public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) {
|
||||
int configuredMinutes =
|
||||
applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes();
|
||||
// If not configured or invalid, default to 30 days (43200 minutes)
|
||||
return configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured web token expiry time in minutes.
|
||||
*
|
||||
* @param applicationProperties the application properties
|
||||
* @return web token expiry in minutes
|
||||
*/
|
||||
public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) {
|
||||
int configuredMinutes =
|
||||
applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes();
|
||||
return configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
}
|
||||
}
|
||||
+86
-3
@@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -36,6 +38,7 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.RefreshRateLimitService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -53,11 +56,17 @@ class AuthControllerLoginTest {
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
@Mock private RefreshRateLimitService refreshRateLimitService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
securityProperties.getJwt().setTokenExpiryMinutes(60);
|
||||
securityProperties.getJwt().setRefreshGraceMinutes(5);
|
||||
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.setSecurity(securityProperties);
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
|
||||
loginAttemptService,
|
||||
mfaService,
|
||||
totpService,
|
||||
securityProperties);
|
||||
refreshRateLimitService,
|
||||
securityProperties,
|
||||
applicationProperties);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("[email protected]");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "[email protected]");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
// Rate limiting is not checked for valid tokens, so no stub needed
|
||||
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user").exists())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
||||
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
||||
.andExpect(
|
||||
jsonPath("$.session.expires_in")
|
||||
.value(3600)); // 60 minutes * 60 = 3600 seconds
|
||||
|
||||
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
|
||||
// period
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "[email protected]");
|
||||
claims.put(
|
||||
"exp",
|
||||
new Date(
|
||||
System.currentTimeMillis()
|
||||
- (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Token refresh failed"));
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "[email protected]");
|
||||
claims.put(
|
||||
"exp",
|
||||
new Date(
|
||||
System.currentTimeMillis()
|
||||
- 60_000)); // 1 minute ago, within 5 minute grace
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true);
|
||||
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"));
|
||||
|
||||
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
|
||||
// period
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRejectsWhenRateLimitExceeded() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "[email protected]");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.error").value("Too many refresh attempts"))
|
||||
.andExpect(jsonPath("$.max_attempts").exists());
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
verify(refreshRateLimitService, never()).clearRefreshAttempts(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+7
-1
@@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
|
||||
oauth2Props.setAutoCreateUser(true);
|
||||
oauth2Props.setBlockRegistration(false);
|
||||
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setOauth2(oauth2Props);
|
||||
applicationProperties.setSecurity(securityProperties);
|
||||
|
||||
CustomOAuth2AuthenticationSuccessHandler handler =
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
oauth2Props,
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService);
|
||||
licenseSettingsService,
|
||||
applicationProperties);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||
|
||||
+3
-15
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
@@ -64,7 +65,8 @@ class JwtServiceTest {
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||
|
||||
jwtService = new JwtService(true, keystoreService);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
jwtService = new JwtService(true, keystoreService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,8 +75,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -94,8 +94,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -114,8 +112,6 @@ class JwtServiceTest {
|
||||
void testValidateTokenSuccess() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn("testuser");
|
||||
|
||||
@@ -179,8 +175,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(user);
|
||||
when(user.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -207,8 +201,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -281,8 +273,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -307,8 +297,6 @@ class JwtServiceTest {
|
||||
// First, generate a token successfully
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user