This commit is contained in:
Anthony Stirling
2026-02-15 01:13:27 +00:00
parent 691aaafae6
commit 59361b4231
6 changed files with 105 additions and 11 deletions
@@ -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
@@ -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)
@@ -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)
@@ -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<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 +147,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;
@@ -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;
}
}
@@ -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<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
*
+8 -1
View File
@@ -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);