diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 33ad3cf671..d45549a442 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -324,7 +324,7 @@ public class SecurityConfiguration { userInfoEndpoint .oidcUserService( new CustomOAuth2UserService( - securityProperties, + securityProperties.getOauth2(), userService, loginAttemptService)) .userAuthoritiesMapper( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 4d74dbfd8e..36fa23a6aa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository { Optional findByApiKey(String apiKey); + Optional findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId); + List findByAuthenticationTypeIgnoreCase(String authenticationType); @Query("SELECT u FROM User u WHERE u.team IS NULL") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 5506b7e07c..4d76378254 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -213,14 +213,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { case OAUTH2 -> { ApplicationProperties.Security.OAUTH2 oauth2Properties = securityProperties.getOauth2(); + // Provider IDs should already be set during initial authentication + // Pass null here since this is validating an existing JWT token userService.processSSOPostLogin( - username, oauth2Properties.getAutoCreateUser(), OAUTH2); + username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2); } case SAML2 -> { ApplicationProperties.Security.SAML2 saml2Properties = securityProperties.getSaml2(); + // Provider IDs should already be set during initial authentication + // Pass null here since this is validating an existing JWT token userService.processSSOPostLogin( - username, saml2Properties.getAutoCreateUser(), SAML2); + username, null, null, saml2Properties.getAutoCreateUser(), SAML2); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 616067bed9..5f239c4e3c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -1,12 +1,15 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -59,6 +62,13 @@ public class User implements UserDetails, Serializable { @Column(name = "authenticationtype") private String authenticationType; + // todo: could these be linked to PII in anyway? + @Column(name = "sso_provider_id") + private String ssoProviderId; + + @Column(name = "sso_provider") + private String ssoProvider; + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") private Set authorities = new HashSet<>(); @@ -74,6 +84,14 @@ public class User implements UserDetails, Serializable { @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) private Map settings = new HashMap<>(); // Key-value pairs of settings. + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + public String getRoleName() { return Role.getRoleNameByRoleId(getRolesAsString()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 32ddf1e9d4..248aea603f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -10,6 +10,7 @@ import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; @@ -92,9 +93,19 @@ public class CustomOAuth2AuthenticationSuccessHandler response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true"); return; } - if (principal instanceof OAuth2User) { + if (principal instanceof OAuth2User oAuth2User) { + // Extract SSO provider information from OAuth2User + String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID + // Extract provider from authentication - need to get it from the token/request + // For now, we'll extract it in a more generic way + String ssoProvider = extractProviderFromAuthentication(authentication); + userService.processSSOPostLogin( - username, oauth2Properties.getAutoCreateUser(), OAUTH2); + username, + ssoProviderId, + ssoProvider, + oauth2Properties.getAutoCreateUser(), + OAUTH2); } // Generate JWT if v2 is enabled @@ -126,4 +137,17 @@ public class CustomOAuth2AuthenticationSuccessHandler } } } + + /** + * Extracts the OAuth2 provider registration ID from the authentication object. + * + * @param authentication The authentication object + * @return The provider registration ID (e.g., "google", "github"), or null if not available + */ + private String extractProviderFromAuthentication(Authentication authentication) { + if (authentication instanceof OAuth2AuthenticationToken oauth2Token) { + return oauth2Token.getAuthorizedClientRegistrationId(); + } + return null; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 29feb3fe94..c66b8e7bf7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -116,9 +116,20 @@ public class CustomSaml2AuthenticationSuccessHandler contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } - log.debug("Processing SSO post-login for user: {}", username); + + // Extract SSO provider information from SAML2 assertion + String ssoProviderId = saml2Principal.nameId(); + String ssoProvider = "saml2"; // fixme + + log.debug("Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})", + username, ssoProvider, ssoProviderId); + userService.processSSOPostLogin( - username, saml2Properties.getAutoCreateUser(), SAML2); + username, + ssoProviderId, + ssoProvider, + saml2Properties.getAutoCreateUser(), + SAML2); log.debug("Successfully processed authentication for user: {}", username); // Generate JWT if v2 is enabled diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index 8f9afbe3db..256cb8ade0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -27,13 +27,13 @@ public class CustomOAuth2UserService implements OAuth2UserService internalUser = - userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey)); + // Extract SSO provider information + String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim + String ssoProvider = userRequest.getClientRegistration().getRegistrationId(); + String username = user.getAttribute(usernameAttributeKey); + + log.debug("OAuth2 login - Provider: {}, ProviderId: {}, Username: {}", + ssoProvider, ssoProviderId, username); + + Optional internalUser = userService.findByUsernameIgnoreCase(username); if (internalUser.isPresent()) { String internalUsername = internalUser.get().getUsername(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index a65c79665a..8e0da35c1e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -14,6 +14,7 @@ import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -38,9 +39,11 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { - private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; + @Value("${security.jwt.issuer:${server.url:https://stirling.com}}") + private String issuer; + private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; @@ -85,7 +88,7 @@ public class JwtService implements JwtServiceInterface { Jwts.builder() .claims(claims) .subject(username) - .issuer(ISSUER) + .issuer(issuer) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 6f213b25eb..34b9eac939 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -60,19 +60,45 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; - // Handle OAUTH2 login and user auto creation. public void processSSOPostLogin( - String username, boolean autoCreateUser, AuthenticationType type) + String username, + String ssoProviderId, + String ssoProvider, + boolean autoCreateUser, + AuthenticationType type) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return; } - Optional existingUser = findByUsernameIgnoreCase(username); + + // Find user by SSO provider ID first + Optional existingUser; + if (ssoProviderId != null && ssoProvider != null) { + existingUser = userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId); + + if (existingUser.isPresent()) { + log.debug("User found by SSO provider ID: {}", ssoProviderId); + return; + } + } + + existingUser = findByUsernameIgnoreCase(username); if (existingUser.isPresent()) { + User user = existingUser.get(); + + // Migrate existing user to use provider ID if not already set + if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) { + log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId); + user.setSsoProviderId(ssoProviderId); + user.setSsoProvider(ssoProvider); + userRepository.save(user); + databaseService.exportDatabase(); + } return; } + if (autoCreateUser) { - saveUser(username, type); + saveUser(username, ssoProviderId, ssoProvider, type); } } @@ -154,6 +180,21 @@ public class UserService implements UserServiceInterface { saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId()); } + public void saveUser( + String username, + String ssoProviderId, + String ssoProvider, + AuthenticationType authenticationType) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + saveUser( + username, + ssoProviderId, + ssoProvider, + authenticationType, + (Long) null, + Role.USER.getRoleId()); + } + private User saveUser(Optional user, String apiKey) { if (user.isPresent()) { user.get().setApiKey(apiKey); @@ -168,6 +209,30 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username null, // password + null, // ssoProviderId + null, // ssoProvider + authenticationType, // authenticationType + teamId, // teamId + null, // team + role, // role + false, // firstLogin + true // enabled + ); + } + + public User saveUser( + String username, + String ssoProviderId, + String ssoProvider, + AuthenticationType authenticationType, + Long teamId, + String role) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + return saveUserCore( + username, // username + null, // password + ssoProviderId, // ssoProviderId + ssoProvider, // ssoProvider authenticationType, // authenticationType teamId, // teamId null, // team @@ -183,6 +248,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username null, // password + null, // ssoProviderId + null, // ssoProvider authenticationType, // authenticationType null, // teamId team, // team @@ -197,6 +264,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -212,6 +281,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType null, // teamId team, // team @@ -227,6 +298,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -247,6 +320,8 @@ public class UserService implements UserServiceInterface { saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -411,6 +486,8 @@ public class UserService implements UserServiceInterface { * * @param username Username for the new user * @param password Password for the user (may be null for SSO/OAuth users) + * @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users) + * @param ssoProvider Name of the SSO provider (may be null for non-SSO users) * @param authenticationType Type of authentication (WEB, SSO, etc.) * @param teamId ID of the team to assign (may be null to use default) * @param team Team object to assign (takes precedence over teamId if both provided) @@ -425,6 +502,8 @@ public class UserService implements UserServiceInterface { private User saveUserCore( String username, String password, + String ssoProviderId, + String ssoProvider, AuthenticationType authenticationType, Long teamId, Team team, @@ -445,6 +524,12 @@ public class UserService implements UserServiceInterface { user.setPassword(passwordEncoder.encode(password)); } + // Set SSO provider details if provided + if (ssoProviderId != null && ssoProvider != null) { + user.setSsoProviderId(ssoProviderId); + user.setSsoProvider(ssoProvider); + } + // Set authentication type user.setAuthenticationType(authenticationType);