diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index c6988098cf..37d3e589fe 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -398,7 +398,9 @@ public class ApplicationProperties { private boolean enableKeystore = true; private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; - private int keyRetentionDays = 7; + private int keyRetentionDays = 30; + private int tokenExpiryMinutes = 1440; + private int allowedClockSkewSeconds = 60; } @Data diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a15da437d5..0fb505307b 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -64,7 +64,9 @@ 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. + keyRetentionDays: 30 # Number of days to retain old keys. The default is 30 days. + tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes (1 day). + allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift. validation: # PDF signature validation settings trust: serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index c7ebdbdb4f..7d68877eac 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -34,6 +34,7 @@ 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; @@ -180,7 +181,12 @@ public class AuthController { 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()))); } catch (UsernameNotFoundException e) { String username = request.getUsername(); @@ -272,8 +278,7 @@ public class AuthController { .body(Map.of("error", "No token found")); } - jwtService.validateToken(token); - String username = jwtService.extractUsername(token); + String username = jwtService.extractUsernameAllowExpired(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); User user = (User) userDetails; @@ -289,8 +294,17 @@ public class AuthController { 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()))); + } catch (AuthenticationFailureException e) { + log.error("Token refresh error", e); + 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 +546,12 @@ public class AuthController { return userMap; } + private long getTokenExpirySeconds() { + int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes(); + int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : 720; + return expiryMinutes * 60L; + } + private ResponseEntity ensureWebAuth(User user) { if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 60472fef42..cecb609f72 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -30,6 +30,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +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; @@ -39,17 +40,21 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin public class JwtService implements JwtServiceInterface { private static final String ISSUER = "https://stirling.com"; - private static final long EXPIRATION = 43200000; + private static final long ONE_MINUTE_MILLIS = 60_000L; + private static final int DEFAULT_EXPIRY_MINUTES = 720; 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 @@ -86,7 +91,8 @@ public class JwtService implements JwtServiceInterface { .subject(username) .issuer(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(); @@ -114,12 +120,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 extractClaims(String token) { Claims claims = extractAllClaims(token); return new HashMap<>(claims); } + @Override + public Map 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 +147,21 @@ public class JwtService implements JwtServiceInterface { } private T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaims(token); + final Claims claims = extractAllClaims(token, false); + return claimsResolver.apply(claims); + } + + private T extractClaim( + String token, Function 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; @@ -181,6 +208,7 @@ public class JwtService implements JwtServiceInterface { return Jwts.parser() .verifyWith(keyPair.getPublic()) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -191,6 +219,9 @@ public class JwtService implements JwtServiceInterface { log.warn("Invalid token: {}", e.getMessage()); throw new AuthenticationFailureException("Invalid token", e); } catch (ExpiredJwtException e) { + if (allowExpired) { + return e.getClaims(); + } log.warn("The token has expired: {}", e.getMessage()); throw new AuthenticationFailureException("The token has expired", e); } catch (UnsupportedJwtException e) { @@ -210,6 +241,7 @@ public class JwtService implements JwtServiceInterface { keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey()); return Jwts.parser() .verifyWith(publicKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -230,6 +262,7 @@ public class JwtService implements JwtServiceInterface { verificationKey.getVerifyingKey()); return Jwts.parser() .verifyWith(publicKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parseSignedClaims(token) .getPayload(); @@ -276,6 +309,7 @@ public class JwtService implements JwtServiceInterface { (String) Jwts.parser() .verifyWith(signingKey) + .clockSkewSeconds(getAllowedClockSkewSeconds()) .build() .parse(token) .getHeader() @@ -286,4 +320,15 @@ public class JwtService implements JwtServiceInterface { return null; } } + + private long getExpirationMillis() { + int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes(); + int expiryMinutes = configuredMinutes > 0 ? configuredMinutes : DEFAULT_EXPIRY_MINUTES; + return expiryMinutes * ONE_MINUTE_MILLIS; + } + + private long getAllowedClockSkewSeconds() { + int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds(); + return configuredSeconds >= 0 ? configuredSeconds : 60L; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 2107f2ffd6..311f046a11 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -41,6 +41,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 +58,15 @@ public interface JwtServiceInterface { */ Map 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 extractClaimsAllowExpired(String token); + /** * Check if token is expired * diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index b3f3da9114..b410e05d0d 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -542,7 +542,14 @@ export class AuthService { } ); - const { token } = response.data; + const token = response.data?.session?.access_token ?? response.data?.token; + + if (!token) { + console.error('[Desktop AuthService] Refresh response missing token payload'); + this.setAuthStatus('unauthenticated', null); + await this.logout(); + return false; + } // Save token to all storage locations await this.saveTokenEverywhere(token);