grandfather users (#4984)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com>
This commit is contained in:
Anthony Stirling 2025-11-25 21:23:32 +00:00 committed by GitHub
parent daf749e6be
commit e6db57e031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 564 additions and 71 deletions

View File

@ -397,7 +397,8 @@ public class ProprietaryUIDataController {
Map<Long, Date> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) {
Long teamId = (Long) result[0];
Date lastActivity = (Date) result[1];
Instant instant = (Instant) result[1];
Date lastActivity = instant != null ? Date.from(instant) : null;
teamLastRequest.put(teamId, lastActivity);
}
@ -441,7 +442,8 @@ public class ProprietaryUIDataController {
Map<String, Date> userLastRequest = new HashMap<>();
for (Object[] result : userSessions) {
String username = (String) result[0];
Date lastRequest = (Date) result[1];
Instant instant = (Instant) result[1];
Date lastRequest = instant != null ? Date.from(instant) : null;
userLastRequest.put(username, lastRequest);
}

View File

@ -238,6 +238,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
path = "errorOAuth=oAuth2AutoCreateDisabled";
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
path = "errorOAuth=oAuth2AdminBlockedUser";
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
path = "errorOAuth=oAuth2RequiresLicense";
} else if (request.getParameter("saml2RequiresLicense") != null) {
path = "errorOAuth=saml2RequiresLicense";
} else if (request.getParameter("maxUsersReached") != null) {
path = "errorOAuth=maxUsersReached";
} else if (request.getParameter("userIsDisabled") != null) {
path = "errorOAuth=userIsDisabled";
} else if ((errorMessage = request.getParameter("error")) != null) {

View File

@ -62,6 +62,7 @@ public class InitialSecuritySetup {
private void initializeUserLicenseSettings() {
licenseSettingsService.initializeGrandfatheredCount();
licenseSettingsService.updateLicenseMaxUsers();
licenseSettingsService.grandfatherExistingOAuthUsers();
}
private void configureJWTSettings() {

View File

@ -23,7 +23,7 @@ public class PremiumEndpointAspect {
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
if (!runningProOrHigher) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license");
HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license");
}
return joinPoint.proceed();
}

View File

@ -84,6 +84,8 @@ public class SecurityConfiguration {
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@ -103,7 +105,9 @@ public class SecurityConfiguration {
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@ -120,10 +124,11 @@ public class SecurityConfiguration {
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.licenseSettingsService = licenseSettingsService;
}
@Bean
public PasswordEncoder passwordEncoder() {
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@ -354,7 +359,8 @@ public class SecurityConfiguration {
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
jwtService,
licenseSettingsService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@ -395,7 +401,8 @@ public class SecurityConfiguration {
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService))
jwtService,
licenseSettingsService))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(

View File

@ -32,7 +32,8 @@ public class EEAppConfig {
@Bean(name = "runningProOrHigher")
@Primary
public boolean runningProOrHigher() {
return licenseKeyChecker.getPremiumLicenseEnabledResult() != License.NORMAL;
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
}
@Profile("security")

View File

@ -30,7 +30,7 @@ public class KeygenLicenseVerifier {
public enum License {
NORMAL,
PRO,
SERVER,
ENTERPRISE
}
@ -76,7 +76,7 @@ public class KeygenLicenseVerifier {
log.info("Detected certificate-based license. Processing...");
boolean isValid = verifyCertificateLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
} else {
license = License.NORMAL;
}
@ -84,7 +84,7 @@ public class KeygenLicenseVerifier {
log.info("Detected JWT-style license key. Processing...");
boolean isValid = verifyJWTLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
} else {
license = License.NORMAL;
}
@ -92,7 +92,7 @@ public class KeygenLicenseVerifier {
log.info("Detected standard license key. Processing...");
boolean isValid = verifyStandardLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
} else {
license = License.NORMAL;
}
@ -261,10 +261,23 @@ public class KeygenLicenseVerifier {
// Extract metadata
JSONObject metadataObj = attributesObj.optJSONObject("metadata");
if (metadataObj != null) {
int users = metadataObj.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
// Check if this is an old license (no planType) with isEnterprise flag
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false);
// Extract user count - default based on license type
// Old licenses: Only had isEnterprise flag
// New licenses: Have planType field with "server" or "enterprise"
int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
}
// Check license status if available
@ -431,28 +444,31 @@ public class KeygenLicenseVerifier {
}
// Extract max users and isEnterprise from policy or metadata
int users = policyObj.optInt("users", 1);
context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false);
int users = policyObj.optInt("users", -1);
if (users > 0) {
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
} else {
// Try to get users from metadata if present
if (users == -1) {
// Try to get users from metadata if not at policy level
Object metadataObj = policyObj.opt("metadata");
if (metadataObj instanceof JSONObject metadata) {
users = metadata.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users (from metadata)", users);
// Check for isEnterprise flag in metadata
context.isEnterpriseLicense = metadata.optBoolean("isEnterprise", false);
context.isEnterpriseLicense =
metadata.optBoolean("isEnterprise", context.isEnterpriseLicense);
users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0);
} else {
// Default value
applicationProperties.getPremium().setMaxUsers(1);
log.info("Using default of 1 user for license");
// Default based on license type
users = context.isEnterpriseLicense ? 1 : 0;
}
}
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
}
return true;
@ -584,17 +600,7 @@ public class KeygenLicenseVerifier {
context.maxMachines);
}
// Extract user count, default to 1 if not specified
int users =
jsonResponse
.path("data")
.path("attributes")
.path("metadata")
.path("users")
.asInt(1);
applicationProperties.getPremium().setMaxUsers(users);
// Extract isEnterprise flag
// Extract isEnterprise flag first
context.isEnterpriseLicense =
jsonResponse
.path("data")
@ -603,6 +609,24 @@ public class KeygenLicenseVerifier {
.path("isEnterprise")
.asBoolean(false);
// Extract user count - default based on license type
int users =
jsonResponse
.path("data")
.path("attributes")
.path("metadata")
.path("users")
.asInt(context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
log.debug(applicationProperties.toString());
} else {

View File

@ -68,8 +68,8 @@ public class LicenseKeyChecker {
premiumEnabledResult = licenseService.verifyLicense(licenseKey);
if (License.ENTERPRISE == premiumEnabledResult) {
log.info("License key is Enterprise.");
} else if (License.PRO == premiumEnabledResult) {
log.info("License key is Pro.");
} else if (License.SERVER == premiumEnabledResult) {
log.info("License key is Server.");
} else {
log.info("License key is invalid, defaulting to non pro license.");
}

View File

@ -87,6 +87,19 @@ public class UserController {
.body(Map.of("error", "Password is required"));
}
if (licenseSettingsService.wouldExceedLimit(1)) {
long availableSlots = licenseSettingsService.getAvailableUserSlots();
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Maximum number of users reached. Allowed: "
+ maxAllowed
+ ", Available slots: "
+ availableSlots));
}
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
User user =
userService.saveUser(

View File

@ -39,4 +39,28 @@ public interface UserRepository extends JpaRepository<User, Long> {
long countByTeam(Team team);
List<User> findAllByTeam(Team team);
// OAuth grandfathering queries
long countBySsoProviderIsNotNull();
long countByOauthGrandfatheredTrue();
List<User> findAllBySsoProviderIsNotNull();
/**
* Finds all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
* This catches V1 users who were created via SSO but never signed in (sso_provider is null).
*/
@Query(
"SELECT u FROM User u WHERE u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
List<User> findAllSsoUsers();
/**
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
*/
@Query(
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
long countSsoUsers();
}

View File

@ -71,6 +71,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "sso_provider")
private String ssoProvider;
@Column(name = "oauth_grandfathered")
private Boolean oauthGrandfathered = false;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities = new HashSet<>();
@ -135,4 +138,12 @@ public class User implements UserDetails, Serializable {
public boolean hasPassword() {
return this.password != null && !this.password.isEmpty();
}
public boolean isOauthGrandfathered() {
return oauthGrandfathered != null && oauthGrandfathered;
}
public void setOauthGrandfathered(boolean oauthGrandfathered) {
this.oauthGrandfathered = oauthGrandfathered;
}
}

View File

@ -50,6 +50,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@ -66,6 +68,25 @@ public class CustomOAuth2AuthenticationSuccessHandler
username = detailsUser.getUsername();
}
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for OAuth (grandfathered or system has paid license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
// Get the saved request
HttpSession session = request.getSession(false);
String contextPath = request.getContextPath();
@ -91,7 +112,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
.sendRedirect(request, response, "/logout?userIsDisabled=true");
return;
}
if (userService.usernameExistsIgnoreCase(username)
if (userExists
&& userService.hasPassword(username)
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
@ -106,6 +127,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
return;
}
if (principal instanceof OAuth2User oAuth2User) {
// Extract SSO provider information from OAuth2User
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID

View File

@ -49,6 +49,8 @@ public class CustomSaml2AuthenticationSuccessHandler
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@ -63,6 +65,26 @@ public class CustomSaml2AuthenticationSuccessHandler
String username = saml2Principal.name();
log.debug("Authenticated principal found for user: {}", username);
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has paid license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block SAML login
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
HttpSession session = request.getSession(false);
String contextPath = request.getContextPath();
SavedRequest savedRequest =
@ -96,7 +118,6 @@ public class CustomSaml2AuthenticationSuccessHandler
"Your account has been locked due to too many failed login attempts.");
}
boolean userExists = userService.usernameExistsIgnoreCase(username);
boolean hasPassword = userExists && userService.hasPassword(username);
boolean isSSOUser =
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
@ -129,6 +150,10 @@ public class CustomSaml2AuthenticationSuccessHandler
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
return;
}
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();

View File

@ -734,4 +734,48 @@ public class UserService implements UserServiceInterface {
public void saveAll(List<User> users) {
userRepository.saveAll(users);
}
/**
* Counts the number of OAuth/SAML users. Includes users with sso_provider set OR
* authenticationType is sso/oauth2/saml2 (catches V1 users who never signed in).
*
* @return Count of OAuth users
*/
public long countOAuthUsers() {
return userRepository.countSsoUsers();
}
/**
* Counts the number of OAuth users who are grandfathered.
*
* @return Count of grandfathered OAuth users
*/
public long countGrandfatheredOAuthUsers() {
return userRepository.countByOauthGrandfatheredTrue();
}
/**
* Grandfathers all existing OAuth/SAML users. This marks all users with an SSO provider as
* grandfathered, allowing them to keep OAuth access even without a paid license.
*
* @return Number of users updated
*/
@Transactional
public int grandfatherAllOAuthUsers() {
List<User> ssoUsers = userRepository.findAllSsoUsers();
int updated = 0;
for (User user : ssoUsers) {
if (!user.isOauthGrandfathered()) {
user.setOauthGrandfathered(true);
updated++;
}
}
if (updated > 0) {
userRepository.saveAll(ssoUsers);
}
return updated;
}
}

View File

@ -69,7 +69,7 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
private boolean hasProOrEnterpriseAccess() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.PRO || license == License.ENTERPRISE;
return license == License.SERVER || license == License.ENTERPRISE;
}
public boolean isEnabled() {

View File

@ -10,6 +10,7 @@ import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -18,6 +19,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@ -45,6 +48,7 @@ public class UserLicenseSettingsService {
private final UserLicenseSettingsRepository settingsRepository;
private final UserService userService;
private final ApplicationProperties applicationProperties;
private final ObjectProvider<LicenseKeyChecker> licenseKeyChecker;
/**
* Gets the current user license settings, creating them if they don't exist.
@ -151,7 +155,7 @@ public class UserLicenseSettingsService {
UserLicenseSettings settings = getOrCreateSettings();
int licenseMaxUsers = 0;
if (applicationProperties.getPremium().isEnabled()) {
if (hasPaidLicense()) {
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
}
@ -162,6 +166,40 @@ public class UserLicenseSettingsService {
}
}
/**
* Grandfathers existing OAuth users on first run. This is a one-time migration that marks all
* existing OAuth/SAML users as grandfathered, allowing them to keep OAuth access even without a
* paid license.
*
* <p>New users created after this migration will NOT be grandfathered and will require a paid
* license to use OAuth.
*/
@Transactional
public void grandfatherExistingOAuthUsers() {
UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration
if (settings.getId() != null && settings.isGrandfatheringLocked()) {
// Migration should happen at the same time as grandfathering is locked
long oauthUsersCount = userService.countOAuthUsers();
long grandfatheredCount = userService.countGrandfatheredOAuthUsers();
if (oauthUsersCount > 0 && grandfatheredCount == 0) {
// We have OAuth users but none are grandfathered - this is first run after upgrade
int updated = userService.grandfatherAllOAuthUsers();
log.warn(
"OAuth GRANDFATHERING: Marked {} existing OAuth/SAML users as grandfathered. "
+ "They will retain OAuth access even without a paid license. "
+ "New users will require a paid license for OAuth.",
updated);
} else if (grandfatheredCount > 0) {
log.debug(
"OAuth grandfathering already completed: {} users grandfathered",
grandfatheredCount);
}
}
}
/**
* Validates and enforces the integrity of license settings. This ensures that even if someone
* manually modifies the database, the grandfathering rules are still enforced.
@ -241,12 +279,15 @@ public class UserLicenseSettingsService {
* <p>Logic:
*
* <ul>
* <li>Grandfathered limit = max(5, existing user count at initialization)
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
* <li>If premium disabled: total limit = grandfathered limit
* <li>Grandfathered limit = max(5, existing user count at V1V2 migration)
* <li>No license: Uses grandfathered limit only
* <li>SERVER license (maxUsers=0): Unlimited users (Integer.MAX_VALUE)
* <li>ENTERPRISE license (maxUsers>0): License seats only (NO grandfathering added)
* </ul>
*
* @return Maximum number of users allowed
* <p>IMPORTANT: Paid licenses REPLACE the limit, they don't add to grandfathering.
*
* @return Maximum number of users allowed (Integer.MAX_VALUE for unlimited)
*/
public int calculateMaxAllowedUsers() {
validateSettingsIntegrity();
@ -259,20 +300,52 @@ public class UserLicenseSettingsService {
grandfatheredLimit = DEFAULT_USER_LIMIT;
}
int totalLimit = grandfatheredLimit;
if (applicationProperties.getPremium().isEnabled()) {
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
// No license: use grandfathered limit
if (!hasPaidLicense()) {
log.debug("No license: using grandfathered limit of {}", grandfatheredLimit);
return grandfatheredLimit;
}
log.debug(
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
totalLimit,
grandfatheredLimit,
settings.getLicenseMaxUsers(),
applicationProperties.getPremium().isEnabled());
int licenseMaxUsers = settings.getLicenseMaxUsers();
return totalLimit;
// SERVER license (maxUsers=0): unlimited users
if (licenseMaxUsers == 0) {
log.debug("SERVER license: unlimited users allowed");
return Integer.MAX_VALUE;
}
// ENTERPRISE license (maxUsers>0): license seats only (replaces grandfathering)
log.debug(
"ENTERPRISE license: {} seats (grandfathered {} not added)",
licenseMaxUsers,
grandfatheredLimit);
return licenseMaxUsers;
}
/**
* Checks if a user is eligible to use OAuth/SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SSO is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true;
}
// Users can use OAuth/SAML only if system has ENTERPRISE license
boolean hasEnterpriseLicense = hasEnterpriseLicense();
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
return hasEnterpriseLicense;
}
/**
@ -408,4 +481,28 @@ public class UserLicenseSettingsService {
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
}
}
private boolean hasPaidLicense() {
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
}
/**
* Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO
* (OAuth/SAML).
*
* @return true if ENTERPRISE license is active
*/
private boolean hasEnterpriseLicense() {
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.ENTERPRISE;
}
}

View File

@ -103,6 +103,9 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
@ -208,6 +211,9 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
when(request.getScheme()).thenReturn("http");
@ -237,6 +243,9 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");

View File

@ -44,13 +44,13 @@ class LicenseKeyCheckerTest {
ApplicationProperties props = new ApplicationProperties();
props.getPremium().setEnabled(true);
props.getPremium().setKey("abc");
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
when(verifier.verifyLicense("abc")).thenReturn(License.SERVER);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
checker.init();
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
assertEquals(License.SERVER, checker.getPremiumLicenseEnabledResult());
verify(verifier).verifyLicense("abc");
}

View File

@ -0,0 +1,201 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.beans.factory.ObjectProvider;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UserLicenseSettingsServiceTest {
@Mock private UserLicenseSettingsRepository settingsRepository;
@Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium;
@Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
private UserLicenseSettingsService service;
private UserLicenseSettings mockSettings;
@BeforeEach
void setUp() {
mockSettings = new UserLicenseSettings();
mockSettings.setId(1L);
mockSettings.setGrandfatheredUserCount(80);
mockSettings.setGrandfatheringLocked(true);
mockSettings.setIntegritySalt("test-salt");
mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium);
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(licenseKeyChecker);
// Create service with overridden validateSettingsIntegrity to bypass signature validation
service =
new UserLicenseSettingsService(
settingsRepository,
userService,
applicationProperties,
licenseKeyCheckerProvider) {
@Override
public void validateSettingsIntegrity() {
// Override to do nothing in tests - avoid HMAC signature validation
// complexity
}
};
}
@Test
void noLicense_returnsGrandfatheredLimit() {
// No license active
when(premium.isEnabled()).thenReturn(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
int result = service.calculateMaxAllowedUsers();
assertEquals(80, result, "Should return grandfathered user count when no license");
}
@Test
void serverLicense_returnsUnlimited() {
// SERVER license with users=0
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
mockSettings.setLicenseMaxUsers(0);
int result = service.calculateMaxAllowedUsers();
assertEquals(Integer.MAX_VALUE, result, "SERVER license should return unlimited users");
}
@Test
void enterpriseLicense_returnsLicenseSeatsOnly() {
// ENTERPRISE license with 5 seats
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(5);
int result = service.calculateMaxAllowedUsers();
assertEquals(
5,
result,
"ENTERPRISE license should return license seats only (NOT grandfathered + seats)");
}
@Test
void enterpriseLicense_ignoresGrandfathering() {
// ENTERPRISE with 20 seats, grandfathered was 80
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(20);
mockSettings.setGrandfatheredUserCount(80); // This should be ignored
int result = service.calculateMaxAllowedUsers();
assertEquals(
20,
result,
"ENTERPRISE license should ignore grandfathering and use license seats only");
}
@Test
void freshInstall_noLicense_returnsFive() {
// Fresh install with default 5 users grandfathered
mockSettings.setGrandfatheredUserCount(5);
when(premium.isEnabled()).thenReturn(false);
int result = service.calculateMaxAllowedUsers();
assertEquals(5, result, "Fresh install with no license should return 5 users");
}
@Test
void freshInstall_serverLicense_returnsUnlimited() {
// Fresh install with SERVER license
mockSettings.setGrandfatheredUserCount(5);
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
mockSettings.setLicenseMaxUsers(0);
int result = service.calculateMaxAllowedUsers();
assertEquals(
Integer.MAX_VALUE,
result,
"Fresh install with SERVER license should return unlimited");
}
@Test
void freshInstall_enterpriseLicense_returnsLicenseSeats() {
// Fresh install with ENTERPRISE 10 seats
mockSettings.setGrandfatheredUserCount(5);
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(10);
int result = service.calculateMaxAllowedUsers();
assertEquals(
10, result, "Fresh install with ENTERPRISE license should return license seats");
}
@Test
void v1MigrationWith80Users_noLicense_returns80() {
// V1V2 migration with 80 users, no paid license
mockSettings.setGrandfatheredUserCount(80);
when(premium.isEnabled()).thenReturn(false);
int result = service.calculateMaxAllowedUsers();
assertEquals(80, result, "V1→V2 migration should preserve 80 grandfathered users");
}
@Test
void v1MigrationWith80Users_thenEnterpriseWith5Seats_returns5() {
// V1V2 with 80 users, then buy ENTERPRISE 5 seats
mockSettings.setGrandfatheredUserCount(80);
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(5);
int result = service.calculateMaxAllowedUsers();
assertEquals(
5, result, "ENTERPRISE 5 seats should override grandfathered 80 users (not 85)");
}
@Test
void zeroGrandfathered_fallsBackToDefault() {
// Edge case: grandfathered is 0 (should not happen)
mockSettings.setGrandfatheredUserCount(0);
when(premium.isEnabled()).thenReturn(false);
int result = service.calculateMaxAllowedUsers();
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
}
}

View File

@ -3473,6 +3473,9 @@
"ssoSignIn": "Login via Single Sign-on",
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
"oAuth2AdminBlockedUser": "Registration or logging in of non-registered users is currently blocked. Please contact the administrator.",
"oAuth2RequiresLicense": "OAuth/SSO login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan.",
"saml2RequiresLicense": "SAML login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan.",
"maxUsersReached": "Maximum number of users reached for your current license. Please contact the administrator to upgrade your plan or add more seats.",
"oauth2RequestNotFound": "Authorization request not found",
"oauth2InvalidUserInfoResponse": "Invalid User Info Response",
"oauth2invalidRequest": "Invalid Request",

View File

@ -101,8 +101,8 @@ export class UpdateService {
async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise<UpdateSummary | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
if (machineInfo.licenseType === 'SERVER') {
type = 'server';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
@ -133,8 +133,8 @@ export class UpdateService {
async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise<FullUpdateInfo | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
if (machineInfo.licenseType === 'SERVER') {
type = 'server';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}

View File

@ -70,7 +70,7 @@ export interface LicenseKeyResponse {
}
export interface LicenseInfo {
licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE';
licenseType: 'NORMAL' | 'SERVER' | 'ENTERPRISE';
enabled: boolean;
maxUsers: number;
hasKey: boolean;
@ -496,8 +496,8 @@ export const mapLicenseToTier = (licenseInfo: LicenseInfo | null): 'free' | 'ser
return 'free';
}
// PRO type (no seats) = Server tier
if (licenseInfo.licenseType === 'PRO') {
// SERVER type (unlimited users) = Server tier
if (licenseInfo.licenseType === 'SERVER') {
return 'server';
}