Fixing logout

This commit is contained in:
Dario Ghunney Ware 2025-07-30 12:45:01 +01:00
parent 07dcce6a3c
commit 29d6ca4f35
22 changed files with 122 additions and 119 deletions

View File

@ -147,7 +147,9 @@ jobs:
- name: Generate OpenAPI documentation - name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation - name: Upload OpenAPI Documentation
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:

View File

@ -46,7 +46,7 @@ public class InstallationPathConfig {
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator;
SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator;
PRIVATE_KEY_PATH = CUSTOM_FILES_PATH + "keys" + File.separator; PRIVATE_KEY_PATH = CONFIG_PATH + "keys" + File.separator;
} }
private static String initializeBasePath() { private static String initializeBasePath() {

View File

@ -304,8 +304,8 @@ public class ApplicationProperties {
private boolean enableKeystore = true; private boolean enableKeystore = true;
private boolean enableKeyRotation = false; private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true; private boolean enableKeyCleanup = true;
private int keyRetentionDays; private int keyRetentionDays = 7;
private int cleanupBatchSize; private int cleanupBatchSize = 100;
} }
} }

View File

@ -31,7 +31,7 @@ security:
google: google:
clientId: '' # client ID for Google OAuth2 clientId: '' # client ID for Google OAuth2
clientSecret: '' # client secret for Google OAuth2 clientSecret: '' # client secret for Google OAuth2
scopes: https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile # scopes for Google OAuth2 scopes: email, profile # scopes for Google OAuth2
useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name] useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name]
github: github:
clientId: '' # client ID for GitHub OAuth2 clientId: '' # client ID for GitHub OAuth2
@ -51,20 +51,21 @@ security:
provider: '' # The name of your Provider provider: '' # The name of your Provider
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
registrationId: stirlingpdf-dario-saml # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
idpMetadataUri: https://authentik.dev.stirlingpdf.com/api/v3/providers/saml/5/metadata/ # The uri for your Provider's metadata idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
idpSingleLoginUrl: https://authentik.dev.stirlingpdf.com/application/saml/stirlingpdf-dario-saml/sso/binding/post/ # The URL for initiating SSO. Provided by your Provider idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
idpSingleLogoutUrl: https://authentik.dev.stirlingpdf.com/application/saml/stirlingpdf-dario-saml/slo/binding/post/ # The URL for initiating SLO. Provided by your Provider idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
idpIssuer: authentik # The ID of your Provider idpIssuer: '' # The ID of your Provider
idpCert: classpath:authentik-Self-signed_Certificate_certificate.pem # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:private_key.key # Your private key. Generated from your keypair privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:certificate.crt # Your signing certificate. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
jwt: jwt:
enableKeyStore: true # Set to 'true' to enable JWT key store persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable JWT key rotation enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable JWT key cleanup enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
cleanupBatchSize: 100 # Number of keys to clean up in each batch cleanupBatchSize: 100 # Number of keys to clean up in each batch. The default is 100.
secureCookie: false # Set to 'true' to use secure cookies for JWTs
premium: premium:
key: 00000000-0000-0000-0000-000000000000 key: 00000000-0000-0000-0000-000000000000

View File

@ -62,11 +62,15 @@ window.JWTManager = {
fetch('/logout', { fetch('/logout', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include'
}).then(() => { }).then(response => {
window.location.href = '/login'; if (response.redirected) {
window.location.href = response.url;
} else {
window.location.href = '/login?logout=true';
}
}).catch(() => { }).catch(() => {
// Even if logout fails, redirect to login // If logout fails, let server handle it
window.location.href = '/login'; window.location.href = '/logout';
}); });
} }
}; };

View File

@ -47,11 +47,14 @@
console.log('User is not authenticated or token expired'); console.log('User is not authenticated or token expired');
// Only redirect to login if we're not already on login/register pages // Only redirect to login if we're not already on login/register pages
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const currentSearch = window.location.search;
// Don't redirect if we're on logout page or already being logged out
if (!currentPath.includes('/login') && if (!currentPath.includes('/login') &&
!currentPath.includes('/register') && !currentPath.includes('/register') &&
!currentPath.includes('/oauth') && !currentPath.includes('/oauth') &&
!currentPath.includes('/saml') && !currentPath.includes('/saml') &&
!currentPath.includes('/error')) { !currentPath.includes('/error') &&
!currentSearch.includes('logout=true')) {
// Redirect to login after a short delay to allow other scripts to load // Redirect to login after a short delay to allow other scripts to load
setTimeout(() => { setTimeout(() => {
window.location.href = '/login'; window.location.href = '/login';

View File

@ -43,7 +43,6 @@ public class InitialSecuritySetup {
} }
} }
userService.migrateOauth2ToSSO();
assignUsersToDefaultTeamIfMissing(); assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser(); initializeInternalApiUser();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {

View File

@ -77,8 +77,11 @@ public class AccountWebController {
@GetMapping("/login") @GetMapping("/login")
public String login(HttpServletRequest request, Model model, Authentication authentication) { public String login(HttpServletRequest request, Model model, Authentication authentication) {
// If the user is already authenticated, redirect them to the home page. // If the user is already authenticated and it's not a logout scenario, redirect them to the
if (authentication != null && authentication.isAuthenticated()) { // home page.
if (authentication != null
&& authentication.isAuthenticated()
&& request.getParameter("logout") == null) {
return "redirect:/"; return "redirect:/";
} }

View File

@ -186,7 +186,7 @@ public class SecurityConfiguration {
// Configure session management based on JWT setting // Configure session management based on JWT setting
http.sessionManagement( http.sessionManagement(
sessionManagement -> { sessionManagement -> {
if (v2Enabled && !securityProperties.isSaml2Active()) { if (v2Enabled) {
sessionManagement.sessionCreationPolicy( sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS); SessionCreationPolicy.STATELESS);
} else { } else {

View File

@ -69,10 +69,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
} }
String jwtToken = jwtService.extractToken(request); String jwtToken = jwtService.extractToken(request);
// todo: X-API-KEY
if (jwtToken == null) { if (jwtToken == null) {
// If they are unauthenticated and navigating to '/', redirect to '/login' instead of // If they are unauthenticated and navigating to '/', redirect to '/login' instead of
// sending a 401 // sending a 401
// todo: any unauthenticated requests should redirect to login
if ("/".equals(request.getRequestURI()) if ("/".equals(request.getRequestURI())
&& "GET".equalsIgnoreCase(request.getMethod())) { && "GET".equalsIgnoreCase(request.getMethod())) {
response.sendRedirect("/login"); response.sendRedirect("/login");
@ -130,11 +131,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
log.info(
"JWT authentication successful for user: {} - Authentication set in SecurityContext",
username);
} else { } else {
throw new UsernameNotFoundException("User not found: " + username); throw new UsernameNotFoundException("User not found: " + username);
} }

View File

@ -65,13 +65,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI(); String requestURI = request.getRequestURI();
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.info(
"UserAuthenticationFilter - Authentication from SecurityContext: {}",
authentication != null
? authentication.getClass().getSimpleName()
+ " for "
+ authentication.getName()
: "null");
// Check for session expiration (unsure if needed) // Check for session expiration (unsure if needed)
// if (authentication != null && authentication.isAuthenticated()) { // if (authentication != null && authentication.isAuthenticated()) {
@ -117,7 +110,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String method = request.getMethod(); String method = request.getMethod();
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) { if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page response.sendRedirect(contextPath + "/login"); // redirect to the login page
} else { } else {
response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setStatus(HttpStatus.UNAUTHORIZED.value());

View File

@ -2,7 +2,8 @@ package stirling.software.proprietary.security.model;
public enum AuthenticationType { public enum AuthenticationType {
WEB, WEB,
@Deprecated(since = "1.0.2") SSO, @Deprecated(since = "1.0.2")
SSO,
OAUTH2, OAUTH2,
SAML2 SAML2
} }

View File

@ -121,7 +121,7 @@ public class CustomSaml2AuthenticationSuccessHandler
username, saml2Properties.getAutoCreateUser(), SAML2); username, saml2Properties.getAutoCreateUser(), SAML2);
log.debug("Successfully processed authentication for user: {}", username); log.debug("Successfully processed authentication for user: {}", username);
generateJWT(response, authentication); generateJwt(response, authentication);
response.sendRedirect(contextPath + "/"); response.sendRedirect(contextPath + "/");
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.debug( log.debug(
@ -136,7 +136,7 @@ public class CustomSaml2AuthenticationSuccessHandler
} }
} }
private void generateJWT(HttpServletResponse response, Authentication authentication) { private void generateJwt(HttpServletResponse response, Authentication authentication) {
if (jwtService.isJwtEnabled()) { if (jwtService.isJwtEnabled()) {
String jwt = String jwt =
jwtService.generateToken( jwtService.generateToken(

View File

@ -10,6 +10,7 @@ import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
@ -41,15 +42,17 @@ public class JwtService implements JwtServiceInterface {
private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
private static final String ISSUER = "Stirling PDF"; private static final String ISSUER = "Stirling PDF";
private static final long EXPIRATION = 300000; // 5 minutes in milliseconds private static final long EXPIRATION = 3600000;
private final JwtKeystoreServiceInterface keystoreService; @Value("${stirling.security.jwt.secureCookie:true}")
private boolean secureCookie;
private final KeystoreServiceInterface keystoreService;
private final boolean v2Enabled; private final boolean v2Enabled;
@Autowired @Autowired
public JwtService( public JwtService(
@Qualifier("v2Enabled") boolean v2Enabled, @Qualifier("v2Enabled") boolean v2Enabled, KeystoreServiceInterface keystoreService) {
JwtKeystoreServiceInterface keystoreService) {
this.v2Enabled = v2Enabled; this.v2Enabled = v2Enabled;
this.keystoreService = keystoreService; this.keystoreService = keystoreService;
} }
@ -127,13 +130,13 @@ public class JwtService implements JwtServiceInterface {
private Claims extractAllClaims(String token) { private Claims extractAllClaims(String token) {
try { try {
// Extract key ID from token header if present
String keyId = extractKeyId(token); String keyId = extractKeyId(token);
KeyPair keyPair; KeyPair keyPair;
if (keyId != null) { if (keyId != null) {
log.debug("Looking up key pair for key ID: {}", keyId); log.debug("Looking up key pair for key ID: {}", keyId);
Optional<KeyPair> specificKeyPair = keystoreService.getKeyPairByKeyId(keyId); Optional<KeyPair> specificKeyPair =
keystoreService.getKeyPairByKeyId(keyId); // todo: move to in-memory cache
if (specificKeyPair.isPresent()) { if (specificKeyPair.isPresent()) {
keyPair = specificKeyPair.get(); keyPair = specificKeyPair.get();
@ -179,13 +182,8 @@ public class JwtService implements JwtServiceInterface {
@Override @Override
public String extractToken(HttpServletRequest request) { public String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader(AUTHORIZATION_HEADER);
if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
return authHeader.substring(BEARER_PREFIX.length());
}
Cookie[] cookies = request.getCookies(); Cookie[] cookies = request.getCookies();
if (cookies != null) { if (cookies != null) {
for (Cookie cookie : cookies) { for (Cookie cookie : cookies) {
if (JWT_COOKIE_NAME.equals(cookie.getName())) { if (JWT_COOKIE_NAME.equals(cookie.getName())) {
@ -204,8 +202,8 @@ public class JwtService implements JwtServiceInterface {
ResponseCookie cookie = ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
.httpOnly(true) .httpOnly(true)
.secure(true) .secure(secureCookie)
.sameSite("None") .sameSite("Strict")
.maxAge(EXPIRATION / 1000) .maxAge(EXPIRATION / 1000)
.path("/") .path("/")
.build(); .build();
@ -220,7 +218,7 @@ public class JwtService implements JwtServiceInterface {
ResponseCookie cookie = ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, "") ResponseCookie.from(JWT_COOKIE_NAME, "")
.httpOnly(true) .httpOnly(true)
.secure(true) .secure(secureCookie)
.sameSite("None") .sameSite("None")
.maxAge(0) .maxAge(0)
.path("/") .path("/")

View File

@ -10,12 +10,15 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
@ -25,16 +28,17 @@ import stirling.software.proprietary.security.model.JwtSigningKey;
@Slf4j @Slf4j
@Service @Service
public class JwtKeyCleanupService { @ConditionalOnBooleanProperty("v2")
public class KeyPairCleanupService {
private final JwtSigningKeyRepository signingKeyRepository; private final JwtSigningKeyRepository signingKeyRepository;
private final JwtKeystoreService keystoreService; private final KeystoreService keystoreService;
private final ApplicationProperties.Security.Jwt jwtProperties; private final ApplicationProperties.Security.Jwt jwtProperties;
@Autowired @Autowired
public JwtKeyCleanupService( public KeyPairCleanupService(
JwtSigningKeyRepository signingKeyRepository, JwtSigningKeyRepository signingKeyRepository,
JwtKeystoreService keystoreService, KeystoreService keystoreService,
ApplicationProperties applicationProperties) { ApplicationProperties applicationProperties) {
this.signingKeyRepository = signingKeyRepository; this.signingKeyRepository = signingKeyRepository;
this.keystoreService = keystoreService; this.keystoreService = keystoreService;
@ -42,6 +46,7 @@ public class JwtKeyCleanupService {
} }
@Transactional @Transactional
@PostConstruct
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS) @Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS)
public void cleanup() { public void cleanup() {
if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) { if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) {
@ -111,7 +116,7 @@ public class JwtKeyCleanupService {
} }
Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
Path keyFile = privateKeyDirectory.resolve(keyId + JwtKeystoreService.KEY_SUFFIX); Path keyFile = privateKeyDirectory.resolve(keyId + KeystoreService.KEY_SUFFIX);
if (Files.exists(keyFile)) { if (Files.exists(keyFile)) {
Files.delete(keyFile); Files.delete(keyFile);

View File

@ -33,7 +33,7 @@ import stirling.software.proprietary.security.model.JwtSigningKey;
@Slf4j @Slf4j
@Service @Service
public class JwtKeystoreService implements JwtKeystoreServiceInterface { public class KeystoreService implements KeystoreServiceInterface {
public static final String KEY_SUFFIX = ".key"; public static final String KEY_SUFFIX = ".key";
private final JwtSigningKeyRepository signingKeyRepository; private final JwtSigningKeyRepository signingKeyRepository;
@ -43,7 +43,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
private volatile String currentKeyId; private volatile String currentKeyId;
@Autowired @Autowired
public JwtKeystoreService( public KeystoreService(
JwtSigningKeyRepository signingKeyRepository, JwtSigningKeyRepository signingKeyRepository,
ApplicationProperties applicationProperties) { ApplicationProperties applicationProperties) {
this.signingKeyRepository = signingKeyRepository; this.signingKeyRepository = signingKeyRepository;
@ -53,7 +53,6 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
@PostConstruct @PostConstruct
public void initializeKeystore() { public void initializeKeystore() {
if (!isKeystoreEnabled()) { if (!isKeystoreEnabled()) {
log.info("Keystore is disabled, using in-memory key generation");
return; return;
} }
@ -61,7 +60,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
ensurePrivateKeyDirectoryExists(); ensurePrivateKeyDirectoryExists();
loadOrGenerateKeypair(); loadOrGenerateKeypair();
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to initialize keystore, falling back to in-memory generation", e); log.error("Failed to initialize keystore, using in-memory generation", e);
} }
} }
@ -153,7 +152,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
} }
private KeyPair generateRSAKeypair() { private KeyPair generateRSAKeypair() {
KeyPairGenerator keyPairGenerator = null; KeyPairGenerator keyPairGenerator;
try { try {
keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator = KeyPairGenerator.getInstance("RSA");
@ -213,6 +212,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface {
byte[] keyBytes = Base64.getDecoder().decode(encodedKey); byte[] keyBytes = Base64.getDecoder().decode(encodedKey);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA"); KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec); return keyFactory.generatePrivate(keySpec);
} }

View File

@ -3,7 +3,7 @@ package stirling.software.proprietary.security.service;
import java.security.KeyPair; import java.security.KeyPair;
import java.util.Optional; import java.util.Optional;
public interface JwtKeystoreServiceInterface { public interface KeystoreServiceInterface {
KeyPair getActiveKeyPair(); KeyPair getActiveKeyPair();

View File

@ -1,8 +1,5 @@
package stirling.software.proprietary.security.service; package stirling.software.proprietary.security.service;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
@ -63,17 +60,6 @@ public class UserService implements UserServiceInterface {
private final ApplicationProperties.Security.OAUTH2 oAuth2; private final ApplicationProperties.Security.OAUTH2 oAuth2;
@Transactional
public void migrateOauth2ToSSO() {
userRepository
.findByAuthenticationTypeIgnoreCase(OAUTH2.toString())
.forEach(
user -> {
user.setAuthenticationType(SSO);
userRepository.save(user);
});
}
// Handle OAUTH2 login and user auto creation. // Handle OAUTH2 login and user auto creation.
public void processSSOPostLogin( public void processSSOPostLogin(
String username, boolean autoCreateUser, AuthenticationType type) String username, boolean autoCreateUser, AuthenticationType type)

View File

@ -11,6 +11,8 @@ import java.util.Optional;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -55,7 +57,7 @@ class JwtServiceTest {
private HttpServletResponse response; private HttpServletResponse response;
@Mock @Mock
private JwtKeystoreServiceInterface keystoreService; private KeystoreServiceInterface keystoreService;
private JwtService jwtService; private JwtService jwtService;
private KeyPair testKeyPair; private KeyPair testKeyPair;
@ -201,19 +203,10 @@ class JwtServiceTest {
assertThrows(AuthenticationFailureException.class, () -> jwtService.extractClaims("invalid-token")); assertThrows(AuthenticationFailureException.class, () -> jwtService.extractClaims("invalid-token"));
} }
@Test
void testExtractTokenWithAuthorizationHeader() {
String token = "test-token";
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
assertEquals(token, jwtService.extractToken(request));
}
@Test @Test
void testExtractTokenWithCookie() { void testExtractTokenWithCookie() {
String token = "test-token"; String token = "test-token";
Cookie[] cookies = { new Cookie("stirling_jwt", token) }; Cookie[] cookies = { new Cookie("stirling_jwt", token) };
when(request.getHeader("Authorization")).thenReturn(null);
when(request.getCookies()).thenReturn(cookies); when(request.getCookies()).thenReturn(cookies);
assertEquals(token, jwtService.extractToken(request)); assertEquals(token, jwtService.extractToken(request));
@ -221,7 +214,6 @@ class JwtServiceTest {
@Test @Test
void testExtractTokenWithNoCookies() { void testExtractTokenWithNoCookies() {
when(request.getHeader("Authorization")).thenReturn(null);
when(request.getCookies()).thenReturn(null); when(request.getCookies()).thenReturn(null);
assertNull(jwtService.extractToken(request)); assertNull(jwtService.extractToken(request));
@ -230,7 +222,6 @@ class JwtServiceTest {
@Test @Test
void testExtractTokenWithWrongCookie() { void testExtractTokenWithWrongCookie() {
Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")};
when(request.getHeader("Authorization")).thenReturn(null);
when(request.getCookies()).thenReturn(cookies); when(request.getCookies()).thenReturn(cookies);
assertNull(jwtService.extractToken(request)); assertNull(jwtService.extractToken(request));
@ -238,22 +229,30 @@ class JwtServiceTest {
@Test @Test
void testExtractTokenWithInvalidAuthorizationHeader() { void testExtractTokenWithInvalidAuthorizationHeader() {
when(request.getHeader("Authorization")).thenReturn("Basic token");
when(request.getCookies()).thenReturn(null); when(request.getCookies()).thenReturn(null);
assertNull(jwtService.extractToken(request)); assertNull(jwtService.extractToken(request));
} }
@Test @ParameterizedTest
void testAddToken() { @ValueSource(booleans = {true, false})
void testAddToken(boolean secureCookie) throws Exception {
String token = "test-token"; String token = "test-token";
jwtService.addToken(response, token); // Create new JwtService instance with the secureCookie parameter
JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie);
testJwtService.addToken(response, token);
verify(response).setHeader("Authorization", "Bearer " + token); verify(response).setHeader("Authorization", "Bearer " + token);
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token)); verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token));
verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly")); verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly"));
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
if (secureCookie) {
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
} else {
verify(response, org.mockito.Mockito.never()).addHeader(eq("Set-Cookie"), contains("Secure"));
}
} }
@Test @Test
@ -327,4 +326,16 @@ class JwtServiceTest {
// Verify fallback to active keypair was used (called multiple times during token operations) // Verify fallback to active keypair was used (called multiple times during token operations)
verify(keystoreService, atLeast(1)).getActiveKeyPair(); verify(keystoreService, atLeast(1)).getActiveKeyPair();
} }
private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception {
// Use reflection to create JwtService with custom secureCookie value
JwtService testService = new JwtService(true, keystoreService);
// Set the secureCookie field using reflection
java.lang.reflect.Field secureCookieField = JwtService.class.getDeclaredField("secureCookie");
secureCookieField.setAccessible(true);
secureCookieField.set(testService, secureCookie);
return testService;
}
} }

View File

@ -27,13 +27,13 @@ import stirling.software.proprietary.security.database.repository.JwtSigningKeyR
import stirling.software.proprietary.security.model.JwtSigningKey; import stirling.software.proprietary.security.model.JwtSigningKey;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class JwtKeyCleanupServiceTest { class KeyPairCleanupServiceTest {
@Mock @Mock
private JwtSigningKeyRepository signingKeyRepository; private JwtSigningKeyRepository signingKeyRepository;
@Mock @Mock
private JwtKeystoreService keystoreService; private KeystoreService keystoreService;
@Mock @Mock
private ApplicationProperties applicationProperties; private ApplicationProperties applicationProperties;
@ -47,7 +47,7 @@ class JwtKeyCleanupServiceTest {
@TempDir @TempDir
private Path tempDir; private Path tempDir;
private JwtKeyCleanupService cleanupService; private KeyPairCleanupService cleanupService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -59,7 +59,7 @@ class JwtKeyCleanupServiceTest {
lenient().when(jwtConfig.getCleanupBatchSize()).thenReturn(100); lenient().when(jwtConfig.getCleanupBatchSize()).thenReturn(100);
lenient().when(keystoreService.isKeystoreEnabled()).thenReturn(true); lenient().when(keystoreService.isKeystoreEnabled()).thenReturn(true);
cleanupService = new JwtKeyCleanupService(signingKeyRepository, keystoreService, applicationProperties); cleanupService = new KeyPairCleanupService(signingKeyRepository, keystoreService, applicationProperties);
} }
@ -101,7 +101,7 @@ class JwtKeyCleanupServiceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
createTestKeyFile("key-1"); createTestKeyFile("key-1");
createTestKeyFile("key-2"); createTestKeyFile("key-2");
@ -134,7 +134,7 @@ class JwtKeyCleanupServiceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
createTestKeyFile("key-1"); createTestKeyFile("key-1");
createTestKeyFile("key-2"); createTestKeyFile("key-2");
createTestKeyFile("key-3"); createTestKeyFile("key-3");
@ -161,7 +161,7 @@ class JwtKeyCleanupServiceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
createTestKeyFile("key-1"); createTestKeyFile("key-1");
when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L); when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L);

View File

@ -32,7 +32,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class JwtKeystoreServiceInterfaceTest { class KeystoreServiceInterfaceTest {
@Mock @Mock
private JwtSigningKeyRepository repository; private JwtSigningKeyRepository repository;
@ -49,7 +49,7 @@ class JwtKeystoreServiceInterfaceTest {
@TempDir @TempDir
Path tempDir; Path tempDir;
private JwtKeystoreService keystoreService; private KeystoreService keystoreService;
private KeyPair testKeyPair; private KeyPair testKeyPair;
@BeforeEach @BeforeEach
@ -70,7 +70,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled()); assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled());
} }
@ -82,7 +82,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
KeyPair result = keystoreService.getActiveKeyPair(); KeyPair result = keystoreService.getActiveKeyPair();
@ -98,7 +98,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
KeyPair result = keystoreService.getActiveKeyPair(); KeyPair result = keystoreService.getActiveKeyPair();
@ -122,7 +122,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
KeyPair result = keystoreService.getActiveKeyPair(); KeyPair result = keystoreService.getActiveKeyPair();
@ -146,7 +146,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
Optional<KeyPair> result = keystoreService.getKeyPairByKeyId(keyId); Optional<KeyPair> result = keystoreService.getKeyPairByKeyId(keyId);
@ -163,7 +163,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
Optional<KeyPair> result = keystoreService.getKeyPairByKeyId(keyId); Optional<KeyPair> result = keystoreService.getKeyPairByKeyId(keyId);
@ -177,7 +177,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
Optional<KeyPair> result = keystoreService.getKeyPairByKeyId("any-key"); Optional<KeyPair> result = keystoreService.getKeyPairByKeyId("any-key");
@ -191,7 +191,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
assertTrue(Files.exists(tempDir)); assertTrue(Files.exists(tempDir));
@ -209,7 +209,7 @@ class JwtKeystoreServiceInterfaceTest {
try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) { try (MockedStatic<InstallationPathConfig> mockedStatic = mockStatic(InstallationPathConfig.class)) {
mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString());
keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService = new KeystoreService(repository, applicationProperties);
keystoreService.initializeKeystore(); keystoreService.initializeKeystore();
KeyPair result = keystoreService.getActiveKeyPair(); KeyPair result = keystoreService.getActiveKeyPair();

View File

@ -20,6 +20,7 @@ services:
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "false" DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true" SECURITY_ENABLELOGIN: "true"
V2: "false"
PUID: 1002 PUID: 1002
PGID: 1002 PGID: 1002
UMASK: "022" UMASK: "022"