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.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService; 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.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService; import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -68,6 +69,7 @@ public class ProprietaryUIDataController {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final UserLicenseSettingsService licenseSettingsService; private final UserLicenseSettingsService licenseSettingsService;
private final PersistentAuditEventRepository auditRepository; private final PersistentAuditEventRepository auditRepository;
private final MfaService mfaService;
public ProprietaryUIDataController( public ProprietaryUIDataController(
ApplicationProperties applicationProperties, ApplicationProperties applicationProperties,
@@ -80,7 +82,8 @@ public class ProprietaryUIDataController {
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE, @Qualifier("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService, UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository) { PersistentAuditEventRepository auditRepository,
MfaService mfaService) {
this.applicationProperties = applicationProperties; this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig; this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry; this.sessionPersistentRegistry = sessionPersistentRegistry;
@@ -92,6 +95,7 @@ public class ProprietaryUIDataController {
this.runningEE = runningEE; this.runningEE = runningEE;
this.licenseSettingsService = licenseSettingsService; this.licenseSettingsService = licenseSettingsService;
this.auditRepository = auditRepository; this.auditRepository = auditRepository;
this.mfaService = mfaService;
} }
/** /**
@@ -245,12 +249,14 @@ public class ProprietaryUIDataController {
Map<String, Boolean> userSessions = new HashMap<>(); Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>(); Map<String, Date> userLastRequest = new HashMap<>();
Map<String, Map<String, String>> userSettings = new HashMap<>();
int activeUsers = 0; int activeUsers = 0;
int disabledUsers = 0; int disabledUsers = 0;
while (iterator.hasNext()) { while (iterator.hasNext()) {
User user = iterator.next(); User user = iterator.next();
if (user != null) { if (user != null) {
String username = user.getUsername();
boolean shouldRemove = false; boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER // Check if user is an INTERNAL_API_USER
@@ -264,7 +270,7 @@ public class ProprietaryUIDataController {
// Check if user is part of the Internal team // Check if user is part of the Internal team
if (user.getTeam() != null if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
shouldRemove = true; shouldRemove = true;
} }
@@ -278,7 +284,7 @@ public class ProprietaryUIDataController {
boolean hasActiveSession = false; boolean hasActiveSession = false;
Date lastRequest = null; Date lastRequest = null;
Optional<SessionEntity> latestSession = Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername()); sessionPersistentRegistry.findLatestSession(username);
if (latestSession.isPresent()) { if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get(); SessionEntity sessionEntity = latestSession.get();
@@ -299,8 +305,21 @@ public class ProprietaryUIDataController {
lastRequest = new Date(0); lastRequest = new Date(0);
} }
userSessions.put(user.getUsername(), hasActiveSession); User userWithSettings =
userLastRequest.put(user.getUsername(), lastRequest); 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 (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++; if (!user.isEnabled()) disabledUsers++;
@@ -329,7 +348,7 @@ public class ProprietaryUIDataController {
List<Team> allTeams = List<Team> allTeams =
teamRepository.findAll().stream() teamRepository.findAll().stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) .filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
.toList(); .toList();
// Calculate license limits // Calculate license limits
@@ -356,6 +375,7 @@ public class ProprietaryUIDataController {
data.setLicenseMaxUsers(licenseMaxUsers); data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled); data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled()); data.setMailEnabled(applicationProperties.getMail().isEnabled());
data.setUserSettings(userSettings);
return ResponseEntity.ok(data); return ResponseEntity.ok(data);
} }
@@ -407,6 +427,8 @@ public class ProprietaryUIDataController {
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange()); data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
data.setOAuth2Login(isOAuth2Login); data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login); data.setSaml2Login(isSaml2Login);
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
return ResponseEntity.ok(data); return ResponseEntity.ok(data);
} }
@@ -418,7 +440,7 @@ public class ProprietaryUIDataController {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount(); List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List<TeamWithUserCountDTO> teamsWithCounts = List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream() allTeamsWithCounts.stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) .filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
.toList(); .toList();
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam(); List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
@@ -446,7 +468,7 @@ public class ProprietaryUIDataController {
.findById(id) .findById(id)
.orElseThrow(() -> new RuntimeException("Team not found")); .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(); return ResponseEntity.status(403).build();
} }
@@ -459,11 +481,8 @@ public class ProprietaryUIDataController {
(user.getTeam() == null (user.getTeam() == null
|| !user.getTeam().getId().equals(id)) || !user.getTeam().getId().equals(id))
&& (user.getTeam() == null && (user.getTeam() == null
|| !user.getTeam() || !TeamService.INTERNAL_TEAM_NAME.equals(
.getName() user.getTeam().getName())))
.equals(
TeamService
.INTERNAL_TEAM_NAME)))
.toList(); .toList();
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id); List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
@@ -541,6 +560,7 @@ public class ProprietaryUIDataController {
private int licenseMaxUsers; private int licenseMaxUsers;
private boolean premiumEnabled; private boolean premiumEnabled;
private boolean mailEnabled; private boolean mailEnabled;
private Map<String, Map<String, String>> userSettings;
} }
@Data @Data
@@ -551,6 +571,8 @@ public class ProprietaryUIDataController {
private boolean changeCredsFlag; private boolean changeCredsFlag;
private boolean oAuth2Login; private boolean oAuth2Login;
private boolean saml2Login; private boolean saml2Login;
private boolean mfaEnabled;
private boolean mfaRequired;
} }
@Data @Data
@@ -19,6 +19,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.DatabaseServiceInterface; 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.TeamService;
import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService; import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -113,8 +114,14 @@ public class InitialSecuritySetup {
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) { && userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
Team team = teamService.getOrCreateDefaultTeam(); Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser( SaveUserRequest.Builder builder =
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false); SaveUserRequest.builder()
.username(initialUsername)
.password(initialPassword)
.team(team)
.role(Role.ADMIN.getRoleId())
.firstLogin(false);
userService.saveUserCore(builder.build());
log.info("Admin user created: {}", initialUsername); log.info("Admin user created: {}", initialUsername);
} else { } else {
createDefaultAdminUser(); createDefaultAdminUser();
@@ -127,8 +134,14 @@ public class InitialSecuritySetup {
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) { if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
Team team = teamService.getOrCreateDefaultTeam(); Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser( SaveUserRequest.Builder builder =
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true); 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); log.info("Default admin user created: {}", defaultUsername);
} }
} }
@@ -137,12 +150,14 @@ public class InitialSecuritySetup {
throws IllegalArgumentException, SQLException, UnsupportedProviderException { throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) { if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
Team team = teamService.getOrCreateInternalTeam(); Team team = teamService.getOrCreateInternalTeam();
userService.saveUser( SaveUserRequest.Builder builder =
Role.INTERNAL_API_USER.getRoleId(), SaveUserRequest.builder()
UUID.randomUUID().toString(), .username(Role.INTERNAL_API_USER.getRoleId())
team, .password(UUID.randomUUID().toString())
Role.INTERNAL_API_USER.getRoleId(), .team(team)
false); .role(Role.INTERNAL_API_USER.getRoleId())
.firstLogin(false);
userService.saveUserCore(builder.build());
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId()); userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId()); log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
} else { } else {
@@ -11,7 +11,12 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException; 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; 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.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User; 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.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService; 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; import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */ /** REST API Controller for authentication operations. */
@@ -45,6 +53,8 @@ public class AuthController {
private final JwtServiceInterface jwtService; private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService; private final LoginAttemptService loginAttemptService;
private final MfaService mfaService;
private final TotpService totpService;
private final ApplicationProperties.Security securityProperties; private final ApplicationProperties.Security securityProperties;
/** /**
@@ -58,7 +68,7 @@ public class AuthController {
@PostMapping("/login") @PostMapping("/login")
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public ResponseEntity<?> login( public ResponseEntity<?> login(
@RequestBody UsernameAndPass request, @RequestBody UsernameAndPassMfa request,
HttpServletRequest httpRequest, HttpServletRequest httpRequest,
HttpServletResponse response) { HttpServletResponse response) {
try { try {
@@ -116,6 +126,47 @@ public class AuthController {
.body(Map.of("error", "User account is disabled")); .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<>(); Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.WEB.toString()); claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString()); claims.put("role", user.getRolesAsString());
@@ -163,7 +214,7 @@ public class AuthController {
if (auth == null if (auth == null
|| !auth.isAuthenticated() || !auth.isAuthenticated()
|| auth.getPrincipal().equals("anonymousUser")) { || "anonymousUser".equals(auth.getPrincipal())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated")); .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 * Helper method to build user response object
* *
@@ -266,6 +521,19 @@ public class AuthController {
appMetadata.put("provider", user.getAuthenticationType()); appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata); 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; 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.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService; 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.TeamService;
import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.service.UserService;
@@ -90,7 +91,8 @@ public class InviteLinkController {
.body( .body(
Map.of( Map.of(
"error", "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 // If sendEmail is requested but no email provided, reject
@@ -123,7 +125,8 @@ public class InviteLinkController {
+ (currentUserCount + activeInvites) + (currentUserCount + activeInvites)
+ "/" + "/"
+ maxUsers + 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 // Create the user account
userService.saveUser( SaveUserRequest.Builder builder =
effectiveEmail, SaveUserRequest.builder()
password, .username(effectiveEmail)
invite.getTeamId(), .password(password)
invite.getRole(), .teamId(invite.getTeamId())
false); // Don't force password change .role(invite.getRole());
userService.saveUserCore(builder.build());
// Mark invite as used // Mark invite as used
invite.setUsed(true); 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.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; 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.mail.MessagingException;
import jakarta.servlet.http.HttpServletRequest; 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.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.EmailService; 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.TeamService;
import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -66,26 +70,24 @@ public class UserController {
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass) public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
throws SQLException, UnsupportedProviderException { throws SQLException, UnsupportedProviderException {
String username = usernameAndPass.getUsername();
String password = usernameAndPass.getPassword();
try { try {
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername()); log.debug("Registration attempt for user: {}", username);
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) { if (userService.usernameExistsIgnoreCase(username)) {
log.warn( log.warn("Registration failed: username already exists: {}", username);
"Registration failed: username already exists: {}",
usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST) return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "User already exists")); .body(Map.of("error", "User already exists"));
} }
if (!userService.isUsernameValid(usernameAndPass.getUsername())) { if (!userService.isUsernameValid(username)) {
log.warn( log.warn("Registration failed: invalid username format: {}", username);
"Registration failed: invalid username format: {}",
usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST) return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid username format")); .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) return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required")); .body(Map.of("error", "Password is required"));
} }
@@ -102,17 +104,16 @@ public class UserController {
+ ", Available slots: " + ", Available slots: "
+ availableSlots)); + availableSlots));
} }
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
User user = SaveUserRequest.Builder builder =
userService.saveUser( SaveUserRequest.builder()
usernameAndPass.getUsername(), .username(username)
usernameAndPass.getPassword(), .password(password)
team, .team(team)
Role.USER.getRoleId(), .enabled(false);
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) return ResponseEntity.status(HttpStatus.CREATED)
.body( .body(
@@ -127,7 +128,7 @@ public class UserController {
return ResponseEntity.status(HttpStatus.BAD_REQUEST) return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage())); .body(Map.of("error", e.getMessage()));
} catch (Exception e) { } 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) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Registration failed: " + e.getMessage())); .body(Map.of("error", "Registration failed: " + e.getMessage()));
} }
@@ -222,6 +223,7 @@ public class UserController {
Principal principal, Principal principal,
@RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "currentPassword") String currentPassword,
@RequestParam(name = "newPassword") String newPassword, @RequestParam(name = "newPassword") String newPassword,
@RequestParam(name = "confirmPassword") String confirmPassword,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) HttpServletResponse response)
throws SQLException, UnsupportedProviderException { throws SQLException, UnsupportedProviderException {
@@ -234,6 +236,43 @@ public class UserController {
return ResponseEntity.status(HttpStatus.NOT_FOUND) return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User 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(); User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) { if (!userService.isPasswordCorrect(user, currentPassword)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED) return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
@@ -326,7 +365,9 @@ public class UserController {
@RequestParam(name = "teamId", required = false) Long teamId, @RequestParam(name = "teamId", required = false) Long teamId,
@RequestParam(name = "authType") String authType, @RequestParam(name = "authType") String authType,
@RequestParam(name = "forceChange", required = false, defaultValue = "false") @RequestParam(name = "forceChange", required = false, defaultValue = "false")
boolean forceChange) boolean forceChange,
@RequestParam(name = "forceMFA", required = false, defaultValue = "false")
boolean forceMFA)
throws IllegalArgumentException, SQLException, UnsupportedProviderException { throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(username)) { if (!userService.isUsernameValid(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST) return ResponseEntity.status(HttpStatus.BAD_REQUEST)
@@ -348,8 +389,9 @@ public class UserController {
+ availableSlots)); + availableSlots));
} }
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username); Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
User user = null;
if (userOpt.isPresent()) { if (userOpt.isPresent()) {
User user = userOpt.get(); user = userOpt.get();
if (user.getUsername().equalsIgnoreCase(username)) { if (user.getUsername().equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.CONFLICT) return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Username already exists.")); .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; AuthenticationType requestedAuthType;
if ("SSO".equalsIgnoreCase(authType)) { if ("SSO".equalsIgnoreCase(authType)) {
requestedAuthType = AuthenticationType.OAUTH2; requestedAuthType = AuthenticationType.OAUTH2;
@@ -402,6 +447,7 @@ public class UserController {
.body(Map.of("error", "Invalid authentication type specified.")); .body(Map.of("error", "Invalid authentication type specified."));
} }
} }
builder.authenticationType(requestedAuthType);
if (requestedAuthType == AuthenticationType.WEB) { if (requestedAuthType == AuthenticationType.WEB) {
if (password == null || password.isBlank()) { if (password == null || password.isBlank()) {
@@ -412,10 +458,9 @@ public class UserController {
return ResponseEntity.status(HttpStatus.BAD_REQUEST) return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters.")); .body(Map.of("error", "Password must be at least 6 characters."));
} }
userService.saveUser(username, password, effectiveTeamId, role, forceChange); builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
} else {
userService.saveUser(username, requestedAuthType, effectiveTeamId, role);
} }
userService.saveUserCore(builder.build());
return ResponseEntity.ok(Map.of("message", "User created successfully")); return ResponseEntity.ok(Map.of("message", "User created successfully"));
} }
@@ -440,7 +485,8 @@ public class UserController {
.body( .body(
Map.of( Map.of(
"error", "error",
"Email service is not configured. Please configure SMTP settings.")); "Email service is not configured. Please configure SMTP"
+ " settings."));
} }
// Parse comma-separated email addresses // Parse comma-separated email addresses
@@ -658,7 +704,8 @@ public class UserController {
.body( .body(
Map.of( Map.of(
"error", "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); String loginUrl = buildLoginUrl(request);
@@ -839,7 +886,14 @@ public class UserController {
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12); String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
// Create user with forceChange=true // 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 // Send invite email
try { try {
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; 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)") @Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)")
Optional<User> findByUsernameIgnoreCaseWithSettings(@Param("username") String 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> findByUsername(String username);
Optional<User> findByApiKey(String apiKey); 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 " "SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')") + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
long countSsoUsers(); 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.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.sql.DataSource; import javax.sql.DataSource;
@@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface {
String checksum = bytesToHex(digest.digest(content)); String checksum = bytesToHex(digest.digest(content));
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum); 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 ?")) { PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
stmt.setString(1, backupPath.toString()); stmt.setString(1, backupPath.toString());
stmt.execute(); stmt.execute();
} }
return true; return true;
} catch (IOException | NoSuchAlgorithmException | SQLException e) { } 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; 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; 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.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
@@ -101,7 +106,13 @@ public class UserService implements UserServiceInterface {
} }
if (autoCreateUser) { 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; 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) { public User refreshApiKeyForUser(String username) {
// reuse the add API key method for refreshing // reuse the add API key method for refreshing
return addApiKeyToUser(username); return addApiKeyToUser(username);
@@ -179,162 +198,6 @@ public class UserService implements UserServiceInterface {
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey()); 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) { public void deleteUser(String username) {
Optional<User> userOpt = findByUsernameIgnoreCase(username); Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) { 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 * Core method to save a user based on the provided SaveUserRequest.
* the common logic for all saveUser variants.
* *
* @param username Username for the new user * @param request The SaveUserRequest containing user details
* @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
* @return The saved User object * @return The saved User object
* @throws IllegalArgumentException If username is invalid or team is invalid * @throws IllegalArgumentException If the username is invalid
* @throws SQLException If database operation fails * @throws SQLException If a database error occurs
* @throws UnsupportedProviderException If provider is not supported * @throws UnsupportedProviderException If an unsupported provider is specified
*/ */
private User saveUserCore( public User saveUserCore(SaveUserRequest request)
String username,
String password,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
Team team,
String role,
boolean firstLogin,
boolean enabled)
throws IllegalArgumentException, SQLException, UnsupportedProviderException { throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) { if (!isUsernameValid(request.getUsername())) {
throw new IllegalArgumentException(getInvalidUsernameMessage()); throw new IllegalArgumentException(getInvalidUsernameMessage());
} }
User user = new User(); User user = new User();
user.setUsername(username); user.setUsername(request.getUsername());
// Set password if provided // Set password if provided
if (password != null && !password.isEmpty()) { if (request.getPassword() != null && !request.getPassword().isEmpty()) {
user.setPassword(passwordEncoder.encode(password)); user.setPassword(passwordEncoder.encode(request.getPassword()));
} }
// Set SSO provider details if provided // Set SSO provider details if provided
if (ssoProviderId != null && ssoProvider != null) { if (request.getSsoProviderId() != null && request.getSsoProvider() != null) {
user.setSsoProviderId(ssoProviderId); user.setSsoProviderId(request.getSsoProviderId());
user.setSsoProvider(ssoProvider); user.setSsoProvider(request.getSsoProvider());
} }
// Set authentication type // Set authentication type
user.setAuthenticationType(authenticationType); user.setAuthenticationType(request.getAuthenticationType());
// Set enabled status // Set enabled status
user.setEnabled(enabled); user.setEnabled(request.isEnabled());
// Set first login flag // 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) // Set role (authority)
if (role == null) { user.addAuthority(new Authority(request.getRole(), user));
role = Role.USER.getRoleId();
}
user.addAuthority(new Authority(role, user));
// Resolve and set team // Resolve and set team
if (team != null) { if (request.getTeam() != null) {
user.setTeam(team); user.setTeam(request.getTeam());
} else { } else {
user.setTeam(resolveTeam(teamId, this::getDefaultTeam)); user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam));
} }
// Save user // Save user
@@ -678,6 +537,7 @@ public class UserService implements UserServiceInterface {
return null; return null;
} }
@Override
public boolean isCurrentUserAdmin() { public boolean isCurrentUserAdmin() {
try { try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -693,6 +553,7 @@ public class UserService implements UserServiceInterface {
return false; return false;
} }
@Override
public boolean isCurrentUserFirstLogin() { public boolean isCurrentUserFirstLogin() {
try { try {
String username = getCurrentUsername(); 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; package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*; 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 static org.mockito.Mockito.*;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource; import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
class UserServiceTest { class UserServiceTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository; @Mock private TeamRepository teamRepository;
@Mock private AuthorityRepository authorityRepository; @Mock private AuthorityRepository authorityRepository;
@Mock private PasswordEncoder passwordEncoder; @Mock private PasswordEncoder passwordEncoder;
@Mock private MessageSource messageSource; @Mock private MessageSource messageSource;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private DatabaseServiceInterface databaseService; @Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties; @Spy @InjectMocks private UserService userService;
@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);
}
@Test @Test
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception { void saveUserCore_populatesFieldsAndPersists()
// Given throws SQLException, UnsupportedProviderException {
String username = "testuser"; Long teamId = 42L;
AuthenticationType authType = AuthenticationType.WEB; 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)); SaveUserRequest request =
when(userRepository.save(any(User.class))).thenReturn(mockUser); SaveUserRequest.builder()
doNothing().when(databaseService).exportDatabase(); .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 User saved = userService.saveUserCore(request);
userService.saveUser(username, authType);
// Then ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(any(User.class)); verify(userRepository).save(userCaptor.capture());
verify(databaseService).exportDatabase(); 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 @Test
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception { void saveUserCore_withoutTeam_usesDefaultTeam()
// Given throws SQLException, UnsupportedProviderException {
String username = "testuser"; Team defaultTeam = new Team();
String password = "password123"; defaultTeam.setName("Default");
Long teamId = 1L; when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
String encodedPassword = "encodedPassword123"; when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword); SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When User saved = userService.saveUserCore(request);
User result = userService.saveUser(username, password, teamId);
// Then verify(teamRepository).findByName("Default");
assertNotNull(result); verify(teamRepository, never()).findById(anyLong());
verify(passwordEncoder).encode(password);
verify(teamRepository).findById(teamId);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase(); verify(databaseService).exportDatabase();
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
} }
@Test @Test
void testSaveUser_WithTeamAndRole_Success() throws Exception { void processSSOPostLogin_autoCreatesUserWhenMissing()
// Given throws IllegalArgumentException, SQLException, UnsupportedProviderException {
String username = "testuser"; String username = "autoUser";
String password = "password123"; doReturn(true).when(userService).isUsernameValid(username);
String role = Role.ADMIN.getRoleId(); when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
boolean firstLogin = true; .thenReturn(Optional.empty());
String encodedPassword = "encodedPassword123"; 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); userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
User result = userService.saveUser(username, password, mockTeam, role, firstLogin); verify(userService).saveUserCore(reqCaptor.capture());
SaveUserRequest captured = reqCaptor.getValue();
// Then assertEquals(username, captured.getUsername());
assertNotNull(result); assertEquals("id", captured.getSsoProviderId());
verify(passwordEncoder).encode(password); assertEquals("prov", captured.getSsoProvider());
verify(userRepository).save(any(User.class)); assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
verify(databaseService).exportDatabase();
} }
@Test @Test
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception { void addApiKeyToUserGeneratesAndPersists() {
// Given User user = new User();
String invalidUsername = "ab"; // Too short (less than 3 characters) user.setUsername("user");
AuthenticationType authType = AuthenticationType.WEB; 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 User updated = userService.addApiKeyToUser("user");
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
verify(userRepository, never()).save(any(User.class)); assertNotNull(updated.getApiKey());
verify(databaseService, never()).exportDatabase(); verify(userRepository).save(user);
} }
@Test @Test
void testSaveUser_WithNullPassword_Success() throws Exception { void getApiKeyForUserCreatesWhenMissing() {
// Given User user = new User();
String username = "testuser"; user.setUsername("user");
Long teamId = 1L; 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)); String apiKey = userService.getApiKeyForUser("user");
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When assertNotNull(apiKey);
User result = userService.saveUser(username, null, teamId); verify(userRepository).save(user);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
} }
@Test @Test
void testSaveUser_WithEmptyPassword_Success() throws Exception { void isUsernameValidRejectsReservedAndAcceptsEmail() {
// Given assertFalse(userService.isUsernameValid("ALL_USERS"));
String username = "testuser"; assertTrue(userService.isUsernameValid("[email protected]"));
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();
} }
} }
@@ -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" syncToBrowser = "Sync Account -> Browser"
syncToAccount = "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] [adminUserSettings]
title = "User Control Settings" title = "User Control Settings"
header = "Admin 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." credentialsUpdated = "Your credentials have been updated. Please sign in again."
defaultCredentials = "Default Login Credentials" defaultCredentials = "Default Login Credentials"
changePasswordWarning = "Please change your password after logging in for the first time" 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] [login.slides.overview]
alt = "Stirling PDF overview" alt = "Stirling PDF overview"
@@ -5826,6 +5858,7 @@ usernameRequired = "Username and password are required"
passwordTooShort = "Password must be at least 6 characters" passwordTooShort = "Password must be at least 6 characters"
success = "User created successfully" success = "User created successfully"
error = "Failed to create user" error = "Failed to create user"
forceMFA = "Force MFA setup on next login"
[workspace.people.authType] [workspace.people.authType]
password = "Password" password = "Password"
@@ -5935,6 +5968,11 @@ slotsAvailable = "{{count}} user slot(s) available"
noSlotsAvailable = "No slots available" noSlotsAvailable = "No slots available"
currentUsage = "Currently using {{current}} of {{max}} user licences" 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] [workspace.teams]
title = "Teams" title = "Teams"
description = "Manage teams and organize workspace members" description = "Manage teams and organize workspace members"
+36 -7
View File
@@ -33,15 +33,20 @@ fn get_keyring_entry() -> Result<Entry, String> {
#[tauri::command] #[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> { 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"); log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string()); return Err("Token cannot be empty".to_string());
} }
let entry = get_keyring_entry()?; let entry = get_keyring_entry()?;
if trimmed.len() != token.len() {
log::debug!("Auth token had surrounding whitespace; storing trimmed token");
}
entry entry
.set_password(&token) .set_password(trimmed)
.map_err(|e| { .map_err(|e| {
log::error!("Failed to set password in keyring: {}", e); log::error!("Failed to set password in keyring: {}", e);
format!("Failed to save token to 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 // Verify the save worked
match entry.get_password() { match entry.get_password() {
Ok(retrieved_token) => { Ok(retrieved_token) => {
if retrieved_token != token { if retrieved_token != trimmed {
log::error!("Token verification failed: Retrieved token doesn't match"); log::error!("Token verification failed: Retrieved token doesn't match");
return Err("Token verification failed after save".to_string()); return Err("Token verification failed after save".to_string());
} }
@@ -201,6 +206,7 @@ pub async fn login(
server_url: String, server_url: String,
username: String, username: String,
password: String, password: String,
mfa_code: Option<String>,
supabase_key: String, supabase_key: String,
saas_server_url: String, saas_server_url: String,
) -> Result<LoginResponse, 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('/')); let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Spring Boot login URL: {}", login_url); 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 let response = client
.post(&login_url) .post(&login_url)
.json(&serde_json::json!({ .json(&payload)
"username": username,
"password": password,
}))
.send() .send()
.await .await
.map_err(|e| format!("Network error: {}", e))?; .map_err(|e| format!("Network error: {}", e))?;
@@ -292,6 +308,19 @@ pub async fn login(
.unwrap_or_else(|_| "Unknown error".to_string()); .unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Spring Boot login failed with status {}: {}", status, error_text); 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 { return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string() "Invalid username or password".to_string()
} else if status.as_u16() == 403 { } 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); 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 { .heroIconsContainer {
display: flex; display: flex;
gap: 32px; gap: 32px;
@@ -26,6 +26,8 @@ import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient'; import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css'; import '@app/components/onboarding/OnboardingTour.css';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from '@app/auth/UseSession';
export default function Onboarding() { export default function Onboarding() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -45,15 +47,30 @@ export default function Onboarding() {
const [analyticsLoading, setAnalyticsLoading] = useState(false); const [analyticsLoading, setAnalyticsLoading] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false); const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const [analyticsModalDismissed, setAnalyticsModalDismissed] = 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) => { const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role }); actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin'); serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]); }, [actions, serverExperience]);
const handlePasswordChanged = useCallback(() => { const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const handlePasswordChanged = useCallback(async () => {
actions.updateRuntimeState({ requiresPasswordChange: false }); 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]); }, [actions]);
// Check if we should show analytics modal before onboarding // Check if we should show analytics modal before onboarding
@@ -68,17 +85,17 @@ export default function Onboarding() {
setAnalyticsLoading(true); setAnalyticsLoading(true);
setAnalyticsError(null); setAnalyticsError(null);
try { const formData = new FormData();
const formData = new FormData(); formData.append('enabled', enableAnalytics.toString());
formData.append('enabled', enableAnalytics.toString());
try {
await apiClient.post('/api/v1/settings/update-enable-analytics', formData); await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig(); await refetchConfig();
setShowAnalyticsModal(false); setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true); setAnalyticsModalDismissed(true);
setAnalyticsLoading(false);
} catch (error) { } catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error'); setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setAnalyticsLoading(false); setAnalyticsLoading(false);
} }
}, [analyticsLoading, refetchConfig]); }, [analyticsLoading, refetchConfig]);
@@ -223,6 +240,27 @@ export default function Onboarding() {
return () => removeAllGlows(); return () => removeAllGlows();
}, [isTourOpen]); }, [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(() => { const finishTour = useCallback(() => {
setIsTourOpen(false); setIsTourOpen(false);
if (runtimeState.tourType === 'admin') { if (runtimeState.tourType === 'admin') {
@@ -272,8 +310,9 @@ export default function Onboarding() {
usingDefaultCredentials: runtimeState.usingDefaultCredentials, usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError, analyticsError,
analyticsLoading, 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(() => { const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length; 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) { if (showLicenseSlide) {
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license']; const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
// Remove back button for external license notice // Remove back button for external license notice
@@ -403,6 +501,7 @@ export default function Onboarding() {
currentModalSlideIndex={currentModalSlideIndex} currentModalSlideIndex={currentModalSlideIndex}
onSkip={actions.skip} onSkip={actions.skip}
onAction={handleButtonAction} 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 FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide'; import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide'; import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types'; import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId = export type SlideId =
@@ -16,7 +17,8 @@ export type SlideId =
| 'admin-overview' | 'admin-overview'
| 'server-license' | 'server-license'
| 'tour-overview' | 'tour-overview'
| 'analytics-choice'; | 'analytics-choice'
| 'mfa-setup';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics'; export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
@@ -61,6 +63,7 @@ export interface SlideFactoryParams {
usingDefaultCredentials?: boolean; usingDefaultCredentials?: boolean;
analyticsError?: string | null; analyticsError?: string | null;
analyticsLoading?: boolean; analyticsLoading?: boolean;
onMfaSetupComplete?: () => void;
} }
export interface HeroDefinition { 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' | 'tool-layout'
| 'tour-overview' | 'tour-overview'
| 'server-license' | 'server-license'
| 'analytics-choice'; | 'analytics-choice'
| 'mfa-setup';
export type OnboardingStepType = export type OnboardingStepType =
| 'modal-slide' | 'modal-slide'
@@ -30,6 +31,7 @@ export interface OnboardingRuntimeState {
requiresPasswordChange: boolean; requiresPasswordChange: boolean;
firstLoginUsername: string; firstLoginUsername: string;
usingDefaultCredentials: boolean; usingDefaultCredentials: boolean;
requiresMfaSetup: boolean;
} }
export interface OnboardingConditionContext extends OnboardingRuntimeState { export interface OnboardingConditionContext extends OnboardingRuntimeState {
@@ -41,7 +43,8 @@ export interface OnboardingStep {
id: OnboardingStepId; id: OnboardingStepId;
type: OnboardingStepType; type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean; 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 = { export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
@@ -61,6 +64,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
firstLoginUsername: '', firstLoginUsername: '',
usingDefaultCredentials: false, usingDefaultCredentials: false,
desktopSlideEnabled: true, desktopSlideEnabled: true,
requiresMfaSetup: false,
}; };
export const ONBOARDING_STEPS: OnboardingStep[] = [ export const ONBOARDING_STEPS: OnboardingStep[] = [
@@ -111,6 +115,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'server-license', slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, 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 { 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 { export interface OnboardingOrchestratorState {
/** Whether onboarding is currently active */ /** Whether onboarding is currently active */
isActive: boolean; isActive: boolean;
@@ -205,8 +217,10 @@ export function useOnboardingOrchestrator(
requiresPasswordChange: accountData.changeCredsFlag, requiresPasswordChange: accountData.changeCredsFlag,
firstLoginUsername: accountData.username, firstLoginUsername: accountData.username,
usingDefaultCredentials: loginPageData.showDefaultCredentials, 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 // Account endpoint failed - user not logged in or security disabled
} }
}; };
@@ -230,12 +244,8 @@ export function useOnboardingOrchestrator(
}), [serverExperience, runtimeState]); }), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => { 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)); return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext, runtimeState.requiresPasswordChange]); }, [conditionContext]);
// Wait for config AND admin status before calculating initial step // Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && ( const adminStatusResolved = !configLoading && (
@@ -255,7 +265,7 @@ export function useOnboardingOrchestrator(
} }
// If onboarding has been completed, don't show it // If onboarding has been completed, don't show it
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) { if (isOnboardingCompleted()) {
setCurrentStepIndex(activeFlow.length); setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true; initialIndexSet.current = true;
return; return;
@@ -266,7 +276,7 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(0); setCurrentStepIndex(0);
initialIndexSet.current = true; initialIndexSet.current = true;
} }
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]); }, [activeFlow, configLoading, adminStatusResolved]);
const totalSteps = activeFlow.length; const totalSteps = activeFlow.length;
@@ -51,7 +51,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
setLoading(true); setLoading(true);
setError(''); setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword); await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
showToast({ showToast({
alertType: 'success', alertType: 'success',
@@ -127,6 +127,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')} placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)} onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required required
styles={{ styles={{
input: { height: 44 }, input: { height: 44 },
@@ -139,6 +140,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)} onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required required
minLength={8}
styles={{ styles={{
input: { height: 44 }, input: { height: 44 },
}} }}
@@ -148,7 +150,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
fullWidth fullWidth
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={!newPassword || !confirmPassword} disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
size="md" size="md"
mt="xs" mt="xs"
> >
@@ -169,9 +171,9 @@ export default function FirstLoginSlide({
key: 'first-login', key: 'first-login',
title: 'Set Your Password', title: 'Set Your Password',
body: ( body: (
<FirstLoginForm <FirstLoginForm
username={username} username={username}
onPasswordChanged={onPasswordChanged} onPasswordChanged={onPasswordChanged}
usingDefaultCredentials={usingDefaultCredentials} 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); setLoading(true);
setError(''); setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword); await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
alert({ alert({
alertType: 'success', alertType: 'success',
@@ -133,6 +133,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')} placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)} onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required required
/> />
@@ -141,6 +142,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')} placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)} onChange={(e) => setConfirmPassword(e.currentTarget.value)}
minLength={8}
required required
/> />
@@ -148,7 +150,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
fullWidth fullWidth
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={!currentPassword || !newPassword || !confirmPassword} disabled={!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
mt="md" mt="md"
> >
{t('firstLogin.changePassword', 'Change Password')} {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'; import apiClient from '@app/services/apiClient';
export interface AccountData { export interface AccountData {
@@ -7,6 +8,7 @@ export interface AccountData {
changeCredsFlag: boolean; changeCredsFlag: boolean;
oAuth2Login: boolean; oAuth2Login: boolean;
saml2Login: boolean; saml2Login: boolean;
mfaEnabled?: boolean;
} }
export interface LoginPageData { export interface LoginPageData {
@@ -50,11 +52,12 @@ export const accountService = {
/** /**
* Change user password on first login (resets firstLogin flag) * 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(); const formData = new FormData();
formData.append('currentPassword', currentPassword); formData.append('currentPassword', currentPassword);
formData.append('newPassword', newPassword); 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); formData.append('newUsername', newUsername);
await apiClient.post('/api/v1/user/change-username', formData); 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[]; enabledOAuthProviders?: SSOProviderConfig[];
onLogin: (username: string, password: string) => Promise<void>; onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>; onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
mfaCode: string;
setMfaCode: (value: string) => void;
requiresMfa: boolean;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
@@ -24,6 +27,9 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
enabledOAuthProviders, enabledOAuthProviders,
onLogin, onLogin,
onOAuthSuccess, onOAuthSuccess,
mfaCode,
setMfaCode,
requiresMfa,
loading, loading,
error, error,
}) => { }) => {
@@ -44,6 +50,11 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
return; return;
} }
if (requiresMfa && !mfaCode.trim()) {
setValidationError(t('login.mfaRequired', 'Two-factor code required'));
return;
}
setValidationError(null); setValidationError(null);
await onLogin(username.trim(), password); await onLogin(username.trim(), password);
}; };
@@ -98,6 +109,13 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
setPassword(value); setPassword(value);
setValidationError(null); setValidationError(null);
}} }}
mfaCode={mfaCode}
setMfaCode={(value) => {
setMfaCode(value);
setValidationError(null);
}}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={loading} isSubmitting={loading}
submitButtonText={t('setup.login.submit', 'Login')} 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 { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen'; import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService'; 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 { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { listen } from '@tauri-apps/api/event'; 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 [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
const handleSaaSLogin = async (username: string, password: string) => { const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) { if (!serverConfig) {
@@ -97,6 +99,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleServerSelection = (config: ServerConfig) => { const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config); setServerConfig(config);
setError(null); setError(null);
setSelfHostedMfaCode('');
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.SelfHostedLogin); setActiveStep(SetupStep.SelfHostedLogin);
}; };
@@ -116,9 +120,14 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setError(null); setError(null);
console.log('[SetupWizard] Step 1: Authenticating with server...'); 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'); console.log('[SetupWizard] ✅ Authentication successful');
setSelfHostedMfaRequired(false);
setSelfHostedMfaCode('');
console.log('[SetupWizard] Step 2: Switching to self-hosted mode...'); console.log('[SetupWizard] Step 2: Switching to self-hosted mode...');
await connectionModeService.switchToSelfHosted(serverConfig); await connectionModeService.switchToSelfHosted(serverConfig);
console.log('[SetupWizard] ✅ Switched to self-hosted mode'); console.log('[SetupWizard] ✅ Switched to self-hosted mode');
@@ -131,7 +140,20 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
onComplete(); onComplete();
} catch (err) { } catch (err) {
console.error('[SetupWizard] ❌ Self-hosted login failed:', 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); console.error('[SetupWizard] Error message:', errorMessage);
setError(errorMessage); setError(errorMessage);
setLoading(false); setLoading(false);
@@ -239,6 +261,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const handleBack = () => { const handleBack = () => {
setError(null); setError(null);
if (activeStep === SetupStep.SelfHostedLogin) { if (activeStep === SetupStep.SelfHostedLogin) {
setSelfHostedMfaCode('');
setSelfHostedMfaRequired(false);
setActiveStep(SetupStep.ServerSelection); setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) { } else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.SaaSLogin); setActiveStep(SetupStep.SaaSLogin);
@@ -286,6 +310,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
enabledOAuthProviders={serverConfig?.enabledOAuthProviders} enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
onLogin={handleSelfHostedLogin} onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess} onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading} loading={loading}
error={error} error={error}
/> />
@@ -101,6 +101,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Handle 401 Unauthorized - try to refresh token // Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
originalRequest._retry = true; originalRequest._retry = true;
const isRemote = await operationRouter.isSelfHostedMode(); const isRemote = await operationRouter.isSelfHostedMode();
+33 -12
View File
@@ -11,6 +11,15 @@ export interface UserInfo {
email?: string; email?: string;
} }
export class AuthServiceError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
interface LoginResponse { interface LoginResponse {
token: string; token: string;
username: string; username: string;
@@ -82,7 +91,6 @@ export class AuthService {
// Try Tauri store first // Try Tauri store first
try { try {
const token = await invoke<string | null>('get_auth_token'); const token = await invoke<string | null>('get_auth_token');
if (token) { if (token) {
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`); console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
return token; 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] 🔐 Starting login to: ${serverUrl}`);
console.log(`[Desktop AuthService] Username: ${username}`); console.log(`[Desktop AuthService] Username: ${username}`);
@@ -246,6 +254,7 @@ export class AuthService {
serverUrl, serverUrl,
username, username,
password, password,
mfaCode,
supabaseKey: SUPABASE_KEY, supabaseKey: SUPABASE_KEY,
saasServerUrl: STIRLING_SAAS_URL, saasServerUrl: STIRLING_SAAS_URL,
}); });
@@ -286,8 +295,21 @@ export class AuthService {
console.error('[Desktop AuthService] ❌ Login failed:', error); console.error('[Desktop AuthService] ❌ Login failed:', error);
// Provide more detailed error messages based on the error type // Provide more detailed error messages based on the error type
if (error instanceof Error) { if (error instanceof Error || typeof error === 'string') {
const errMsg = error.message.toLowerCase(); 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 // Authentication errors
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) { if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
@@ -412,7 +434,6 @@ export class AuthService {
this.cachedToken = token; this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval'); console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
} }
return token; return token;
} catch (error) { } catch (error) {
console.error('[Desktop AuthService] Failed to get auth token:', error); console.error('[Desktop AuthService] Failed to get auth token:', error);
@@ -449,7 +470,7 @@ export class AuthService {
async refreshToken(serverUrl: string): Promise<boolean> { async refreshToken(serverUrl: string): Promise<boolean> {
try { try {
console.log('Refreshing auth token'); console.log('[Desktop AuthService] Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo); this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken(); const currentToken = await this.getAuthToken();
@@ -477,10 +498,10 @@ export class AuthService {
const userInfo = await this.getUserInfo(); const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo); this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully'); console.log('[Desktop AuthService] Token refreshed successfully');
return true; return true;
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('[Desktop AuthService] Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null); this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure // 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> { async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise<UserInfo> {
try { try {
console.log('Starting OAuth login with provider:', provider); console.log('[Desktop AuthService] Starting OAuth login with provider:', provider);
this.setAuthStatus('oauth_pending', null); this.setAuthStatus('oauth_pending', null);
// Validate Supabase key is configured for OAuth // Validate Supabase key is configured for OAuth
@@ -554,7 +575,7 @@ export class AuthService {
errorHtml, errorHtml,
}); });
console.log('OAuth authentication successful, storing tokens'); console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
// Save token to all storage locations // Save token to all storage locations
await this.saveTokenEverywhere(result.access_token); await this.saveTokenEverywhere(result.access_token);
@@ -569,11 +590,11 @@ export class AuthService {
}); });
this.setAuthStatus('authenticated', userInfo); this.setAuthStatus('authenticated', userInfo);
console.log('OAuth login successful'); console.log('[Desktop AuthService] OAuth login successful');
return userInfo; return userInfo;
} catch (error) { } catch (error) {
console.error('Failed to complete OAuth login:', error); console.error('[Desktop AuthService] Failed to complete OAuth login:', error);
this.setAuthStatus('unauthenticated', null); this.setAuthStatus('unauthenticated', null);
throw error; throw error;
} }
@@ -26,6 +26,7 @@ export interface TauriHttpRequestConfig {
// Custom properties for desktop // Custom properties for desktop
operationName?: string; operationName?: string;
skipBackendReadyCheck?: boolean; skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
// Axios compatibility properties (ignored by Tauri HTTP) // Axios compatibility properties (ignored by Tauri HTTP)
suppressErrorToast?: boolean; suppressErrorToast?: boolean;
cancelToken?: any; cancelToken?: any;
+2
View File
@@ -15,11 +15,13 @@ declare module 'assets/material-symbols-icons.json' {
declare module 'axios' { declare module 'axios' {
export interface AxiosRequestConfig<_D = unknown> { export interface AxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean; suppressErrorToast?: boolean;
skipAuthRedirect?: boolean;
skipBackendReadyCheck?: boolean; skipBackendReadyCheck?: boolean;
} }
export interface InternalAxiosRequestConfig<_D = unknown> { export interface InternalAxiosRequestConfig<_D = unknown> {
suppressErrorToast?: boolean; suppressErrorToast?: boolean;
skipAuthRedirect?: boolean;
skipBackendReadyCheck?: boolean; skipBackendReadyCheck?: boolean;
} }
} }
@@ -77,6 +77,8 @@ export interface Session {
export interface AuthError { export interface AuthError {
message: string; message: string;
status?: number; status?: number;
code?: string;
mfaRequired?: boolean;
} }
export interface AuthResponse { export interface AuthResponse {
@@ -181,11 +183,13 @@ class SpringAuthClient {
async signInWithPassword(credentials: { async signInWithPassword(credentials: {
email: string; email: string;
password: string; password: string;
mfaCode?: string;
}): Promise<AuthResponse> { }): Promise<AuthResponse> {
try { try {
const response = await apiClient.post('/api/v1/auth/login', { const response = await apiClient.post('/api/v1/auth/login', {
username: credentials.email, username: credentials.email,
password: credentials.password password: credentials.password,
mfaCode: credentials.mfaCode,
}, { }, {
withCredentials: true, // Include cookies for CSRF withCredentials: true, // Include cookies for CSRF
}); });
@@ -213,6 +217,24 @@ class SpringAuthClient {
return { user: data.user, session, error: null }; return { user: data.user, session, error: null };
} catch (error: unknown) { } catch (error: unknown) {
console.error('[SpringAuth] signInWithPassword error:', error); 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 { return {
user: null, user: null,
session: null, session: null,
@@ -58,6 +58,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: undefined as number | undefined, teamId: undefined as number | undefined,
authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2', authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2',
forceChange: false, forceChange: false,
forceMFA: false,
}); });
// Form state for email invite // Form state for email invite
@@ -141,6 +142,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: inviteForm.teamId, teamId: inviteForm.teamId,
authType: inviteForm.authType, authType: inviteForm.authType,
forceChange: inviteForm.forceChange, forceChange: inviteForm.forceChange,
forceMFA: inviteForm.forceMFA,
}); });
alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
onClose(); onClose();
@@ -153,6 +155,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: undefined, teamId: undefined,
authType: 'WEB', authType: 'WEB',
forceChange: false, forceChange: false,
forceMFA: false,
}); });
} catch (error: any) { } catch (error: any) {
console.error('Failed to invite user:', error); console.error('Failed to invite user:', error);
@@ -253,6 +256,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
teamId: undefined, teamId: undefined,
authType: 'WEB', authType: 'WEB',
forceChange: false, forceChange: false,
forceMFA: false,
}); });
setEmailInviteForm({ setEmailInviteForm({
emails: '', emails: '',
@@ -283,7 +287,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
handleInviteUser(); handleInviteUser();
} }
}; };
return ( return (
<Modal <Modal
opened={opened} opened={opened}
@@ -543,6 +547,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
checked={inviteForm.forceChange} checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })} 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 React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core'; import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon'; import LocalIcon from '@app/components/shared/LocalIcon';
import { alert as showToast } from '@app/components/toast'; import { alert as showToast } from '@app/components/toast';
import { useAuth } from '@app/auth/UseSession'; import { useAuth } from '@app/auth/UseSession';
import { accountService } from '@app/services/accountService'; import { accountService } from '@app/services/accountService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { QRCodeSVG } from 'qrcode.react';
import { useAccountLogout } from '@app/extensions/accountLogout'; 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 AccountSection: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -25,6 +28,17 @@ const AccountSection: React.FC = () => {
const [newUsername, setNewUsername] = useState(''); const [newUsername, setNewUsername] = useState('');
const [usernameError, setUsernameError] = useState(''); const [usernameError, setUsernameError] = useState('');
const [usernameSubmitting, setUsernameSubmitting] = useState(false); 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 authTypeFromMetadata = useMemo(() => {
const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined; 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) => { const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -183,6 +315,50 @@ const AccountSection: React.FC = () => {
</Stack> </Stack>
</Paper> </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 <Modal
opened={passwordModalOpen} opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)} onClose={() => setPasswordModalOpen(false)}
@@ -238,6 +414,124 @@ const AccountSection: React.FC = () => {
</form> </form>
</Modal> </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 <Modal
opened={usernameModalOpen} opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)} onClose={() => setUsernameModalOpen(false)}
@@ -261,7 +555,7 @@ const AccountSection: React.FC = () => {
label={t('changeCreds.newUsername', 'New Username')} label={t('changeCreds.newUsername', 'New Username')}
placeholder={t('changeCreds.newUsername', 'New Username')} placeholder={t('changeCreds.newUsername', 'New Username')}
value={newUsername} value={newUsername}
onChange={(event) => setNewUsername(event.currentTarget.value)} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
required required
/> />
@@ -269,7 +563,7 @@ const AccountSection: React.FC = () => {
label={t('changeCreds.oldPassword', 'Current Password')} label={t('changeCreds.oldPassword', 'Current Password')}
placeholder={t('changeCreds.oldPassword', 'Current Password')} placeholder={t('changeCreds.oldPassword', 'Current Password')}
value={currentPasswordForUsername} value={currentPasswordForUsername}
onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)} onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCurrentPasswordForUsername(event.currentTarget.value)}
required required
/> />
@@ -112,6 +112,7 @@ export default function PeopleSection() {
...user, ...user,
isActive: adminData.userSessions[user.username] || false, isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined, lastRequest: adminData.userLastRequest[user.username] || undefined,
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true',
})); }));
setUsers(enrichedUsers); setUsers(enrichedUsers);
@@ -200,7 +201,7 @@ export default function PeopleSection() {
}); });
} }
} catch (error) { } 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' }); alert({ alertType: 'error', title: 'Failed to load people data' });
} finally { } finally {
setLoading(false); setLoading(false);
@@ -221,7 +222,7 @@ export default function PeopleSection() {
closeEditModal(); closeEditModal();
fetchData(); fetchData();
} catch (error: any) { } catch (error: any) {
console.error('Failed to update user:', error); console.error('[PeopleSection] Failed to update user:', error);
const errorMessage = error.response?.data?.message || const errorMessage = error.response?.data?.message ||
error.response?.data?.error || error.response?.data?.error ||
error.message || error.message ||
@@ -238,7 +239,7 @@ export default function PeopleSection() {
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') }); alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
fetchData(); fetchData();
} catch (error: any) { } 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 || const errorMessage = error.response?.data?.message ||
error.response?.data?.error || error.response?.data?.error ||
error.message || error.message ||
@@ -258,7 +259,7 @@ export default function PeopleSection() {
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') }); alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
fetchData(); fetchData();
} catch (error: any) { } catch (error: any) {
console.error('Failed to delete user:', error); console.error('[PeopleSection] Failed to delete user:', error);
const errorMessage = error.response?.data?.message || const errorMessage = error.response?.data?.message ||
error.response?.data?.error || error.response?.data?.error ||
error.message || error.message ||
@@ -576,6 +577,7 @@ export default function PeopleSection() {
</Tooltip> </Tooltip>
{/* Actions menu */} {/* Actions menu */}
{!isCurrentUser(user) && (
<Menu position="bottom-end" withinPortal> <Menu position="bottom-end" withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon variant="subtle" disabled={!loginEnabled}> <ActionIcon variant="subtle" disabled={!loginEnabled}>
@@ -589,9 +591,10 @@ export default function PeopleSection() {
onClick={() => openEditModal(user)} onClick={() => openEditModal(user)}
disabled={!loginEnabled} disabled={!loginEnabled}
> >
{t('workspace.people.editRole')} {t('workspace.people.editRole', 'Edit Role & Team')}
</Menu.Item> </Menu.Item>
)} )}
{!isCurrentUser(user) && (
<Menu.Item <Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />} leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)} onClick={() => openChangePasswordModal(user)}
@@ -599,6 +602,7 @@ export default function PeopleSection() {
> >
{t('workspace.people.changePassword.action', 'Change password')} {t('workspace.people.changePassword.action', 'Change password')}
</Menu.Item> </Menu.Item>
)}
{!isCurrentUser(user) && ( {!isCurrentUser(user) && (
<Menu.Item <Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />} 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')} {user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
</Menu.Item> </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) && ( {!isCurrentUser(user) && (
<> <>
<Menu.Divider /> <Menu.Divider />
@@ -618,6 +647,7 @@ export default function PeopleSection() {
)} )}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
)}
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
+18 -1
View File
@@ -33,6 +33,8 @@ export default function Login() {
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState(() => searchParams.get('email') ?? ''); const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [requiresMfa, setRequiresMfa] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]); const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false); const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null); const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
@@ -273,6 +275,11 @@ export default function Login() {
return; return;
} }
if (requiresMfa && !mfaCode.trim()) {
setError(t('login.mfaRequired', 'Two-factor code required'));
return;
}
try { try {
setIsSigningIn(true); setIsSigningIn(true);
setError(null); setError(null);
@@ -281,14 +288,20 @@ export default function Login() {
const { user, session, error } = await springAuth.signInWithPassword({ const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(), email: email.trim(),
password: password password: password,
mfaCode: requiresMfa ? mfaCode.trim() : undefined,
}); });
if (error) { if (error) {
console.error('[Login] Email sign in error:', error); console.error('[Login] Email sign in error:', error);
setError(error.message); setError(error.message);
if (error.mfaRequired || error.code === 'invalid_mfa_code') {
setRequiresMfa(true);
}
} else if (user && session) { } else if (user && session) {
console.log('[Login] Email sign in successful'); console.log('[Login] Email sign in successful');
setRequiresMfa(false);
setMfaCode('');
// Auth state will update automatically and Landing will redirect to home // Auth state will update automatically and Landing will redirect to home
// No need to navigate manually here // No need to navigate manually here
} }
@@ -362,6 +375,10 @@ export default function Login() {
password={password} password={password}
setEmail={setEmail} setEmail={setEmail}
setPassword={setPassword} setPassword={setPassword}
mfaCode={mfaCode}
setMfaCode={setMfaCode}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={signInWithEmail} onSubmit={signInWithEmail}
isSubmitting={isSigningIn} isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')} submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')}
@@ -22,6 +22,10 @@ interface EmailPasswordFormProps {
password: string password: string
setEmail: (email: string) => void setEmail: (email: string) => void
setPassword: (password: string) => void setPassword: (password: string) => void
mfaCode?: string
setMfaCode?: (code: string) => void
showMfaField?: boolean
requiresMfa?: boolean
onSubmit: () => void onSubmit: () => void
isSubmitting: boolean isSubmitting: boolean
submitButtonText: string submitButtonText: string
@@ -29,6 +33,7 @@ interface EmailPasswordFormProps {
fieldErrors?: { fieldErrors?: {
email?: string email?: string
password?: string password?: string
mfaCode?: string
} }
} }
@@ -37,6 +42,10 @@ export default function EmailPasswordForm({
password, password,
setEmail, setEmail,
setPassword, setPassword,
mfaCode = '',
setMfaCode,
showMfaField = false,
requiresMfa = false,
onSubmit, onSubmit,
isSubmitting, isSubmitting,
submitButtonText, submitButtonText,
@@ -66,6 +75,7 @@ export default function EmailPasswordForm({
error={fieldErrors.email} error={fieldErrors.email}
classNames={{ label: 'auth-label' }} classNames={{ label: 'auth-label' }}
styles={authInputStyles} styles={authInputStyles}
autoFocus
/> />
</div> </div>
@@ -85,11 +95,32 @@ export default function EmailPasswordForm({
/> />
</div> </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> </div>
<Button <Button
type="submit" type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password)} disabled={isSubmitting || !email || (showPasswordField && !password) || (requiresMfa && !mfaCode.trim())}
className="auth-button" className="auth-button"
fullWidth fullWidth
loading={isSubmitting} loading={isSubmitting}
@@ -18,6 +18,7 @@ export interface User {
// Enriched client-side fields // Enriched client-side fields
isActive?: boolean; isActive?: boolean;
lastRequest?: number; // timestamp in milliseconds lastRequest?: number; // timestamp in milliseconds
mfaEnabled?: boolean; // whether MFA is enabled for the user
} }
export interface AdminSettingsData { export interface AdminSettingsData {
@@ -38,6 +39,7 @@ export interface AdminSettingsData {
licenseMaxUsers: number; licenseMaxUsers: number;
premiumEnabled: boolean; premiumEnabled: boolean;
mailEnabled: boolean; mailEnabled: boolean;
userSettings?: Record<string, any>;
} }
export interface CreateUserRequest { export interface CreateUserRequest {
@@ -47,6 +49,7 @@ export interface CreateUserRequest {
teamId?: number; teamId?: number;
authType: 'WEB' | 'OAUTH2' | 'SAML2'; authType: 'WEB' | 'OAUTH2' | 'SAML2';
forceChange?: boolean; forceChange?: boolean;
forceMFA?: boolean;
} }
export interface UpdateUserRoleRequest { export interface UpdateUserRoleRequest {
@@ -145,6 +148,9 @@ export const userManagementService = {
if (data.forceChange !== undefined) { if (data.forceChange !== undefined) {
formData.append('forceChange', data.forceChange.toString()); 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, { await apiClient.post('/api/v1/user/admin/saveUser', formData, {
suppressErrorToast: true, // Component will handle error display suppressErrorToast: true, // Component will handle error display
} as any); } as any);
@@ -291,4 +297,12 @@ export const userManagementService = {
suppressErrorToast: true, // Component will handle error display suppressErrorToast: true, // Component will handle error display
} as any); } 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);
},
}; };