mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)
# Description of Changes This pull request introduces several improvements and new features across the authentication and admin data APIs, with a particular focus on multi-factor authentication (MFA) support and better handling of user settings. The changes include integrating MFA status into account data responses, masking sensitive user settings in admin views, and refactoring code to use more robust user creation methods. Additionally, there are minor code cleanups and consistency improvements. ### Multi-factor Authentication (MFA) Integration * Added `mfaEnabled` and `mfaRequired` fields to the `AccountData` response, populated using the new `MfaService`, to provide clients with MFA status information for users. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98) [[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575) ### User Settings Handling and Security * Admin settings API now returns user settings for each user, with the `mfaSecret` field masked to protect sensitive information. This is achieved by fetching settings via `findByIdWithSettings` and copying/masking the relevant field before returning. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563) ### Refactoring and Code Consistency * Refactored user creation in `InitialSecuritySetup` to use the `SaveUserRequest` builder and `saveUserCore` for better maintainability and clarity, replacing direct calls to `saveUser`. [[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22) [[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124) [[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144) [[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160) * Standardized checks for internal team membership by comparing with `TeamService.INTERNAL_TEAM_NAME` on the left side for consistency. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471) [[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485) ### API and DTO Changes * Changed the login API to accept `UsernameAndPassMfa` instead of `UsernameAndPass`, paving the way for MFA code support in authentication requests. [[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41) [[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71) * Updated import statements and controller dependencies to include new DTOs and services related to MFA. [[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19) [[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57) These updates improve security, prepare the system for MFA rollout, and make admin and authentication APIs more robust and informative. --- ## 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: Copilot <[email protected]>
This commit is contained in:
+35
-13
@@ -49,6 +49,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -68,6 +69,7 @@ public class ProprietaryUIDataController {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final MfaService mfaService;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -80,7 +82,8 @@ public class ProprietaryUIDataController {
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
PersistentAuditEventRepository auditRepository) {
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
MfaService mfaService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@@ -92,6 +95,7 @@ public class ProprietaryUIDataController {
|
||||
this.runningEE = runningEE;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.auditRepository = auditRepository;
|
||||
this.mfaService = mfaService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,12 +249,14 @@ public class ProprietaryUIDataController {
|
||||
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
Map<String, Map<String, String>> userSettings = new HashMap<>();
|
||||
int activeUsers = 0;
|
||||
int disabledUsers = 0;
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
User user = iterator.next();
|
||||
if (user != null) {
|
||||
String username = user.getUsername();
|
||||
boolean shouldRemove = false;
|
||||
|
||||
// Check if user is an INTERNAL_API_USER
|
||||
@@ -264,7 +270,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
// Check if user is part of the Internal team
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
|
||||
@@ -278,7 +284,7 @@ public class ProprietaryUIDataController {
|
||||
boolean hasActiveSession = false;
|
||||
Date lastRequest = null;
|
||||
Optional<SessionEntity> latestSession =
|
||||
sessionPersistentRegistry.findLatestSession(user.getUsername());
|
||||
sessionPersistentRegistry.findLatestSession(username);
|
||||
|
||||
if (latestSession.isPresent()) {
|
||||
SessionEntity sessionEntity = latestSession.get();
|
||||
@@ -299,8 +305,21 @@ public class ProprietaryUIDataController {
|
||||
lastRequest = new Date(0);
|
||||
}
|
||||
|
||||
userSessions.put(user.getUsername(), hasActiveSession);
|
||||
userLastRequest.put(user.getUsername(), lastRequest);
|
||||
User userWithSettings =
|
||||
userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
|
||||
// Mask mfaSecret if present in settings
|
||||
Map<String, String> originalSettings = userWithSettings.getSettings();
|
||||
Map<String, String> settingsCopy =
|
||||
originalSettings != null
|
||||
? new HashMap<>(originalSettings)
|
||||
: new HashMap<>();
|
||||
if (settingsCopy.containsKey("mfaSecret")) {
|
||||
settingsCopy.put("mfaSecret", "********");
|
||||
}
|
||||
userSettings.put(username, settingsCopy);
|
||||
userSessions.put(username, hasActiveSession);
|
||||
userLastRequest.put(username, lastRequest);
|
||||
|
||||
if (hasActiveSession) activeUsers++;
|
||||
if (!user.isEnabled()) disabledUsers++;
|
||||
@@ -329,7 +348,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
List<Team> allTeams =
|
||||
teamRepository.findAll().stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
|
||||
.toList();
|
||||
|
||||
// Calculate license limits
|
||||
@@ -356,6 +375,7 @@ public class ProprietaryUIDataController {
|
||||
data.setLicenseMaxUsers(licenseMaxUsers);
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
data.setMailEnabled(applicationProperties.getMail().isEnabled());
|
||||
data.setUserSettings(userSettings);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -407,6 +427,8 @@ public class ProprietaryUIDataController {
|
||||
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
|
||||
data.setOAuth2Login(isOAuth2Login);
|
||||
data.setSaml2Login(isSaml2Login);
|
||||
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
|
||||
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -418,7 +440,7 @@ public class ProprietaryUIDataController {
|
||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||
List<TeamWithUserCountDTO> teamsWithCounts =
|
||||
allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
|
||||
.toList();
|
||||
|
||||
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
|
||||
@@ -446,7 +468,7 @@ public class ProprietaryUIDataController {
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
@@ -459,11 +481,8 @@ public class ProprietaryUIDataController {
|
||||
(user.getTeam() == null
|
||||
|| !user.getTeam().getId().equals(id))
|
||||
&& (user.getTeam() == null
|
||||
|| !user.getTeam()
|
||||
.getName()
|
||||
.equals(
|
||||
TeamService
|
||||
.INTERNAL_TEAM_NAME)))
|
||||
|| !TeamService.INTERNAL_TEAM_NAME.equals(
|
||||
user.getTeam().getName())))
|
||||
.toList();
|
||||
|
||||
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
|
||||
@@ -541,6 +560,7 @@ public class ProprietaryUIDataController {
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
private boolean mailEnabled;
|
||||
private Map<String, Map<String, String>> userSettings;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -551,6 +571,8 @@ public class ProprietaryUIDataController {
|
||||
private boolean changeCredsFlag;
|
||||
private boolean oAuth2Login;
|
||||
private boolean saml2Login;
|
||||
private boolean mfaEnabled;
|
||||
private boolean mfaRequired;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+25
-10
@@ -19,6 +19,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -113,8 +114,14 @@ public class InitialSecuritySetup {
|
||||
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
|
||||
|
||||
Team team = teamService.getOrCreateDefaultTeam();
|
||||
userService.saveUser(
|
||||
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(initialUsername)
|
||||
.password(initialPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(false);
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Admin user created: {}", initialUsername);
|
||||
} else {
|
||||
createDefaultAdminUser();
|
||||
@@ -127,8 +134,14 @@ public class InitialSecuritySetup {
|
||||
|
||||
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
||||
Team team = teamService.getOrCreateDefaultTeam();
|
||||
userService.saveUser(
|
||||
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(defaultUsername)
|
||||
.password(defaultPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true);
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Default admin user created: {}", defaultUsername);
|
||||
}
|
||||
}
|
||||
@@ -137,12 +150,14 @@ public class InitialSecuritySetup {
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
Team team = teamService.getOrCreateInternalTeam();
|
||||
userService.saveUser(
|
||||
Role.INTERNAL_API_USER.getRoleId(),
|
||||
UUID.randomUUID().toString(),
|
||||
team,
|
||||
Role.INTERNAL_API_USER.getRoleId(),
|
||||
false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(Role.INTERNAL_API_USER.getRoleId())
|
||||
.password(UUID.randomUUID().toString())
|
||||
.team(team)
|
||||
.role(Role.INTERNAL_API_USER.getRoleId())
|
||||
.firstLogin(false);
|
||||
userService.saveUserCore(builder.build());
|
||||
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
||||
} else {
|
||||
|
||||
+272
-4
@@ -11,7 +11,12 @@ import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@@ -27,10 +32,13 @@ import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@@ -45,6 +53,8 @@ public class AuthController {
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final MfaService mfaService;
|
||||
private final TotpService totpService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
/**
|
||||
@@ -58,7 +68,7 @@ public class AuthController {
|
||||
@PostMapping("/login")
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request,
|
||||
@RequestBody UsernameAndPassMfa request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
@@ -116,6 +126,47 @@ public class AuthController {
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
String code = request.getMfaCode();
|
||||
if (code == null || code.isBlank()) {
|
||||
log.warn(
|
||||
"MFA required but no code provided for user: {} from IP: {}",
|
||||
username,
|
||||
ip);
|
||||
// loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "mfa_required",
|
||||
"message", "Two-factor code required"));
|
||||
}
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
log.error("MFA enabled but no secret stored for user: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "MFA configuration error"));
|
||||
}
|
||||
Long timeStep = totpService.getValidTimeStep(secret, code);
|
||||
if (timeStep == null) {
|
||||
log.warn("Invalid MFA code for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "invalid_mfa_code",
|
||||
"message", "Invalid two-factor code"));
|
||||
}
|
||||
if (!mfaService.markTotpStepUsed(user, timeStep)) {
|
||||
log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "invalid_mfa_code",
|
||||
"message", "Invalid two-factor code"));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
@@ -163,7 +214,7 @@ public class AuthController {
|
||||
|
||||
if (auth == null
|
||||
|| !auth.isAuthenticated()
|
||||
|| auth.getPrincipal().equals("anonymousUser")) {
|
||||
|| "anonymousUser".equals(auth.getPrincipal())) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
@@ -244,6 +295,210 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/mfa/setup")
|
||||
public ResponseEntity<?> setupMfa(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "MFA already enabled"));
|
||||
}
|
||||
|
||||
try {
|
||||
String secret = totpService.generateSecret();
|
||||
mfaService.setSecret(user, secret);
|
||||
String otpAuthUri = totpService.buildOtpAuthUri(username, secret);
|
||||
|
||||
return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to setup MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to setup MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/enable")
|
||||
public ResponseEntity<?> enableMfa(
|
||||
@RequestBody MfaCodeRequest request, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA setup required"));
|
||||
}
|
||||
|
||||
if (request == null || request.getCode() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA code is required"));
|
||||
}
|
||||
|
||||
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
|
||||
if (timeStep == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mfaService.isTotpStepUsable(user, timeStep)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
mfaService.enableMfa(user);
|
||||
mfaService.markTotpStepUsed(user, timeStep);
|
||||
mfaService.setMfaRequired(user, false);
|
||||
return ResponseEntity.ok(Map.of("enabled", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to enable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/disable")
|
||||
public ResponseEntity<?> disableMfa(
|
||||
@RequestBody MfaCodeRequest request, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
if (!mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
}
|
||||
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA configuration missing"));
|
||||
}
|
||||
|
||||
if (request == null || request.getCode() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA code is required"));
|
||||
}
|
||||
|
||||
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
|
||||
if (timeStep == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mfaService.isTotpStepUsable(user, timeStep)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
mfaService.disableMfa(user);
|
||||
mfaService.markTotpStepUsed(user, timeStep);
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to disable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to disable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/setup/cancel")
|
||||
public ResponseEntity<?> cancelMfaSetup(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "MFA already enabled"));
|
||||
}
|
||||
|
||||
try {
|
||||
mfaService.clearPendingSecret(user);
|
||||
return ResponseEntity.ok(Map.of("cleared", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to clear MFA setup for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to clear MFA setup"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint to disable MFA for a user
|
||||
*
|
||||
* @param username Username of the user to disable MFA for
|
||||
* @return Response indicating success or failure
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/mfa/disable/admin/{username}")
|
||||
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
|
||||
try {
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
if (!mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
}
|
||||
|
||||
mfaService.disableMfa(user);
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found for MFA disable: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to disable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to disable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
@@ -266,6 +521,19 @@ public class AuthController {
|
||||
appMetadata.put("provider", user.getAuthenticationType());
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
// Add user metadata
|
||||
Map<String, Object> userMetadata = new HashMap<>();
|
||||
userMetadata.put("firstLogin", user.isFirstLogin());
|
||||
userMap.put("user_metadata", userMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "MFA settings are only available for web accounts"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -23,6 +23,7 @@ import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -90,7 +91,8 @@ public class InviteLinkController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"An active invite already exists for this email address"));
|
||||
"An active invite already exists for this email"
|
||||
+ " address"));
|
||||
}
|
||||
|
||||
// If sendEmail is requested but no email provided, reject
|
||||
@@ -123,7 +125,8 @@ public class InviteLinkController {
|
||||
+ (currentUserCount + activeInvites)
|
||||
+ "/"
|
||||
+ maxUsers
|
||||
+ " users). Contact your administrator to upgrade your license."));
|
||||
+ " users). Contact your administrator to"
|
||||
+ " upgrade your license."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,12 +460,13 @@ public class InviteLinkController {
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
userService.saveUser(
|
||||
effectiveEmail,
|
||||
password,
|
||||
invite.getTeamId(),
|
||||
invite.getRole(),
|
||||
false); // Don't force password change
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(effectiveEmail)
|
||||
.password(password)
|
||||
.teamId(invite.getTeamId())
|
||||
.role(invite.getRole());
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Mark invite as used
|
||||
invite.setUsed(true);
|
||||
|
||||
+83
-29
@@ -18,7 +18,10 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -43,6 +46,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@@ -66,26 +70,24 @@ public class UserController {
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
String username = usernameAndPass.getUsername();
|
||||
String password = usernameAndPass.getPassword();
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
|
||||
log.debug("Registration attempt for user: {}", username);
|
||||
|
||||
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: username already exists: {}",
|
||||
usernameAndPass.getUsername());
|
||||
if (userService.usernameExistsIgnoreCase(username)) {
|
||||
log.warn("Registration failed: username already exists: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: invalid username format: {}",
|
||||
usernameAndPass.getUsername());
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
log.warn("Registration failed: invalid username format: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
@@ -102,17 +104,16 @@ public class UserController {
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
usernameAndPass.getUsername(),
|
||||
usernameAndPass.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.password(password)
|
||||
.team(team)
|
||||
.enabled(false);
|
||||
User user = userService.saveUserCore(builder.build());
|
||||
|
||||
log.info("User registered successfully: {}", usernameAndPass.getUsername());
|
||||
log.info("User registered successfully: {}", username);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
@@ -127,7 +128,7 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
|
||||
log.error("Registration error for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
@@ -222,6 +223,7 @@ public class UserController {
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
@RequestParam(name = "confirmPassword") String confirmPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
@@ -234,6 +236,43 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
|
||||
if (currentPassword == null
|
||||
|| currentPassword.isEmpty()
|
||||
|| newPassword == null
|
||||
|| newPassword.isEmpty()
|
||||
|| confirmPassword == null
|
||||
|| confirmPassword.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"missingParameters",
|
||||
"message",
|
||||
"Current password, new password, and confirmation are"
|
||||
+ " required"));
|
||||
}
|
||||
|
||||
if (!newPassword.equals(confirmPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"passwordMismatch",
|
||||
"message",
|
||||
"New password and confirmation do not match"));
|
||||
}
|
||||
|
||||
if (newPassword.equals(currentPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"passwordUnchanged",
|
||||
"message",
|
||||
"New password must be different from the current password"));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -326,7 +365,9 @@ public class UserController {
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@RequestParam(name = "authType") String authType,
|
||||
@RequestParam(name = "forceChange", required = false, defaultValue = "false")
|
||||
boolean forceChange)
|
||||
boolean forceChange,
|
||||
@RequestParam(name = "forceMFA", required = false, defaultValue = "false")
|
||||
boolean forceMFA)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
@@ -348,8 +389,9 @@ public class UserController {
|
||||
+ availableSlots));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
User user = null;
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
user = userOpt.get();
|
||||
if (user.getUsername().equalsIgnoreCase(username)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
@@ -391,6 +433,9 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder().username(username).teamId(effectiveTeamId).role(role);
|
||||
|
||||
AuthenticationType requestedAuthType;
|
||||
if ("SSO".equalsIgnoreCase(authType)) {
|
||||
requestedAuthType = AuthenticationType.OAUTH2;
|
||||
@@ -402,6 +447,7 @@ public class UserController {
|
||||
.body(Map.of("error", "Invalid authentication type specified."));
|
||||
}
|
||||
}
|
||||
builder.authenticationType(requestedAuthType);
|
||||
|
||||
if (requestedAuthType == AuthenticationType.WEB) {
|
||||
if (password == null || password.isBlank()) {
|
||||
@@ -412,10 +458,9 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters."));
|
||||
}
|
||||
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
|
||||
} else {
|
||||
userService.saveUser(username, requestedAuthType, effectiveTeamId, role);
|
||||
builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
|
||||
}
|
||||
userService.saveUserCore(builder.build());
|
||||
return ResponseEntity.ok(Map.of("message", "User created successfully"));
|
||||
}
|
||||
|
||||
@@ -440,7 +485,8 @@ public class UserController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Email service is not configured. Please configure SMTP settings."));
|
||||
"Email service is not configured. Please configure SMTP"
|
||||
+ " settings."));
|
||||
}
|
||||
|
||||
// Parse comma-separated email addresses
|
||||
@@ -658,7 +704,8 @@ public class UserController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"User's email is not a valid email address. Notifications are disabled."));
|
||||
"User's email is not a valid email address. Notifications"
|
||||
+ " are disabled."));
|
||||
}
|
||||
|
||||
String loginUrl = buildLoginUrl(request);
|
||||
@@ -839,7 +886,14 @@ public class UserController {
|
||||
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
|
||||
|
||||
// Create user with forceChange=true
|
||||
userService.saveUser(email, temporaryPassword, teamId, role, true);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(email)
|
||||
.password(temporaryPassword)
|
||||
.teamId(teamId)
|
||||
.role(role)
|
||||
.firstLogin(true);
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
|
||||
+16
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -18,6 +19,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)")
|
||||
Optional<User> findByUsernameIgnoreCaseWithSettings(@Param("username") String username);
|
||||
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
|
||||
Optional<User> findByIdWithSettings(@Param("id") Long id);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
@@ -76,4 +80,16 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
long countSsoUsers();
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(u) FROM User u JOIN u.settings settings "
|
||||
+ "WHERE KEY(settings) = :key AND settings = :value")
|
||||
long countUsersBySetting(@Param("key") String key, @Param("value") String value);
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value = "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN (:keys)",
|
||||
nativeQuery = true)
|
||||
void deleteSettingsByUserIdAndKeys(
|
||||
@Param("userId") Long userId, @Param("keys") List<String> keys);
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.security.model.api.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class MfaCodeRequest {
|
||||
|
||||
@Schema(description = "6-digit authentication code from your authenticator app")
|
||||
private String code;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.security.model.api.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UsernameAndPassMfa extends UsernameAndPass {
|
||||
|
||||
@Schema(description = "6-digit authentication code from authenticator app", example = "123456")
|
||||
private String mfaCode;
|
||||
}
|
||||
+6
-2
@@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
@@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
String checksum = bytesToHex(digest.digest(content));
|
||||
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
|
||||
|
||||
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
|
||||
String verifyDbUrl = "jdbc:h2:mem:backupVerify_" + UUID.randomUUID();
|
||||
// Use a fresh in-memory database per verification to avoid leftover objects between
|
||||
// runs.
|
||||
try (Connection conn = DriverManager.getConnection(verifyDbUrl);
|
||||
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
|
||||
stmt.setString(1, backupPath.toString());
|
||||
stmt.execute();
|
||||
}
|
||||
return true;
|
||||
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Service for managing multi-factor authentication (MFA) settings for users.
|
||||
*
|
||||
* <p>This service reads and writes MFA-related settings such as secrets, enablement flags, and
|
||||
* last-used TOTP steps.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MfaService {
|
||||
|
||||
public static final String MFA_ENABLED_KEY = "mfaEnabled";
|
||||
public static final String MFA_SECRET_KEY = "mfaSecret";
|
||||
public static final String MFA_LAST_USED_STEP_KEY = "mfaLastUsedStep";
|
||||
public static final String MFA_REQUIRED_KEY = "mfaRequired";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
|
||||
/**
|
||||
* Determines whether MFA is enabled for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return {@code true} if MFA is enabled
|
||||
*/
|
||||
public boolean isMfaEnabled(User user) {
|
||||
String value = getSetting(user, MFA_ENABLED_KEY);
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the MFA secret for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return Base32-encoded secret, or {@code null} if not set
|
||||
*/
|
||||
public String getSecret(User user) {
|
||||
return getSetting(user, MFA_SECRET_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a new MFA secret and marks MFA as pending (disabled) for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @param secret Base32-encoded secret to store
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void setSecret(User user, String secret)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
// Clear existing values and flush the removals before inserting a new secret. This keeps
|
||||
// the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
|
||||
// same keys within a single transaction.
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
if (managedUser != null && managedUser.getId() != null) {
|
||||
userRepository.deleteSettingsByUserIdAndKeys(
|
||||
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
|
||||
userRepository.flush();
|
||||
}
|
||||
settings.put(MFA_SECRET_KEY, secret);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables MFA for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "true");
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending MFA setup data for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables MFA and clears stored secrets for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a TOTP time step has not been used before.
|
||||
*
|
||||
* @param user target user
|
||||
* @param timeStep candidate TOTP time step
|
||||
* @return {@code true} if the time step is usable
|
||||
*/
|
||||
public boolean isTotpStepUsable(User user, long timeStep) {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = managedUser.getSettings();
|
||||
if (settings == null) {
|
||||
return true;
|
||||
}
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
long lastUsedStep = Long.parseLong(lastUsed);
|
||||
return timeStep > lastUsedStep;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a TOTP time step as used, preventing replay.
|
||||
*
|
||||
* @param user target user
|
||||
* @param timeStep time step to mark as used
|
||||
* @return {@code true} if the time step was marked, {@code false} if it was already used
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public boolean markTotpStepUsed(User user, long timeStep)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed != null) {
|
||||
try {
|
||||
long lastUsedStep = Long.parseLong(lastUsed);
|
||||
if (timeStep <= lastUsedStep) {
|
||||
return false;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// treat malformed value as unused
|
||||
}
|
||||
}
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, Long.toString(timeStep));
|
||||
persist(managedUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether MFA is required for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return {@code true} if MFA is required
|
||||
*/
|
||||
public boolean isMfaRequired(User user) {
|
||||
String value = getSetting(user, MFA_REQUIRED_KEY);
|
||||
if (value == null) {
|
||||
value = "false";
|
||||
}
|
||||
log.info("MFA required for user {}: {}", user.getUsername(), value);
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether MFA is required for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @param required {@code true} to require MFA
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void setMfaRequired(User user, boolean required)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
|
||||
log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
private String getSetting(User user, String key) {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = managedUser.getSettings();
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
return settings.get(key);
|
||||
}
|
||||
|
||||
private User getUserWithSettings(User user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return user;
|
||||
}
|
||||
return userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
}
|
||||
|
||||
private Map<String, String> ensureSettings(User user) {
|
||||
Map<String, String> settings = user.getSettings();
|
||||
if (settings == null) {
|
||||
settings = new HashMap<>();
|
||||
user.setSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private void persist(User user) throws SQLException, UnsupportedProviderException {
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
|
||||
/**
|
||||
* Carries all attributes required to create or update a user account, including credentials,
|
||||
* SSO/provider details, team association, role and MFA configuration. Used by the security service
|
||||
* layer to persist or update users.
|
||||
*
|
||||
* <p>Defaults:
|
||||
*
|
||||
* <ul>
|
||||
* <li>password: null
|
||||
* <li>ssoProviderId: null
|
||||
* <li>ssoProvider: null
|
||||
* <li>authenticationType: {@code AuthenticationType.WEB}
|
||||
* <li>teamId: null
|
||||
* <li>team: null
|
||||
* <li>role: {@code Role.USER.getRoleId()}
|
||||
* <li>firstLogin: false
|
||||
* <li>enabled: true
|
||||
* <li>requireMfa: false
|
||||
* <li>mfaEnabled: false
|
||||
* <li>mfaSecret: null
|
||||
* <li>mfaLastUsedStep: null
|
||||
* </ul>
|
||||
*/
|
||||
@Getter
|
||||
@Builder(builderClassName = "Builder")
|
||||
public class SaveUserRequest {
|
||||
private final String username;
|
||||
@Builder.Default private final String password = null;
|
||||
@Builder.Default private final String ssoProviderId = null;
|
||||
@Builder.Default private final String ssoProvider = null;
|
||||
@Builder.Default private final AuthenticationType authenticationType = AuthenticationType.WEB;
|
||||
@Builder.Default private final Long teamId = null;
|
||||
@Builder.Default private final Team team = null;
|
||||
@Builder.Default private final String role = Role.USER.getRoleId();
|
||||
@Builder.Default private final boolean firstLogin = false;
|
||||
@Builder.Default private final boolean enabled = true;
|
||||
@Builder.Default private final boolean requireMfa = false;
|
||||
@Builder.Default private final boolean mfaEnabled = false;
|
||||
@Builder.Default private final String mfaSecret = null;
|
||||
@Builder.Default private final Long mfaLastUsedStep = null;
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
/**
|
||||
* Service for generating and validating TOTP secrets and codes for MFA authentication.
|
||||
*
|
||||
* <p>This service handles secret generation, code validation across time steps, and building
|
||||
* otpauth:// URIs to provision authenticator apps.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TotpService {
|
||||
|
||||
private static final int SECRET_LENGTH_BYTES = 20;
|
||||
private static final int CODE_DIGITS = 6;
|
||||
private static final int PERIOD_SECONDS = 30;
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA1";
|
||||
private static final String DEFAULT_ISSUER = "Stirling PDF";
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Generates a new random TOTP secret encoded in Base32.
|
||||
*
|
||||
* @return Base32-encoded secret suitable for provisioning an authenticator app
|
||||
*/
|
||||
public String generateSecret() {
|
||||
byte[] secret = new byte[SECRET_LENGTH_BYTES];
|
||||
SECURE_RANDOM.nextBytes(secret);
|
||||
return Base32Codec.encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a submitted TOTP code is valid for the current time window.
|
||||
*
|
||||
* @param secret Base32-encoded shared secret
|
||||
* @param code six-digit code supplied by the user
|
||||
* @return {@code true} if the code is valid for the current time window
|
||||
*/
|
||||
public boolean isValidCode(String secret, String code) {
|
||||
return getValidTimeStep(secret, code) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a TOTP code and returns the time step it matches.
|
||||
*
|
||||
* @param secret Base32-encoded shared secret
|
||||
* @param code six-digit code supplied by the user
|
||||
* @return matching time step, or {@code null} if the code is invalid
|
||||
*/
|
||||
public Long getValidTimeStep(String secret, String code) {
|
||||
if (secret == null || secret.isBlank() || code == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedCode = code.replace(" ", "");
|
||||
if (!normalizedCode.matches("\\d{6}")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] secretKey;
|
||||
try {
|
||||
secretKey = Base32Codec.decode(secret);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
long timeStep = Instant.now().getEpochSecond() / PERIOD_SECONDS;
|
||||
byte[] normalizedCodeBytes = normalizedCode.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
for (int offset = -1; offset <= 1; offset++) {
|
||||
long candidate = timeStep + offset;
|
||||
String generatedCode = generateCode(secretKey, candidate);
|
||||
if (MessageDigest.isEqual(
|
||||
generatedCode.getBytes(StandardCharsets.UTF_8), normalizedCodeBytes)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an otpauth:// URI for configuring TOTP in authenticator apps.
|
||||
*
|
||||
* @param username account identifier to embed in the label
|
||||
* @param secret Base32-encoded secret to embed in the URI
|
||||
* @return otpauth URI that can be encoded as a QR code
|
||||
*/
|
||||
public String buildOtpAuthUri(String username, String secret) {
|
||||
String issuer = resolveIssuer();
|
||||
String label = encodeForOtpAuth(issuer + ":" + username);
|
||||
String encodedIssuer = encodeForOtpAuth(issuer);
|
||||
|
||||
return "otpauth://totp/"
|
||||
+ label
|
||||
+ "?secret="
|
||||
+ secret
|
||||
+ "&issuer="
|
||||
+ encodedIssuer
|
||||
+ "&algorithm=SHA1&digits="
|
||||
+ CODE_DIGITS
|
||||
+ "&period="
|
||||
+ PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
private String encodeForOtpAuth(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
|
||||
private String resolveIssuer() {
|
||||
if (applicationProperties.getUi() != null) {
|
||||
String appName = applicationProperties.getUi().getAppNameNavbar();
|
||||
if (appName != null && !appName.isBlank()) {
|
||||
return appName.trim();
|
||||
}
|
||||
}
|
||||
return DEFAULT_ISSUER;
|
||||
}
|
||||
|
||||
private String generateCode(byte[] secret, long timeStep) {
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(8);
|
||||
buffer.putLong(timeStep);
|
||||
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(secret, HMAC_ALGORITHM));
|
||||
byte[] hash = mac.doFinal(buffer.array());
|
||||
|
||||
int offset = hash[hash.length - 1] & 0x0F;
|
||||
int binary =
|
||||
((hash[offset] & 0x7F) << 24)
|
||||
| ((hash[offset + 1] & 0xFF) << 16)
|
||||
| ((hash[offset + 2] & 0xFF) << 8)
|
||||
| (hash[offset + 3] & 0xFF);
|
||||
|
||||
int otp = binary % (int) Math.pow(10, CODE_DIGITS);
|
||||
return String.format("%0" + CODE_DIGITS + "d", otp);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate TOTP code", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-200
@@ -1,5 +1,10 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_ENABLED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_LAST_USED_STEP_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -101,7 +106,13 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
if (autoCreateUser) {
|
||||
saveUser(username, ssoProviderId, ssoProvider, type);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.ssoProviderId(ssoProviderId)
|
||||
.ssoProvider(ssoProvider)
|
||||
.authenticationType(type);
|
||||
saveUserCore(builder.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +152,14 @@ public class UserService implements UserServiceInterface {
|
||||
return user;
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User refreshApiKeyForUser(String username) {
|
||||
// reuse the add API key method for refreshing
|
||||
return addApiKeyToUser(username);
|
||||
@@ -179,162 +198,6 @@ public class UserService implements UserServiceInterface {
|
||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||
}
|
||||
|
||||
public void saveUser(String username, AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
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> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, AuthenticationType authenticationType, Long teamId, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
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
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, AuthenticationType authenticationType, Team team, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(String username, String password, Long teamId)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
Role.USER.getRoleId(), // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, String password, Team team, String role, boolean firstLogin)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
role, // role
|
||||
firstLogin, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, String password, Long teamId, String role, boolean firstLogin)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
firstLogin, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public void saveUser(String username, String password, Long teamId, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(username, password, teamId, role, false);
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username, String password, Long teamId, boolean firstLogin, boolean enabled)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
Role.USER.getRoleId(), // role
|
||||
firstLogin, // firstLogin
|
||||
enabled // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public void deleteUser(String username) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
@@ -485,75 +348,71 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core implementation for saving a user with all possible parameters. This method centralizes
|
||||
* the common logic for all saveUser variants.
|
||||
* Core method to save a user based on the provided SaveUserRequest.
|
||||
*
|
||||
* @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)
|
||||
* @param role Role to assign to the user
|
||||
* @param firstLogin Whether this is the user's first login
|
||||
* @param enabled Whether the user account is enabled
|
||||
* @param request The SaveUserRequest containing user details
|
||||
* @return The saved User object
|
||||
* @throws IllegalArgumentException If username is invalid or team is invalid
|
||||
* @throws SQLException If database operation fails
|
||||
* @throws UnsupportedProviderException If provider is not supported
|
||||
* @throws IllegalArgumentException If the username is invalid
|
||||
* @throws SQLException If a database error occurs
|
||||
* @throws UnsupportedProviderException If an unsupported provider is specified
|
||||
*/
|
||||
private User saveUserCore(
|
||||
String username,
|
||||
String password,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
Team team,
|
||||
String role,
|
||||
boolean firstLogin,
|
||||
boolean enabled)
|
||||
public User saveUserCore(SaveUserRequest request)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
|
||||
if (!isUsernameValid(username)) {
|
||||
if (!isUsernameValid(request.getUsername())) {
|
||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setUsername(request.getUsername());
|
||||
|
||||
// Set password if provided
|
||||
if (password != null && !password.isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
if (request.getPassword() != null && !request.getPassword().isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
}
|
||||
|
||||
// Set SSO provider details if provided
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
if (request.getSsoProviderId() != null && request.getSsoProvider() != null) {
|
||||
user.setSsoProviderId(request.getSsoProviderId());
|
||||
user.setSsoProvider(request.getSsoProvider());
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
user.setAuthenticationType(authenticationType);
|
||||
user.setAuthenticationType(request.getAuthenticationType());
|
||||
|
||||
// Set enabled status
|
||||
user.setEnabled(enabled);
|
||||
user.setEnabled(request.isEnabled());
|
||||
|
||||
// Set first login flag
|
||||
user.setFirstLogin(firstLogin);
|
||||
user.setFirstLogin(request.isFirstLogin());
|
||||
|
||||
// Set MFA requirement
|
||||
Map<String, String> settings = user.getSettings();
|
||||
settings.put(MFA_REQUIRED_KEY, String.valueOf(request.isRequireMfa()));
|
||||
settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
|
||||
if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
|
||||
settings.put(MFA_SECRET_KEY, request.getMfaSecret());
|
||||
} else {
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
}
|
||||
if (request.getMfaLastUsedStep() != null) {
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
|
||||
} else {
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
}
|
||||
log.info(
|
||||
"MFA required set to true for user {} {}",
|
||||
request.getUsername(),
|
||||
user.getSettings().toString());
|
||||
|
||||
// Set role (authority)
|
||||
if (role == null) {
|
||||
role = Role.USER.getRoleId();
|
||||
}
|
||||
user.addAuthority(new Authority(role, user));
|
||||
user.addAuthority(new Authority(request.getRole(), user));
|
||||
|
||||
// Resolve and set team
|
||||
if (team != null) {
|
||||
user.setTeam(team);
|
||||
if (request.getTeam() != null) {
|
||||
user.setTeam(request.getTeam());
|
||||
} else {
|
||||
user.setTeam(resolveTeam(teamId, this::getDefaultTeam));
|
||||
user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam));
|
||||
}
|
||||
|
||||
// Save user
|
||||
@@ -678,6 +537,7 @@ public class UserService implements UserServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrentUserAdmin() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
@@ -693,6 +553,7 @@ public class UserService implements UserServiceInterface {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrentUserFirstLogin() {
|
||||
try {
|
||||
String username = getCurrentUsername();
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* RFC 4648 Base32 encoder/decoder for handling TOTP secrets.
|
||||
*
|
||||
* <p>This implementation is used to encode binary secrets into Base32 strings and decode them back
|
||||
* into byte arrays for TOTP processing.
|
||||
*/
|
||||
public final class Base32Codec {
|
||||
|
||||
private static final char[] ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
|
||||
private static final int[] LOOKUP_TABLE = new int[128];
|
||||
|
||||
static {
|
||||
Arrays.fill(LOOKUP_TABLE, -1);
|
||||
for (int i = 0; i < ALPHABET.length; i++) {
|
||||
LOOKUP_TABLE[ALPHABET[i]] = i;
|
||||
}
|
||||
}
|
||||
|
||||
private Base32Codec() {}
|
||||
|
||||
/**
|
||||
* Encodes the provided bytes into a Base32 string.
|
||||
*
|
||||
* @param data the raw bytes to encode
|
||||
* @return Base32-encoded representation of the input, or an empty string for null/empty input
|
||||
*/
|
||||
public static String encode(byte[] data) {
|
||||
if (data == null || data.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder((data.length * 8 + 4) / 5);
|
||||
int buffer = data[0] & 0xFF;
|
||||
int bitsLeft = 8;
|
||||
int index = 1;
|
||||
|
||||
while (bitsLeft > 0 || index < data.length) {
|
||||
if (bitsLeft < 5) {
|
||||
if (index < data.length) {
|
||||
buffer = (buffer << 8) | (data[index++] & 0xFF);
|
||||
bitsLeft += 8;
|
||||
} else {
|
||||
int padding = 5 - bitsLeft;
|
||||
buffer <<= padding;
|
||||
bitsLeft += padding;
|
||||
}
|
||||
}
|
||||
|
||||
int digit = (buffer >> (bitsLeft - 5)) & 0x1F;
|
||||
bitsLeft -= 5;
|
||||
result.append(ALPHABET[digit]);
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a Base32 string into raw bytes.
|
||||
*
|
||||
* @param value Base32-encoded string (padding and whitespace are ignored)
|
||||
* @return decoded byte array, or an empty array for null/blank input
|
||||
* @throws IllegalArgumentException when the input contains invalid Base32 characters
|
||||
*/
|
||||
public static byte[] decode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
String normalized = value.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(normalized.length());
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
|
||||
for (char character : normalized.toCharArray()) {
|
||||
if (character >= LOOKUP_TABLE.length || LOOKUP_TABLE[character] == -1) {
|
||||
throw new IllegalArgumentException("Invalid Base32 character: " + character);
|
||||
}
|
||||
|
||||
buffer = (buffer << 5) | LOOKUP_TABLE[character];
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.write((buffer >> (bitsLeft - 8)) & 0xFF);
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user