diff --git a/src/main/java/stirling/software/SPDF/config/AppConfig.java b/src/main/java/stirling/software/SPDF/config/AppConfig.java index cb9dbcd80..62f82a278 100644 --- a/src/main/java/stirling/software/SPDF/config/AppConfig.java +++ b/src/main/java/stirling/software/SPDF/config/AppConfig.java @@ -35,10 +35,7 @@ public class AppConfig { } @Bean - @ConditionalOnProperty( - name = "system.customHTMLFiles", - havingValue = "true", - matchIfMissing = false) + @ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true") public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) { SpringTemplateEngine templateEngine = new SpringTemplateEngine(); templateEngine.addTemplateResolver(new FileFallbackTemplateResolver(resourceLoader)); @@ -129,8 +126,8 @@ public class AppConfig { } @ConditionalOnMissingClass("stirling.software.SPDF.config.security.SecurityConfiguration") - @Bean(name = "activSecurity") - public boolean missingActivSecurity() { + @Bean(name = "activeSecurity") + public boolean missingActiveSecurity() { return false; } diff --git a/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java index 47aebc1e6..d74f3842a 100644 --- a/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/CustomLogoutSuccessHandler.java @@ -6,6 +6,7 @@ import java.security.interfaces.RSAPrivateKey; import java.util.ArrayList; import java.util.List; +import org.apache.commons.lang3.StringUtils; import org.springframework.core.io.Resource; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -14,6 +15,7 @@ import org.springframework.security.saml2.provider.service.authentication.Saml2A import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; import com.coveo.saml.SamlClient; +import com.coveo.saml.SamlException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -49,9 +51,8 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { } else if (authentication instanceof OAuth2AuthenticationToken) { // Handle OAuth2 logout redirection getRedirect_oauth2(request, response, authentication); - } - // Handle Username/Password logout - else if (authentication instanceof UsernamePasswordAuthenticationToken) { + } else if (authentication instanceof UsernamePasswordAuthenticationToken) { + // Handle Username/Password logout getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Handle unknown authentication types @@ -90,27 +91,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { certificates.add(certificate); // Construct URLs required for SAML configuration - String serverUrl = - SPDFApplication.getStaticBaseUrl() + ":" + SPDFApplication.getStaticPort(); - - String relyingPartyIdentifier = - serverUrl + "/saml2/service-provider-metadata/" + registrationId; - - String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId; - - String idpUrl = samlConf.getIdpSingleLogoutUrl(); - - String idpIssuer = samlConf.getIdpIssuer(); - - // Create SamlClient instance for SAML logout - SamlClient samlClient = - new SamlClient( - relyingPartyIdentifier, - assertionConsumerServiceUrl, - idpUrl, - idpIssuer, - certificates, - SamlClient.SamlIdpBinding.POST); + SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates); // Read private key for service provider Resource privateKeyResource = samlConf.getPrivateKey(); @@ -127,7 +108,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { } } - // Redirect for OAuth2 authentication logout private void getRedirect_oauth2( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { @@ -164,12 +144,45 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { response.sendRedirect(redirectUrl); } default -> { - log.info("Redirecting to default logout URL: {}", redirectUrl); - response.sendRedirect(redirectUrl); + String logoutUrl = oauth.getLogoutUrl(); + + if (StringUtils.isNotBlank(logoutUrl)) { + log.info("Redirecting to logout URL: {}", logoutUrl); + response.sendRedirect(logoutUrl); + } else { + log.info("Redirecting to default logout URL: {}", redirectUrl); + response.sendRedirect(redirectUrl); + } } } } + // Redirect for OAuth2 authentication logout + private static SamlClient getSamlClient( + String registrationId, SAML2 samlConf, List certificates) + throws SamlException { + String serverUrl = + SPDFApplication.getStaticBaseUrl() + ":" + SPDFApplication.getStaticPort(); + + String relyingPartyIdentifier = + serverUrl + "/saml2/service-provider-metadata/" + registrationId; + + String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId; + + String idpUrl = samlConf.getIdpSingleLogoutUrl(); + + String idpIssuer = samlConf.getIdpIssuer(); + + // Create SamlClient instance for SAML logout + return new SamlClient( + relyingPartyIdentifier, + assertionConsumerServiceUrl, + idpUrl, + idpIssuer, + certificates, + SamlClient.SamlIdpBinding.POST); + } + /** * Handles different error scenarios during logout. Will return a String containing * the error request parameter. diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index 792db2006..a0077da95 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -23,6 +23,10 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.security.web.context.DelegatingSecurityContextRepository; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.savedrequest.NullRequestCache; @@ -51,11 +55,7 @@ public class SecurityConfiguration { private final CustomUserDetailsService userDetailsService; private final UserService userService; - - @Qualifier("loginEnabled") private final boolean loginEnabledValue; - - @Qualifier("runningEE") private final boolean runningEE; private final ApplicationProperties applicationProperties; @@ -105,10 +105,11 @@ public class SecurityConfiguration { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, SecurityContextRepository securityContextRepository) throws Exception { if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) { http.csrf(csrf -> csrf.disable()); } + if (loginEnabledValue) { http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -164,8 +165,7 @@ public class SecurityConfiguration { .logoutSuccessHandler( new CustomLogoutSuccessHandler(applicationProperties)) .clearAuthentication(true) - .invalidateHttpSession( // Invalidate session - true) + .invalidateHttpSession(true) .deleteCookies("JSESSIONID", "remember-me")); http.rememberMe( rememberMeConfigurer -> // Use the configurator directly @@ -234,7 +234,7 @@ public class SecurityConfiguration { . /* This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. - If user exists, login proceeds as usual. If user does not exist, then it is autocreated but only if 'OAUTH2AutoCreateUser' + If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' is set as true, else login fails with an error message advising the same. */ successHandler( @@ -258,14 +258,20 @@ public class SecurityConfiguration { .permitAll()); } // Handle SAML - if (applicationProperties.getSecurity().isSaml2Active()) { - // && runningEE + if (applicationProperties.getSecurity().isSaml2Active() && runningEE) { // Configure the authentication provider OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter( new CustomSaml2ResponseAuthenticationConverter(userService)); http.authenticationProvider(authenticationProvider) + .securityContext(security -> + security.securityContextRepository( + new DelegatingSecurityContextRepository( + new RequestAttributeSecurityContextRepository(), + new HttpSessionSecurityContextRepository()) + ) + ) .saml2Login( saml2 -> { try { @@ -284,12 +290,13 @@ public class SecurityConfiguration { .authenticationRequestResolver( saml2AuthenticationRequestResolver); } catch (Exception e) { - log.error("Error configuring SAML2 login", e); + log.error("Error configuring SAML 2 login", e); throw new RuntimeException(e); } }); } } else { + log.info("SAML 2 login is not enabled. Using default."); http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); } return http.build(); @@ -315,7 +322,7 @@ public class SecurityConfiguration { } @Bean - public boolean activSecurity() { + public boolean activeSecurity() { return true; } } diff --git a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java index 214288cf7..e6274882f 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserAuthenticationFilter.java @@ -88,7 +88,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { // Use API key to authenticate. This requires you to have an authentication // provider for API keys. Optional user = userService.getUserByApiKey(apiKey); - if (!user.isPresent()) { + if (user.isEmpty()) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("Invalid API Key."); return; diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index 5a2730062..f69df516d 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -373,18 +373,15 @@ public class UserService implements UserServiceInterface { public void invalidateUserSessions(String username) { String usernameP = ""; + for (Object principal : sessionRegistry.getAllPrincipals()) { for (SessionInformation sessionsInformation : sessionRegistry.getAllSessions(principal, false)) { - if (principal instanceof UserDetails) { - UserDetails userDetails = (UserDetails) principal; + if (principal instanceof UserDetails userDetails) { usernameP = userDetails.getUsername(); - } else if (principal instanceof OAuth2User) { - OAuth2User oAuth2User = (OAuth2User) principal; + } else if (principal instanceof OAuth2User oAuth2User) { usernameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal) { - CustomSaml2AuthenticatedPrincipal saml2User = - (CustomSaml2AuthenticatedPrincipal) principal; + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { usernameP = saml2User.getName(); } else if (principal instanceof String) { usernameP = (String) principal; @@ -398,6 +395,7 @@ public class UserService implements UserServiceInterface { public String getCurrentUsername() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (principal instanceof UserDetails) { return ((UserDetails) principal).getUsername(); } else if (principal instanceof OAuth2User) { @@ -406,8 +404,6 @@ public class UserService implements UserServiceInterface { applicationProperties.getSecurity().getOauth2().getUseAsUsername()); } else if (principal instanceof CustomSaml2AuthenticatedPrincipal) { return ((CustomSaml2AuthenticatedPrincipal) principal).getName(); - } else if (principal instanceof String) { - return (String) principal; } else { return principal.toString(); } diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 067ebe3c2..be58ac776 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -50,8 +50,11 @@ public class CustomOAuth2AuthenticationFailureHandler if (error.getErrorCode().equals("Password must not be null")) { errorCode = "userAlreadyExistsWeb"; } - log.error("OAuth2 Authentication error: " + errorCode); - log.error("OAuth2AuthenticationException", exception); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); getRedirectStrategy().sendRedirect(request, response, "/login?errorOAuth=" + errorCode); } log.error("Unhandled authentication exception", exception); diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2UserService.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2UserService.java index 2ca1bd928..117c9de8f 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2UserService.java @@ -47,23 +47,31 @@ public class CustomOAuth2UserService implements OAuth2UserService internalUser = userService.findByUsernameIgnoreCase(username); + // todo: save user by OIDC ID instead of username + Optional internalUser = + userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey)); if (internalUser.isPresent()) { - if (loginAttemptService.isBlocked(username)) { + String internalUsername = internalUser.get().getUsername(); + if (loginAttemptService.isBlocked(internalUsername)) { throw new LockedException( - "Your account has been locked due to too many failed login attempts."); + "The account " + + internalUsername + + " has been locked due to too many failed login attempts."); } - if (userService.hasPassword(username)) { + if (userService.hasPassword(usernameAttributeKey)) { throw new IllegalArgumentException("Password must not be null"); } } // Return a new OidcUser with adjusted attributes return new DefaultOidcUser( - user.getAuthorities(), userRequest.getIdToken(), user.getUserInfo(), username); + user.getAuthorities(), + userRequest.getIdToken(), + user.getUserInfo(), + usernameAttributeKey); } catch (IllegalArgumentException e) { log.error("Error loading OIDC user: {}", e.getMessage()); throw new OAuth2AuthenticationException(new OAuth2Error(e.getMessage()), e); diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/OAuth2Configuration.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/OAuth2Configuration.java index 97ebba8c9..764c9533c 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/OAuth2Configuration.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/OAuth2Configuration.java @@ -94,7 +94,7 @@ public class OAuth2Configuration { .clientId(keycloak.getClientId()) .clientSecret(keycloak.getClientSecret()) .scope(keycloak.getScopes()) - .userNameAttributeName(keycloak.getUseAsUsername().name()) + .userNameAttributeName(keycloak.getUseAsUsername().getName()) .clientName(keycloak.getClientName()) .build()) : Optional.empty(); @@ -125,7 +125,7 @@ public class OAuth2Configuration { .authorizationUri(google.getAuthorizationUri()) .tokenUri(google.getTokenUri()) .userInfoUri(google.getUserInfoUri()) - .userNameAttributeName(google.getUseAsUsername().name()) + .userNameAttributeName(google.getUseAsUsername().getName()) .clientName(google.getClientName()) .redirectUri(REDIRECT_URI_PATH + google.getName()) .authorizationGrantType(AUTHORIZATION_CODE) @@ -158,7 +158,7 @@ public class OAuth2Configuration { .authorizationUri(github.getAuthorizationUri()) .tokenUri(github.getTokenUri()) .userInfoUri(github.getUserInfoUri()) - .userNameAttributeName(github.getUseAsUsername().name()) + .userNameAttributeName(github.getUseAsUsername().getName()) .clientName(github.getClientName()) .redirectUri(REDIRECT_URI_PATH + github.getName()) .authorizationGrantType(AUTHORIZATION_CODE) @@ -186,6 +186,7 @@ public class OAuth2Configuration { oauth.getClientSecret(), oauth.getScopes(), UsernameAttribute.valueOf(oauth.getUseAsUsername().toUpperCase()), + oauth.getLogoutUrl(), null, null, null); @@ -220,9 +221,7 @@ public class OAuth2Configuration { */ @Bean - @ConditionalOnProperty( - value = "security.oauth2.enabled", - havingValue = "true") + @ConditionalOnProperty(value = "security.oauth2.enabled", havingValue = "true") GrantedAuthoritiesMapper userAuthoritiesMapper() { return (authorities) -> { Set mappedAuthorities = new HashSet<>(); diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java index 82a1bd742..7d6077861 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java @@ -8,7 +8,6 @@ import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -22,7 +21,9 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) - throws IOException, ServletException { + throws IOException { + log.error("Authentication error", exception); + if (exception instanceof Saml2AuthenticationException) { Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error(); getRedirectStrategy() @@ -34,6 +35,5 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica response, "/login?errorOAuth=not_authentication_provider_found"); } - log.error("AuthenticationException: " + exception); } } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 7f5db942e..ed6057b9c 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -112,13 +112,11 @@ public class CustomSaml2AuthenticationSuccessHandler userService.processSSOPostLogin(username, saml2.getAutoCreateUser()); log.debug("Successfully processed authentication for user: {}", username); response.sendRedirect(contextPath + "/"); - return; } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( "Invalid username detected for user: {}, redirecting to logout", username); response.sendRedirect(contextPath + "/logout?invalidUsername=true"); - return; } } } else { diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index 47a89414d..934b37429 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -21,7 +21,7 @@ import stirling.software.SPDF.model.User; public class CustomSaml2ResponseAuthenticationConverter implements Converter { - private UserService userService; + private final UserService userService; public CustomSaml2ResponseAuthenticationConverter(UserService userService) { this.userService = userService; @@ -61,10 +61,10 @@ public class CustomSaml2ResponseAuthenticationConverter Map> attributes = extractAttributes(assertion); // Debug log with actual values - log.debug("Extracted SAML Attributes: " + attributes); + log.debug("Extracted SAML Attributes: {}", attributes); // Try to get username/identifier in order of preference - String userIdentifier = null; + String userIdentifier; if (hasAttribute(attributes, "username")) { userIdentifier = getFirstAttributeValue(attributes, "username"); } else if (hasAttribute(attributes, "emailaddress")) { @@ -84,10 +84,8 @@ public class CustomSaml2ResponseAuthenticationConverter SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_USER"); if (userOpt.isPresent()) { User user = userOpt.get(); - if (user != null) { - simpleGrantedAuthority = - new SimpleGrantedAuthority(userService.findRole(user).getAuthority()); - } + simpleGrantedAuthority = + new SimpleGrantedAuthority(userService.findRole(user).getAuthority()); } List sessionIndexes = new ArrayList<>(); @@ -102,7 +100,7 @@ public class CustomSaml2ResponseAuthenticationConverter return new Saml2Authentication( principal, responseToken.getToken().getSaml2Response(), - Collections.singletonList(simpleGrantedAuthority)); + List.of(simpleGrantedAuthority)); } private boolean hasAttribute(Map> attributes, String name) { diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/SAML2Configuration.java b/src/main/java/stirling/software/SPDF/config/security/saml2/SAML2Configuration.java index f0652fe44..a6b487ec6 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/SAML2Configuration.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/SAML2Configuration.java @@ -11,10 +11,12 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType; +import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; +import org.springframework.security.saml2.provider.service.web.HttpSessionSaml2AuthenticationRequestRepository; import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; import jakarta.servlet.http.HttpServletRequest; @@ -39,7 +41,7 @@ public class SAML2Configuration { @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getidpCert()); + X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert()); Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); @@ -51,15 +53,26 @@ public class SAML2Configuration { RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) .signingX509Credentials(c -> c.add(signingCredential)) + .entityId(samlConf.getIdpIssuer()) + .singleLogoutServiceBinding(Saml2MessageBinding.POST) + .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) + .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .assertionConsumerServiceBinding(Saml2MessageBinding.POST) + .assertionConsumerServiceLocation( + "{baseUrl}/login/saml2/sso/{registrationId}") .assertingPartyMetadata( metadata -> metadata.entityId(samlConf.getIdpIssuer()) - .singleSignOnServiceLocation( - samlConf.getIdpSingleLoginUrl()) .verificationX509Credentials( c -> c.add(verificationCredential)) .singleSignOnServiceBinding( Saml2MessageBinding.POST) + .singleSignOnServiceLocation( + samlConf.getIdpSingleLoginUrl()) + .singleLogoutServiceBinding( + Saml2MessageBinding.POST) + .singleLogoutServiceLocation( + samlConf.getIdpSingleLogoutUrl()) .wantAuthnRequestsSigned(true)) .build(); return new InMemoryRelyingPartyRegistrationRepository(rp); @@ -71,58 +84,93 @@ public class SAML2Configuration { RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { OpenSaml4AuthenticationRequestResolver resolver = new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); + resolver.setAuthnRequestCustomizer( customizer -> { - log.debug("Customizing SAML Authentication request"); - AuthnRequest authnRequest = customizer.getAuthnRequest(); - log.debug("AuthnRequest ID: {}", authnRequest.getID()); - if (authnRequest.getID() == null) { - authnRequest.setID("ARQ" + UUID.randomUUID().toString()); - } - log.debug("AuthnRequest new ID after set: {}", authnRequest.getID()); - log.debug("AuthnRequest IssueInstant: {}", authnRequest.getIssueInstant()); - log.debug( - "AuthnRequest Issuer: {}", - authnRequest.getIssuer() != null - ? authnRequest.getIssuer().getValue() - : "null"); HttpServletRequest request = customizer.getRequest(); - // Log HTTP request details - log.debug("HTTP Request Method: {}", request.getMethod()); - log.debug("Request URI: {}", request.getRequestURI()); - log.debug("Request URL: {}", request.getRequestURL().toString()); - log.debug("Query String: {}", request.getQueryString()); - log.debug("Remote Address: {}", request.getRemoteAddr()); - // Log headers - Collections.list(request.getHeaderNames()) - .forEach( - headerName -> { - log.debug( - "Header - {}: {}", - headerName, - request.getHeader(headerName)); - }); - // Log SAML specific parameters - log.debug("SAML Request Parameters:"); - log.debug("SAMLRequest: {}", request.getParameter("SAMLRequest")); - log.debug("RelayState: {}", request.getParameter("RelayState")); - // Log session debugrmation if exists - if (request.getSession(false) != null) { - log.debug("Session ID: {}", request.getSession().getId()); - } - // Log any assertions consumer service details if present - if (authnRequest.getAssertionConsumerServiceURL() != null) { + AuthnRequest authnRequest = customizer.getAuthnRequest(); + HttpSessionSaml2AuthenticationRequestRepository requestRepository = + new HttpSessionSaml2AuthenticationRequestRepository(); + AbstractSaml2AuthenticationRequest saml2AuthenticationRequest = + requestRepository.loadAuthenticationRequest(request); + + if (saml2AuthenticationRequest != null) { + String sessionId = request.getSession(false).getId(); + log.debug( - "AssertionConsumerServiceURL: {}", - authnRequest.getAssertionConsumerServiceURL()); - } - // Log NameID policy if present - if (authnRequest.getNameIDPolicy() != null) { - log.debug( - "NameIDPolicy Format: {}", - authnRequest.getNameIDPolicy().getFormat()); + "Retrieving SAML 2 authentication request ID from the current HTTP session {}", + sessionId); + + String authenticationRequestId = saml2AuthenticationRequest.getId(); + + if (!authenticationRequestId.isBlank()) { + authnRequest.setID(authenticationRequestId); + } else { + log.warn( + "No authentication request found for HTTP session {}. Generating new ID", + sessionId); + authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); + } + } else { + log.debug("Generating new authentication request ID"); + authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); } + + logAuthnRequestDetails(authnRequest); + logHttpRequestDetails(request); }); return resolver; } + + private static void logAuthnRequestDetails(AuthnRequest authnRequest) { + String message = + """ + AuthnRequest: + + ID: {} + Issuer: {} + IssueInstant: {} + AssertionConsumerService (ACS) URL: {} + """; + log.debug( + message, + authnRequest.getID(), + authnRequest.getIssuer() != null ? authnRequest.getIssuer().getValue() : null, + authnRequest.getIssueInstant(), + authnRequest.getAssertionConsumerServiceURL()); + + if (authnRequest.getNameIDPolicy() != null) { + log.debug("NameIDPolicy Format: {}", authnRequest.getNameIDPolicy().getFormat()); + } + } + + private static void logHttpRequestDetails(HttpServletRequest request) { + log.debug("HTTP Headers: "); + Collections.list(request.getHeaderNames()) + .forEach( + headerName -> + log.debug("{}: {}", headerName, request.getHeader(headerName))); + String message = + """ + HTTP Request Method: {} + Session ID: {} + Request Path: {} + Query String: {} + Remote Address: {} + + SAML Request Parameters: + + SAMLRequest: {} + RelayState: {} + """; + log.debug( + message, + request.getMethod(), + request.getSession().getId(), + request.getRequestURI(), + request.getQueryString(), + request.getRemoteAddr(), + request.getParameter("SAMLRequest"), + request.getParameter("RelayState")); + } } diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index b70e12eef..527bb1a35 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -126,7 +126,7 @@ public class UserController { return new RedirectView("/change-creds?messageType=notAuthenticated", true); } Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); - if (userOpt == null || userOpt.isEmpty()) { + if (userOpt.isEmpty()) { return new RedirectView("/change-creds?messageType=userNotFound", true); } User user = userOpt.get(); @@ -154,7 +154,7 @@ public class UserController { return new RedirectView("/account?messageType=notAuthenticated", true); } Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); - if (userOpt == null || userOpt.isEmpty()) { + if (userOpt.isEmpty()) { return new RedirectView("/account?messageType=userNotFound", true); } User user = userOpt.get(); @@ -176,7 +176,7 @@ public class UserController { for (Map.Entry entry : paramMap.entrySet()) { updates.put(entry.getKey(), entry.getValue()[0]); } - log.debug("Processed updates: " + updates); + log.debug("Processed updates: {}", updates); // Assuming you have a method in userService to update the settings for a user userService.updateUserSettings(principal.getName(), updates); // Redirect to a page of your choice after updating @@ -199,7 +199,7 @@ public class UserController { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { User user = userOpt.get(); - if (user != null && user.getUsername().equalsIgnoreCase(username)) { + if (user.getUsername().equalsIgnoreCase(username)) { return new RedirectView("/addUsers?messageType=usernameExists", true); } } @@ -276,7 +276,7 @@ public class UserController { Authentication authentication) throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); - if (!userOpt.isPresent()) { + if (userOpt.isEmpty()) { return new RedirectView("/addUsers?messageType=userNotFound", true); } if (!userService.usernameExistsIgnoreCase(username)) { @@ -295,7 +295,7 @@ public class UserController { List principals = sessionRegistry.getAllPrincipals(); String userNameP = ""; for (Object principal : principals) { - List sessionsInformations = + List sessionsInformation = sessionRegistry.getAllSessions(principal, false); if (principal instanceof UserDetails) { userNameP = ((UserDetails) principal).getUsername(); @@ -307,8 +307,8 @@ public class UserController { userNameP = (String) principal; } if (userNameP.equalsIgnoreCase(username)) { - for (SessionInformation sessionsInformation : sessionsInformations) { - sessionRegistry.expireSession(sessionsInformation.getSessionId()); + for (SessionInformation sessionInfo : sessionsInformation) { + sessionRegistry.expireSession(sessionInfo.getSessionId()); } } } diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 290ad1e53..cd4c2a305 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -4,7 +4,12 @@ import static stirling.software.SPDF.utils.validation.Validator.validateProvider import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.*; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import org.springframework.security.access.prepost.PreAuthorize; @@ -26,11 +31,15 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; -import stirling.software.SPDF.model.*; +import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client; import stirling.software.SPDF.model.ApplicationProperties.Security.SAML2; +import stirling.software.SPDF.model.Authority; +import stirling.software.SPDF.model.Role; +import stirling.software.SPDF.model.SessionEntity; +import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.provider.GitHubProvider; import stirling.software.SPDF.model.provider.GoogleProvider; import stirling.software.SPDF.model.provider.KeycloakProvider; @@ -74,7 +83,7 @@ public class AccountWebController { String firstChar = String.valueOf(oauth.getProvider().charAt(0)); String clientName = oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase()); - providerList.put(OAUTH_2_AUTHORIZATION + "oidc", clientName); + providerList.put(OAUTH_2_AUTHORIZATION + oauth.getProvider(), clientName); } Client client = oauth.getClient(); @@ -108,8 +117,16 @@ public class AccountWebController { SAML2 saml2 = securityProps.getSaml2(); if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality()) { - providerList.put("/saml2/authenticate/" + saml2.getRegistrationId(), "SAML 2"); + && applicationProperties.getSystem().getEnableAlphaFunctionality() + && applicationProperties.getEnterpriseEdition().isEnabled()) { + String samlIdp = saml2.getProvider(); + String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + + if (applicationProperties.getEnterpriseEdition().isSsoAutoLogin()) { + return "redirect:login" + saml2AuthenticationPath; + } else { + providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + } } // Remove any null keys/values from the providerList @@ -134,7 +151,9 @@ public class AccountWebController { model.addAttribute("error", error); } + String errorOAuth = request.getParameter("errorOAuth"); + if (errorOAuth != null) { switch (errorOAuth) { case "oAuth2AutoCreateDisabled" -> errorOAuth = "login.oAuth2AutoCreateDisabled"; @@ -142,19 +161,23 @@ public class AccountWebController { case "userAlreadyExistsWeb" -> errorOAuth = "userAlreadyExistsWebMessage"; case "oAuth2AuthenticationErrorWeb" -> errorOAuth = "login.oauth2InvalidUserType"; case "invalid_token_response" -> errorOAuth = "login.oauth2InvalidTokenResponse"; - case "authorization_request_not_found" -> errorOAuth = "login.oauth2RequestNotFound"; + case "authorization_request_not_found" -> + errorOAuth = "login.oauth2RequestNotFound"; case "access_denied" -> errorOAuth = "login.oauth2AccessDenied"; - case "invalid_user_info_response" -> errorOAuth = "login.oauth2InvalidUserInfoResponse"; + case "invalid_user_info_response" -> + errorOAuth = "login.oauth2InvalidUserInfoResponse"; case "invalid_request" -> errorOAuth = "login.oauth2invalidRequest"; case "invalid_id_token" -> errorOAuth = "login.oauth2InvalidIdToken"; case "oAuth2AdminBlockedUser" -> errorOAuth = "login.oAuth2AdminBlockedUser"; case "userIsDisabled" -> errorOAuth = "login.userIsDisabled"; case "invalid_destination" -> errorOAuth = "login.invalid_destination"; - case "relying_party_registration_not_found" -> errorOAuth = "login.relyingPartyRegistrationNotFound"; + case "relying_party_registration_not_found" -> + errorOAuth = "login.relyingPartyRegistrationNotFound"; // Valid InResponseTo was not available from the validation context, unable to // evaluate case "invalid_in_response_to" -> errorOAuth = "login.invalid_in_response_to"; - case "not_authentication_provider_found" -> errorOAuth = "login.not_authentication_provider_found"; + case "not_authentication_provider_found" -> + errorOAuth = "login.not_authentication_provider_found"; } model.addAttribute("errorOAuth", errorOAuth); @@ -211,13 +234,11 @@ public class AccountWebController { .plus(maxInactiveInterval, ChronoUnit.SECONDS); if (now.isAfter(expirationTime)) { sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); - hasActiveSession = false; } else { hasActiveSession = !sessionEntity.isExpired(); } lastRequest = sessionEntity.getLastRequest(); } else { - hasActiveSession = false; // No session, set default last request time lastRequest = new Date(0); } @@ -254,53 +275,41 @@ public class AccountWebController { }) .collect(Collectors.toList()); String messageType = request.getParameter("messageType"); - String deleteMessage = null; + + String deleteMessage; if (messageType != null) { - switch (messageType) { - case "deleteCurrentUser": - deleteMessage = "deleteCurrentUserMessage"; - break; - case "deleteUsernameExists": - deleteMessage = "deleteUsernameExistsMessage"; - break; - default: - break; - } + deleteMessage = + switch (messageType) { + case "deleteCurrentUser" -> "deleteCurrentUserMessage"; + case "deleteUsernameExists" -> "deleteUsernameExistsMessage"; + default -> null; + }; + model.addAttribute("deleteMessage", deleteMessage); - String addMessage = null; - switch (messageType) { - case "usernameExists": - addMessage = "usernameExistsMessage"; - break; - case "invalidUsername": - addMessage = "invalidUsernameMessage"; - break; - case "invalidPassword": - addMessage = "invalidPasswordMessage"; - break; - default: - break; - } + + String addMessage; + addMessage = + switch (messageType) { + case "usernameExists" -> "usernameExistsMessage"; + case "invalidUsername" -> "invalidUsernameMessage"; + case "invalidPassword" -> "invalidPasswordMessage"; + default -> null; + }; model.addAttribute("addMessage", addMessage); } - String changeMessage = null; + + String changeMessage; if (messageType != null) { - switch (messageType) { - case "userNotFound": - changeMessage = "userNotFoundMessage"; - break; - case "downgradeCurrentUser": - changeMessage = "downgradeCurrentUserMessage"; - break; - case "disabledCurrentUser": - changeMessage = "disabledCurrentUserMessage"; - break; - default: - changeMessage = messageType; - break; - } + changeMessage = + switch (messageType) { + case "userNotFound" -> "userNotFoundMessage"; + case "downgradeCurrentUser" -> "downgradeCurrentUserMessage"; + case "disabledCurrentUser" -> "disabledCurrentUserMessage"; + default -> messageType; + }; model.addAttribute("changeMessage", changeMessage); } + model.addAttribute("users", sortedUsers); model.addAttribute("currentUsername", authentication.getName()); model.addAttribute("roleDetails", roleDetails); @@ -321,39 +330,35 @@ public class AccountWebController { if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); String username = null; + + // Retrieve username and other attributes and add login attributes to the model if (principal instanceof UserDetails userDetails) { - // Retrieve username and other attributes username = userDetails.getUsername(); - // Add oAuth2 Login attributes to the model model.addAttribute("oAuth2Login", false); } if (principal instanceof OAuth2User userDetails) { - // Retrieve username and other attributes username = userDetails.getName(); - // Add oAuth2 Login attributes to the model model.addAttribute("oAuth2Login", true); } if (principal instanceof CustomSaml2AuthenticatedPrincipal userDetails) { - // Retrieve username and other attributes username = userDetails.getName(); - // Add oAuth2 Login attributes to the model - model.addAttribute("oAuth2Login", true); + model.addAttribute("saml2Login", true); } if (username != null) { - // Fetch user details from the database, assuming findByUsername method exists + // Fetch user details from the database Optional user = userRepository.findByUsernameIgnoreCaseWithSettings(username); - if (!user.isPresent()) { + if (user.isEmpty()) { return "redirect:/error"; } + // Convert settings map to JSON string ObjectMapper objectMapper = new ObjectMapper(); String settingsJson; try { settingsJson = objectMapper.writeValueAsString(user.get().getSettings()); } catch (JsonProcessingException e) { - // Handle JSON conversion error - log.error("exception", e); + log.error("Error converting settings map", e); return "redirect:/error"; } @@ -367,7 +372,7 @@ public class AccountWebController { case "invalidUsername" -> messageType = "invalidUsernameMessage"; } } - // Add attributes to the model + model.addAttribute("username", username); model.addAttribute("messageType", messageType); model.addAttribute("role", user.get().getRolesAsString()); @@ -390,19 +395,12 @@ public class AccountWebController { } if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); - if (principal instanceof UserDetails) { - // Cast the principal object to UserDetails - UserDetails userDetails = (UserDetails) principal; - // Retrieve username and other attributes + if (principal instanceof UserDetails userDetails) { String username = userDetails.getUsername(); // Fetch user details from the database - Optional user = - userRepository - .findByUsernameIgnoreCase( // Assuming findByUsername method exists - username); - if (!user.isPresent()) { - // Handle error appropriately - // Example redirection in case of error + Optional user = userRepository.findByUsernameIgnoreCase(username); + if (user.isEmpty()) { + // Handle error appropriately, example redirection in case of error return "redirect:/error"; } String messageType = request.getParameter("messageType"); @@ -425,7 +423,7 @@ public class AccountWebController { } model.addAttribute("messageType", messageType); } - // Add attributes to the model + model.addAttribute("username", username); } } else { diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index 6fa3e5e78..431c5c132 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -14,7 +14,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; -import java.util.stream.Collectors; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -110,7 +109,7 @@ public class ApplicationProperties { private int loginAttemptCount; private long loginResetTimeMinutes; private String loginMethod = "all"; - private String customGlobalAPIKey; + private String customGlobalAPIKey; // todo: expose? public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); @@ -139,13 +138,13 @@ public class ApplicationProperties { || loginMethod.equalsIgnoreCase(LoginMethods.ALL.toString())); } - public boolean isOauth2Activ() { + public boolean isOauth2Active() { return (oauth2 != null && oauth2.getEnabled() && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); } - public boolean isSaml2Activ() { + public boolean isSaml2Active() { return (saml2 != null && saml2.getEnabled() && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); @@ -161,6 +160,7 @@ public class ApplicationProperties { @Setter @ToString public static class SAML2 { + private String provider; private Boolean enabled = false; private Boolean autoCreateUser = false; private Boolean blockRegistration = false; @@ -198,7 +198,7 @@ public class ApplicationProperties { } } - public Resource getidpCert() { + public Resource getIdpCert() { if (idpCert == null) return null; if (idpCert.startsWith("classpath:")) { return new ClassPathResource(idpCert.substring("classpath:".length())); @@ -228,12 +228,11 @@ public class ApplicationProperties { private Collection scopes = new ArrayList<>(); private String provider; private Client client = new Client(); + private String logoutUrl; public void setScopes(String scopes) { List scopesList = - Arrays.stream(scopes.split(",")) - .map(String::trim) - .toList(); + Arrays.stream(scopes.split(",")).map(String::trim).toList(); this.scopes.addAll(scopesList); } @@ -266,7 +265,9 @@ public class ApplicationProperties { case "keycloak" -> getKeycloak(); default -> throw new UnsupportedProviderException( - "Logout from the provider " + registrationId + " is not supported. " + "Logout from the provider " + + registrationId + + " is not supported. " + "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues"); }; } diff --git a/src/main/java/stirling/software/SPDF/model/UsernameAttribute.java b/src/main/java/stirling/software/SPDF/model/UsernameAttribute.java index 24bccaf96..23e098a49 100644 --- a/src/main/java/stirling/software/SPDF/model/UsernameAttribute.java +++ b/src/main/java/stirling/software/SPDF/model/UsernameAttribute.java @@ -4,14 +4,17 @@ import lombok.Getter; @Getter public enum UsernameAttribute { - NAME("name"), EMAIL("email"), - GIVEN_NAME("given_name"), - PREFERRED_NAME("preferred_name"), - PREFERRED_USERNAME("preferred_username"), LOGIN("login"), + PROFILE("profile"), + NAME("name"), + USERNAME("username"), + NICKNAME("nickname"), + GIVEN_NAME("given_name"), + MIDDLE_NAME("middle_name"), FAMILY_NAME("family_name"), - NICKNAME("nickname"); + PREFERRED_NAME("preferred_name"), + PREFERRED_USERNAME("preferred_username"); private final String name; diff --git a/src/main/java/stirling/software/SPDF/model/provider/GitHubProvider.java b/src/main/java/stirling/software/SPDF/model/provider/GitHubProvider.java index e2b368a33..8ca61094f 100644 --- a/src/main/java/stirling/software/SPDF/model/provider/GitHubProvider.java +++ b/src/main/java/stirling/software/SPDF/model/provider/GitHubProvider.java @@ -28,6 +28,7 @@ public class GitHubProvider extends Provider { clientSecret, scopes, useAsUsername != null ? useAsUsername : UsernameAttribute.LOGIN, + null, AUTHORIZATION_URI, TOKEN_URI, USER_INFO_URI); diff --git a/src/main/java/stirling/software/SPDF/model/provider/GoogleProvider.java b/src/main/java/stirling/software/SPDF/model/provider/GoogleProvider.java index fb3388f82..4cf29c402 100644 --- a/src/main/java/stirling/software/SPDF/model/provider/GoogleProvider.java +++ b/src/main/java/stirling/software/SPDF/model/provider/GoogleProvider.java @@ -29,6 +29,7 @@ public class GoogleProvider extends Provider { clientSecret, scopes, useAsUsername, + null, AUTHORIZATION_URI, TOKEN_URI, USER_INFO_URI); diff --git a/src/main/java/stirling/software/SPDF/model/provider/KeycloakProvider.java b/src/main/java/stirling/software/SPDF/model/provider/KeycloakProvider.java index d989e5f88..6b89e5b1e 100644 --- a/src/main/java/stirling/software/SPDF/model/provider/KeycloakProvider.java +++ b/src/main/java/stirling/software/SPDF/model/provider/KeycloakProvider.java @@ -28,6 +28,7 @@ public class KeycloakProvider extends Provider { useAsUsername, null, null, + null, null); } diff --git a/src/main/java/stirling/software/SPDF/model/provider/Provider.java b/src/main/java/stirling/software/SPDF/model/provider/Provider.java index 5ba1ba2c4..9b262e9d5 100644 --- a/src/main/java/stirling/software/SPDF/model/provider/Provider.java +++ b/src/main/java/stirling/software/SPDF/model/provider/Provider.java @@ -16,6 +16,8 @@ import stirling.software.SPDF.model.exception.UnsupportedUsernameAttribute; @NoArgsConstructor public class Provider { + public static final String EXCEPTION_MESSAGE = "The attribute %s is not supported for %s."; + private String issuer; private String name; private String clientName; @@ -23,6 +25,7 @@ public class Provider { private String clientSecret; private Collection scopes; private UsernameAttribute useAsUsername; + private String logoutUrl; private String authorizationUri; private String tokenUri; private String userInfoUri; @@ -35,6 +38,7 @@ public class Provider { String clientSecret, Collection scopes, UsernameAttribute useAsUsername, + String logoutUrl, String authorizationUri, String tokenUri, String userInfoUri) { @@ -46,6 +50,7 @@ public class Provider { this.scopes = scopes == null ? new ArrayList<>() : scopes; this.useAsUsername = useAsUsername != null ? validateUsernameAttribute(useAsUsername) : EMAIL; + this.logoutUrl = logoutUrl; this.authorizationUri = authorizationUri; this.tokenUri = tokenUri; this.userInfoUri = userInfoUri; @@ -83,41 +88,29 @@ public class Provider { } default -> throw new UnsupportedUsernameAttribute( - "The attribute " - + usernameAttribute - + "is not supported for " - + clientName - + "."); + String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName)); } } private UsernameAttribute validateGoogleUsernameAttribute(UsernameAttribute usernameAttribute) { switch (usernameAttribute) { - case EMAIL, NAME, GIVEN_NAME, PREFERRED_NAME -> { + case EMAIL, NAME, GIVEN_NAME, FAMILY_NAME -> { return usernameAttribute; } default -> throw new UnsupportedUsernameAttribute( - "The attribute " - + usernameAttribute - + "is not supported for " - + clientName - + "."); + String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName)); } } private UsernameAttribute validateGitHubUsernameAttribute(UsernameAttribute usernameAttribute) { switch (usernameAttribute) { - case EMAIL, NAME, LOGIN -> { + case LOGIN, EMAIL, NAME -> { return usernameAttribute; } default -> throw new UnsupportedUsernameAttribute( - "The attribute " - + usernameAttribute - + "is not supported for " - + clientName - + "."); + String.format(EXCEPTION_MESSAGE, usernameAttribute, clientName)); } } diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 215dd89d3..818167f2f 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -16,7 +16,7 @@ security: csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts - loginMethod: saml2 # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) + loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) initialLogin: username: '' # initial username for the first login password: '' # initial password for the first login @@ -28,42 +28,44 @@ security: clientId: '' # client ID for Keycloak OAuth2 clientSecret: '' # client secret for Keycloak OAuth2 scopes: openid, profile, email # scopes for Keycloak OAuth2 - useAsUsername: preferred_username # field to use as the username for Keycloak OAuth2 + useAsUsername: preferred_username # field to use as the username for Keycloak OAuth2. Available options are: [email | preferred_name] google: clientId: '' # client ID for Google OAuth2 clientSecret: '' # client secret for Google OAuth2 scopes: email, profile # scopes for Google OAuth2 - useAsUsername: email # field to use as the username for Google OAuth2 + useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name] github: clientId: '' # client ID for GitHub OAuth2 clientSecret: '' # client secret for GitHub OAuth2 scopes: read:user # scope for GitHub OAuth2 - useAsUsername: login # field to use as the username for GitHub OAuth2 - issuer: '' # set to any provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint - clientId: '' # client ID from your provider - clientSecret: '' # client secret from your provider + useAsUsername: login # field to use as the username for GitHub OAuth2. Available options are: [email | login | name] + issuer: https://trial-6373896.okta.com/home/okta_flow_sso/0oaok4lk1nVvNBnqK697/alnbibn6b0OPFATt20g7 # set to any provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint + clientId: 0oaok4lk4eNm6PtFD697 # client ID from your provider + clientSecret: lmwlmxFZSJ0miOoRpUAKf2jg8tVPPXhUxgL2VB-b4uJfhnk4sI02YodKWRX8fLSq # client secret from your provider + logoutUrl: '' 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 - useAsUsername: email # default is 'email'; custom fields can be used as the username - scopes: openid, profile, email # specify the scopes for which the application will request permissions + useAsUsername: username # default is 'email'; custom fields can be used as the username + scopes: okta.users.read, okta.users.read.self, okta.users.manage.self, okta.groups.read # specify the scopes for which the application will request permissions provider: google # set this to your OAuth provider's name, e.g., 'google' or 'keycloak' saml2: - enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) + enabled: true # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) + provider: okta 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 - registrationId: stirling - idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata - idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml - idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml - idpIssuer: http://www.okta.com/externalKey - idpCert: classpath:okta.crt - privateKey: classpath:saml-private-key.key - spCert: classpath:saml-public-cert.crt + registrationId: stirlingpdf-dario-saml + idpMetadataUri: https://trial-6373896.okta.com/app/exkomkf71reALy12X697/sso/saml/metadata # todo: remove + idpSingleLoginUrl: https://trial-6373896.okta.com/app/trial-6373896_stirlingpdfsaml2_1/exkoot0g5ipqOF3Bo697/sso/saml # todo: remove + idpSingleLogoutUrl: https://trial-6373896.okta.com/app/trial-6373896_stirlingpdfsaml2_1/exkoot0g5ipqOF3Bo697/slo/saml # todo: remove + idpIssuer: http://www.okta.com/exkoot0g5ipqOF3Bo697 + idpCert: classpath:okta.cert + privateKey: classpath:private_key.key + spCert: classpath:certificate.crt enterpriseEdition: - enabled: false # set to 'true' to enable enterprise edition + enabled: true # set to 'true' to enable enterprise edition key: 00000000-0000-0000-0000-000000000000 - SSOAutoLogin: false # Enable to auto login to first provided SSO + SSOAutoLogin: true # Enable to auto login to first provided SSO CustomMetadata: autoUpdateMetadata: false # set to 'true' to automatically update metadata with below values author: username # supports text such as 'John Doe' or types such as username to autopopulate with user's username @@ -80,7 +82,7 @@ legal: system: defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc) googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow - enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) + enableAlphaFunctionality: true # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) showUpdate: false # see when a new update is available showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true' customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index e054bdbd2..ae7c72f9f 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -34,7 +34,7 @@ - +

Change Username?

@@ -53,7 +53,7 @@ - +

Change Password?

diff --git a/src/main/resources/templates/fragments/navbar.html b/src/main/resources/templates/fragments/navbar.html index 09160ee23..5503f1ae9 100644 --- a/src/main/resources/templates/fragments/navbar.html +++ b/src/main/resources/templates/fragments/navbar.html @@ -253,11 +253,11 @@
- Account Settings
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 91f799e42..e10ff6758 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -98,7 +98,7 @@ favicon

Stirling-PDF

-
+
Login Via SSO

diff --git a/src/test/java/stirling/software/SPDF/utils/validation/ValidatorTest.java b/src/test/java/stirling/software/SPDF/utils/validation/ValidatorTest.java index 986351984..57b8f1ba2 100644 --- a/src/test/java/stirling/software/SPDF/utils/validation/ValidatorTest.java +++ b/src/test/java/stirling/software/SPDF/utils/validation/ValidatorTest.java @@ -29,7 +29,6 @@ class ValidatorTest { when(provider.getClientId()).thenReturn("clientId"); when(provider.getClientSecret()).thenReturn("clientSecret"); when(provider.getScopes()).thenReturn(List.of("read:user")); - when(provider.getUseAsUsername()).thenReturn(UsernameAttribute.EMAIL); assertTrue(Validator.validateProvider(provider)); } @@ -44,15 +43,11 @@ class ValidatorTest { Provider generic = null; var google = new GoogleProvider(null, "clientSecret", List.of("scope"), UsernameAttribute.EMAIL); var github = new GitHubProvider("clientId", "", List.of("scope"), UsernameAttribute.LOGIN); - var keycloak = new KeycloakProvider("issuer", "clientId", "clientSecret", List.of("scope"), UsernameAttribute.EMAIL); - - keycloak.setUseAsUsername(null); return Stream.of( Arguments.of(generic), Arguments.of(google), - Arguments.of(github), - Arguments.of(keycloak) + Arguments.of(github) ); }