diff --git a/app/common/src/main/java/stirling/software/common/constants/JwtConstants.java b/app/common/src/main/java/stirling/software/common/constants/JwtConstants.java new file mode 100644 index 0000000000..a05df9bde4 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/constants/JwtConstants.java @@ -0,0 +1,49 @@ +package stirling.software.common.constants; + +/** + * Centralized constants for JWT token management. + * + *

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. + * + *

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. + * + *

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. + * + *

Prevents abuse of expired tokens by limiting refresh attempts. + */ + public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3; +} 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..d0c3691958 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 @@ -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. + * + *

BREAKING CHANGE (v2.0): 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. + * + *

Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours). + * + *

BREAKING CHANGE: 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). + * + *

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). + * + *

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. + * + *

Default: 43200 minutes (30 days). + */ + private int desktopTokenExpiryMinutes = 43200; + + /** + * Allowed clock skew in seconds for JWT validation. + * + *

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. + * + *

Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds. + */ + private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS; + + /** + * Grace period in minutes for refreshing expired tokens. + * + *

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. + * + *

Rate limiting is applied to prevent abuse of expired tokens within the grace + * window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts). + * + *

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. + * + *

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. + * + *

Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440) + * + *

The buffer includes: + * + *

+ * + * @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 diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java index 7bd56e700a..be51dd74eb 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -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") diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a15da437d5..53c252e2db 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java index ba074a5da1..501826a084 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java @@ -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() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 06efcf3a1d..f3a8f25b58 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -361,7 +361,8 @@ public class SecurityConfiguration { securityProperties.getOauth2(), userService, jwtService, - licenseSettingsService)) + licenseSettingsService, + applicationProperties)) .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) // Add existing Authorities from the database .userInfoEndpoint( 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..62b5f55477 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 @@ -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 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 claims = new HashMap<>(); - claims.put("authType", user.getAuthenticationType()); - claims.put("role", user.getRolesAsString()); + Map 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 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. + * + *

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) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 08332dd4b9..e86857d33d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -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 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 = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 8076829ec0..f790cbac36 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -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 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 = 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..a551ab9070 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 @@ -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 claims, int expiryMinutes) { + try { + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + Optional 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 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 +184,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; @@ -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. + * + *

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 header = + OBJECT_MAPPER.readValue( + headerBytes, new TypeReference>() {}); + 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; + } } 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..cded5b31fc 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 @@ -25,6 +25,16 @@ public interface JwtServiceInterface { */ String generateToken(String username, Map 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 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 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/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java index 48bcddac0d..d0c9f879be 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -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. + * + *

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 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. + * + *

Private key stored as: keyId.key + * + *

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. + * + *

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()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java new file mode 100644 index 0000000000..bb4e454290 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/RefreshRateLimitService.java @@ -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. + * + *

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 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 getStats() { + return Map.of( + "tracked_tokens", + attempts.size(), + "max_attempts_allowed", + JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java new file mode 100644 index 0000000000..3ccfd8e773 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/DesktopClientUtils.java @@ -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. + * + *

Desktop clients (Tauri, Electron) receive longer-lived tokens because: + * + *

    + *
  • They run on personal devices (not shared computers) + *
  • Tokens stored in OS-level encrypted keychain (not browser localStorage) + *
  • Better UX (users expect desktop apps to stay logged in) + *
+ */ +@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; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index 86bcf50e8c..fd1a99e7b1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -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("user@example.com"); + Map claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + 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("user@example.com")).thenReturn(user); when(jwtService.generateToken(eq("user@example.com"), 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 claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + 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 claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + 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("user@example.com")).thenReturn(user); + when(jwtService.generateToken(eq("user@example.com"), 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 claims = new HashMap<>(); + claims.put("sub", "user@example.com"); + 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 diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java index 376d0b4ed7..b61883949e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandlerTest.java @@ -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); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index e8a6d60453..787aa57e3f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -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); diff --git a/build.gradle b/build.gradle index fdf864b862..fd706cb6ca 100644 --- a/build.gradle +++ b/build.gradle @@ -67,7 +67,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.4.5' + version = '2.5.0' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 4c6d86c4a6..f927cea0a2 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -1236,9 +1236,21 @@ label = "Enable Key Cleanup" description = "Automatically rotate JWT signing keys periodically" label = "Enable Key Rotation" -[admin.settings.security.jwt.keyRetentionDays] -description = "Number of days to retain old JWT keys for verification" -label = "Key Retention Days" +[admin.settings.security.jwt.tokenExpiryMinutes] +description = "Access token lifetime in minutes for web clients (default: 1440 = 24 hours)" +label = "Web Token Expiry (minutes)" + +[admin.settings.security.jwt.desktopTokenExpiryMinutes] +description = "Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)" +label = "Desktop Token Expiry (minutes)" + +[admin.settings.security.jwt.allowedClockSkewSeconds] +description = "Tolerance for client/server time drift during token validation (default: 60 seconds)" +label = "Clock Skew Tolerance (seconds)" + +[admin.settings.security.jwt.refreshGraceMinutes] +description = "Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)" +label = "Refresh Grace Period (minutes)" [admin.settings.security.jwt.persistence] description = "Store JWT keys persistently to survive server restarts" @@ -1427,13 +1439,16 @@ applyChanges = "Apply Changes" backgroundColor = "Background colour" borderOff = "Border: Off" borderOn = "Border: On" +changeColor = "Change Colour" chooseColor = "Choose colour" circle = "Circle" clearBackground = "Remove background" color = "Colour" contents = "Text" +delete = "Delete" desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." drawing = "Drawing" +duplicate = "Duplicate" editCircle = "Edit Circle" editInk = "Edit Pen" editLine = "Edit Line" @@ -1463,6 +1478,7 @@ notesStamps = "Notes & Stamps" opacity = "Opacity" pen = "Pen" polygon = "Polygon" +properties = "Properties" rectangle = "Rectangle" redo = "Redo" saveChanges = "Save Changes" @@ -1488,6 +1504,7 @@ title = "Annotate" underline = "Underline" undo = "Undo" unsupportedType = "This annotation type is not fully supported for editing." +width = "Width" [app] description = "The Free Adobe Acrobat alternative (10M+ Downloads)" @@ -4218,6 +4235,56 @@ title = "Page Editor" zoomIn = "Zoom In" zoomOut = "Zoom Out" +[viewer] +cannotPreviewFile = "Cannot Preview File" +dualPageView = "Dual Page View" +firstPage = "First Page" +lastPage = "Last Page" +nextPage = "Next Page" +onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." +previousPage = "Previous Page" +singlePageView = "Single Page View" +unknownFile = "Unknown file" +zoomIn = "Zoom In" +zoomOut = "Zoom Out" + +[rightRail] +closeSelected = "Close Selected Files" +selectAll = "Select All" +deselectAll = "Deselect All" +selectByNumber = "Select by Page Numbers" +deleteSelected = "Delete Selected Pages" +closePdf = "Close PDF" +exportAll = "Export PDF" +downloadSelected = "Download Selected Files" +annotations = "Annotations" +exportSelected = "Export Selected Pages" +formFill = "Fill Form" +saveChanges = "Save Changes" +toggleAttachments = "Toggle Attachments" +toggleTheme = "Toggle Theme" +language = "Language" +toggleAnnotations = "Toggle Annotations Visibility" +search = "Search PDF" +panMode = "Pan Mode" +applyRedactionsFirst = "Apply redactions first" +rotateLeft = "Rotate Left" +rotateRight = "Rotate Right" +toggleSidebar = "Toggle Sidebar" +toggleBookmarks = "Toggle Bookmarks" +print = "Print PDF" +draw = "Draw" +redact = "Redact" +exitRedaction = "Exit Redaction Mode" +save = "Save" +downloadAll = "Download All" +saveAll = "Save All" + +[textAlign] +left = "Left" +center = "Center" +right = "Right" + [pageExtracter] header = "Extract Pages" placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" @@ -5299,38 +5366,6 @@ title = "High Contrast" text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." title = "Invert All Colours" -[rightRail] -annotations = "Annotations" -applyRedactionsFirst = "Apply redactions first" -closePdf = "Close PDF" -closeSelected = "Close Selected Files" -formFill = "Fill Form" -deleteSelected = "Delete Selected Pages" -deselectAll = "Deselect All" -downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" -exitRedaction = "Exit Redaction Mode" -exportAll = "Export PDF" -exportSelected = "Export Selected Pages" -language = "Language" -panMode = "Pan Mode" -print = "Print PDF" -redact = "Redact" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -save = "Save" -saveAll = "Save All" -saveChanges = "Save Changes" -search = "Search PDF" -selectAll = "Select All" -selectByNumber = "Select by Page Numbers" -toggleAnnotations = "Toggle Annotations Visibility" -toggleBookmarks = "Toggle Bookmarks" -toggleAttachments = "Toggle Attachments" -toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" - [rotate] rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" @@ -6183,11 +6218,6 @@ title = "API Documentation" [tableExtraxt] tags = "CSV,Table Extraction,extract,convert" -[textAlign] -center = "Center" -left = "Left" -right = "Right" - [theme] toggle = "Toggle Theme" @@ -6472,19 +6502,6 @@ fileManager = "File Manager" pageEditor = "Page Editor" viewer = "Viewer" -[viewer] -cannotPreviewFile = "Cannot Preview File" -dualPageView = "Dual Page View" -firstPage = "First Page" -lastPage = "Last Page" -nextPage = "Next Page" -onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format." -previousPage = "Previous Page" -singlePageView = "Single Page View" -unknownFile = "Unknown file" -zoomIn = "Zoom In" -zoomOut = "Zoom Out" - [viewer.attachments] title = "Attachments" searchPlaceholder = "Search attachments" diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 0da39ba5a6..7c5b3d957f 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling-PDF", - "version": "2.4.6", + "version": "2.5.0", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx new file mode 100644 index 0000000000..51153c071f --- /dev/null +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -0,0 +1,59 @@ +import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core'; +import { useState } from 'react'; + +interface ColorControlProps { + value: string; + onChange: (color: string) => void; + label: string; + disabled?: boolean; +} + +export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) { + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx new file mode 100644 index 0000000000..27b1f10dd9 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx @@ -0,0 +1,60 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import OpacityIcon from '@mui/icons-material/Opacity'; + +interface OpacityControlProps { + value: number; // 0-100 + onChange: (value: number) => void; + disabled?: boolean; +} + +export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + {t('annotation.opacity', 'Opacity')} + + `${val}%`} + /> + + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx new file mode 100644 index 0000000000..bde9609b5f --- /dev/null +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -0,0 +1,211 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import TuneIcon from '@mui/icons-material/Tune'; +import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; +import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter'; +import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; + +type AnnotationType = 'text' | 'note' | 'shape'; + +interface PropertiesPopoverProps { + annotationType: AnnotationType; + annotation: any; + onUpdate: (patch: Record) => void; + disabled?: boolean; +} + +export function PropertiesPopover({ + annotationType, + annotation, + onUpdate, + disabled = false, +}: PropertiesPopoverProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + const obj = annotation?.object; + + // Get current values + const fontSize = obj?.fontSize ?? 14; + const textAlign = obj?.textAlign; + const currentAlign = + typeof textAlign === 'number' + ? textAlign === 1 + ? 'center' + : textAlign === 2 + ? 'right' + : 'left' + : textAlign === 'center' + ? 'center' + : textAlign === 'right' + ? 'right' + : 'left'; + + // For shapes + const opacity = Math.round((obj?.opacity ?? 1) * 100); + const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2; + const borderVisible = strokeWidth > 0; + + const renderTextNoteControls = () => ( + + {/* Font Size */} +
+ + {t('annotation.fontSize', 'Font size')} + + onUpdate({ fontSize: val })} + min={8} + max={32} + label={(val) => `${val}pt`} + /> +
+ + {/* Opacity */} +
+ + {t('annotation.opacity', 'Opacity')} + + onUpdate({ opacity: val / 100 })} + min={10} + max={100} + label={(val) => `${val}%`} + /> +
+ + {/* Text Alignment */} +
+ + {t('annotation.textAlignment', 'Text Alignment')} + + + onUpdate({ textAlign: 0 })} + size="md" + > + + + onUpdate({ textAlign: 1 })} + size="md" + > + + + onUpdate({ textAlign: 2 })} + size="md" + > + + + +
+
+ ); + + const renderShapeControls = () => ( + + {/* Opacity */} +
+ + {t('annotation.opacity', 'Opacity')} + + { + const newOpacity = val / 100; + onUpdate({ + opacity: newOpacity, + strokeOpacity: newOpacity, + fillOpacity: newOpacity, + }); + }} + min={10} + max={100} + label={(val) => `${val}%`} + /> +
+ + {/* Stroke Width */} +
+ +
+ + {t('annotation.strokeWidth', 'Stroke')} + + { + onUpdate({ + borderWidth: val, + strokeWidth: val, + lineWidth: val, + }); + }} + min={0} + max={12} + label={(val) => `${val}pt`} + /> +
+ +
+
+
+ ); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + {(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()} + {annotationType === 'shape' && renderShapeControls()} + + + ); +} diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx new file mode 100644 index 0000000000..b99d35c996 --- /dev/null +++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx @@ -0,0 +1,62 @@ +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import LineWeightIcon from '@mui/icons-material/LineWeight'; + +interface WidthControlProps { + value: number; + onChange: (value: number) => void; + min: number; // 1 for ink, 0 for shapes + max: number; // 12 for ink, 20 for highlighter + disabled?: boolean; +} + +export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + + return ( + + + + setOpened(!opened)} + disabled={disabled} + styles={{ + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }} + > + + + + + + + + {t('annotation.width', 'Width')} + + `${val}pt`} + /> + + + + ); +} diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx new file mode 100644 index 0000000000..088e315630 --- /dev/null +++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx @@ -0,0 +1,620 @@ +import { ActionIcon, Tooltip, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import { useEffect, useState, useRef, useCallback } from 'react'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { useAnnotation } from '@embedpdf/plugin-annotation/react'; +import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId'; +import { OpacityControl } from '@app/components/annotation/shared/OpacityControl'; +import { WidthControl } from '@app/components/annotation/shared/WidthControl'; +import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover'; +import { ColorControl } from '@app/components/annotation/shared/ColorControl'; + +/** + * Props interface matching EmbedPDF's annotation selection menu pattern + * This matches the type from @embedpdf/plugin-annotation + */ +export interface AnnotationSelectionMenuProps { + documentId?: string; + context?: { + type: 'annotation'; + annotation: any; + pageIndex: number; + }; + selected: boolean; + menuWrapperProps?: { + ref?: (node: HTMLDivElement | null) => void; + style?: React.CSSProperties; + }; +} + +export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) { + const activeDocumentId = useActiveDocumentId(); + + // Don't render until we have a valid document ID + if (!activeDocumentId) { + return null; + } + + return ( + + ); +} + +type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown'; + +function AnnotationSelectionMenuInner({ + documentId, + context, + selected, + menuWrapperProps, +}: AnnotationSelectionMenuProps & { documentId: string }) { + const annotation = context?.annotation; + const pageIndex = context?.pageIndex; + const { t } = useTranslation(); + const { provides } = useAnnotation(documentId); + const wrapperRef = useRef(null); + const textareaRef = useRef(null); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); + const [isTextEditorOpen, setIsTextEditorOpen] = useState(false); + const [textDraft, setTextDraft] = useState(''); + const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null); + + // Merge refs - menuWrapperProps.ref is a callback ref + const setRef = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + // Call the EmbedPDF ref callback + menuWrapperProps?.ref?.(node); + }, [menuWrapperProps]); + + // Type detection + const getAnnotationType = useCallback((): AnnotationType => { + const type = annotation?.object?.type; + const toolId = annotation?.object?.customData?.toolId; + + // Map type numbers to categories + if ([9, 10, 11, 12].includes(type)) return 'textMarkup'; + if (type === 15) { + return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink'; + } + if (type === 3) { + return toolId === 'note' ? 'note' : 'text'; + } + if ([5, 6, 7].includes(type)) return 'shape'; + if ([4, 8].includes(type)) return 'line'; + if (type === 13) return 'stamp'; + + return 'unknown'; + }, [annotation]); + + // Calculate menu width based on annotation type + const calculateWidth = (annotationType: AnnotationType): number => { + switch (annotationType) { + case 'stamp': + return 80; + case 'inkHighlighter': + return 220; + case 'shape': + return 200; + default: + return 180; + } + }; + + // Get annotation properties + const obj = annotation?.object; + const annotationType = getAnnotationType(); + const annotationId = obj?.id; + + // Get current colors + const getCurrentColor = (): string => { + if (!obj) return '#000000'; + const type = obj.type; + // Text annotations use textColor + if (type === 3) return obj.textColor || obj.color || '#000000'; + // Shape annotations use strokeColor + if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000'; + // Default to color property + return obj.color || obj.strokeColor || '#000000'; + }; + + const getStrokeColor = (): string => { + return obj?.strokeColor || obj?.color || '#000000'; + }; + + const getFillColor = (): string => { + return obj?.color || obj?.fillColor || '#0000ff'; + }; + + const getBackgroundColor = (): string => { + // Check multiple possible properties for background color + return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff'; + }; + + const getTextColor = (): string => { + return obj?.textColor || obj?.color || '#000000'; + }; + + const getOpacity = (): number => { + return Math.round((obj?.opacity ?? 1) * 100); + }; + + const getWidth = (): number => { + return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2; + }; + + // Handlers + const handleDelete = useCallback(() => { + if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) { + provides.deleteAnnotation(pageIndex, annotationId); + } + }, [provides, annotationId, pageIndex]); + + const handleOpenTextEditor = useCallback(() => { + if (!annotation) return; + + // Try to find the annotation element in the DOM + const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement; + + let fontSize = (obj?.fontSize || 14) * 1.33; + let fontFamily = 'Helvetica'; + + if (annotationElement) { + const rect = annotationElement.getBoundingClientRect(); + + // Try multiple selectors to find the text element + const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement; + if (textElement) { + const computedStyle = window.getComputedStyle(textElement); + const computedSize = parseFloat(computedStyle.fontSize); + if (computedSize && computedSize > 0) { + fontSize = computedSize; + } + fontFamily = computedStyle.fontFamily || fontFamily; + } + + setTextBoxPosition({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + fontSize: fontSize, + fontFamily: fontFamily, + }); + } else if (wrapperRef.current) { + // Fallback to wrapper position + const rect = wrapperRef.current.getBoundingClientRect(); + setTextBoxPosition({ + top: rect.top, + left: rect.left, + width: Math.max(rect.width, 200), + height: Math.max(rect.height, 50), + fontSize: fontSize, + fontFamily: fontFamily, + }); + } else { + return; + } + + setTextDraft(obj?.contents || ''); + setIsTextEditorOpen(true); + + // Focus the textarea after it renders + setTimeout(() => { + textareaRef.current?.focus(); + textareaRef.current?.select(); + }, 0); + }, [obj, annotation, annotationId]); + + const handleSaveText = useCallback(() => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + contents: textDraft, + }); + setIsTextEditorOpen(false); + setTextBoxPosition(null); + }, [provides, annotationId, pageIndex, textDraft]); + + const handleCloseTextEdit = useCallback(() => { + setIsTextEditorOpen(false); + setTextBoxPosition(null); + }, []); + + const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + const type = obj?.type; + const patch: any = {}; + + if (target === 'stroke') { + // Shape stroke - preserve fill color + patch.strokeColor = color; + patch.color = obj?.color || '#0000ff'; // Preserve fill + patch.strokeWidth = getWidth(); + } else if (target === 'fill') { + // Shape fill - preserve stroke color + patch.color = color; + patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke + patch.strokeWidth = getWidth(); + } else if (target === 'background') { + // Background color for text/note - set multiple properties for compatibility + patch.backgroundColor = color; + patch.fillColor = color; + patch.color = color; + } else if (target === 'text') { + // Text color for text/note - TRY PROPERTY COMBINATIONS + patch.textColor = color; + patch.fontColor = color; // EmbedPDF might expect this instead + + // Include font metadata (EmbedPDF might require these together) + patch.fontSize = obj?.fontSize ?? 14; + patch.fontFamily = obj?.fontFamily ?? 'Helvetica'; + + // Re-submit text content + patch.contents = obj?.contents ?? ''; + } else { + // Main color - for highlights, ink, etc. + patch.color = color; + + // For text markup annotations (highlight, underline, strikeout, squiggly) + if ([9, 10, 11, 12].includes(type)) { + patch.strokeColor = color; + patch.fillColor = color; + patch.opacity = obj?.opacity ?? 1; + } + + // For line annotations (type 4, 8), include stroke properties + if ([4, 8].includes(type)) { + patch.strokeColor = color; + patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2; + patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2; + } + + // For ink annotations (type 15), include all stroke-related properties + if (type === 15) { + patch.strokeColor = color; + patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2; + patch.opacity = obj?.opacity ?? 1; + } + } + + provides.updateAnnotation(pageIndex, annotationId, patch); + }, [provides, annotationId, pageIndex, obj]); + + const handleOpacityChange = useCallback((opacity: number) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + opacity: opacity / 100, + }); + }, [provides, annotationId, pageIndex]); + + const handleWidthChange = useCallback((width: number) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, { + strokeWidth: width, + }); + }, [provides, annotationId, pageIndex]); + + const handlePropertiesUpdate = useCallback((patch: Record) => { + if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return; + + provides.updateAnnotation(pageIndex, annotationId, patch); + }, [provides, annotationId, pageIndex]); + + // Render button groups based on annotation type + const renderButtons = () => { + const commonButtonStyles = { + root: { + flexShrink: 0, + backgroundColor: 'var(--bg-raised)', + border: '1px solid var(--border-default)', + color: 'var(--text-secondary)', + '&:hover': { + backgroundColor: 'var(--hover-bg)', + borderColor: 'var(--border-strong)', + color: 'var(--text-primary)', + }, + }, + }; + + const EditTextButton = () => ( + + + + + + ); + + const DeleteButton = () => ( + + + + + + ); + + switch (annotationType) { + case 'textMarkup': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'ink': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'inkHighlighter': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + + ); + + case 'text': + case 'note': + return ( + <> + handleColorChange(color, 'text')} + label={t('annotation.color', 'Color')} + /> + handleColorChange(color, 'background')} + label={t('annotation.backgroundColor', 'Background color')} + /> + + + + + ); + + case 'shape': + return ( + <> + handleColorChange(color, 'stroke')} + label={t('annotation.strokeColor', 'Stroke Colour')} + /> + handleColorChange(color, 'fill')} + label={t('annotation.fillColor', 'Fill Colour')} + /> + + + + ); + + case 'line': + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'stamp': + return ; + + default: + return ( + <> + handleColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + ); + } + }; + + // Calculate position for portal based on wrapper element + useEffect(() => { + if (!selected || !annotation || !wrapperRef.current) { + setMenuPosition(null); + return; + } + + const updatePosition = () => { + const wrapper = wrapperRef.current; + if (!wrapper) { + setMenuPosition(null); + return; + } + + const wrapperRect = wrapper.getBoundingClientRect(); + // Position menu below the wrapper, centered + // Use getBoundingClientRect which gives viewport-relative coordinates + // Since we're using fixed positioning in the portal, we don't need to add scroll offsets + setMenuPosition({ + top: wrapperRect.bottom + 8, + left: wrapperRect.left + wrapperRect.width / 2, + }); + }; + + updatePosition(); + + // Update position on scroll/resize + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [selected, annotation]); + + // Early return AFTER all hooks have been called + if (!selected || !annotation) return null; + + const menuContent = menuPosition ? ( +
+ + {renderButtons()} + +
+ ) : null; + + const textEditorOverlay = isTextEditorOpen && textBoxPosition ? ( +
+