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:
Ludy
2026-01-23 21:34:57 +00:00
committed by GitHub
co-authored by Copilot
parent 0436460c03
commit 0b86dd79d3
52 changed files with 3739 additions and 558 deletions
@@ -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
@@ -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 {
@@ -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;
}
}
@@ -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);
@@ -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 {
@@ -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);
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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();
@@ -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();
}
}
@@ -0,0 +1,137 @@
package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.Map;
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.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class ProprietaryUIDataControllerTest {
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock private SessionRepository sessionRepository;
@Mock private DatabaseService databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private MfaService mfaService;
private ApplicationProperties applicationProperties;
private AuditConfigurationProperties auditConfig;
private ObjectMapper objectMapper;
private ProprietaryUIDataController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getUi().setLanguages(List.of("en", "de"));
applicationProperties.getSystem().setDefaultLocale("en");
applicationProperties.getSecurity().setEnableLogin(true);
applicationProperties.getSecurity().getOauth2().setEnabled(false);
applicationProperties.getSecurity().getSaml2().setEnabled(false);
auditConfig = new AuditConfigurationProperties(applicationProperties);
objectMapper = new ObjectMapper();
controller =
new ProprietaryUIDataController(
applicationProperties,
auditConfig,
sessionPersistentRegistry,
userRepository,
teamRepository,
sessionRepository,
databaseService,
objectMapper,
false,
licenseSettingsService,
auditRepository,
mfaService);
}
@Test
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
when(userRepository.findAll()).thenReturn(Collections.emptyList());
ResponseEntity<LoginData> response = controller.getLoginData();
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
LoginData body = response.getBody();
assertThat(body.isFirstTimeSetup()).isTrue();
assertThat(body.isShowDefaultCredentials()).isTrue();
assertThat(body.getLanguages()).containsExactly("en", "de");
assertThat(body.getDefaultLocale()).isEqualTo("en");
}
@Test
void accountDataReturnsUserSettingsAndMfaFlags() {
User user = new User();
user.setUsername("[email protected]");
Authority authority = new Authority();
authority.setAuthority(Role.USER.getRoleId());
user.addAuthority(authority);
user.setSettings(Map.of("theme", "dark"));
when(userRepository.findByUsernameIgnoreCaseWithSettings("[email protected]"))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
when(mfaService.isMfaRequired(user)).thenReturn(false);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
ResponseEntity<AccountData> response = controller.getAccountData(authentication);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
AccountData data = response.getBody();
assertThat(data.getUsername()).isEqualTo("[email protected]");
assertThat(data.isMfaEnabled()).isTrue();
assertThat(data.isMfaRequired()).isFalse();
assertThat(data.getSettings()).contains("\"theme\":\"dark\"");
}
@Test
void databaseDataMarksUnknownVersion() {
when(databaseService.getBackupList()).thenReturn(List.of());
when(databaseService.getH2Version()).thenReturn("Unknown");
ResponseEntity<DatabaseData> response = controller.getDatabaseData();
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
DatabaseData data = response.getBody();
assertThat(data.getBackupFiles()).isEmpty();
assertThat(data.isVersionUnknown()).isTrue();
}
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.SQLException;
import java.util.Collections;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
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;
@ExtendWith(MockitoExtension.class)
class InitialSecuritySetupTest {
@Mock private UserService userService;
@Mock private TeamService teamService;
@Mock private DatabaseServiceInterface databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties applicationProperties;
private InitialSecuritySetup initialSecuritySetup;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getSecurity().getInitialLogin().setUsername("admin");
applicationProperties.getSecurity().getInitialLogin().setPassword("password");
Team internalTeam = new Team();
internalTeam.setName(TeamService.INTERNAL_TEAM_NAME);
User internalUser = new User();
internalUser.setUsername(Role.INTERNAL_API_USER.getRoleId());
internalUser.setTeam(internalTeam);
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(Optional.of(internalUser));
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
initialSecuritySetup =
new InitialSecuritySetup(
userService,
teamService,
applicationProperties,
databaseService,
licenseSettingsService);
}
@Test
void initImportsBackupWhenPresent() throws SQLException, UnsupportedProviderException {
when(userService.hasUsers()).thenReturn(false);
when(databaseService.hasBackup()).thenReturn(true);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(true);
initialSecuritySetup.init();
verify(databaseService).importDatabase();
verify(userService, never()).saveUserCore(any());
verify(licenseSettingsService).initializeGrandfatheredCount();
verify(licenseSettingsService).updateLicenseMaxUsers();
}
@Test
void initCreatesConfiguredAdminWhenNoBackup()
throws SQLException, UnsupportedProviderException {
when(userService.hasUsers()).thenReturn(false);
when(databaseService.hasBackup()).thenReturn(false);
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.empty());
Team defaultTeam = new Team();
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(teamService.getOrCreateDefaultTeam()).thenReturn(defaultTeam);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(true);
initialSecuritySetup.init();
ArgumentCaptor<SaveUserRequest> captor = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(captor.capture());
SaveUserRequest saved = captor.getValue();
assertThat(saved.getUsername()).isEqualTo("admin");
assertThat(saved.getPassword()).isEqualTo("password");
}
@Test
void configureJwtSettingsDisablesKeyCleanupWhenJwtDisabled() {
ReflectionTestUtils.setField(initialSecuritySetup, "v2Enabled", true);
applicationProperties.getSecurity().getJwt().setEnableKeystore(false);
when(userService.hasUsers()).thenReturn(true);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(any())).thenReturn(true);
initialSecuritySetup.init();
assertThat(applicationProperties.getSecurity().getJwt().isEnableKeyCleanup()).isFalse();
}
}
@@ -0,0 +1,234 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Map;
import java.util.Set;
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.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
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;
@ExtendWith(MockitoExtension.class)
class AuthControllerLoginTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
private ApplicationProperties.Security securityProperties;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private CustomUserDetailsService userDetailsService;
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
AuthController controller =
new AuthController(
userService,
jwtService,
userDetailsService,
loginAttemptService,
mfaService,
totpService,
securityProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void loginRejectsWhenUserPassDisabled() throws Exception {
securityProperties.setLoginMethod(
ApplicationProperties.Security.LoginMethods.OAUTH2.toString());
UsernameAndPassMfa payload = buildPayload(null);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isForbidden())
.andExpect(
jsonPath("$.error")
.value(
"Username/password authentication is not enabled. Please use the configured authentication method."));
verify(userDetailsService, never()).loadUserByUsername(any());
}
@Test
void loginBlockedAccountReturnsUnauthorized() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
when(loginAttemptService.isBlocked("[email protected]")).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(
jsonPath("$.error")
.value("Account is locked due to too many failed attempts"));
verify(loginAttemptService, never()).loginSucceeded(any());
}
@Test
void loginRequiresMfaCodeWhenEnabled() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("mfa_required"));
verify(loginAttemptService, never()).loginSucceeded(any());
}
@Test
void loginFailsWhenPasswordIncorrect() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(false);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Invalid credentials"));
verify(loginAttemptService).loginFailed("[email protected]");
}
@Test
void loginSucceedsAndGeneratesToken() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
.thenReturn("token-123");
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.session.access_token").value("token-123"))
.andExpect(jsonPath("$.user.username").value("[email protected]"));
verify(loginAttemptService).loginSucceeded("[email protected]");
}
@Test
void refreshReturnsUnauthorizedWhenTokenMissing() throws Exception {
when(jwtService.extractToken(any())).thenReturn(null);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("No token found"));
}
@Test
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
when(jwtService.extractUsername("old")).thenReturn("[email protected]");
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
.thenReturn("new-token");
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.access_token").value("new-token"))
.andExpect(jsonPath("$.expires_in").value(3600));
}
@Test
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
SecurityContextHolder.clearContext();
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Not authenticated"));
}
@Test
void getCurrentUserReturnsUserDetails() throws Exception {
User user = buildUser();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.user.username").value("[email protected]"))
.andExpect(
jsonPath("$.user.authenticationType")
.value(AuthenticationType.WEB.name().toLowerCase()));
SecurityContextHolder.clearContext();
}
private User buildUser() {
User user = new User();
user.setUsername("[email protected]");
user.setEnabled(true);
user.setAuthenticationType(AuthenticationType.WEB);
Authority authority = new Authority();
authority.setAuthority(Role.USER.getRoleId());
user.addAuthorities(Set.of(authority));
return user;
}
private UsernameAndPassMfa buildPayload(String mfaCode) {
UsernameAndPassMfa payload = new UsernameAndPassMfa();
payload.setUsername("[email protected]");
payload.setPassword("pw");
payload.setMfaCode(mfaCode);
return payload;
}
}
@@ -0,0 +1,218 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import java.util.Map;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
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;
@ExtendWith(MockitoExtension.class)
class AuthControllerMfaTest {
private static final String USERNAME = "[email protected]";
private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
private Authentication authentication;
private User user;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private CustomUserDetailsService userDetailsService;
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@InjectMocks private AuthController authController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
authentication = new UsernamePasswordAuthenticationToken(USERNAME, "password", List.of());
user = new User();
user.setUsername(USERNAME);
user.setAuthenticationType(AuthenticationType.WEB);
}
@Test
void setupMfaRequiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/auth/mfa/setup"))
.andExpect(status().isUnauthorized())
.andExpect(content().json("{\"error\":\"Not authenticated\"}"));
}
@Test
void setupMfaReturnsSecretAndUri() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(totpService.generateSecret()).thenReturn("SECRET");
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.secret").value("SECRET"))
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
verify(mfaService).setSecret(user, "SECRET");
}
@Test
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isConflict())
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
verify(totpService, never()).generateSecret();
}
@Test
void setupMfaRejectsNonWebAuthenticationType() throws Exception {
user.setAuthenticationType(AuthenticationType.OAUTH2);
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isForbidden())
.andExpect(
content()
.json(
"{\"error\":\"MFA settings are only available for web accounts\"}"));
}
@Test
void enableMfaRejectsMissingCode() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.getSecret(user)).thenReturn("SECRET");
mockMvc.perform(
post("/api/v1/auth/mfa/enable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(content().json("{\"error\":\"MFA code is required\"}"));
}
@Test
void enableMfaCompletesWorkflow() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.getSecret(user)).thenReturn("SECRET");
when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(42L);
when(mfaService.isTotpStepUsable(user, 42L)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/mfa/enable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "123456"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(true));
verify(mfaService).enableMfa(user);
verify(mfaService).markTotpStepUsed(user, 42L);
verify(mfaService).setMfaRequired(user, false);
}
@Test
void disableMfaCompletesWorkflow() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
when(mfaService.getSecret(user)).thenReturn("SECRET");
when(totpService.getValidTimeStep("SECRET", "654321")).thenReturn(7L);
when(mfaService.isTotpStepUsable(user, 7L)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/mfa/disable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(false));
verify(mfaService).disableMfa(user);
verify(mfaService).markTotpStepUsed(user, 7L);
}
@Test
void disableMfaReturnsDisabledWhenNotEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
mockMvc.perform(
post("/api/v1/auth/mfa/disable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(false));
verify(mfaService, never()).getSecret(user);
verifyNoInteractions(totpService);
}
@Test
void cancelMfaSetupClearsPendingSecret() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cleared").value(true));
verify(mfaService).clearPendingSecret(user);
}
@Test
void cancelMfaSetupReturnsConflictWhenEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
.andExpect(status().isConflict())
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
verify(mfaService, never()).clearPendingSecret(user);
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.controller.api;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.security.Principal;
import java.time.LocalDateTime;
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.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
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.TeamService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class InviteLinkControllerTest {
@Mock private InviteTokenRepository inviteTokenRepository;
@Mock private TeamRepository teamRepository;
@Mock private UserService userService;
@Mock private EmailService emailService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
private Principal adminPrincipal;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getMail().setEnableInvites(true);
applicationProperties.getMail().setInviteLinkExpiryHours(24);
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com");
adminPrincipal = () -> "admin";
InviteLinkController controller =
new InviteLinkController(
inviteTokenRepository,
teamRepository,
userService,
applicationProperties,
Optional.of(emailService));
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void generateInviteLinkRejectsWhenInvitesDisabled() throws Exception {
applicationProperties.getMail().setEnableInvites(false);
mockMvc.perform(post("/api/v1/invite/generate").principal(adminPrincipal))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Email invites are not enabled"));
verify(inviteTokenRepository, never()).save(any());
}
@Test
void generateInviteLinkRejectsInvalidEmail() throws Exception {
applicationProperties.getMail().setEnableInvites(true);
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "not-an-email"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Invalid email address"));
}
@Test
void generateInviteLinkBlocksOnLicenseLimit() throws Exception {
applicationProperties.getPremium().setEnabled(true);
applicationProperties.getPremium().setMaxUsers(1);
when(userService.getTotalUsersCount()).thenReturn(1L);
when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L);
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "[email protected]"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value(startsWith("License limit reached")));
}
@Test
void generateInviteLinkBuildsFrontendUrl() throws Exception {
Team defaultTeam = new Team();
defaultTeam.setId(5L);
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(defaultTeam));
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
when(inviteTokenRepository.findByEmail("[email protected]")).thenReturn(Optional.empty());
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "[email protected]"))
.andExpect(status().isOk())
.andExpect(
jsonPath("$.inviteUrl")
.value(startsWith("https://frontend.example.com/invite?token=")))
.andExpect(jsonPath("$.email").value("[email protected]"));
verify(inviteTokenRepository).save(any());
}
@Test
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
InviteToken expired = new InviteToken();
expired.setToken("abc");
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
expired.setRole(Role.USER.getRoleId());
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
mockMvc.perform(get("/api/v1/invite/validate/abc"))
.andExpect(status().isGone())
.andExpect(jsonPath("$.error").value("This invite link has expired"));
}
@Test
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
InviteToken invite = new InviteToken();
invite.setToken("abc");
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
invite.setRole(Role.USER.getRoleId());
invite.setUsed(false);
invite.setEmail(null); // email required from request
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
mockMvc.perform(
post("/api/v1/invite/accept/abc")
.param("email", "[email protected]")
.param("password", "password123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Account created successfully"))
.andExpect(jsonPath("$.username").value("[email protected]"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
}
}
@@ -0,0 +1,140 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
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.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Mock private UserService userService;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private TeamRepository teamRepository;
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getPremium().setMaxUsers(10);
applicationProperties.getMail().setEnabled(true);
UserController controller =
new UserController(
userService,
sessionRegistry,
applicationProperties,
teamRepository,
userRepository,
Optional.of(emailService),
licenseSettingsService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void registerRejectsExistingUser() throws Exception {
UsernameAndPass payload = new UsernameAndPass();
payload.setUsername("[email protected]");
payload.setPassword("pw");
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(true);
mockMvc.perform(
post("/api/v1/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("User already exists"));
verify(userService, never()).saveUserCore(any());
}
@Test
void registerCreatesUserWhenValid() throws Exception {
UsernameAndPass payload = new UsernameAndPass();
payload.setUsername("[email protected]");
payload.setPassword("pw");
Team defaultTeam = new Team();
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
when(userService.isUsernameValid("[email protected]")).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(defaultTeam));
User savedUser = new User();
savedUser.setUsername("[email protected]");
savedUser.setEnabled(false);
when(userService.saveUserCore(any())).thenReturn(savedUser);
mockMvc.perform(
post("/api/v1/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.user.username").value("[email protected]"));
}
@Test
void changeUserEnabledPreventsSelfDisable() throws Exception {
User user = new User();
user.setUsername("admin");
when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true);
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(user));
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "pw");
mockMvc.perform(
post("/api/v1/user/admin/changeUserEnabled/admin")
.param("enabled", "false")
.principal(authentication))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Cannot disable your own account."));
}
@Test
void changePasswordRejectsMissingUser() throws Exception {
Authentication authentication = new UsernamePasswordAuthenticationToken("ghost", "pw");
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
mockMvc.perform(post("/api/v1/user/admin/deleteUser/ghost").principal(authentication))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("User not found."));
}
}
@@ -0,0 +1,131 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import javax.sql.DataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
class DatabaseServiceTest {
@TempDir Path tempDir;
@Mock private DatabaseNotificationServiceInterface notificationService;
private DatabaseService databaseService;
private ApplicationProperties.Datasource datasourceProps;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
datasourceProps = new ApplicationProperties.Datasource();
datasourceProps.setType(ApplicationProperties.Driver.H2.name());
datasourceProps.setCustomDatabaseUrl("jdbc:h2:mem:test");
datasourceProps.setEnableCustomDatabase(false);
DataSource dataSource =
new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
databaseService = new DatabaseService(datasourceProps, dataSource, notificationService);
ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir);
}
@Test
void hasBackupReturnsFalseWhenEmpty() {
assertThat(databaseService.hasBackup()).isFalse();
assertThat(Files.exists(tempDir)).isTrue();
}
@Test
void getBackupListReturnsEntries() throws IOException {
Path backup =
tempDir.resolve(
"backup_"
+ LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
+ ".sql");
Files.writeString(backup, "CREATE TABLE TEST(ID INT);");
List<stirling.software.common.model.FileInfo> backups = databaseService.getBackupList();
assertThat(backups).hasSize(1);
assertThat(backups.get(0).getFileName()).isEqualTo(backup.getFileName().toString());
}
@Test
void importDatabaseFromUICopiesBackupAndDeletesTemp() throws IOException {
Path script = Files.createTempFile("script", ".sql");
Files.writeString(script, "CREATE TABLE SAMPLE(ID INT PRIMARY KEY);\n");
boolean result = databaseService.importDatabaseFromUI(script);
assertThat(result).isTrue();
assertThat(Files.exists(script)).isFalse();
try (var stream = Files.list(tempDir)) {
assertThat(stream.toList()).isNotEmpty();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
void getBackupFilePathPreventsTraversal() {
assertThatThrownBy(() -> databaseService.getBackupFilePath("../evil.sql"))
.isInstanceOf(SecurityException.class);
}
@Test
void deleteBackupFileRejectsInvalidName() throws Exception {
boolean deleted = databaseService.deleteBackupFile("..bad.sql");
assertThat(deleted).isFalse();
}
@Test
void deleteAllBackupsRemovesFiles() throws Exception {
Path first = tempDir.resolve("backup_first.sql");
Path second = tempDir.resolve("backup_second.sql");
Files.writeString(first, "SELECT 1;");
Files.writeString(second, "SELECT 1;");
List<org.apache.commons.lang3.tuple.Pair<FileInfo, Boolean>> results =
databaseService.deleteAllBackups();
assertThat(results).hasSize(2);
assertThat(results.stream().allMatch(org.apache.commons.lang3.tuple.Pair::getRight))
.isTrue();
assertThat(Files.exists(first)).isFalse();
assertThat(Files.exists(second)).isFalse();
}
@Test
void exportDatabaseCreatesScript() {
databaseService.exportDatabase();
assertThat(tempDir.toFile().list()).isNotEmpty();
verify(notificationService)
.notifyBackupsSuccess(
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString());
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
@ExtendWith(MockitoExtension.class)
class MfaServiceTest {
@Mock private UserRepository userRepository;
@Mock private DatabaseServiceInterface databaseService;
@InjectMocks private MfaService mfaService;
@Test
void setSecretStoresSecretAndDisablesMfa() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setSecret(user, "NEWSECRET");
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void enableMfaSetsEnabledFlag() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.enableMfa(user);
assertEquals("true", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
verify(databaseService).exportDatabase();
}
@Test
void disableMfaClearsSecretAndUsage() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.disableMfa(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void markTotpStepUsedTracksNewestStep() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
assertTrue(mfaService.markTotpStepUsed(user, 100L));
assertEquals("100", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void markTotpStepUsedRejectsReplays() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "200");
assertFalse(mfaService.markTotpStepUsed(user, 199L));
assertFalse(mfaService.markTotpStepUsed(user, 200L));
verify(databaseService, never()).exportDatabase();
}
@Test
void markTotpStepUsedIgnoresMalformedStoredValue() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "not-a-number");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
assertTrue(mfaService.markTotpStepUsed(user, 5L));
assertEquals("5", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService, times(1)).exportDatabase();
}
@Test
void isTotpStepUsableRespectsLastStep() {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "50");
assertFalse(mfaService.isTotpStepUsable(user, 50));
assertFalse(mfaService.isTotpStepUsable(user, 40));
assertTrue(mfaService.isTotpStepUsable(user, 51));
}
@Test
void isMfaRequiredDefaultsToFalse() {
User user = new User();
assertFalse(mfaService.isMfaRequired(user));
}
@Test
void setMfaRequiredStoresFlag() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setMfaRequired(user, true);
assertEquals("true", user.getSettings().get(MfaService.MFA_REQUIRED_KEY));
verify(databaseService).exportDatabase();
}
@Test
void clearPendingSecretResetsValues() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.clearPendingSecret(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void isMfaEnabledAndGetSecretReadSettings() {
User user = new User();
user.getSettings().put(MfaService.MFA_ENABLED_KEY, "true");
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
assertTrue(mfaService.isMfaEnabled(user));
assertEquals("SECRET", mfaService.getSecret(user));
}
}
@@ -0,0 +1,96 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.util.Base32Codec;
class TotpServiceTest {
private TotpService buildService(String appName) {
ApplicationProperties properties = new ApplicationProperties();
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppNameNavbar(appName);
properties.setUi(ui);
return new TotpService(properties);
}
@Test
void generateSecretReturnsBase32String() {
TotpService service = buildService("Test App");
String secret = service.generateSecret();
assertNotNull(secret);
assertEquals(32, secret.length());
assertTrue(secret.matches("[A-Z2-7]+"));
}
@Test
void buildOtpAuthUriIncludesIssuerAndUsername() {
TotpService service = buildService("Stirling Test");
String uri = service.buildOtpAuthUri("[email protected]", "SECRET");
assertTrue(uri.contains("issuer=Stirling%20Test"));
assertTrue(uri.contains("Stirling%20Test%3Auser%40example.com"));
}
@Test
void isValidCodeAcceptsCurrentAndAdjacentTimeSteps() throws Exception {
TotpService service = buildService("Test App");
byte[] secretBytes = "super-secret".getBytes(StandardCharsets.UTF_8);
String secret = Base32Codec.encode(secretBytes);
long timeStep = Instant.now().getEpochSecond() / 30;
String currentCode = generateCode(service, secretBytes, timeStep);
String nextCode = generateCode(service, secretBytes, timeStep + 1);
assertTrue(service.isValidCode(secret, currentCode));
assertEquals(timeStep, service.getValidTimeStep(secret, currentCode));
assertTrue(service.isValidCode(secret, nextCode));
}
@Test
void isValidCodeRejectsInvalidFormats() {
TotpService service = buildService("Test App");
assertFalse(service.isValidCode("SECRET", "ABCDEF"));
assertFalse(service.isValidCode("SECRET", "12345"));
assertFalse(service.isValidCode(null, "123456"));
}
@Test
void isValidCodeRejectsInvalidSecrets() {
TotpService service = buildService("Test App");
assertFalse(service.isValidCode("INVALID*", "123456"));
}
@Test
void buildOtpAuthUriUsesDefaultIssuerWhenMissing() {
ApplicationProperties properties = new ApplicationProperties();
TotpService service = new TotpService(properties);
String uri = service.buildOtpAuthUri("[email protected]", "SECRET");
assertTrue(uri.contains("issuer=Stirling%20PDF"));
}
private String generateCode(TotpService service, byte[] secretBytes, long timeStep)
throws Exception {
Method generateCode =
TotpService.class.getDeclaredMethod("generateCode", byte[].class, long.class);
generateCode.setAccessible(true);
return (String) generateCode.invoke(service, secretBytes, timeStep);
}
}
@@ -1,22 +1,26 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
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.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock private AuthorityRepository authorityRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private MessageSource messageSource;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@InjectMocks private UserService userService;
private Team mockTeam;
private User mockUser;
@BeforeEach
void setUp() {
mockTeam = new Team();
mockTeam.setId(1L);
mockTeam.setName("Test Team");
mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setEnabled(true);
}
@Spy @InjectMocks private UserService userService;
@Test
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
// Given
String username = "testuser";
AuthenticationType authType = AuthenticationType.WEB;
void saveUserCore_populatesFieldsAndPersists()
throws SQLException, UnsupportedProviderException {
Long teamId = 42L;
Team team = new Team();
team.setId(teamId);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(team));
when(passwordEncoder.encode("plain")).thenReturn("encoded");
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
SaveUserRequest request =
SaveUserRequest.builder()
.username("validUser")
.password("plain")
.ssoProviderId("sso-id")
.ssoProvider("provider-x")
.authenticationType(AuthenticationType.OAUTH2)
.teamId(teamId)
.role(Role.ADMIN.getRoleId())
.firstLogin(true)
.enabled(false)
.requireMfa(true)
.mfaEnabled(true)
.mfaSecret("top-secret")
.mfaLastUsedStep(5L)
.build();
// When
userService.saveUser(username, authType);
User saved = userService.saveUserCore(request);
// Then
verify(userRepository).save(any(User.class));
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userCaptor.capture());
verify(databaseService).exportDatabase();
User persisted = userCaptor.getValue();
assertEquals("validUser", persisted.getUsername());
assertEquals("encoded", persisted.getPassword());
assertEquals("provider-x", persisted.getSsoProvider());
assertEquals("sso-id", persisted.getSsoProviderId());
assertEquals(team, persisted.getTeam());
assertEquals("oauth2", persisted.getAuthenticationType());
assertFalse(persisted.isEnabled());
assertTrue(persisted.isFirstLogin());
assertTrue(
persisted.getAuthorities().stream()
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())));
assertEquals(
"true",
persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY),
"MFA requirement should be stored");
assertEquals(
"true",
persisted.getSettings().get(MfaService.MFA_ENABLED_KEY),
"MFA enabled flag should be stored");
assertEquals(
"top-secret",
persisted.getSettings().get(MfaService.MFA_SECRET_KEY),
"MFA secret should be stored");
assertEquals(
"5",
persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY),
"MFA last used step should be stored");
assertSame(saved, persisted, "Returned user should be the persisted instance");
}
@Test
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
void saveUserCore_withoutTeam_usesDefaultTeam()
throws SQLException, UnsupportedProviderException {
Team defaultTeam = new Team();
defaultTeam.setName("Default");
when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
// When
User result = userService.saveUser(username, password, teamId);
User saved = userService.saveUserCore(request);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(teamRepository).findById(teamId);
verify(userRepository).save(any(User.class));
verify(teamRepository).findByName("Default");
verify(teamRepository, never()).findById(anyLong());
verify(databaseService).exportDatabase();
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
}
@Test
void testSaveUser_WithTeamAndRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
String role = Role.ADMIN.getRoleId();
boolean firstLogin = true;
String encodedPassword = "encodedPassword123";
void processSSOPostLogin_autoCreatesUserWhenMissing()
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
String username = "autoUser";
doReturn(true).when(userService).isUsernameValid(username);
when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
.thenReturn(Optional.empty());
when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
User created = new User();
doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
// When
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(reqCaptor.capture());
SaveUserRequest captured = reqCaptor.getValue();
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
assertEquals(username, captured.getUsername());
assertEquals("id", captured.getSsoProviderId());
assertEquals("prov", captured.getSsoProvider());
assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
}
@Test
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
// Given
String invalidUsername = "ab"; // Too short (less than 3 characters)
AuthenticationType authType = AuthenticationType.WEB;
void addApiKeyToUserGeneratesAndPersists() {
User user = new User();
user.setUsername("user");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
User updated = userService.addApiKeyToUser("user");
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
assertNotNull(updated.getApiKey());
verify(userRepository).save(user);
}
@Test
void testSaveUser_WithNullPassword_Success() throws Exception {
// Given
String username = "testuser";
Long teamId = 1L;
void getApiKeyForUserCreatesWhenMissing() {
User user = new User();
user.setUsername("user");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
String apiKey = userService.getApiKeyForUser("user");
// When
User result = userService.saveUser(username, null, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
assertNotNull(apiKey);
verify(userRepository).save(user);
}
@Test
void testSaveUser_WithEmptyPassword_Success() throws Exception {
// Given
String username = "testuser";
String emptyPassword = "";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, emptyPassword, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithValidEmail_Success() throws Exception {
// Given
String emailUsername = "[email protected]";
AuthenticationType authType = AuthenticationType.OAUTH2;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(emailUsername, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
// Given
String reservedUsername = "all_users";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
// Given
String anonymousUsername = "anonymoususer";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
// When & Then
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
// Verify user was still saved before the exception
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
boolean firstLogin = true;
boolean enabled = false;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, firstLogin, enabled);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithCustomRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String customRole = Role.LIMITED_API_USER.getRoleId();
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, customRole);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
void isUsernameValidRejectsReservedAndAcceptsEmail() {
assertFalse(userService.isUsernameValid("ALL_USERS"));
assertTrue(userService.isUsernameValid("[email protected]"));
}
}
@@ -0,0 +1,45 @@
package stirling.software.proprietary.security.util;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class Base32CodecTest {
@Test
void encodeReturnsEmptyStringForEmptyInput() {
assertEquals("", Base32Codec.encode(new byte[0]));
assertEquals("", Base32Codec.encode(null));
}
@Test
void decodeReturnsEmptyArrayForBlankInput() {
assertArrayEquals(new byte[0], Base32Codec.decode(null));
assertArrayEquals(new byte[0], Base32Codec.decode(""));
assertArrayEquals(new byte[0], Base32Codec.decode(" "));
}
@Test
void encodeDecodeRoundTrip() {
byte[] input = "hello world".getBytes(StandardCharsets.UTF_8);
String encoded = Base32Codec.encode(input);
byte[] decoded = Base32Codec.decode(encoded);
assertArrayEquals(input, decoded);
}
@Test
void decodeAcceptsPaddingSpacesAndLowercase() {
byte[] decoded = Base32Codec.decode("mzxw6y tboi====");
assertEquals("foobar", new String(decoded, StandardCharsets.UTF_8));
}
@Test
void decodeRejectsInvalidCharacters() {
assertThrows(IllegalArgumentException.class, () -> Base32Codec.decode("MZXW6$"));
}
}
@@ -556,6 +556,30 @@ webBrowserSettings = "Web Browser Setting"
syncToBrowser = "Sync Account -> Browser"
syncToAccount = "Sync Account <- Browser"
[account.mfa]
title = "Two-factor authentication"
setupFailed = "Unable to start two-factor setup. Please try again."
codeRequired = "Enter the authentication code to continue."
enabled = "Two-factor authentication enabled."
enableFailed = "Unable to enable two-factor authentication. Check the code and try again."
disabled = "Two-factor authentication disabled."
disableFailed = "Unable to disable two-factor authentication. Check the code and try again."
description = "Add an extra layer of security to your account."
enableButton = "Enable two-factor authentication"
disableButton = "Disable two-factor authentication"
setupTitle = "Set up two-factor authentication"
setupDescription = "Scan the QR code with your authenticator app, then enter the 6-digit code to confirm."
manualKey = "Manual setup key"
secretWarning = "Keep this key private. Anyone with access can generate valid authentication codes."
codePlaceholder = "Enter 6-digit code"
confirmEnable = "Enable"
disableTitle = "Disable two-factor authentication"
disableDescription = "Enter a valid authentication code to disable two-factor authentication."
codeLabel = "Authentication code"
confirmDisable = "Disable"
ssoDescription = "Two-factor authentication is managed by your identity provider for single sign-on accounts."
ssoManaged = "Configure MFA through your identity provider."
[adminUserSettings]
title = "User Control Settings"
header = "Admin User Control Settings"
@@ -3736,6 +3760,14 @@ passwordChangedSuccess = "Password changed successfully! Please sign in with you
credentialsUpdated = "Your credentials have been updated. Please sign in again."
defaultCredentials = "Default Login Credentials"
changePasswordWarning = "Please change your password after logging in for the first time"
mfaRequired = "Two-factor code required"
mfaCode = "Authentication Code"
enterMfaCode = "Enter 6-digit code"
mfaPromptTitle = "Two-factor authentication"
mfaPromptBody = "Enter the authentication code from your authenticator app to continue."
verifyingMfa = "Verifying..."
verifyMfa = "Verify code"
[login.slides.overview]
alt = "Stirling PDF overview"
@@ -5826,6 +5858,7 @@ usernameRequired = "Username and password are required"
passwordTooShort = "Password must be at least 6 characters"
success = "User created successfully"
error = "Failed to create user"
forceMFA = "Force MFA setup on next login"
[workspace.people.authType]
password = "Password"
@@ -5935,6 +5968,11 @@ slotsAvailable = "{{count}} user slot(s) available"
noSlotsAvailable = "No slots available"
currentUsage = "Currently using {{current}} of {{max}} user licences"
[workspace.people.mfa]
adminDisableSuccess = "MFA disabled successfully for user"
adminDisableError = "Failed to disable MFA for user"
disableByAdmin = "Disable MFA"
[workspace.teams]
title = "Teams"
description = "Manage teams and organize workspace members"
+36 -7
View File
@@ -33,15 +33,20 @@ fn get_keyring_entry() -> Result<Entry, String> {
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
if token.is_empty() {
let trimmed = token.trim();
if trimmed.is_empty() {
log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string());
}
let entry = get_keyring_entry()?;
if trimmed.len() != token.len() {
log::debug!("Auth token had surrounding whitespace; storing trimmed token");
}
entry
.set_password(&token)
.set_password(trimmed)
.map_err(|e| {
log::error!("Failed to set password in keyring: {}", e);
format!("Failed to save token to keyring: {}", e)
@@ -50,7 +55,7 @@ pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<()
// Verify the save worked
match entry.get_password() {
Ok(retrieved_token) => {
if retrieved_token != token {
if retrieved_token != trimmed {
log::error!("Token verification failed: Retrieved token doesn't match");
return Err("Token verification failed after save".to_string());
}
@@ -201,6 +206,7 @@ pub async fn login(
server_url: String,
username: String,
password: String,
mfa_code: Option<String>,
supabase_key: String,
saas_server_url: String,
) -> Result<LoginResponse, String> {
@@ -272,12 +278,22 @@ pub async fn login(
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Spring Boot login URL: {}", login_url);
let mut payload = serde_json::json!({
"username": username,
"password": password,
});
if let Some(code) = mfa_code
.as_ref()
.map(|c| c.trim())
.filter(|c| !c.is_empty())
{
payload["mfaCode"] = serde_json::Value::String(code.to_string());
}
let response = client
.post(&login_url)
.json(&serde_json::json!({
"username": username,
"password": password,
}))
.json(&payload)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
@@ -292,6 +308,19 @@ pub async fn login(
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&error_text) {
let error_code = error_json
.get("error")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
if let Some(code) = error_code {
if code == "mfa_required" || code == "invalid_mfa_code" {
return Err(code);
}
}
}
return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
+23
View File
@@ -0,0 +1,23 @@
export interface AuthContextType {
session: null;
user: null;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
/**
* Core (open-source) auth hook stub.
* Proprietary/desktop builds override this file via path resolution.
*/
export function useAuth(): AuthContextType {
return {
session: null,
user: null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
};
}
@@ -68,6 +68,57 @@
color: var(--onboarding-body, #1f2937);
}
.mfaSlideContent {
display: flex;
justify-content: center;
margin-top: 12px;
}
.mfaCard {
width: 100%;
max-width: 560px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
}
.mfaSetupGrid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 16px;
align-items: center;
}
.mfaQrCard {
padding: 12px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
display: flex;
justify-content: center;
}
.mfaSteps {
margin: 0;
padding-left: 18px;
color: var(--onboarding-body, #1f2937);
font-size: 14px;
line-height: 1.5;
}
.mfaForm {
width: 100%;
}
@media (max-width: 640px) {
.mfaSetupGrid {
grid-template-columns: 1fr;
justify-items: center;
}
}
.heroIconsContainer {
display: flex;
gap: 32px;
@@ -26,6 +26,8 @@ import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from '@app/auth/UseSession';
export default function Onboarding() {
const { t } = useTranslation();
@@ -45,15 +47,30 @@ export default function Onboarding() {
const [analyticsLoading, setAnalyticsLoading] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
const [firstLoginModalOpen, setFirstLoginModalOpen] = useState(false);
const [mfaModalOpen, setMfaModalOpen] = useState(false);
const accountLogout = useAccountLogout();
const { signOut } = useAuth();
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]);
const handlePasswordChanged = useCallback(() => {
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const handlePasswordChanged = useCallback(async () => {
actions.updateRuntimeState({ requiresPasswordChange: false });
window.location.href = '/login';
// delete session and redirect to login page
await accountLogout({ signOut, redirectToLogin });
}, [actions, accountLogout, redirectToLogin, signOut]);
const handleMfaSetupComplete = useCallback(() => {
actions.updateRuntimeState({ requiresMfaSetup: false });
setMfaModalOpen(false);
actions.complete();
}, [actions]);
// Check if we should show analytics modal before onboarding
@@ -68,17 +85,17 @@ export default function Onboarding() {
setAnalyticsLoading(true);
setAnalyticsError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
try {
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
setAnalyticsLoading(false);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setAnalyticsLoading(false);
}
}, [analyticsLoading, refetchConfig]);
@@ -223,6 +240,27 @@ export default function Onboarding() {
return () => removeAllGlows();
}, [isTourOpen]);
// Handle first-login password change modal
useEffect(() => {
if(runtimeState.requiresPasswordChange === true) {
console.log('[Onboarding] User requires password change on first login.');
setFirstLoginModalOpen(true);
} else {
setFirstLoginModalOpen(false);
}
}, [runtimeState.requiresPasswordChange]);
// Handle MFA setup modal
useEffect(() => {
if(runtimeState.requiresMfaSetup === true) {
console.log('[Onboarding] User requires MFA setup.');
setMfaModalOpen(true);
} else {
console.log('[Onboarding] User does not require MFA setup.');
setMfaModalOpen(false);
}
}, [runtimeState.requiresMfaSetup]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === 'admin') {
@@ -272,8 +310,9 @@ export default function Onboarding() {
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError,
analyticsLoading,
onMfaSetupComplete: handleMfaSetupComplete,
});
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged, handleMfaSetupComplete]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
@@ -325,6 +364,65 @@ export default function Onboarding() {
);
}
if (firstLoginModalOpen) {
const baseSlideDefinition = SLIDE_DEFINITIONS['first-login'];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
selectedRole: null,
onRoleSelect: () => {},
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
});
return (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
handlePasswordChanged();
}
}}
allowDismiss={false}
/>
);
}
if (mfaModalOpen) {
console.log('[Onboarding] Rendering MFA setup modal slide.');
const baseSlideDefinition = SLIDE_DEFINITIONS['mfa-setup'];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
selectedRole: null,
onRoleSelect: () => {},
onMfaSetupComplete: handleMfaSetupComplete,
});
return (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
handleMfaSetupComplete();
}
}}
allowDismiss={false}
/>
);
}
if (showLicenseSlide) {
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
// Remove back button for external license notice
@@ -403,6 +501,7 @@ export default function Onboarding() {
currentModalSlideIndex={currentModalSlideIndex}
onSkip={actions.skip}
onAction={handleButtonAction}
allowDismiss={currentStep.allowDismiss}
/>
);
@@ -6,6 +6,7 @@ import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseS
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
@@ -16,7 +17,8 @@ export type SlideId =
| 'admin-overview'
| 'server-license'
| 'tour-overview'
| 'analytics-choice';
| 'analytics-choice'
| 'mfa-setup';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
@@ -61,6 +63,7 @@ export interface SlideFactoryParams {
usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
onMfaSetupComplete?: () => void;
}
export interface HeroDefinition {
@@ -281,5 +284,11 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
],
},
'mfa-setup': {
id: 'mfa-setup',
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
hero: { type: 'lock' },
buttons: [], // Form has its own submit button
},
};
@@ -7,7 +7,8 @@ export type OnboardingStepId =
| 'tool-layout'
| 'tour-overview'
| 'server-license'
| 'analytics-choice';
| 'analytics-choice'
| 'mfa-setup';
export type OnboardingStepType =
| 'modal-slide'
@@ -30,6 +31,7 @@ export interface OnboardingRuntimeState {
requiresPasswordChange: boolean;
firstLoginUsername: string;
usingDefaultCredentials: boolean;
requiresMfaSetup: boolean;
}
export interface OnboardingConditionContext extends OnboardingRuntimeState {
@@ -41,7 +43,8 @@ export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice' | 'mfa-setup';
allowDismiss?: boolean;
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
@@ -61,6 +64,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
firstLoginUsername: '',
usingDefaultCredentials: false,
desktopSlideEnabled: true,
requiresMfaSetup: false,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
@@ -111,6 +115,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'mfa-setup',
type: 'modal-slide',
slideId: 'mfa-setup',
condition: (ctx) => ctx.requiresMfaSetup,
}
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
@@ -89,6 +89,18 @@ function clearRuntimeStateSession(): void {
}
}
function parseMfaRequired(settings: string | null | undefined): boolean {
if (!settings) return false;
try {
const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === 'true';
} catch (error) {
console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error);
return false;
}
}
export interface OnboardingOrchestratorState {
/** Whether onboarding is currently active */
isActive: boolean;
@@ -205,8 +217,10 @@ export function useOnboardingOrchestrator(
requiresPasswordChange: accountData.changeCredsFlag,
firstLoginUsername: accountData.username,
usingDefaultCredentials: loginPageData.showDefaultCredentials,
requiresMfaSetup: parseMfaRequired(accountData.settings),
}));
} catch {
} catch (error) {
console.log('[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:', error);
// Account endpoint failed - user not logged in or security disabled
}
};
@@ -230,12 +244,8 @@ export function useOnboardingOrchestrator(
}), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => {
// If password change is required, ONLY show the first-login step
if (runtimeState.requiresPasswordChange) {
return ONBOARDING_STEPS.filter((step) => step.id === 'first-login');
}
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext, runtimeState.requiresPasswordChange]);
}, [conditionContext]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
@@ -255,7 +265,7 @@ export function useOnboardingOrchestrator(
}
// If onboarding has been completed, don't show it
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
if (isOnboardingCompleted()) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
return;
@@ -266,7 +276,7 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(0);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
}, [activeFlow, configLoading, adminStatusResolved]);
const totalSteps = activeFlow.length;
@@ -51,7 +51,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
setLoading(true);
setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
showToast({
alertType: 'success',
@@ -127,6 +127,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required
styles={{
input: { height: 44 },
@@ -139,6 +140,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
minLength={8}
styles={{
input: { height: 44 },
}}
@@ -148,7 +150,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!newPassword || !confirmPassword}
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
size="md"
mt="xs"
>
@@ -169,9 +171,9 @@ export default function FirstLoginSlide({
key: 'first-login',
title: 'Set Your Password',
body: (
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
usingDefaultCredentials={usingDefaultCredentials}
/>
),
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from "@app/auth/UseSession";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
interface MFASetupSlideProps {
onMfaSetupComplete?: () => void;
}
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCompleteRef = useRef(false);
const { signOut } = useAuth();
const accountLogout = useAccountLogout();
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
const fetchMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again.");
} finally {
setMfaLoading(false);
}
}, []);
useEffect(() => {
setupCompleteRef.current = setupComplete;
}, [setupComplete]);
useEffect(() => {
void fetchMfaSetup();
return () => {
if (!setupCompleteRef.current) {
void accountService.cancelMfaSetup();
}
};
}, [fetchMfaSetup]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const onLogout = useCallback(async() => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
const handleEnableMfa = useCallback(
async (event: FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError("Enter the authentication code to continue.");
return;
}
try {
setSubmitting(true);
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setSetupComplete(true);
onMfaSetupComplete?.();
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again."
);
} finally {
setSubmitting(false);
}
},
[mfaSetupCode, onMfaSetupComplete]
);
const isReady = Boolean(mfaSetupData);
const mfaSetupContent = mfaSetupData ? (
<div className={styles.mfaSetupGrid}>
<Box className={styles.mfaQrCard}>
<QRCodeSVG
value={mfaSetupData.otpauthUri ?? ""}
size={168}
level="H"
imageSettings={{
src: qrLogoSrc,
height: 36,
width: 36,
excavate: true,
}}
/>
</Box>
<Stack gap="xs">
<Text size="sm" fw={600}>
Step-by-step
</Text>
<ol className={styles.mfaSteps}>
<li>Open Google Authenticator, Authy, or 1Password.</li>
<li>Scan the QR code or enter the setup key below.</li>
<li>Enter the 6-digit code from your app.</li>
</ol>
<Text size="xs" c="dimmed">
Setup key (manual entry)
</Text>
<TextInput
value={mfaSetupData.secret ?? ""}
readOnly
variant="filled"
styles={{ input: { fontFamily: "monospace" } }}
/>
</Stack>
</div>
) : null;
return (
<div className={styles.mfaSlideContent}>
<div className={styles.mfaCard}>
<Stack gap="md">
<Text size="sm" c="dimmed">
Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the
6-digit code to finish.
</Text>
{mfaError && (
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
{mfaError}
</Alert>
)}
{mfaLoading && !isReady && (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">Generating your QR code</Text>
</Group>
)}
{isReady && mfaSetupContent}
<form onSubmit={handleEnableMfa} className={styles.mfaForm}>
<Stack gap="sm">
<TextInput
id="mfa-setup-code"
label="Authentication code"
placeholder="123456"
value={mfaSetupCode}
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
inputMode="numeric"
maxLength={6}
minLength={6}
disabled={!isReady || submitting || setupComplete}
/>
<Group justify="space-between" wrap="wrap">
<Button
type="button"
variant="light"
onClick={fetchMfaSetup}
disabled={mfaLoading || submitting || setupComplete}
>
Regenerate QR code
</Button>
<Button
type="submit"
loading={submitting}
disabled={!isReady || setupComplete || mfaSetupCode.length < 6}
>
Enable MFA
</Button>
<Button
type="button"
variant="light"
onClick={onLogout}
>
Logout
</Button>
</Group>
{setupComplete && (
<Alert color="green" variant="light">
MFA has been enabled. You can now continue.
</Alert>
)}
</Stack>
</form>
</Stack>
</div>
</div>
);
}
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
return {
key: "mfa-setup-slide",
title: "Multi-Factor Authentication Setup",
body: <MFASetupContent onMfaSetupComplete={onMfaSetupComplete} />,
background: {
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
alert({
alertType: 'success',
@@ -133,6 +133,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required
/>
@@ -141,6 +142,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
minLength={8}
required
/>
@@ -148,7 +150,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!currentPassword || !newPassword || !confirmPassword}
disabled={!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
mt="md"
>
{t('firstLogin.changePassword', 'Change Password')}
@@ -0,0 +1,20 @@
type SignOutFn = () => Promise<void>;
interface AccountLogoutDeps {
signOut: SignOutFn;
redirectToLogin: () => void;
}
/**
* Core (open-source) logout handler: sign out and redirect to /login.
* Proprietary/desktop builds override this file via path resolution.
*/
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
try {
await signOut();
} finally {
redirectToLogin();
}
};
}
@@ -0,0 +1,24 @@
export interface MfaErrorResponse {
error: string;
}
export interface MfaSetupResponse {
otpauthUri: string | null;
secret: string | null;
error: MfaErrorResponse | null;
}
export interface MfaSetupCancelResponse {
cleared: boolean | null;
error: MfaErrorResponse | null;
}
/**
* /mfa/disable/admin/{username}
* /mfa/disable
* /mfa/enable
*/
export interface MfaStatusResponse {
enabled: boolean | null;
error: MfaErrorResponse | null;
}
+22 -2
View File
@@ -1,3 +1,4 @@
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
import apiClient from '@app/services/apiClient';
export interface AccountData {
@@ -7,6 +8,7 @@ export interface AccountData {
changeCredsFlag: boolean;
oAuth2Login: boolean;
saml2Login: boolean;
mfaEnabled?: boolean;
}
export interface LoginPageData {
@@ -50,11 +52,12 @@ export const accountService = {
/**
* Change user password on first login (resets firstLogin flag)
*/
async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise<void> {
async changePasswordOnLogin(currentPassword: string, newPassword: string, confirmPassword: string): Promise<void> {
const formData = new FormData();
formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData);
formData.append('confirmPassword', confirmPassword);
await apiClient.post('/api/v1/user/change-password-on-login', formData, { responseType: 'json'});
},
/**
@@ -66,4 +69,21 @@ export const accountService = {
formData.append('newUsername', newUsername);
await apiClient.post('/api/v1/user/change-username', formData);
},
async requestMfaSetup(): Promise<MfaSetupResponse> {
const response = await apiClient.get<MfaSetupResponse>('/api/v1/auth/mfa/setup', { suppressErrorToast: true });
return response.data;
},
async enableMfa(code: string): Promise<void> {
await apiClient.post('/api/v1/auth/mfa/enable', { code }, { skipAuthRedirect: true });
},
async disableMfa(code: string): Promise<void> {
await apiClient.post('/api/v1/auth/mfa/disable', { code }, { skipAuthRedirect: true });
},
async cancelMfaSetup(): Promise<void> {
await apiClient.post('/api/v1/auth/mfa/setup/cancel', undefined, { suppressErrorToast: true });
},
};
@@ -15,6 +15,9 @@ interface SelfHostedLoginScreenProps {
enabledOAuthProviders?: SSOProviderConfig[];
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
mfaCode: string;
setMfaCode: (value: string) => void;
requiresMfa: boolean;
loading: boolean;
error: string | null;
}
@@ -24,6 +27,9 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
enabledOAuthProviders,
onLogin,
onOAuthSuccess,
mfaCode,
setMfaCode,
requiresMfa,
loading,
error,
}) => {
@@ -44,6 +50,11 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
return;
}
if (requiresMfa && !mfaCode.trim()) {
setValidationError(t('login.mfaRequired', 'Two-factor code required'));
return;
}
setValidationError(null);
await onLogin(username.trim(), password);
};
@@ -98,6 +109,13 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
setPassword(value);
setValidationError(null);
}}
mfaCode={mfaCode}
setMfaCode={(value) => {
setMfaCode(value);
setValidationError(null);
}}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={handleSubmit}
isSubmitting={loading}
submitButtonText={t('setup.login.submit', 'Login')}
@@ -6,7 +6,7 @@ import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { listen } from '@tauri-apps/api/event';
@@ -30,6 +30,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
@@ -97,6 +99,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
setSelfHostedMfaCode('');
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.SelfHostedLogin);
};
@@ -116,9 +120,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setError(null);
console.log('[SetupWizard] Step 1: Authenticating with server...');
await authService.login(serverConfig.url, username, password);
const trimmedMfa = selfHostedMfaCode.trim();
const mfaCode = trimmedMfa ? trimmedMfa : undefined;
await authService.login(serverConfig.url, username, password, mfaCode);
console.log('[SetupWizard] ✅ Authentication successful');
setSelfHostedMfaRequired(false);
setSelfHostedMfaCode('');
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
await connectionModeService.switchToSelfHosted(serverConfig);
console.log('[SetupWizard] ✅ Switched to self-hosted mode');
@@ -131,7 +140,20 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
onComplete();
} catch (err) {
console.error('[SetupWizard] ❌ Self-hosted login failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Self-hosted login failed';
let errorMessage = 'Self-hosted login failed';
if (err instanceof AuthServiceError) {
if (err.code === 'mfa_required' || err.code === 'invalid_mfa_code') {
setSelfHostedMfaRequired(true);
}
errorMessage = err.message;
} else if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
}
if (errorMessage.toLowerCase().includes('mfa_required') || errorMessage.toLowerCase().includes('invalid_mfa_code')) {
setSelfHostedMfaRequired(true);
}
console.error('[SetupWizard] Error message:', errorMessage);
setError(errorMessage);
setLoading(false);
@@ -239,6 +261,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.SelfHostedLogin) {
setSelfHostedMfaCode('');
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.SaaSLogin);
@@ -286,6 +310,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading}
error={error}
/>
@@ -101,6 +101,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
originalRequest._retry = true;
const isRemote = await operationRouter.isSelfHostedMode();
+33 -12
View File
@@ -11,6 +11,15 @@ export interface UserInfo {
email?: string;
}
export class AuthServiceError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
interface LoginResponse {
token: string;
username: string;
@@ -82,7 +91,6 @@ export class AuthService {
// Try Tauri store first
try {
const token = await invoke<string | null>('get_auth_token');
if (token) {
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
return token;
@@ -222,7 +230,7 @@ export class AuthService {
}
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
console.log(`[Desktop AuthService] Username: ${username}`);
@@ -246,6 +254,7 @@ export class AuthService {
serverUrl,
username,
password,
mfaCode,
supabaseKey: SUPABASE_KEY,
saasServerUrl: STIRLING_SAAS_URL,
});
@@ -286,8 +295,21 @@ export class AuthService {
console.error('[Desktop AuthService] ❌ Login failed:', error);
// Provide more detailed error messages based on the error type
if (error instanceof Error) {
const errMsg = error.message.toLowerCase();
if (error instanceof Error || typeof error === 'string') {
const rawMessage = typeof error === 'string' ? error : error.message;
const errMsg = rawMessage.toLowerCase();
if (errMsg.includes('mfa_required')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Two-factor authentication required');
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
}
if (errMsg.includes('invalid_mfa_code')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Invalid two-factor code provided');
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
}
// Authentication errors
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
@@ -412,7 +434,6 @@ export class AuthService {
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
}
return token;
} catch (error) {
console.error('[Desktop AuthService] Failed to get auth token:', error);
@@ -449,7 +470,7 @@ export class AuthService {
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('Refreshing auth token');
console.log('[Desktop AuthService] Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
@@ -477,10 +498,10 @@ export class AuthService {
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully');
console.log('[Desktop AuthService] Token refreshed successfully');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
console.error('[Desktop AuthService] Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
@@ -533,7 +554,7 @@ export class AuthService {
*/
async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise<UserInfo> {
try {
console.log('Starting OAuth login with provider:', provider);
console.log('[Desktop AuthService] Starting OAuth login with provider:', provider);
this.setAuthStatus('oauth_pending', null);
// Validate Supabase key is configured for OAuth
@@ -554,7 +575,7 @@ export class AuthService {
errorHtml,
});
console.log('OAuth authentication successful, storing tokens');
console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
// Save token to all storage locations
await this.saveTokenEverywhere(result.access_token);
@@ -569,11 +590,11 @@ export class AuthService {
});
this.setAuthStatus('authenticated', userInfo);
console.log('OAuth login successful');
console.log('[Desktop AuthService] OAuth login successful');
return userInfo;
} catch (error) {
console.error('Failed to complete OAuth login:', error);
console.error('[Desktop AuthService] Failed to complete OAuth login:', error);
this.setAuthStatus('unauthenticated', null);
throw error;
}
@@ -26,6 +26,7 @@ export interface TauriHttpRequestConfig {
// Custom properties for desktop
operationName?: string;
skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
// Axios compatibility properties (ignored by Tauri HTTP)
suppressErrorToast?: boolean;
cancelToken?: any;
+2
View File
@@ -15,11 +15,13 @@ declare module 'assets/material-symbols-icons.json' {
declare module 'axios' {
export interface AxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean;
skipAuthRedirect?: boolean;
skipBackendReadyCheck?: boolean;
}
export interface InternalAxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean;
skipAuthRedirect?: boolean;
skipBackendReadyCheck?: boolean;
}
}
@@ -77,6 +77,8 @@ export interface Session {
export interface AuthError {
message: string;
status?: number;
code?: string;
mfaRequired?: boolean;
}
export interface AuthResponse {
@@ -181,11 +183,13 @@ class SpringAuthClient {
async signInWithPassword(credentials: {
email: string;
password: string;
mfaCode?: string;
}): Promise<AuthResponse> {
try {
const response = await apiClient.post('/api/v1/auth/login', {
username: credentials.email,
password: credentials.password
password: credentials.password,
mfaCode: credentials.mfaCode,
}, {
withCredentials: true, // Include cookies for CSRF
});
@@ -213,6 +217,24 @@ class SpringAuthClient {
return { user: data.user, session, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signInWithPassword error:', error);
if (error instanceof AxiosError) {
const errorCode = error.response?.data?.error as string | undefined;
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
'Login failed';
return {
user: null,
session: null,
error: {
message: errorMessage,
status: error.response?.status,
code: errorCode,
mfaRequired: errorCode === 'mfa_required',
},
};
}
return {
user: null,
session: null,
@@ -58,6 +58,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: undefined as number | undefined,
authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2',
forceChange: false,
forceMFA: false,
});
// Form state for email invite
@@ -141,6 +142,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: inviteForm.teamId,
authType: inviteForm.authType,
forceChange: inviteForm.forceChange,
forceMFA: inviteForm.forceMFA,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
onClose();
@@ -153,6 +155,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: undefined,
authType: 'WEB',
forceChange: false,
forceMFA: false,
});
} catch (error: any) {
console.error('Failed to invite user:', error);
@@ -253,6 +256,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: undefined,
authType: 'WEB',
forceChange: false,
forceMFA: false,
});
setEmailInviteForm({
emails: '',
@@ -283,7 +287,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
handleInviteUser();
}
};
return (
<Modal
opened={opened}
@@ -543,6 +547,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
<Checkbox
label={t('workspace.people.addMember.forceMFA', 'Force MFA setup on next login')}
checked={inviteForm.forceMFA}
onChange={(e) => setInviteForm({ ...inviteForm, forceMFA: e.currentTarget.checked })}
/>
</>
)}
@@ -1,12 +1,15 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert as showToast } from '@app/components/toast';
import { useAuth } from '@app/auth/UseSession';
import { accountService } from '@app/services/accountService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { QRCodeSVG } from 'qrcode.react';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { BASE_PATH } from '@app/constants/app';
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
const AccountSection: React.FC = () => {
const { t } = useTranslation();
@@ -25,6 +28,17 @@ const AccountSection: React.FC = () => {
const [newUsername, setNewUsername] = useState('');
const [usernameError, setUsernameError] = useState('');
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
const [mfaEnabled, setMfaEnabled] = useState(false);
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaDisableCode, setMfaDisableCode] = useState('');
const [mfaError, setMfaError] = useState('');
const [mfaLoading, setMfaLoading] = useState(false);
const [changeButtonDisabled, setChangeButtonDisabled] = useState(false);
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, '').slice(0, 6), []);
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const authTypeFromMetadata = useMemo(() => {
const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined;
@@ -92,6 +106,124 @@ const AccountSection: React.FC = () => {
}
};
useEffect(() => {
const fetchAccountData = async () => {
setChangeButtonDisabled(true);
try {
const data = await accountService.getAccountData().then((data) => data).finally(() => {
setChangeButtonDisabled(false);
});
setMfaEnabled(data.mfaEnabled ?? false);
} catch {
// ignore fetch errors for account data
console.warn('Failed to fetch account data');
} finally {
setChangeButtonDisabled(false);
}
};
void fetchAccountData();
}, []);
const handleStartMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError('');
setMfaSetupCode('');
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
setMfaSetupModalOpen(true);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t('account.mfa.setupFailed', 'Unable to start two-factor setup. Please try again.')
);
} finally {
setMfaLoading(false);
}
}, [t]);
const handleEnableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
return;
}
try {
setMfaLoading(true);
setMfaError('');
await accountService.enableMfa(mfaSetupCode.trim());
setMfaEnabled(true);
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode('');
showToast({
alertType: 'success',
title: t('account.mfa.enabled', 'Two-factor authentication enabled.'),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t('account.mfa.enableFailed', 'Unable to enable two-factor authentication. Check the code and try again.')
);
} finally {
setMfaLoading(false);
}
},
[mfaSetupCode, t]
);
const handleDisableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaDisableCode.trim()) {
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
return;
}
try {
setMfaLoading(true);
setMfaError('');
await accountService.disableMfa(mfaDisableCode.trim());
setMfaEnabled(false);
setMfaDisableModalOpen(false);
setMfaDisableCode('');
showToast({
alertType: 'success',
title: t('account.mfa.disabled', 'Two-factor authentication disabled.'),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t('account.mfa.disableFailed', 'Unable to disable two-factor authentication. Check the code and try again.')
);
} finally {
setMfaLoading(false);
}
},
[mfaDisableCode, t]
);
const handleCloseMfaSetupModal = useCallback(async () => {
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode('');
setMfaError('');
try {
await accountService.cancelMfaSetup();
} catch {
console.warn('Failed to clear pending MFA setup');
}
}, []);
const handleCloseMfaDisableModal = useCallback(() => {
setMfaDisableModalOpen(false);
setMfaDisableCode('');
setMfaError('');
}, []);
const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault();
@@ -183,6 +315,50 @@ const AccountSection: React.FC = () => {
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text fw={600}>{t('account.mfa.title', 'Two-factor authentication')}</Text>
<Text size="sm" c="dimmed">
{t('account.mfa.description', 'Add an extra layer of security to your account.')}
</Text>
{isSsoUser ? (
<Alert icon={<LocalIcon icon="info" width="1rem" height="1rem" />} color="blue" variant="light">
{t(
'account.mfa.ssoManaged',
'Two-factor authentication for this account is managed by your identity provider.'
)}
</Alert>
) : (
<Group gap="sm" wrap="wrap">
{!mfaEnabled ? (
<Button
leftSection={<LocalIcon icon="check-circle-outline-rounded" />}
onClick={handleStartMfaSetup}
loading={mfaLoading}
disabled={changeButtonDisabled}
>
{t('account.mfa.enableButton', 'Enable two-factor authentication')}
</Button>
) : (
<Button
variant="outline"
color="red"
leftSection={<LocalIcon icon="close-rounded" />}
onClick={() => {
setMfaError('');
setMfaDisableCode('');
setMfaDisableModalOpen(true);
}}
disabled={changeButtonDisabled}
>
{t('account.mfa.disableButton', 'Disable two-factor authentication')}
</Button>
)}
</Group>
)}
</Stack>
</Paper>
<Modal
opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
@@ -238,6 +414,124 @@ const AccountSection: React.FC = () => {
</form>
</Modal>
<Modal
opened={mfaSetupModalOpen}
onClose={handleCloseMfaSetupModal}
title={t('account.mfa.setupTitle', 'Set up two-factor authentication')}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleEnableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
'account.mfa.setupDescription',
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.'
)}
</Text>
{mfaSetupData && (
<Stack gap="sm" align="center">
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<QRCodeSVG
value={mfaSetupData.otpauthUri || ''}
size={180}
level="H"
imageSettings={{
src: qrLogoSrc,
height: 40,
width: 40,
excavate: true,
}}
/>
</Box>
<Text size="sm" c="dimmed">
{t('account.mfa.manualKey', 'Manual setup key')}: <strong>{mfaSetupData.secret}</strong>
</Text>
<Text size="xs" c="orange">
{t(
'account.mfa.secretWarning',
'Keep this key private. Anyone with access can generate valid authentication codes.'
)}
</Text>
</Stack>
)}
{mfaError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
{mfaError}
</Alert>
)}
<TextInput
label={t('account.mfa.codeLabel', 'Authentication code')}
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
value={mfaSetupCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
minLength={6}
autoComplete="one-time-code"
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaSetupModal}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="submit" loading={mfaLoading}>
{t('account.mfa.confirmEnable', 'Enable')}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={mfaDisableModalOpen}
onClose={handleCloseMfaDisableModal}
title={t('account.mfa.disableTitle', 'Disable two-factor authentication')}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleDisableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('account.mfa.disableDescription', 'Enter a valid authentication code to disable two-factor authentication.')}
</Text>
{mfaError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
{mfaError}
</Alert>
)}
<TextInput
label={t('account.mfa.codeLabel', 'Authentication code')}
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
value={mfaDisableCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
minLength={6}
autoComplete="one-time-code"
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaDisableModal}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="submit" color="red" loading={mfaLoading}>
{t('account.mfa.confirmDisable', 'Disable')}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)}
@@ -261,7 +555,7 @@ const AccountSection: React.FC = () => {
label={t('changeCreds.newUsername', 'New Username')}
placeholder={t('changeCreds.newUsername', 'New Username')}
value={newUsername}
onChange={(event) => setNewUsername(event.currentTarget.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
required
/>
@@ -269,7 +563,7 @@ const AccountSection: React.FC = () => {
label={t('changeCreds.oldPassword', 'Current Password')}
placeholder={t('changeCreds.oldPassword', 'Current Password')}
value={currentPasswordForUsername}
onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCurrentPasswordForUsername(event.currentTarget.value)}
required
/>
@@ -112,6 +112,7 @@ export default function PeopleSection() {
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true',
}));
setUsers(enrichedUsers);
@@ -200,7 +201,7 @@ export default function PeopleSection() {
});
}
} catch (error) {
console.error('Failed to fetch people data:', error);
console.error('[PeopleSection] Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
} finally {
setLoading(false);
@@ -221,7 +222,7 @@ export default function PeopleSection() {
closeEditModal();
fetchData();
} catch (error: any) {
console.error('Failed to update user:', error);
console.error('[PeopleSection] Failed to update user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
@@ -238,7 +239,7 @@ export default function PeopleSection() {
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
fetchData();
} catch (error: any) {
console.error('Failed to toggle user status:', error);
console.error('[PeopleSection] Failed to toggle user status:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
@@ -258,7 +259,7 @@ export default function PeopleSection() {
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchData();
} catch (error: any) {
console.error('Failed to delete user:', error);
console.error('[PeopleSection] Failed to delete user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
@@ -576,6 +577,7 @@ export default function PeopleSection() {
</Tooltip>
{/* Actions menu */}
{!isCurrentUser(user) && (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
@@ -589,9 +591,10 @@ export default function PeopleSection() {
onClick={() => openEditModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.editRole')}
{t('workspace.people.editRole', 'Edit Role & Team')}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
@@ -599,6 +602,7 @@ export default function PeopleSection() {
>
{t('workspace.people.changePassword.action', 'Change password')}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
@@ -608,6 +612,31 @@ export default function PeopleSection() {
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item>
)}
{!isCurrentUser(user) && user.mfaEnabled && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
onClick={async () => {
try {
await userManagementService.disableMfaByAdmin(user.username);
alert({ alertType: 'success', title: t('workspace.people.mfa.adminDisableSuccess', 'MFA disabled successfully for user') });
} catch (error: any) {
console.error('[PeopleSection] Failed to disable MFA for user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.mfa.adminDisableError', 'Failed to disable MFA for user');
alert({ alertType: 'error', title: errorMessage });
}
}}
disabled={!loginEnabled}
>
{t('workspace.people.mfa.disableByAdmin', 'Disable MFA')}
</Menu.Item>
</>
)}
{!isCurrentUser(user) && (
<>
<Menu.Divider />
@@ -618,6 +647,7 @@ export default function PeopleSection() {
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Table.Td>
</Table.Tr>
+18 -1
View File
@@ -33,6 +33,8 @@ export default function Login() {
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [requiresMfa, setRequiresMfa] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
@@ -273,6 +275,11 @@ export default function Login() {
return;
}
if (requiresMfa && !mfaCode.trim()) {
setError(t('login.mfaRequired', 'Two-factor code required'));
return;
}
try {
setIsSigningIn(true);
setError(null);
@@ -281,14 +288,20 @@ export default function Login() {
const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(),
password: password
password: password,
mfaCode: requiresMfa ? mfaCode.trim() : undefined,
});
if (error) {
console.error('[Login] Email sign in error:', error);
setError(error.message);
if (error.mfaRequired || error.code === 'invalid_mfa_code') {
setRequiresMfa(true);
}
} else if (user && session) {
console.log('[Login] Email sign in successful');
setRequiresMfa(false);
setMfaCode('');
// Auth state will update automatically and Landing will redirect to home
// No need to navigate manually here
}
@@ -362,6 +375,10 @@ export default function Login() {
password={password}
setEmail={setEmail}
setPassword={setPassword}
mfaCode={mfaCode}
setMfaCode={setMfaCode}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
@@ -22,6 +22,10 @@ interface EmailPasswordFormProps {
password: string
setEmail: (email: string) => void
setPassword: (password: string) => void
mfaCode?: string
setMfaCode?: (code: string) => void
showMfaField?: boolean
requiresMfa?: boolean
onSubmit: () => void
isSubmitting: boolean
submitButtonText: string
@@ -29,6 +33,7 @@ interface EmailPasswordFormProps {
fieldErrors?: {
email?: string
password?: string
mfaCode?: string
}
}
@@ -37,6 +42,10 @@ export default function EmailPasswordForm({
password,
setEmail,
setPassword,
mfaCode = '',
setMfaCode,
showMfaField = false,
requiresMfa = false,
onSubmit,
isSubmitting,
submitButtonText,
@@ -66,6 +75,7 @@ export default function EmailPasswordForm({
error={fieldErrors.email}
classNames={{ label: 'auth-label' }}
styles={authInputStyles}
autoFocus
/>
</div>
@@ -85,11 +95,32 @@ export default function EmailPasswordForm({
/>
</div>
)}
{showMfaField && (
<div className="auth-field">
<TextInput
id="mfaCode"
label={t('login.mfaCode', 'Authentication code')}
type="text"
name="mfaCode"
autoComplete="one-time-code"
placeholder={t('login.enterMfaCode', 'Enter 6-digit code')}
value={mfaCode}
inputMode="numeric"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode?.(e.target.value.replace(/\D/g, '').slice(0, 6))}
pattern="[0-9]*"
maxLength={6}
minLength={6}
error={fieldErrors.mfaCode}
classNames={{ label: 'auth-label' }}
styles={authInputStyles}
/>
</div>
)}
</div>
<Button
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)}
disabled={isSubmitting || !email || (showPasswordField && !password) || (requiresMfa && !mfaCode.trim())}
className="auth-button"
fullWidth
loading={isSubmitting}
@@ -18,6 +18,7 @@ export interface User {
// Enriched client-side fields
isActive?: boolean;
lastRequest?: number; // timestamp in milliseconds
mfaEnabled?: boolean; // whether MFA is enabled for the user
}
export interface AdminSettingsData {
@@ -38,6 +39,7 @@ export interface AdminSettingsData {
licenseMaxUsers: number;
premiumEnabled: boolean;
mailEnabled: boolean;
userSettings?: Record<string, any>;
}
export interface CreateUserRequest {
@@ -47,6 +49,7 @@ export interface CreateUserRequest {
teamId?: number;
authType: 'WEB' | 'OAUTH2' | 'SAML2';
forceChange?: boolean;
forceMFA?: boolean;
}
export interface UpdateUserRoleRequest {
@@ -145,6 +148,9 @@ export const userManagementService = {
if (data.forceChange !== undefined) {
formData.append('forceChange', data.forceChange.toString());
}
if (data.forceMFA !== undefined) {
formData.append('forceMFA', data.forceMFA.toString());
}
await apiClient.post('/api/v1/user/admin/saveUser', formData, {
suppressErrorToast: true, // Component will handle error display
} as any);
@@ -291,4 +297,12 @@ export const userManagementService = {
suppressErrorToast: true, // Component will handle error display
} as any);
},
/**
* Disable MFA for a user (admin only)
*/
async disableMfaByAdmin(username: string): Promise<void> {
await apiClient.post(`/api/v1/auth/mfa/disable/admin/${encodeURIComponent(username)}`, undefined);
},
};