Merge branch 'V2' into bug/v2/improved-cache-busting

This commit is contained in:
Reece Browne
2025-12-02 12:38:42 +00:00
committed by GitHub
9 changed files with 131 additions and 19 deletions
@@ -19,9 +19,9 @@ import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures -
* PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated
* signature management (save/delete), see proprietary SignatureController.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
* authenticated signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@@ -283,7 +283,12 @@ public class AdminLicenseController {
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Filename must not contain path separators or '..'"));
.body(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
}
// Validate file extension
@@ -56,6 +56,19 @@ public interface UserRepository extends JpaRepository<User, Long> {
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
List<User> findAllSsoUsers();
/**
* Finds SSO users who have never created a session (pending activation) and are not yet
* grandfathered.
*/
@Query(
"SELECT u FROM User u "
+ "LEFT JOIN SessionEntity s ON u.username = s.principalName "
+ "WHERE (u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) "
+ "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) "
+ "AND s.sessionId IS NULL")
List<User> findPendingSsoUsersWithoutSession();
/**
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
*/
@@ -105,22 +105,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
log.warn("JWT validation failed: {}", e.getMessage());
log.debug("JWT validation failed: {}", e.getMessage());
handleAuthenticationFailure(request, response, e);
return;
}
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -50,7 +50,6 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -256,11 +255,9 @@ public class JwtService implements JwtServiceInterface {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
log.debug("JWT token extracted from Authorization header");
return token;
}
log.debug("No JWT token found in Authorization header");
return null;
}
@@ -283,10 +280,9 @@ public class JwtService implements JwtServiceInterface {
.parse(token)
.getHeader()
.get("kid");
log.debug("Extracted key ID from token: {}", keyId);
return keyId;
} catch (Exception e) {
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
return null;
}
}
@@ -55,7 +55,6 @@ public class KeyPairCleanupService {
return;
}
log.info("Removing keys older than retention period");
removeKeys(eligibleKeys);
keyPersistenceService.refreshActiveKeyPair();
}
@@ -778,4 +778,30 @@ public class UserService implements UserServiceInterface {
return updated;
}
/**
* Grandfathers SSO users who have never created a session (invited/pending accounts). These
* users would otherwise be blocked when SSO requires a paid license despite existing before the
* policy change.
*
* @return Number of pending users updated
*/
@Transactional
public int grandfatherPendingSsoUsersWithoutSession() {
List<User> pendingUsers = userRepository.findPendingSsoUsersWithoutSession();
int updated = 0;
for (User user : pendingUsers) {
if (!user.isOauthGrandfathered()) {
user.setOauthGrandfathered(true);
updated++;
}
}
if (updated > 0) {
userRepository.saveAll(pendingUsers);
}
return updated;
}
}
@@ -192,10 +192,18 @@ public class UserLicenseSettingsService {
+ "They will retain OAuth access even without a paid license. "
+ "New users will require a paid license for OAuth.",
updated);
} else if (grandfatheredCount > 0) {
log.debug(
"OAuth grandfathering already completed: {} users grandfathered",
grandfatheredCount);
}
// Grandfather pending users (invited but never logged in)
// The query filters to non-grandfathered users only, so this is idempotent
if (grandfatheredCount > 0 || oauthUsersCount > 0) {
int pendingUpdated = userService.grandfatherPendingSsoUsersWithoutSession();
if (pendingUpdated > 0) {
log.warn(
"OAuth GRANDFATHERING: Marked {} pending SSO users (no prior sessions) as"
+ " grandfathered.",
pendingUpdated);
}
}
}
}
@@ -2,6 +2,9 @@ package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
@@ -198,4 +201,70 @@ class UserLicenseSettingsServiceTest {
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
}
@Test
void grandfatherExistingOAuthUsers_runsOnlyWhenNoneGrandfathered() {
// With grandfatheredCount == 0, should run grandfathering for all users
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
when(userService.grandfatherAllOAuthUsers()).thenReturn(10);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
service.grandfatherExistingOAuthUsers();
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_skipsMainButRunsPendingWhenSomeAlreadyGrandfathered() {
// V2→V2.1 upgrade: some users already grandfathered, but pending users need to be checked
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(4L);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(2);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_stillChecksPendingWhenAllUsersGrandfathered() {
// All active users grandfathered, but still check for pending users
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(10L);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_skipsWhenNoOAuthUsers() {
when(userService.countOAuthUsers()).thenReturn(0L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, never()).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_grandfathersPendingUsersOnFirstRun() {
// Pending users (invited but never logged in) should be grandfathered
// during the initial grandfathering run (when grandfatheredCount == 0)
when(userService.countOAuthUsers()).thenReturn(5L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
when(userService.grandfatherAllOAuthUsers()).thenReturn(5);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(3);
service.grandfatherExistingOAuthUsers();
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
}