From 0b86dd79d35555a986fba9612b6b4bf8256c88cd Mon Sep 17 00:00:00 2001 From: Ludy Date: Fri, 23 Jan 2026 22:34:57 +0100 Subject: [PATCH] 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 <175728472+Copilot@users.noreply.github.com> --- .../api/ProprietaryUIDataController.java | 48 ++- .../security/InitialSecuritySetup.java | 35 +- .../controller/api/AuthController.java | 276 +++++++++++++- .../controller/api/InviteLinkController.java | 20 +- .../controller/api/UserController.java | 112 ++++-- .../database/repository/UserRepository.java | 16 + .../model/api/user/MfaCodeRequest.java | 12 + .../model/api/user/UsernameAndPassMfa.java | 14 + .../security/service/DatabaseService.java | 8 +- .../security/service/MfaService.java | 247 +++++++++++++ .../security/service/SaveUserRequest.java | 50 +++ .../security/service/TotpService.java | 159 ++++++++ .../security/service/UserService.java | 261 +++---------- .../security/util/Base32Codec.java | 96 +++++ .../api/ProprietaryUIDataControllerTest.java | 137 +++++++ .../security/InitialSecuritySetupTest.java | 115 ++++++ .../api/AuthControllerLoginTest.java | 234 ++++++++++++ .../controller/api/AuthControllerMfaTest.java | 218 +++++++++++ .../api/InviteLinkControllerTest.java | 162 ++++++++ .../controller/api/UserControllerTest.java | 140 +++++++ .../security/service/DatabaseServiceTest.java | 131 +++++++ .../security/service/MfaServiceTest.java | 146 ++++++++ .../security/service/TotpServiceTest.java | 96 +++++ .../security/service/UserServiceTest.java | 348 ++++++------------ .../security/util/Base32CodecTest.java | 45 +++ .../public/locales/en-GB/translation.toml | 38 ++ frontend/src-tauri/src/commands/auth.rs | 43 ++- frontend/src/core/auth/UseSession.tsx | 23 ++ .../InitialOnboardingModal.module.css | 51 +++ .../core/components/onboarding/Onboarding.tsx | 113 +++++- .../onboarding/onboardingFlowConfig.ts | 11 +- .../orchestrator/onboardingConfig.ts | 14 +- .../orchestrator/useOnboardingOrchestrator.ts | 26 +- .../onboarding/slides/FirstLoginSlide.tsx | 12 +- .../onboarding/slides/MFASetupSlide.tsx | 221 +++++++++++ .../components/shared/FirstLoginModal.tsx | 6 +- frontend/src/core/extensions/accountLogout.ts | 20 + .../src/core/responses/Mfa/MfaResponse.ts | 24 ++ frontend/src/core/services/accountService.ts | 24 +- .../SetupWizard/SelfHostedLoginScreen.tsx | 18 + .../desktop/components/SetupWizard/index.tsx | 33 +- .../src/desktop/services/apiClientSetup.ts | 3 + frontend/src/desktop/services/authService.ts | 45 ++- .../src/desktop/services/tauriHttpClient.ts | 1 + frontend/src/global.d.ts | 2 + .../src/proprietary/auth/springAuthClient.ts | 24 +- .../components/shared/InviteMembersModal.tsx | 11 +- .../config/configSections/AccountSection.tsx | 302 ++++++++++++++- .../config/configSections/PeopleSection.tsx | 40 +- frontend/src/proprietary/routes/Login.tsx | 19 +- .../routes/login/EmailPasswordForm.tsx | 33 +- .../services/userManagementService.ts | 14 + 52 files changed, 3739 insertions(+), 558 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/MfaServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/TotpServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/util/Base32CodecTest.java create mode 100644 frontend/src/core/auth/UseSession.tsx create mode 100644 frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx create mode 100644 frontend/src/core/extensions/accountLogout.ts create mode 100644 frontend/src/core/responses/Mfa/MfaResponse.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index ee07879b5..dbf68eba1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -49,6 +49,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.DatabaseService; +import stirling.software.proprietary.security.service.MfaService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; import stirling.software.proprietary.service.UserLicenseSettingsService; @@ -68,6 +69,7 @@ public class ProprietaryUIDataController { private final ObjectMapper objectMapper; private final UserLicenseSettingsService licenseSettingsService; private final PersistentAuditEventRepository auditRepository; + private final MfaService mfaService; public ProprietaryUIDataController( ApplicationProperties applicationProperties, @@ -80,7 +82,8 @@ public class ProprietaryUIDataController { ObjectMapper objectMapper, @Qualifier("runningEE") boolean runningEE, UserLicenseSettingsService licenseSettingsService, - PersistentAuditEventRepository auditRepository) { + PersistentAuditEventRepository auditRepository, + MfaService mfaService) { this.applicationProperties = applicationProperties; this.auditConfig = auditConfig; this.sessionPersistentRegistry = sessionPersistentRegistry; @@ -92,6 +95,7 @@ public class ProprietaryUIDataController { this.runningEE = runningEE; this.licenseSettingsService = licenseSettingsService; this.auditRepository = auditRepository; + this.mfaService = mfaService; } /** @@ -245,12 +249,14 @@ public class ProprietaryUIDataController { Map userSessions = new HashMap<>(); Map userLastRequest = new HashMap<>(); + Map> userSettings = new HashMap<>(); int activeUsers = 0; int disabledUsers = 0; while (iterator.hasNext()) { User user = iterator.next(); if (user != null) { + String username = user.getUsername(); boolean shouldRemove = false; // Check if user is an INTERNAL_API_USER @@ -264,7 +270,7 @@ public class ProprietaryUIDataController { // Check if user is part of the Internal team if (user.getTeam() != null - && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { shouldRemove = true; } @@ -278,7 +284,7 @@ public class ProprietaryUIDataController { boolean hasActiveSession = false; Date lastRequest = null; Optional latestSession = - sessionPersistentRegistry.findLatestSession(user.getUsername()); + sessionPersistentRegistry.findLatestSession(username); if (latestSession.isPresent()) { SessionEntity sessionEntity = latestSession.get(); @@ -299,8 +305,21 @@ public class ProprietaryUIDataController { lastRequest = new Date(0); } - userSessions.put(user.getUsername(), hasActiveSession); - userLastRequest.put(user.getUsername(), lastRequest); + User userWithSettings = + userRepository.findByIdWithSettings(user.getId()).orElse(user); + + // Mask mfaSecret if present in settings + Map originalSettings = userWithSettings.getSettings(); + Map settingsCopy = + originalSettings != null + ? new HashMap<>(originalSettings) + : new HashMap<>(); + if (settingsCopy.containsKey("mfaSecret")) { + settingsCopy.put("mfaSecret", "********"); + } + userSettings.put(username, settingsCopy); + userSessions.put(username, hasActiveSession); + userLastRequest.put(username, lastRequest); if (hasActiveSession) activeUsers++; if (!user.isEnabled()) disabledUsers++; @@ -329,7 +348,7 @@ public class ProprietaryUIDataController { List allTeams = teamRepository.findAll().stream() - .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) + .filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) .toList(); // Calculate license limits @@ -356,6 +375,7 @@ public class ProprietaryUIDataController { data.setLicenseMaxUsers(licenseMaxUsers); data.setPremiumEnabled(premiumEnabled); data.setMailEnabled(applicationProperties.getMail().isEnabled()); + data.setUserSettings(userSettings); return ResponseEntity.ok(data); } @@ -407,6 +427,8 @@ public class ProprietaryUIDataController { data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange()); data.setOAuth2Login(isOAuth2Login); data.setSaml2Login(isSaml2Login); + data.setMfaEnabled(mfaService.isMfaEnabled(user.get())); + data.setMfaRequired(mfaService.isMfaRequired(user.get())); return ResponseEntity.ok(data); } @@ -418,7 +440,7 @@ public class ProprietaryUIDataController { List allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount(); List teamsWithCounts = allTeamsWithCounts.stream() - .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) + .filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) .toList(); List teamActivities = sessionRepository.findLatestActivityByTeam(); @@ -446,7 +468,7 @@ public class ProprietaryUIDataController { .findById(id) .orElseThrow(() -> new RuntimeException("Team not found")); - if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) { return ResponseEntity.status(403).build(); } @@ -459,11 +481,8 @@ public class ProprietaryUIDataController { (user.getTeam() == null || !user.getTeam().getId().equals(id)) && (user.getTeam() == null - || !user.getTeam() - .getName() - .equals( - TeamService - .INTERNAL_TEAM_NAME))) + || !TeamService.INTERNAL_TEAM_NAME.equals( + user.getTeam().getName()))) .toList(); List userSessions = sessionRepository.findLatestSessionByTeamId(id); @@ -541,6 +560,7 @@ public class ProprietaryUIDataController { private int licenseMaxUsers; private boolean premiumEnabled; private boolean mailEnabled; + private Map> userSettings; } @Data @@ -551,6 +571,8 @@ public class ProprietaryUIDataController { private boolean changeCredsFlag; private boolean oAuth2Login; private boolean saml2Login; + private boolean mfaEnabled; + private boolean mfaRequired; } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index bdf87c458..e0dabced4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -19,6 +19,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; +import stirling.software.proprietary.security.service.SaveUserRequest; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @@ -113,8 +114,14 @@ public class InitialSecuritySetup { && userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) { Team team = teamService.getOrCreateDefaultTeam(); - userService.saveUser( - initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false); + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(initialUsername) + .password(initialPassword) + .team(team) + .role(Role.ADMIN.getRoleId()) + .firstLogin(false); + userService.saveUserCore(builder.build()); log.info("Admin user created: {}", initialUsername); } else { createDefaultAdminUser(); @@ -127,8 +134,14 @@ public class InitialSecuritySetup { if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) { Team team = teamService.getOrCreateDefaultTeam(); - userService.saveUser( - defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true); + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(defaultUsername) + .password(defaultPassword) + .team(team) + .role(Role.ADMIN.getRoleId()) + .firstLogin(true); + userService.saveUserCore(builder.build()); log.info("Default admin user created: {}", defaultUsername); } } @@ -137,12 +150,14 @@ public class InitialSecuritySetup { throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) { Team team = teamService.getOrCreateInternalTeam(); - userService.saveUser( - Role.INTERNAL_API_USER.getRoleId(), - UUID.randomUUID().toString(), - team, - Role.INTERNAL_API_USER.getRoleId(), - false); + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(Role.INTERNAL_API_USER.getRoleId()) + .password(UUID.randomUUID().toString()) + .team(team) + .role(Role.INTERNAL_API_USER.getRoleId()) + .firstLogin(false); + userService.saveUserCore(builder.build()); userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId()); log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId()); } else { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index e2e62ccf1..8c5e52dfe 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -11,7 +11,12 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.tags.Tag; @@ -27,10 +32,13 @@ import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.model.api.user.UsernameAndPass; +import stirling.software.proprietary.security.model.api.user.MfaCodeRequest; +import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.service.TotpService; import stirling.software.proprietary.security.service.UserService; /** REST API Controller for authentication operations. */ @@ -45,6 +53,8 @@ public class AuthController { private final JwtServiceInterface jwtService; private final CustomUserDetailsService userDetailsService; private final LoginAttemptService loginAttemptService; + private final MfaService mfaService; + private final TotpService totpService; private final ApplicationProperties.Security securityProperties; /** @@ -58,7 +68,7 @@ public class AuthController { @PostMapping("/login") @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public ResponseEntity login( - @RequestBody UsernameAndPass request, + @RequestBody UsernameAndPassMfa request, HttpServletRequest httpRequest, HttpServletResponse response) { try { @@ -116,6 +126,47 @@ public class AuthController { .body(Map.of("error", "User account is disabled")); } + if (mfaService.isMfaEnabled(user)) { + String code = request.getMfaCode(); + if (code == null || code.isBlank()) { + log.warn( + "MFA required but no code provided for user: {} from IP: {}", + username, + ip); + // loginAttemptService.loginFailed(username); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body( + Map.of( + "error", "mfa_required", + "message", "Two-factor code required")); + } + String secret = mfaService.getSecret(user); + if (secret == null || secret.isBlank()) { + log.error("MFA enabled but no secret stored for user: {}", username); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "MFA configuration error")); + } + Long timeStep = totpService.getValidTimeStep(secret, code); + if (timeStep == null) { + log.warn("Invalid MFA code for user: {} from IP: {}", username, ip); + loginAttemptService.loginFailed(username); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body( + Map.of( + "error", "invalid_mfa_code", + "message", "Invalid two-factor code")); + } + if (!mfaService.markTotpStepUsed(user, timeStep)) { + log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip); + loginAttemptService.loginFailed(username); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body( + Map.of( + "error", "invalid_mfa_code", + "message", "Invalid two-factor code")); + } + } + Map claims = new HashMap<>(); claims.put("authType", AuthenticationType.WEB.toString()); claims.put("role", user.getRolesAsString()); @@ -163,7 +214,7 @@ public class AuthController { if (auth == null || !auth.isAuthenticated() - || auth.getPrincipal().equals("anonymousUser")) { + || "anonymousUser".equals(auth.getPrincipal())) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("error", "Not authenticated")); } @@ -244,6 +295,210 @@ public class AuthController { } } + @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") + @GetMapping("/mfa/setup") + public ResponseEntity setupMfa(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } + + String username = authentication.getName(); + User user = + userService + .findByUsernameIgnoreCaseWithSettings(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + ResponseEntity authTypeResponse = ensureWebAuth(user); + if (authTypeResponse != null) { + return authTypeResponse; + } + + if (mfaService.isMfaEnabled(user)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "MFA already enabled")); + } + + try { + String secret = totpService.generateSecret(); + mfaService.setSecret(user, secret); + String otpAuthUri = totpService.buildOtpAuthUri(username, secret); + + return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri)); + } catch (Exception e) { + log.error("Failed to setup MFA for user: {}", username, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to setup MFA")); + } + } + + @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/mfa/enable") + public ResponseEntity enableMfa( + @RequestBody MfaCodeRequest request, Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } + + String username = authentication.getName(); + User user = + userService + .findByUsernameIgnoreCaseWithSettings(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + ResponseEntity authTypeResponse = ensureWebAuth(user); + if (authTypeResponse != null) { + return authTypeResponse; + } + + String secret = mfaService.getSecret(user); + if (secret == null || secret.isBlank()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "MFA setup required")); + } + + if (request == null || request.getCode() == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "MFA code is required")); + } + + Long timeStep = totpService.getValidTimeStep(secret, request.getCode()); + if (timeStep == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid two-factor code")); + } + + try { + if (!mfaService.isTotpStepUsable(user, timeStep)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid two-factor code")); + } + mfaService.enableMfa(user); + mfaService.markTotpStepUsed(user, timeStep); + mfaService.setMfaRequired(user, false); + return ResponseEntity.ok(Map.of("enabled", true)); + } catch (Exception e) { + log.error("Failed to enable MFA for user: {}", username, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to enable MFA")); + } + } + + @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/mfa/disable") + public ResponseEntity disableMfa( + @RequestBody MfaCodeRequest request, Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } + + String username = authentication.getName(); + User user = + userService + .findByUsernameIgnoreCaseWithSettings(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + ResponseEntity authTypeResponse = ensureWebAuth(user); + if (authTypeResponse != null) { + return authTypeResponse; + } + + if (!mfaService.isMfaEnabled(user)) { + return ResponseEntity.ok(Map.of("enabled", false)); + } + + String secret = mfaService.getSecret(user); + if (secret == null || secret.isBlank()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "MFA configuration missing")); + } + + if (request == null || request.getCode() == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "MFA code is required")); + } + + Long timeStep = totpService.getValidTimeStep(secret, request.getCode()); + if (timeStep == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid two-factor code")); + } + + try { + if (!mfaService.isTotpStepUsable(user, timeStep)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid two-factor code")); + } + mfaService.disableMfa(user); + mfaService.markTotpStepUsed(user, timeStep); + return ResponseEntity.ok(Map.of("enabled", false)); + } catch (Exception e) { + log.error("Failed to disable MFA for user: {}", username, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to disable MFA")); + } + } + + @PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/mfa/setup/cancel") + public ResponseEntity cancelMfaSetup(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } + + String username = authentication.getName(); + User user = + userService + .findByUsernameIgnoreCaseWithSettings(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + if (mfaService.isMfaEnabled(user)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "MFA already enabled")); + } + + try { + mfaService.clearPendingSecret(user); + return ResponseEntity.ok(Map.of("cleared", true)); + } catch (Exception e) { + log.error("Failed to clear MFA setup for user: {}", username, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to clear MFA setup")); + } + } + + /** + * Admin endpoint to disable MFA for a user + * + * @param username Username of the user to disable MFA for + * @return Response indicating success or failure + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/mfa/disable/admin/{username}") + public ResponseEntity disableMfaByAdmin(@PathVariable String username) { + try { + User user = + userService + .findByUsernameIgnoreCaseWithSettings(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + if (!mfaService.isMfaEnabled(user)) { + return ResponseEntity.ok(Map.of("enabled", false)); + } + + mfaService.disableMfa(user); + return ResponseEntity.ok(Map.of("enabled", false)); + } catch (UsernameNotFoundException e) { + log.warn("User not found for MFA disable: {}", username); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found")); + } catch (Exception e) { + log.error("Failed to disable MFA for user: {}", username, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to disable MFA")); + } + } + /** * Helper method to build user response object * @@ -266,6 +521,19 @@ public class AuthController { appMetadata.put("provider", user.getAuthenticationType()); userMap.put("app_metadata", appMetadata); + // Add user metadata + Map userMetadata = new HashMap<>(); + userMetadata.put("firstLogin", user.isFirstLogin()); + userMap.put("user_metadata", userMetadata); + return userMap; } + + private ResponseEntity ensureWebAuth(User user) { + if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "MFA settings are only available for web accounts")); + } + return null; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java index ceddc7a58..58c40cdb0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -23,6 +23,7 @@ import stirling.software.proprietary.security.model.InviteToken; import stirling.software.proprietary.security.repository.InviteTokenRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.security.service.SaveUserRequest; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; @@ -90,7 +91,8 @@ public class InviteLinkController { .body( Map.of( "error", - "An active invite already exists for this email address")); + "An active invite already exists for this email" + + " address")); } // If sendEmail is requested but no email provided, reject @@ -123,7 +125,8 @@ public class InviteLinkController { + (currentUserCount + activeInvites) + "/" + maxUsers - + " users). Contact your administrator to upgrade your license.")); + + " users). Contact your administrator to" + + " upgrade your license.")); } } @@ -457,12 +460,13 @@ public class InviteLinkController { } // Create the user account - userService.saveUser( - effectiveEmail, - password, - invite.getTeamId(), - invite.getRole(), - false); // Don't force password change + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(effectiveEmail) + .password(password) + .teamId(invite.getTeamId()) + .role(invite.getRole()); + userService.saveUserCore(builder.build()); // Mark invite as used invite.setUsed(true); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index b4dff413d..3687029ba 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -18,7 +18,10 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import jakarta.mail.MessagingException; import jakarta.servlet.http.HttpServletRequest; @@ -43,6 +46,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.security.service.SaveUserRequest; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -66,26 +70,24 @@ public class UserController { @PostMapping("/register") public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) throws SQLException, UnsupportedProviderException { + String username = usernameAndPass.getUsername(); + String password = usernameAndPass.getPassword(); try { - log.debug("Registration attempt for user: {}", usernameAndPass.getUsername()); + log.debug("Registration attempt for user: {}", username); - if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) { - log.warn( - "Registration failed: username already exists: {}", - usernameAndPass.getUsername()); + if (userService.usernameExistsIgnoreCase(username)) { + log.warn("Registration failed: username already exists: {}", username); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "User already exists")); } - if (!userService.isUsernameValid(usernameAndPass.getUsername())) { - log.warn( - "Registration failed: invalid username format: {}", - usernameAndPass.getUsername()); + if (!userService.isUsernameValid(username)) { + log.warn("Registration failed: invalid username format: {}", username); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "Invalid username format")); } - if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) { + if (password == null || password.isEmpty()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "Password is required")); } @@ -102,17 +104,16 @@ public class UserController { + ", Available slots: " + availableSlots)); } - Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); - User user = - userService.saveUser( - usernameAndPass.getUsername(), - usernameAndPass.getPassword(), - team, - Role.USER.getRoleId(), - false); + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(username) + .password(password) + .team(team) + .enabled(false); + User user = userService.saveUserCore(builder.build()); - log.info("User registered successfully: {}", usernameAndPass.getUsername()); + log.info("User registered successfully: {}", username); return ResponseEntity.status(HttpStatus.CREATED) .body( @@ -127,7 +128,7 @@ public class UserController { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", e.getMessage())); } catch (Exception e) { - log.error("Registration error for user: {}", usernameAndPass.getUsername(), e); + log.error("Registration error for user: {}", username, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Map.of("error", "Registration failed: " + e.getMessage())); } @@ -222,6 +223,7 @@ public class UserController { Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "newPassword") String newPassword, + @RequestParam(name = "confirmPassword") String confirmPassword, HttpServletRequest request, HttpServletResponse response) throws SQLException, UnsupportedProviderException { @@ -234,6 +236,43 @@ public class UserController { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "userNotFound", "message", "User not found")); } + + if (currentPassword == null + || currentPassword.isEmpty() + || newPassword == null + || newPassword.isEmpty() + || confirmPassword == null + || confirmPassword.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "missingParameters", + "message", + "Current password, new password, and confirmation are" + + " required")); + } + + if (!newPassword.equals(confirmPassword)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "passwordMismatch", + "message", + "New password and confirmation do not match")); + } + + if (newPassword.equals(currentPassword)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "passwordUnchanged", + "message", + "New password must be different from the current password")); + } + User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -326,7 +365,9 @@ public class UserController { @RequestParam(name = "teamId", required = false) Long teamId, @RequestParam(name = "authType") String authType, @RequestParam(name = "forceChange", required = false, defaultValue = "false") - boolean forceChange) + boolean forceChange, + @RequestParam(name = "forceMFA", required = false, defaultValue = "false") + boolean forceMFA) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(username)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -348,8 +389,9 @@ public class UserController { + availableSlots)); } Optional userOpt = userService.findByUsernameIgnoreCase(username); + User user = null; if (userOpt.isPresent()) { - User user = userOpt.get(); + user = userOpt.get(); if (user.getUsername().equalsIgnoreCase(username)) { return ResponseEntity.status(HttpStatus.CONFLICT) .body(Map.of("error", "Username already exists.")); @@ -391,6 +433,9 @@ public class UserController { } } + SaveUserRequest.Builder builder = + SaveUserRequest.builder().username(username).teamId(effectiveTeamId).role(role); + AuthenticationType requestedAuthType; if ("SSO".equalsIgnoreCase(authType)) { requestedAuthType = AuthenticationType.OAUTH2; @@ -402,6 +447,7 @@ public class UserController { .body(Map.of("error", "Invalid authentication type specified.")); } } + builder.authenticationType(requestedAuthType); if (requestedAuthType == AuthenticationType.WEB) { if (password == null || password.isBlank()) { @@ -412,10 +458,9 @@ public class UserController { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "Password must be at least 6 characters.")); } - userService.saveUser(username, password, effectiveTeamId, role, forceChange); - } else { - userService.saveUser(username, requestedAuthType, effectiveTeamId, role); + builder.password(password).firstLogin(forceChange).requireMfa(forceMFA); } + userService.saveUserCore(builder.build()); return ResponseEntity.ok(Map.of("message", "User created successfully")); } @@ -440,7 +485,8 @@ public class UserController { .body( Map.of( "error", - "Email service is not configured. Please configure SMTP settings.")); + "Email service is not configured. Please configure SMTP" + + " settings.")); } // Parse comma-separated email addresses @@ -658,7 +704,8 @@ public class UserController { .body( Map.of( "error", - "User's email is not a valid email address. Notifications are disabled.")); + "User's email is not a valid email address. Notifications" + + " are disabled.")); } String loginUrl = buildLoginUrl(request); @@ -839,7 +886,14 @@ public class UserController { String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12); // Create user with forceChange=true - userService.saveUser(email, temporaryPassword, teamId, role, true); + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(email) + .password(temporaryPassword) + .teamId(teamId) + .role(role) + .firstLogin(true); + userService.saveUserCore(builder.build()); // Send invite email try { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 312c19964..2e8343b66 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -18,6 +19,9 @@ public interface UserRepository extends JpaRepository { @Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)") Optional findByUsernameIgnoreCaseWithSettings(@Param("username") String username); + @Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id") + Optional findByIdWithSettings(@Param("id") Long id); + Optional findByUsername(String username); Optional findByApiKey(String apiKey); @@ -76,4 +80,16 @@ public interface UserRepository extends JpaRepository { "SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL " + "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')") long countSsoUsers(); + + @Query( + "SELECT COUNT(u) FROM User u JOIN u.settings settings " + + "WHERE KEY(settings) = :key AND settings = :value") + long countUsersBySetting(@Param("key") String key, @Param("value") String value); + + @Modifying + @Query( + value = "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN (:keys)", + nativeQuery = true) + void deleteSettingsByUserIdAndKeys( + @Param("userId") Long userId, @Param("keys") List keys); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java new file mode 100644 index 000000000..c002efc6a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java @@ -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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java new file mode 100644 index 000000000..21d433637 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java @@ -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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java index a5755edf6..88165b022 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java @@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; import javax.sql.DataSource; @@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface { String checksum = bytesToHex(digest.digest(content)); log.info("Checksum for {}: {}", backupPath.getFileName(), checksum); - try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify"); + String verifyDbUrl = "jdbc:h2:mem:backupVerify_" + UUID.randomUUID(); + // Use a fresh in-memory database per verification to avoid leftover objects between + // runs. + try (Connection conn = DriverManager.getConnection(verifyDbUrl); PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) { stmt.setString(1, backupPath.toString()); stmt.execute(); } return true; } catch (IOException | NoSuchAlgorithmException | SQLException e) { - log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e); + log.error("Backup verification failed for {}: {}", backupPath, e.getMessage()); } return false; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java new file mode 100644 index 000000000..f8a558ecf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java @@ -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. + * + *

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 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 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 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 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 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 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 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 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 ensureSettings(User user) { + Map 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(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java new file mode 100644 index 000000000..b20cf88dd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java @@ -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. + * + *

Defaults: + * + *

    + *
  • password: null + *
  • ssoProviderId: null + *
  • ssoProvider: null + *
  • authenticationType: {@code AuthenticationType.WEB} + *
  • teamId: null + *
  • team: null + *
  • role: {@code Role.USER.getRoleId()} + *
  • firstLogin: false + *
  • enabled: true + *
  • requireMfa: false + *
  • mfaEnabled: false + *
  • mfaSecret: null + *
  • mfaLastUsedStep: null + *
+ */ +@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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java new file mode 100644 index 000000000..7d64de445 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java @@ -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. + * + *

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); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 59a19c1e7..f2bc3cc2c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -1,5 +1,10 @@ package stirling.software.proprietary.security.service; +import static stirling.software.proprietary.security.service.MfaService.MFA_ENABLED_KEY; +import static stirling.software.proprietary.security.service.MfaService.MFA_LAST_USED_STEP_KEY; +import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY; +import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY; + import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; @@ -101,7 +106,13 @@ public class UserService implements UserServiceInterface { } if (autoCreateUser) { - saveUser(username, ssoProviderId, ssoProvider, type); + SaveUserRequest.Builder builder = + SaveUserRequest.builder() + .username(username) + .ssoProviderId(ssoProviderId) + .ssoProvider(ssoProvider) + .authenticationType(type); + saveUserCore(builder.build()); } } @@ -141,6 +152,14 @@ public class UserService implements UserServiceInterface { return user; } + private User saveUser(Optional user, String apiKey) { + if (user.isPresent()) { + user.get().setApiKey(apiKey); + return userRepository.save(user.get()); + } + throw new UsernameNotFoundException("User not found"); + } + public User refreshApiKeyForUser(String username) { // reuse the add API key method for refreshing return addApiKeyToUser(username); @@ -179,162 +198,6 @@ public class UserService implements UserServiceInterface { return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey()); } - public void saveUser(String username, AuthenticationType authenticationType) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId()); - } - - public void saveUser( - String username, - String ssoProviderId, - String ssoProvider, - AuthenticationType authenticationType) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser( - username, - ssoProviderId, - ssoProvider, - authenticationType, - (Long) null, - Role.USER.getRoleId()); - } - - private User saveUser(Optional user, String apiKey) { - if (user.isPresent()) { - user.get().setApiKey(apiKey); - return userRepository.save(user.get()); - } - throw new UsernameNotFoundException("User not found"); - } - - public User saveUser( - String username, AuthenticationType authenticationType, Long teamId, String role) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - return saveUserCore( - username, // username - null, // password - null, // ssoProviderId - null, // ssoProvider - authenticationType, // authenticationType - teamId, // teamId - null, // team - role, // role - false, // firstLogin - true // enabled - ); - } - - public User saveUser( - String username, - String ssoProviderId, - String ssoProvider, - AuthenticationType authenticationType, - Long teamId, - String role) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - return saveUserCore( - username, // username - null, // password - ssoProviderId, // ssoProviderId - ssoProvider, // ssoProvider - authenticationType, // authenticationType - teamId, // teamId - null, // team - role, // role - false, // firstLogin - true // enabled - ); - } - - public User saveUser( - String username, AuthenticationType authenticationType, Team team, String role) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - return saveUserCore( - username, // username - null, // password - null, // ssoProviderId - null, // ssoProvider - authenticationType, // authenticationType - null, // teamId - team, // team - role, // role - false, // firstLogin - true // enabled - ); - } - - public User saveUser(String username, String password, Long teamId) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - return saveUserCore( - username, // username - password, // password - null, // ssoProviderId - null, // ssoProvider - AuthenticationType.WEB, // authenticationType - teamId, // teamId - null, // team - Role.USER.getRoleId(), // role - false, // firstLogin - true // enabled - ); - } - - public User saveUser( - String username, String password, Team team, String role, boolean firstLogin) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - return saveUserCore( - username, // username - password, // password - null, // ssoProviderId - null, // ssoProvider - AuthenticationType.WEB, // authenticationType - null, // teamId - team, // team - role, // role - firstLogin, // firstLogin - true // enabled - ); - } - - public User saveUser( - String username, String password, Long teamId, String role, boolean firstLogin) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - return saveUserCore( - username, // username - password, // password - null, // ssoProviderId - null, // ssoProvider - AuthenticationType.WEB, // authenticationType - teamId, // teamId - null, // team - role, // role - firstLogin, // firstLogin - true // enabled - ); - } - - public void saveUser(String username, String password, Long teamId, String role) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUser(username, password, teamId, role, false); - } - - public void saveUser( - String username, String password, Long teamId, boolean firstLogin, boolean enabled) - throws IllegalArgumentException, SQLException, UnsupportedProviderException { - saveUserCore( - username, // username - password, // password - null, // ssoProviderId - null, // ssoProvider - AuthenticationType.WEB, // authenticationType - teamId, // teamId - null, // team - Role.USER.getRoleId(), // role - firstLogin, // firstLogin - enabled // enabled - ); - } - public void deleteUser(String username) { Optional userOpt = findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { @@ -485,75 +348,71 @@ public class UserService implements UserServiceInterface { } /** - * Core implementation for saving a user with all possible parameters. This method centralizes - * the common logic for all saveUser variants. + * Core method to save a user based on the provided SaveUserRequest. * - * @param username Username for the new user - * @param password Password for the user (may be null for SSO/OAuth users) - * @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users) - * @param ssoProvider Name of the SSO provider (may be null for non-SSO users) - * @param authenticationType Type of authentication (WEB, SSO, etc.) - * @param teamId ID of the team to assign (may be null to use default) - * @param team Team object to assign (takes precedence over teamId if both provided) - * @param role Role to assign to the user - * @param firstLogin Whether this is the user's first login - * @param enabled Whether the user account is enabled + * @param request The SaveUserRequest containing user details * @return The saved User object - * @throws IllegalArgumentException If username is invalid or team is invalid - * @throws SQLException If database operation fails - * @throws UnsupportedProviderException If provider is not supported + * @throws IllegalArgumentException If the username is invalid + * @throws SQLException If a database error occurs + * @throws UnsupportedProviderException If an unsupported provider is specified */ - private User saveUserCore( - String username, - String password, - String ssoProviderId, - String ssoProvider, - AuthenticationType authenticationType, - Long teamId, - Team team, - String role, - boolean firstLogin, - boolean enabled) + public User saveUserCore(SaveUserRequest request) throws IllegalArgumentException, SQLException, UnsupportedProviderException { - if (!isUsernameValid(username)) { + if (!isUsernameValid(request.getUsername())) { throw new IllegalArgumentException(getInvalidUsernameMessage()); } User user = new User(); - user.setUsername(username); + user.setUsername(request.getUsername()); // Set password if provided - if (password != null && !password.isEmpty()) { - user.setPassword(passwordEncoder.encode(password)); + if (request.getPassword() != null && !request.getPassword().isEmpty()) { + user.setPassword(passwordEncoder.encode(request.getPassword())); } // Set SSO provider details if provided - if (ssoProviderId != null && ssoProvider != null) { - user.setSsoProviderId(ssoProviderId); - user.setSsoProvider(ssoProvider); + if (request.getSsoProviderId() != null && request.getSsoProvider() != null) { + user.setSsoProviderId(request.getSsoProviderId()); + user.setSsoProvider(request.getSsoProvider()); } // Set authentication type - user.setAuthenticationType(authenticationType); + user.setAuthenticationType(request.getAuthenticationType()); // Set enabled status - user.setEnabled(enabled); + user.setEnabled(request.isEnabled()); // Set first login flag - user.setFirstLogin(firstLogin); + user.setFirstLogin(request.isFirstLogin()); + + // Set MFA requirement + Map settings = user.getSettings(); + settings.put(MFA_REQUIRED_KEY, String.valueOf(request.isRequireMfa())); + settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled())); + if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) { + settings.put(MFA_SECRET_KEY, request.getMfaSecret()); + } else { + settings.remove(MFA_SECRET_KEY); + } + if (request.getMfaLastUsedStep() != null) { + settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep())); + } else { + settings.remove(MFA_LAST_USED_STEP_KEY); + } + log.info( + "MFA required set to true for user {} {}", + request.getUsername(), + user.getSettings().toString()); // Set role (authority) - if (role == null) { - role = Role.USER.getRoleId(); - } - user.addAuthority(new Authority(role, user)); + user.addAuthority(new Authority(request.getRole(), user)); // Resolve and set team - if (team != null) { - user.setTeam(team); + if (request.getTeam() != null) { + user.setTeam(request.getTeam()); } else { - user.setTeam(resolveTeam(teamId, this::getDefaultTeam)); + user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam)); } // Save user @@ -678,6 +537,7 @@ public class UserService implements UserServiceInterface { return null; } + @Override public boolean isCurrentUserAdmin() { try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -693,6 +553,7 @@ public class UserService implements UserServiceInterface { return false; } + @Override public boolean isCurrentUserFirstLogin() { try { String username = getCurrentUsername(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java new file mode 100644 index 000000000..bef4e4ef4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java @@ -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. + * + *

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(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java new file mode 100644 index 000000000..a4704adbb --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -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 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("user@example.com"); + Authority authority = new Authority(); + authority.setAuthority(Role.USER.getRoleId()); + user.addAuthority(authority); + user.setSettings(Map.of("theme", "dark")); + + when(userRepository.findByUsernameIgnoreCaseWithSettings("user@example.com")) + .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 response = controller.getAccountData(authentication); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + AccountData data = response.getBody(); + assertThat(data.getUsername()).isEqualTo("user@example.com"); + 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 response = controller.getDatabaseData(); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + DatabaseData data = response.getBody(); + assertThat(data.getBackupFiles()).isEmpty(); + assertThat(data.isVersionUnknown()).isTrue(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java new file mode 100644 index 000000000..12620b6e8 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java @@ -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 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(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java new file mode 100644 index 000000000..48e0700ac --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -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("user@example.com")).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("user@example.com")).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("user@example.com")).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("user@example.com"); + } + + @Test + void loginSucceedsAndGeneratesToken() throws Exception { + UsernameAndPassMfa payload = buildPayload(null); + User user = buildUser(); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(userService.isPasswordCorrect(user, "pw")).thenReturn(true); + when(mfaService.isMfaEnabled(user)).thenReturn(false); + when(jwtService.generateToken(eq("user@example.com"), 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("user@example.com")); + + verify(loginAttemptService).loginSucceeded("user@example.com"); + } + + @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("user@example.com"); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(jwtService.generateToken(eq("user@example.com"), 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("user@example.com")) + .andExpect( + jsonPath("$.user.authenticationType") + .value(AuthenticationType.WEB.name().toLowerCase())); + + SecurityContextHolder.clearContext(); + } + + private User buildUser() { + User user = new User(); + user.setUsername("user@example.com"); + 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("user@example.com"); + payload.setPassword("pw"); + payload.setMfaCode(mfaCode); + return payload; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java new file mode 100644 index 000000000..d91931364 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMfaTest.java @@ -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 = "user@example.com"; + + 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); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java new file mode 100644 index 000000000..c3eafe424 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerTest.java @@ -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", "new@ex.com")) + .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("new@example.com")).thenReturn(false); + when(inviteTokenRepository.findByEmail("new@example.com")).thenReturn(Optional.empty()); + + mockMvc.perform( + post("/api/v1/invite/generate") + .principal(adminPrincipal) + .param("email", "new@example.com")) + .andExpect(status().isOk()) + .andExpect( + jsonPath("$.inviteUrl") + .value(startsWith("https://frontend.example.com/invite?token="))) + .andExpect(jsonPath("$.email").value("new@example.com")); + + 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("new@example.com")).thenReturn(false); + + mockMvc.perform( + post("/api/v1/invite/accept/abc") + .param("email", "new@example.com") + .param("password", "password123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Account created successfully")) + .andExpect(jsonPath("$.username").value("new@example.com")); + + verify(userService).saveUserCore(any()); + verify(inviteTokenRepository).save(invite); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java new file mode 100644 index 000000000..6bfe44a3f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -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("existing@example.com"); + payload.setPassword("pw"); + when(userService.usernameExistsIgnoreCase("existing@example.com")).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("new@example.com"); + payload.setPassword("pw"); + Team defaultTeam = new Team(); + defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME); + + when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false); + when(userService.isUsernameValid("new@example.com")).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("new@example.com"); + 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("new@example.com")); + } + + @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.")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceTest.java new file mode 100644 index 000000000..082c13165 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceTest.java @@ -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 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> 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()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MfaServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MfaServiceTest.java new file mode 100644 index 000000000..b52764bc7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MfaServiceTest.java @@ -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)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TotpServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TotpServiceTest.java new file mode 100644 index 000000000..1e9d1d26e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TotpServiceTest.java @@ -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("user@example.com", "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("user@example.com", "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); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index aa742c0e4..5b4575e64 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -1,22 +1,26 @@ package stirling.software.proprietary.security.service; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; import java.sql.SQLException; import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.MessageSource; import org.springframework.security.crypto.password.PasswordEncoder; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.UserRepository; @@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; class UserServiceTest { @Mock private UserRepository userRepository; - @Mock private TeamRepository teamRepository; - @Mock private AuthorityRepository authorityRepository; - @Mock private PasswordEncoder passwordEncoder; - @Mock private MessageSource messageSource; - - @Mock private SessionPersistentRegistry sessionPersistentRegistry; - + @Mock private SessionPersistentRegistry sessionRegistry; @Mock private DatabaseServiceInterface databaseService; + @Mock private ApplicationProperties.Security.OAUTH2 oAuth2; - @Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties; - - @InjectMocks private UserService userService; - - private Team mockTeam; - private User mockUser; - - @BeforeEach - void setUp() { - mockTeam = new Team(); - mockTeam.setId(1L); - mockTeam.setName("Test Team"); - - mockUser = new User(); - mockUser.setId(1L); - mockUser.setUsername("testuser"); - mockUser.setEnabled(true); - } + @Spy @InjectMocks private UserService userService; @Test - void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception { - // Given - String username = "testuser"; - AuthenticationType authType = AuthenticationType.WEB; + void saveUserCore_populatesFieldsAndPersists() + throws SQLException, UnsupportedProviderException { + Long teamId = 42L; + Team team = new Team(); + team.setId(teamId); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(team)); + when(passwordEncoder.encode("plain")).thenReturn("encoded"); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); - when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); + SaveUserRequest request = + SaveUserRequest.builder() + .username("validUser") + .password("plain") + .ssoProviderId("sso-id") + .ssoProvider("provider-x") + .authenticationType(AuthenticationType.OAUTH2) + .teamId(teamId) + .role(Role.ADMIN.getRoleId()) + .firstLogin(true) + .enabled(false) + .requireMfa(true) + .mfaEnabled(true) + .mfaSecret("top-secret") + .mfaLastUsedStep(5L) + .build(); - // When - userService.saveUser(username, authType); + User saved = userService.saveUserCore(request); - // Then - verify(userRepository).save(any(User.class)); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); verify(databaseService).exportDatabase(); + + User persisted = userCaptor.getValue(); + assertEquals("validUser", persisted.getUsername()); + assertEquals("encoded", persisted.getPassword()); + assertEquals("provider-x", persisted.getSsoProvider()); + assertEquals("sso-id", persisted.getSsoProviderId()); + assertEquals(team, persisted.getTeam()); + assertEquals("oauth2", persisted.getAuthenticationType()); + assertFalse(persisted.isEnabled()); + assertTrue(persisted.isFirstLogin()); + assertTrue( + persisted.getAuthorities().stream() + .anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()))); + assertEquals( + "true", + persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY), + "MFA requirement should be stored"); + assertEquals( + "true", + persisted.getSettings().get(MfaService.MFA_ENABLED_KEY), + "MFA enabled flag should be stored"); + assertEquals( + "top-secret", + persisted.getSettings().get(MfaService.MFA_SECRET_KEY), + "MFA secret should be stored"); + assertEquals( + "5", + persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY), + "MFA last used step should be stored"); + assertSame(saved, persisted, "Returned user should be the persisted instance"); } @Test - void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception { - // Given - String username = "testuser"; - String password = "password123"; - Long teamId = 1L; - String encodedPassword = "encodedPassword123"; + void saveUserCore_withoutTeam_usesDefaultTeam() + throws SQLException, UnsupportedProviderException { + Team defaultTeam = new Team(); + defaultTeam.setName("Default"); + when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam)); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); - when(passwordEncoder.encode(password)).thenReturn(encodedPassword); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); + SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build(); - // When - User result = userService.saveUser(username, password, teamId); + User saved = userService.saveUserCore(request); - // Then - assertNotNull(result); - verify(passwordEncoder).encode(password); - verify(teamRepository).findById(teamId); - verify(userRepository).save(any(User.class)); + verify(teamRepository).findByName("Default"); + verify(teamRepository, never()).findById(anyLong()); verify(databaseService).exportDatabase(); + assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied"); } @Test - void testSaveUser_WithTeamAndRole_Success() throws Exception { - // Given - String username = "testuser"; - String password = "password123"; - String role = Role.ADMIN.getRoleId(); - boolean firstLogin = true; - String encodedPassword = "encodedPassword123"; + void processSSOPostLogin_autoCreatesUserWhenMissing() + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + String username = "autoUser"; + doReturn(true).when(userService).isUsernameValid(username); + when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id")) + .thenReturn(Optional.empty()); + when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty()); + User created = new User(); + doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class)); - when(passwordEncoder.encode(password)).thenReturn(encodedPassword); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); + userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2); - // When - User result = userService.saveUser(username, password, mockTeam, role, firstLogin); + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class); + verify(userService).saveUserCore(reqCaptor.capture()); + SaveUserRequest captured = reqCaptor.getValue(); - // Then - assertNotNull(result); - verify(passwordEncoder).encode(password); - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); + assertEquals(username, captured.getUsername()); + assertEquals("id", captured.getSsoProviderId()); + assertEquals("prov", captured.getSsoProvider()); + assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType()); } @Test - void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception { - // Given - String invalidUsername = "ab"; // Too short (less than 3 characters) - AuthenticationType authType = AuthenticationType.WEB; + void addApiKeyToUserGeneratesAndPersists() { + User user = new User(); + user.setUsername("user"); + when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user)); + when(userRepository.findByApiKey(any())).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); - // When & Then - assertThrows( - IllegalArgumentException.class, - () -> userService.saveUser(invalidUsername, authType)); + User updated = userService.addApiKeyToUser("user"); - verify(userRepository, never()).save(any(User.class)); - verify(databaseService, never()).exportDatabase(); + assertNotNull(updated.getApiKey()); + verify(userRepository).save(user); } @Test - void testSaveUser_WithNullPassword_Success() throws Exception { - // Given - String username = "testuser"; - Long teamId = 1L; + void getApiKeyForUserCreatesWhenMissing() { + User user = new User(); + user.setUsername("user"); + when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user)); + when(userRepository.findByApiKey(any())).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); + String apiKey = userService.getApiKeyForUser("user"); - // When - User result = userService.saveUser(username, null, teamId); - - // Then - assertNotNull(result); - verify(passwordEncoder, never()).encode(anyString()); - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); + assertNotNull(apiKey); + verify(userRepository).save(user); } @Test - void testSaveUser_WithEmptyPassword_Success() throws Exception { - // Given - String username = "testuser"; - String emptyPassword = ""; - Long teamId = 1L; - - when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); - - // When - User result = userService.saveUser(username, emptyPassword, teamId); - - // Then - assertNotNull(result); - verify(passwordEncoder, never()).encode(anyString()); - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); - } - - @Test - void testSaveUser_WithValidEmail_Success() throws Exception { - // Given - String emailUsername = "test@example.com"; - AuthenticationType authType = AuthenticationType.OAUTH2; - - when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); - - // When - userService.saveUser(emailUsername, authType); - - // Then - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); - } - - @Test - void testSaveUser_WithReservedUsername_ThrowsException() throws Exception { - // Given - String reservedUsername = "all_users"; - AuthenticationType authType = AuthenticationType.WEB; - - // When & Then - assertThrows( - IllegalArgumentException.class, - () -> userService.saveUser(reservedUsername, authType)); - - verify(userRepository, never()).save(any(User.class)); - verify(databaseService, never()).exportDatabase(); - } - - @Test - void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception { - // Given - String anonymousUsername = "anonymoususer"; - AuthenticationType authType = AuthenticationType.WEB; - - // When & Then - assertThrows( - IllegalArgumentException.class, - () -> userService.saveUser(anonymousUsername, authType)); - - verify(userRepository, never()).save(any(User.class)); - verify(databaseService, never()).exportDatabase(); - } - - @Test - void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception { - // Given - String username = "testuser"; - String password = "password123"; - Long teamId = 1L; - String encodedPassword = "encodedPassword123"; - - when(passwordEncoder.encode(password)).thenReturn(encodedPassword); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase(); - - // When & Then - assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId)); - - // Verify user was still saved before the exception - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); - } - - @Test - void testSaveUser_WithFirstLoginFlag_Success() throws Exception { - // Given - String username = "testuser"; - String password = "password123"; - Long teamId = 1L; - boolean firstLogin = true; - boolean enabled = false; - String encodedPassword = "encodedPassword123"; - - when(passwordEncoder.encode(password)).thenReturn(encodedPassword); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); - - // When - userService.saveUser(username, password, teamId, firstLogin, enabled); - - // Then - verify(passwordEncoder).encode(password); - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); - } - - @Test - void testSaveUser_WithCustomRole_Success() throws Exception { - // Given - String username = "testuser"; - String password = "password123"; - Long teamId = 1L; - String customRole = Role.LIMITED_API_USER.getRoleId(); - String encodedPassword = "encodedPassword123"; - - when(passwordEncoder.encode(password)).thenReturn(encodedPassword); - when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam)); - when(userRepository.save(any(User.class))).thenReturn(mockUser); - doNothing().when(databaseService).exportDatabase(); - - // When - userService.saveUser(username, password, teamId, customRole); - - // Then - verify(passwordEncoder).encode(password); - verify(userRepository).save(any(User.class)); - verify(databaseService).exportDatabase(); + void isUsernameValidRejectsReservedAndAcceptsEmail() { + assertFalse(userService.isUsernameValid("ALL_USERS")); + assertTrue(userService.isUsernameValid("valid@example.com")); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/util/Base32CodecTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/util/Base32CodecTest.java new file mode 100644 index 000000000..240ade52c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/util/Base32CodecTest.java @@ -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$")); + } +} diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9d060e96f..55f8ab21b 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -556,6 +556,30 @@ webBrowserSettings = "Web Browser Setting" syncToBrowser = "Sync Account -> Browser" syncToAccount = "Sync Account <- Browser" +[account.mfa] +title = "Two-factor authentication" +setupFailed = "Unable to start two-factor setup. Please try again." +codeRequired = "Enter the authentication code to continue." +enabled = "Two-factor authentication enabled." +enableFailed = "Unable to enable two-factor authentication. Check the code and try again." +disabled = "Two-factor authentication disabled." +disableFailed = "Unable to disable two-factor authentication. Check the code and try again." +description = "Add an extra layer of security to your account." +enableButton = "Enable two-factor authentication" +disableButton = "Disable two-factor authentication" +setupTitle = "Set up two-factor authentication" +setupDescription = "Scan the QR code with your authenticator app, then enter the 6-digit code to confirm." +manualKey = "Manual setup key" +secretWarning = "Keep this key private. Anyone with access can generate valid authentication codes." +codePlaceholder = "Enter 6-digit code" +confirmEnable = "Enable" +disableTitle = "Disable two-factor authentication" +disableDescription = "Enter a valid authentication code to disable two-factor authentication." +codeLabel = "Authentication code" +confirmDisable = "Disable" +ssoDescription = "Two-factor authentication is managed by your identity provider for single sign-on accounts." +ssoManaged = "Configure MFA through your identity provider." + [adminUserSettings] title = "User Control Settings" header = "Admin User Control Settings" @@ -3736,6 +3760,14 @@ passwordChangedSuccess = "Password changed successfully! Please sign in with you credentialsUpdated = "Your credentials have been updated. Please sign in again." defaultCredentials = "Default Login Credentials" changePasswordWarning = "Please change your password after logging in for the first time" +mfaRequired = "Two-factor code required" +mfaCode = "Authentication Code" +enterMfaCode = "Enter 6-digit code" +mfaPromptTitle = "Two-factor authentication" +mfaPromptBody = "Enter the authentication code from your authenticator app to continue." +verifyingMfa = "Verifying..." +verifyMfa = "Verify code" + [login.slides.overview] alt = "Stirling PDF overview" @@ -5826,6 +5858,7 @@ usernameRequired = "Username and password are required" passwordTooShort = "Password must be at least 6 characters" success = "User created successfully" error = "Failed to create user" +forceMFA = "Force MFA setup on next login" [workspace.people.authType] password = "Password" @@ -5935,6 +5968,11 @@ slotsAvailable = "{{count}} user slot(s) available" noSlotsAvailable = "No slots available" currentUsage = "Currently using {{current}} of {{max}} user licences" +[workspace.people.mfa] +adminDisableSuccess = "MFA disabled successfully for user" +adminDisableError = "Failed to disable MFA for user" +disableByAdmin = "Disable MFA" + [workspace.teams] title = "Teams" description = "Manage teams and organize workspace members" diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 62a2c832f..64be3ab17 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -33,15 +33,20 @@ fn get_keyring_entry() -> Result { #[tauri::command] pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> { - if token.is_empty() { + let trimmed = token.trim(); + if trimmed.is_empty() { log::warn!("Attempted to save empty auth token"); return Err("Token cannot be empty".to_string()); } let entry = get_keyring_entry()?; + if trimmed.len() != token.len() { + log::debug!("Auth token had surrounding whitespace; storing trimmed token"); + } + entry - .set_password(&token) + .set_password(trimmed) .map_err(|e| { log::error!("Failed to set password in keyring: {}", e); format!("Failed to save token to keyring: {}", e) @@ -50,7 +55,7 @@ pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<() // Verify the save worked match entry.get_password() { Ok(retrieved_token) => { - if retrieved_token != token { + if retrieved_token != trimmed { log::error!("Token verification failed: Retrieved token doesn't match"); return Err("Token verification failed after save".to_string()); } @@ -201,6 +206,7 @@ pub async fn login( server_url: String, username: String, password: String, + mfa_code: Option, supabase_key: String, saas_server_url: String, ) -> Result { @@ -272,12 +278,22 @@ pub async fn login( let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/')); log::debug!("Spring Boot login URL: {}", login_url); + let mut payload = serde_json::json!({ + "username": username, + "password": password, + }); + + if let Some(code) = mfa_code + .as_ref() + .map(|c| c.trim()) + .filter(|c| !c.is_empty()) + { + payload["mfaCode"] = serde_json::Value::String(code.to_string()); + } + let response = client .post(&login_url) - .json(&serde_json::json!({ - "username": username, - "password": password, - })) + .json(&payload) .send() .await .map_err(|e| format!("Network error: {}", e))?; @@ -292,6 +308,19 @@ pub async fn login( .unwrap_or_else(|_| "Unknown error".to_string()); log::error!("Spring Boot login failed with status {}: {}", status, error_text); + if let Ok(error_json) = serde_json::from_str::(&error_text) { + let error_code = error_json + .get("error") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + + if let Some(code) = error_code { + if code == "mfa_required" || code == "invalid_mfa_code" { + return Err(code); + } + } + } + return Err(if status.as_u16() == 401 { "Invalid username or password".to_string() } else if status.as_u16() == 403 { diff --git a/frontend/src/core/auth/UseSession.tsx b/frontend/src/core/auth/UseSession.tsx new file mode 100644 index 000000000..3d687c22e --- /dev/null +++ b/frontend/src/core/auth/UseSession.tsx @@ -0,0 +1,23 @@ +export interface AuthContextType { + session: null; + user: null; + loading: boolean; + error: Error | null; + signOut: () => Promise; + refreshSession: () => Promise; +} + +/** + * 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 () => {}, + }; +} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index 63a9e7977..c50765309 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -68,6 +68,57 @@ color: var(--onboarding-body, #1f2937); } +.mfaSlideContent { + display: flex; + justify-content: center; + margin-top: 12px; +} + +.mfaCard { + width: 100%; + max-width: 560px; + background: var(--bg-surface, #ffffff); + border-radius: 16px; + padding: 20px; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); +} + +.mfaSetupGrid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 16px; + align-items: center; +} + +.mfaQrCard { + padding: 12px; + background: #ffffff; + border-radius: 12px; + box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12); + display: flex; + justify-content: center; +} + +.mfaSteps { + margin: 0; + padding-left: 18px; + color: var(--onboarding-body, #1f2937); + font-size: 14px; + line-height: 1.5; +} + +.mfaForm { + width: 100%; +} + +@media (max-width: 640px) { + .mfaSetupGrid { + grid-template-columns: 1fr; + justify-items: center; + } +} + + .heroIconsContainer { display: flex; gap: 32px; diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index bcfa449bf..d098a801d 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -26,6 +26,8 @@ import { useServerExperience } from '@app/hooks/useServerExperience'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import apiClient from '@app/services/apiClient'; import '@app/components/onboarding/OnboardingTour.css'; +import { useAccountLogout } from '@app/extensions/accountLogout'; +import { useAuth } from '@app/auth/UseSession'; export default function Onboarding() { const { t } = useTranslation(); @@ -45,15 +47,30 @@ export default function Onboarding() { const [analyticsLoading, setAnalyticsLoading] = useState(false); const [showAnalyticsModal, setShowAnalyticsModal] = useState(false); const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false); + const [firstLoginModalOpen, setFirstLoginModalOpen] = useState(false); + const [mfaModalOpen, setMfaModalOpen] = useState(false); + const accountLogout = useAccountLogout(); + const { signOut } = useAuth(); const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => { actions.updateRuntimeState({ selectedRole: role }); serverExperience.setSelfReportedAdmin(role === 'admin'); }, [actions, serverExperience]); - const handlePasswordChanged = useCallback(() => { + const redirectToLogin = useCallback(() => { + window.location.assign('/login'); + }, []); + + const handlePasswordChanged = useCallback(async () => { actions.updateRuntimeState({ requiresPasswordChange: false }); - window.location.href = '/login'; + // delete session and redirect to login page + await accountLogout({ signOut, redirectToLogin }); + }, [actions, accountLogout, redirectToLogin, signOut]); + + const handleMfaSetupComplete = useCallback(() => { + actions.updateRuntimeState({ requiresMfaSetup: false }); + setMfaModalOpen(false); + actions.complete(); }, [actions]); // Check if we should show analytics modal before onboarding @@ -68,17 +85,17 @@ export default function Onboarding() { setAnalyticsLoading(true); setAnalyticsError(null); - try { - const formData = new FormData(); - formData.append('enabled', enableAnalytics.toString()); + const formData = new FormData(); + formData.append('enabled', enableAnalytics.toString()); + try { await apiClient.post('/api/v1/settings/update-enable-analytics', formData); await refetchConfig(); setShowAnalyticsModal(false); setAnalyticsModalDismissed(true); - setAnalyticsLoading(false); } catch (error) { setAnalyticsError(error instanceof Error ? error.message : 'Unknown error'); + } finally { setAnalyticsLoading(false); } }, [analyticsLoading, refetchConfig]); @@ -223,6 +240,27 @@ export default function Onboarding() { return () => removeAllGlows(); }, [isTourOpen]); + // Handle first-login password change modal + useEffect(() => { + if(runtimeState.requiresPasswordChange === true) { + console.log('[Onboarding] User requires password change on first login.'); + setFirstLoginModalOpen(true); + } else { + setFirstLoginModalOpen(false); + } + }, [runtimeState.requiresPasswordChange]); + + // Handle MFA setup modal + useEffect(() => { + if(runtimeState.requiresMfaSetup === true) { + console.log('[Onboarding] User requires MFA setup.'); + setMfaModalOpen(true); + } else { + console.log('[Onboarding] User does not require MFA setup.'); + setMfaModalOpen(false); + } + }, [runtimeState.requiresMfaSetup]); + const finishTour = useCallback(() => { setIsTourOpen(false); if (runtimeState.tourType === 'admin') { @@ -272,8 +310,9 @@ export default function Onboarding() { usingDefaultCredentials: runtimeState.usingDefaultCredentials, analyticsError, analyticsLoading, + onMfaSetupComplete: handleMfaSetupComplete, }); - }, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); + }, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged, handleMfaSetupComplete]); const modalSlideCount = useMemo(() => { return activeFlow.filter((step) => step.type === 'modal-slide').length; @@ -325,6 +364,65 @@ export default function Onboarding() { ); } + if (firstLoginModalOpen) { + const baseSlideDefinition = SLIDE_DEFINITIONS['first-login']; + const slideContent = baseSlideDefinition.createSlide({ + osLabel: '', + osUrl: '', + selectedRole: null, + onRoleSelect: () => {}, + firstLoginUsername: runtimeState.firstLoginUsername, + onPasswordChanged: handlePasswordChanged, + usingDefaultCredentials: runtimeState.usingDefaultCredentials, + }); + + return ( + {}} + 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 ( + {}} + onAction={async (action) => { + if (action === 'complete-close') { + handleMfaSetupComplete(); + } + }} + allowDismiss={false} + /> + ); + } + if (showLicenseSlide) { const baseSlideDefinition = SLIDE_DEFINITIONS['server-license']; // Remove back button for external license notice @@ -403,6 +501,7 @@ export default function Onboarding() { currentModalSlideIndex={currentModalSlideIndex} onSkip={actions.skip} onAction={handleButtonAction} + allowDismiss={currentStep.allowDismiss} /> ); diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts index 0f9f5de02..101fb13ee 100644 --- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -6,6 +6,7 @@ import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseS import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide'; import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide'; import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide'; +import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide'; import { SlideConfig, LicenseNotice } from '@app/types/types'; export type SlideId = @@ -16,7 +17,8 @@ export type SlideId = | 'admin-overview' | 'server-license' | 'tour-overview' - | 'analytics-choice'; + | 'analytics-choice' + | 'mfa-setup'; export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics'; @@ -61,6 +63,7 @@ export interface SlideFactoryParams { usingDefaultCredentials?: boolean; analyticsError?: string | null; analyticsLoading?: boolean; + onMfaSetupComplete?: () => void; } export interface HeroDefinition { @@ -281,5 +284,11 @@ export const SLIDE_DEFINITIONS: Record = { }, ], }, + 'mfa-setup': { + id: 'mfa-setup', + createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }), + hero: { type: 'lock' }, + buttons: [], // Form has its own submit button + }, }; diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 7c9431dc4..51576bc60 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -7,7 +7,8 @@ export type OnboardingStepId = | 'tool-layout' | 'tour-overview' | 'server-license' - | 'analytics-choice'; + | 'analytics-choice' + | 'mfa-setup'; export type OnboardingStepType = | 'modal-slide' @@ -30,6 +31,7 @@ export interface OnboardingRuntimeState { requiresPasswordChange: boolean; firstLoginUsername: string; usingDefaultCredentials: boolean; + requiresMfaSetup: boolean; } export interface OnboardingConditionContext extends OnboardingRuntimeState { @@ -41,7 +43,8 @@ export interface OnboardingStep { id: OnboardingStepId; type: OnboardingStepType; condition: (ctx: OnboardingConditionContext) => boolean; - slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice'; + slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice' | 'mfa-setup'; + allowDismiss?: boolean; } export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { @@ -61,6 +64,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { firstLoginUsername: '', usingDefaultCredentials: false, desktopSlideEnabled: true, + requiresMfaSetup: false, }; export const ONBOARDING_STEPS: OnboardingStep[] = [ @@ -111,6 +115,12 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: 'server-license', condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, }, + { + id: 'mfa-setup', + type: 'modal-slide', + slideId: 'mfa-setup', + condition: (ctx) => ctx.requiresMfaSetup, + } ]; export function getStepById(id: OnboardingStepId): OnboardingStep | undefined { diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index eb3c5367e..54d1d2acf 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -89,6 +89,18 @@ function clearRuntimeStateSession(): void { } } +function parseMfaRequired(settings: string | null | undefined): boolean { + if (!settings) return false; + + try { + const parsed = JSON.parse(settings) as { mfaRequired?: string }; + return parsed.mfaRequired?.toLowerCase() === 'true'; + } catch (error) { + console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error); + return false; + } +} + export interface OnboardingOrchestratorState { /** Whether onboarding is currently active */ isActive: boolean; @@ -205,8 +217,10 @@ export function useOnboardingOrchestrator( requiresPasswordChange: accountData.changeCredsFlag, firstLoginUsername: accountData.username, usingDefaultCredentials: loginPageData.showDefaultCredentials, + requiresMfaSetup: parseMfaRequired(accountData.settings), })); - } catch { + } catch (error) { + console.log('[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:', error); // Account endpoint failed - user not logged in or security disabled } }; @@ -230,12 +244,8 @@ export function useOnboardingOrchestrator( }), [serverExperience, runtimeState]); const activeFlow = useMemo(() => { - // If password change is required, ONLY show the first-login step - if (runtimeState.requiresPasswordChange) { - return ONBOARDING_STEPS.filter((step) => step.id === 'first-login'); - } return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext)); - }, [conditionContext, runtimeState.requiresPasswordChange]); + }, [conditionContext]); // Wait for config AND admin status before calculating initial step const adminStatusResolved = !configLoading && ( @@ -255,7 +265,7 @@ export function useOnboardingOrchestrator( } // If onboarding has been completed, don't show it - if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) { + if (isOnboardingCompleted()) { setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; return; @@ -266,7 +276,7 @@ export function useOnboardingOrchestrator( setCurrentStepIndex(0); initialIndexSet.current = true; } - }, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]); + }, [activeFlow, configLoading, adminStatusResolved]); const totalSteps = activeFlow.length; diff --git a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx index 28a36fdeb..46ec6a0c8 100644 --- a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -51,7 +51,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = setLoading(true); setError(''); - await accountService.changePasswordOnLogin(currentPassword, newPassword); + await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword); showToast({ alertType: 'success', @@ -127,6 +127,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')} value={newPassword} onChange={(e) => setNewPassword(e.currentTarget.value)} + minLength={8} required styles={{ input: { height: 44 }, @@ -139,6 +140,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = value={confirmPassword} onChange={(e) => setConfirmPassword(e.currentTarget.value)} required + minLength={8} styles={{ input: { height: 44 }, }} @@ -148,7 +150,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = fullWidth onClick={handleSubmit} loading={loading} - disabled={!newPassword || !confirmPassword} + disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8} size="md" mt="xs" > @@ -169,9 +171,9 @@ export default function FirstLoginSlide({ key: 'first-login', title: 'Set Your Password', body: ( - ), diff --git a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx new file mode 100644 index 000000000..d8def0528 --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -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(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 ? ( +

+ + + + + + + Step-by-step + +
    +
  1. Open Google Authenticator, Authy, or 1Password.
  2. +
  3. Scan the QR code or enter the setup key below.
  4. +
  5. Enter the 6-digit code from your app.
  6. +
+ + Setup key (manual entry) + + +
+
+ ) : null; + + return ( +
+
+ + + 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. + + + {mfaError && ( + } color="red" variant="light"> + {mfaError} + + )} + + {mfaLoading && !isReady && ( + + + Generating your QR code… + + )} + + {isReady && mfaSetupContent} + +
+ + setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))} + inputMode="numeric" + maxLength={6} + minLength={6} + disabled={!isReady || submitting || setupComplete} + /> + + + + + + + + {setupComplete && ( + + MFA has been enabled. You can now continue. + + )} + +
+
+
+
+ ); +} + +export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig { + return { + key: "mfa-setup-slide", + title: "Multi-Factor Authentication Setup", + body: , + background: { + gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} diff --git a/frontend/src/core/components/shared/FirstLoginModal.tsx b/frontend/src/core/components/shared/FirstLoginModal.tsx index 7cd034edd..877a8e2d0 100644 --- a/frontend/src/core/components/shared/FirstLoginModal.tsx +++ b/frontend/src/core/components/shared/FirstLoginModal.tsx @@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: setLoading(true); setError(''); - await accountService.changePasswordOnLogin(currentPassword, newPassword); + await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword); alert({ alertType: 'success', @@ -133,6 +133,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')} value={newPassword} onChange={(e) => setNewPassword(e.currentTarget.value)} + minLength={8} required /> @@ -141,6 +142,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')} value={confirmPassword} onChange={(e) => setConfirmPassword(e.currentTarget.value)} + minLength={8} required /> @@ -148,7 +150,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: fullWidth onClick={handleSubmit} loading={loading} - disabled={!currentPassword || !newPassword || !confirmPassword} + disabled={!currentPassword || !newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8} mt="md" > {t('firstLogin.changePassword', 'Change Password')} diff --git a/frontend/src/core/extensions/accountLogout.ts b/frontend/src/core/extensions/accountLogout.ts new file mode 100644 index 000000000..005d86a3c --- /dev/null +++ b/frontend/src/core/extensions/accountLogout.ts @@ -0,0 +1,20 @@ +type SignOutFn = () => Promise; + +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 => { + try { + await signOut(); + } finally { + redirectToLogin(); + } + }; +} diff --git a/frontend/src/core/responses/Mfa/MfaResponse.ts b/frontend/src/core/responses/Mfa/MfaResponse.ts new file mode 100644 index 000000000..3f4974f7a --- /dev/null +++ b/frontend/src/core/responses/Mfa/MfaResponse.ts @@ -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; +} diff --git a/frontend/src/core/services/accountService.ts b/frontend/src/core/services/accountService.ts index cc8c2527f..3e4bb734e 100644 --- a/frontend/src/core/services/accountService.ts +++ b/frontend/src/core/services/accountService.ts @@ -1,3 +1,4 @@ +import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse'; import apiClient from '@app/services/apiClient'; export interface AccountData { @@ -7,6 +8,7 @@ export interface AccountData { changeCredsFlag: boolean; oAuth2Login: boolean; saml2Login: boolean; + mfaEnabled?: boolean; } export interface LoginPageData { @@ -50,11 +52,12 @@ export const accountService = { /** * Change user password on first login (resets firstLogin flag) */ - async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise { + async changePasswordOnLogin(currentPassword: string, newPassword: string, confirmPassword: string): Promise { const formData = new FormData(); formData.append('currentPassword', currentPassword); formData.append('newPassword', newPassword); - await apiClient.post('/api/v1/user/change-password-on-login', formData); + formData.append('confirmPassword', confirmPassword); + await apiClient.post('/api/v1/user/change-password-on-login', formData, { responseType: 'json'}); }, /** @@ -66,4 +69,21 @@ export const accountService = { formData.append('newUsername', newUsername); await apiClient.post('/api/v1/user/change-username', formData); }, + + async requestMfaSetup(): Promise { + const response = await apiClient.get('/api/v1/auth/mfa/setup', { suppressErrorToast: true }); + return response.data; + }, + + async enableMfa(code: string): Promise { + await apiClient.post('/api/v1/auth/mfa/enable', { code }, { skipAuthRedirect: true }); + }, + + async disableMfa(code: string): Promise { + await apiClient.post('/api/v1/auth/mfa/disable', { code }, { skipAuthRedirect: true }); + }, + + async cancelMfaSetup(): Promise { + await apiClient.post('/api/v1/auth/mfa/setup/cancel', undefined, { suppressErrorToast: true }); + }, }; diff --git a/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx index f90684518..dd0731eb2 100644 --- a/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx @@ -15,6 +15,9 @@ interface SelfHostedLoginScreenProps { enabledOAuthProviders?: SSOProviderConfig[]; onLogin: (username: string, password: string) => Promise; onOAuthSuccess: (userInfo: UserInfo) => Promise; + mfaCode: string; + setMfaCode: (value: string) => void; + requiresMfa: boolean; loading: boolean; error: string | null; } @@ -24,6 +27,9 @@ export const SelfHostedLoginScreen: React.FC = ({ enabledOAuthProviders, onLogin, onOAuthSuccess, + mfaCode, + setMfaCode, + requiresMfa, loading, error, }) => { @@ -44,6 +50,11 @@ export const SelfHostedLoginScreen: React.FC = ({ return; } + if (requiresMfa && !mfaCode.trim()) { + setValidationError(t('login.mfaRequired', 'Two-factor code required')); + return; + } + setValidationError(null); await onLogin(username.trim(), password); }; @@ -98,6 +109,13 @@ export const SelfHostedLoginScreen: React.FC = ({ setPassword(value); setValidationError(null); }} + mfaCode={mfaCode} + setMfaCode={(value) => { + setMfaCode(value); + setValidationError(null); + }} + showMfaField={requiresMfa || Boolean(mfaCode)} + requiresMfa={requiresMfa} onSubmit={handleSubmit} isSubmitting={loading} submitButtonText={t('setup.login.submit', 'Login')} diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index 1d8ea370f..171684237 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -6,7 +6,7 @@ import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen'; import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen'; import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen'; import { ServerConfig, connectionModeService } from '@app/services/connectionModeService'; -import { authService, UserInfo } from '@app/services/authService'; +import { AuthServiceError, authService, UserInfo } from '@app/services/authService'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; import { listen } from '@tauri-apps/api/event'; @@ -30,6 +30,8 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const [serverConfig, setServerConfig] = useState({ url: STIRLING_SAAS_URL }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [selfHostedMfaCode, setSelfHostedMfaCode] = useState(''); + const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false); const handleSaaSLogin = async (username: string, password: string) => { if (!serverConfig) { @@ -97,6 +99,8 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const handleServerSelection = (config: ServerConfig) => { setServerConfig(config); setError(null); + setSelfHostedMfaCode(''); + setSelfHostedMfaRequired(false); setActiveStep(SetupStep.SelfHostedLogin); }; @@ -116,9 +120,14 @@ export const SetupWizard: React.FC = ({ onComplete }) => { setError(null); console.log('[SetupWizard] Step 1: Authenticating with server...'); - await authService.login(serverConfig.url, username, password); + const trimmedMfa = selfHostedMfaCode.trim(); + const mfaCode = trimmedMfa ? trimmedMfa : undefined; + await authService.login(serverConfig.url, username, password, mfaCode); console.log('[SetupWizard] ✅ Authentication successful'); + setSelfHostedMfaRequired(false); + setSelfHostedMfaCode(''); + console.log('[SetupWizard] Step 2: Switching to self-hosted mode...'); await connectionModeService.switchToSelfHosted(serverConfig); console.log('[SetupWizard] ✅ Switched to self-hosted mode'); @@ -131,7 +140,20 @@ export const SetupWizard: React.FC = ({ onComplete }) => { onComplete(); } catch (err) { console.error('[SetupWizard] ❌ Self-hosted login failed:', err); - const errorMessage = err instanceof Error ? err.message : 'Self-hosted login failed'; + let errorMessage = 'Self-hosted login failed'; + if (err instanceof AuthServiceError) { + if (err.code === 'mfa_required' || err.code === 'invalid_mfa_code') { + setSelfHostedMfaRequired(true); + } + errorMessage = err.message; + } else if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } + if (errorMessage.toLowerCase().includes('mfa_required') || errorMessage.toLowerCase().includes('invalid_mfa_code')) { + setSelfHostedMfaRequired(true); + } console.error('[SetupWizard] Error message:', errorMessage); setError(errorMessage); setLoading(false); @@ -239,6 +261,8 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const handleBack = () => { setError(null); if (activeStep === SetupStep.SelfHostedLogin) { + setSelfHostedMfaCode(''); + setSelfHostedMfaRequired(false); setActiveStep(SetupStep.ServerSelection); } else if (activeStep === SetupStep.ServerSelection) { setActiveStep(SetupStep.SaaSLogin); @@ -286,6 +310,9 @@ export const SetupWizard: React.FC = ({ onComplete }) => { enabledOAuthProviders={serverConfig?.enabledOAuthProviders} onLogin={handleSelfHostedLogin} onOAuthSuccess={handleSelfHostedOAuthSuccess} + mfaCode={selfHostedMfaCode} + setMfaCode={setSelfHostedMfaCode} + requiresMfa={selfHostedMfaRequired} loading={loading} error={error} /> diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index ee9cbcf55..c2d31ebe7 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -101,6 +101,9 @@ export function setupApiInterceptors(client: AxiosInstance): void { // Handle 401 Unauthorized - try to refresh token if (error.response?.status === 401 && !originalRequest._retry) { + if (originalRequest.skipAuthRedirect) { + return Promise.reject(error); + } originalRequest._retry = true; const isRemote = await operationRouter.isSelfHostedMode(); diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 78bb13ccf..41adf954e 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -11,6 +11,15 @@ export interface UserInfo { email?: string; } +export class AuthServiceError extends Error { + code?: string; + + constructor(message: string, code?: string) { + super(message); + this.code = code; + } +} + interface LoginResponse { token: string; username: string; @@ -82,7 +91,6 @@ export class AuthService { // Try Tauri store first try { const token = await invoke('get_auth_token'); - if (token) { console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`); return token; @@ -222,7 +230,7 @@ export class AuthService { } } - async login(serverUrl: string, username: string, password: string): Promise { + async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise { console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`); console.log(`[Desktop AuthService] Username: ${username}`); @@ -246,6 +254,7 @@ export class AuthService { serverUrl, username, password, + mfaCode, supabaseKey: SUPABASE_KEY, saasServerUrl: STIRLING_SAAS_URL, }); @@ -286,8 +295,21 @@ export class AuthService { console.error('[Desktop AuthService] ❌ Login failed:', error); // Provide more detailed error messages based on the error type - if (error instanceof Error) { - const errMsg = error.message.toLowerCase(); + if (error instanceof Error || typeof error === 'string') { + const rawMessage = typeof error === 'string' ? error : error.message; + const errMsg = rawMessage.toLowerCase(); + + if (errMsg.includes('mfa_required')) { + this.setAuthStatus('unauthenticated', null); + console.error('[Desktop AuthService] Two-factor authentication required'); + throw new AuthServiceError('Two-factor code required.', 'mfa_required'); + } + + if (errMsg.includes('invalid_mfa_code')) { + this.setAuthStatus('unauthenticated', null); + console.error('[Desktop AuthService] Invalid two-factor code provided'); + throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code'); + } // Authentication errors if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) { @@ -412,7 +434,6 @@ export class AuthService { this.cachedToken = token; console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval'); } - return token; } catch (error) { console.error('[Desktop AuthService] Failed to get auth token:', error); @@ -449,7 +470,7 @@ export class AuthService { async refreshToken(serverUrl: string): Promise { try { - console.log('Refreshing auth token'); + console.log('[Desktop AuthService] Refreshing auth token'); this.setAuthStatus('refreshing', this.userInfo); const currentToken = await this.getAuthToken(); @@ -477,10 +498,10 @@ export class AuthService { const userInfo = await this.getUserInfo(); this.setAuthStatus('authenticated', userInfo); - console.log('Token refreshed successfully'); + console.log('[Desktop AuthService] Token refreshed successfully'); return true; } catch (error) { - console.error('Token refresh failed:', error); + console.error('[Desktop AuthService] Token refresh failed:', error); this.setAuthStatus('unauthenticated', null); // Clear stored credentials on refresh failure @@ -533,7 +554,7 @@ export class AuthService { */ async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise { try { - console.log('Starting OAuth login with provider:', provider); + console.log('[Desktop AuthService] Starting OAuth login with provider:', provider); this.setAuthStatus('oauth_pending', null); // Validate Supabase key is configured for OAuth @@ -554,7 +575,7 @@ export class AuthService { errorHtml, }); - console.log('OAuth authentication successful, storing tokens'); + console.log('[Desktop AuthService] OAuth authentication successful, storing tokens'); // Save token to all storage locations await this.saveTokenEverywhere(result.access_token); @@ -569,11 +590,11 @@ export class AuthService { }); this.setAuthStatus('authenticated', userInfo); - console.log('OAuth login successful'); + console.log('[Desktop AuthService] OAuth login successful'); return userInfo; } catch (error) { - console.error('Failed to complete OAuth login:', error); + console.error('[Desktop AuthService] Failed to complete OAuth login:', error); this.setAuthStatus('unauthenticated', null); throw error; } diff --git a/frontend/src/desktop/services/tauriHttpClient.ts b/frontend/src/desktop/services/tauriHttpClient.ts index 8ddad03ea..f83d46d1f 100644 --- a/frontend/src/desktop/services/tauriHttpClient.ts +++ b/frontend/src/desktop/services/tauriHttpClient.ts @@ -26,6 +26,7 @@ export interface TauriHttpRequestConfig { // Custom properties for desktop operationName?: string; skipBackendReadyCheck?: boolean; + skipAuthRedirect?: boolean; // Axios compatibility properties (ignored by Tauri HTTP) suppressErrorToast?: boolean; cancelToken?: any; diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index 58bf05d0e..c8a83ccdf 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -15,11 +15,13 @@ declare module 'assets/material-symbols-icons.json' { declare module 'axios' { export interface AxiosRequestConfig<_D = unknown> { suppressErrorToast?: boolean; + skipAuthRedirect?: boolean; skipBackendReadyCheck?: boolean; } export interface InternalAxiosRequestConfig<_D = unknown> { suppressErrorToast?: boolean; + skipAuthRedirect?: boolean; skipBackendReadyCheck?: boolean; } } diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 573d9b101..82deb3407 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -77,6 +77,8 @@ export interface Session { export interface AuthError { message: string; status?: number; + code?: string; + mfaRequired?: boolean; } export interface AuthResponse { @@ -181,11 +183,13 @@ class SpringAuthClient { async signInWithPassword(credentials: { email: string; password: string; + mfaCode?: string; }): Promise { try { const response = await apiClient.post('/api/v1/auth/login', { username: credentials.email, - password: credentials.password + password: credentials.password, + mfaCode: credentials.mfaCode, }, { withCredentials: true, // Include cookies for CSRF }); @@ -213,6 +217,24 @@ class SpringAuthClient { return { user: data.user, session, error: null }; } catch (error: unknown) { console.error('[SpringAuth] signInWithPassword error:', error); + if (error instanceof AxiosError) { + const errorCode = error.response?.data?.error as string | undefined; + const errorMessage = + error.response?.data?.message || + error.response?.data?.error || + error.message || + 'Login failed'; + return { + user: null, + session: null, + error: { + message: errorMessage, + status: error.response?.status, + code: errorCode, + mfaRequired: errorCode === 'mfa_required', + }, + }; + } return { user: null, session: null, diff --git a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx index 70e5e09a0..e01b83399 100644 --- a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx @@ -58,6 +58,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit teamId: undefined as number | undefined, authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2', forceChange: false, + forceMFA: false, }); // Form state for email invite @@ -141,6 +142,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit teamId: inviteForm.teamId, authType: inviteForm.authType, forceChange: inviteForm.forceChange, + forceMFA: inviteForm.forceMFA, }); alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); onClose(); @@ -153,6 +155,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit teamId: undefined, authType: 'WEB', forceChange: false, + forceMFA: false, }); } catch (error: any) { console.error('Failed to invite user:', error); @@ -253,6 +256,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit teamId: undefined, authType: 'WEB', forceChange: false, + forceMFA: false, }); setEmailInviteForm({ emails: '', @@ -283,7 +287,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit handleInviteUser(); } }; - + return ( setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })} /> + setInviteForm({ ...inviteForm, forceMFA: e.currentTarget.checked })} + /> )} diff --git a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx index 81130ec69..a5fc29e1a 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -1,12 +1,15 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { Alert, Button, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; import { alert as showToast } from '@app/components/toast'; import { useAuth } from '@app/auth/UseSession'; import { accountService } from '@app/services/accountService'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { QRCodeSVG } from 'qrcode.react'; import { useAccountLogout } from '@app/extensions/accountLogout'; +import { BASE_PATH } from '@app/constants/app'; +import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse'; const AccountSection: React.FC = () => { const { t } = useTranslation(); @@ -25,6 +28,17 @@ const AccountSection: React.FC = () => { const [newUsername, setNewUsername] = useState(''); const [usernameError, setUsernameError] = useState(''); const [usernameSubmitting, setUsernameSubmitting] = useState(false); + const [mfaEnabled, setMfaEnabled] = useState(false); + const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false); + const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false); + const [mfaSetupData, setMfaSetupData] = useState(null); + const [mfaSetupCode, setMfaSetupCode] = useState(''); + const [mfaDisableCode, setMfaDisableCode] = useState(''); + const [mfaError, setMfaError] = useState(''); + const [mfaLoading, setMfaLoading] = useState(false); + const [changeButtonDisabled, setChangeButtonDisabled] = useState(false); + const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, '').slice(0, 6), []); + const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`; const authTypeFromMetadata = useMemo(() => { const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined; @@ -92,6 +106,124 @@ const AccountSection: React.FC = () => { } }; + useEffect(() => { + const fetchAccountData = async () => { + setChangeButtonDisabled(true); + try { + const data = await accountService.getAccountData().then((data) => data).finally(() => { + setChangeButtonDisabled(false); + }); + setMfaEnabled(data.mfaEnabled ?? false); + } catch { + // ignore fetch errors for account data + console.warn('Failed to fetch account data'); + } finally { + setChangeButtonDisabled(false); + } + }; + void fetchAccountData(); + }, []); + + const handleStartMfaSetup = useCallback(async () => { + try { + setMfaLoading(true); + setMfaError(''); + setMfaSetupCode(''); + const data = await accountService.requestMfaSetup(); + setMfaSetupData(data); + setMfaSetupModalOpen(true); + } catch (err) { + const axiosError = err as { response?: { data?: { error?: string } } }; + setMfaError( + axiosError.response?.data?.error || + t('account.mfa.setupFailed', 'Unable to start two-factor setup. Please try again.') + ); + } finally { + setMfaLoading(false); + } + }, [t]); + + const handleEnableMfa = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!mfaSetupCode.trim()) { + setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.')); + return; + } + try { + setMfaLoading(true); + setMfaError(''); + await accountService.enableMfa(mfaSetupCode.trim()); + setMfaEnabled(true); + setMfaSetupModalOpen(false); + setMfaSetupData(null); + setMfaSetupCode(''); + showToast({ + alertType: 'success', + title: t('account.mfa.enabled', 'Two-factor authentication enabled.'), + }); + } catch (err) { + const axiosError = err as { response?: { data?: { error?: string } } }; + setMfaError( + axiosError.response?.data?.error || + t('account.mfa.enableFailed', 'Unable to enable two-factor authentication. Check the code and try again.') + ); + } finally { + setMfaLoading(false); + } + }, + [mfaSetupCode, t] + ); + + const handleDisableMfa = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!mfaDisableCode.trim()) { + setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.')); + return; + } + try { + setMfaLoading(true); + setMfaError(''); + await accountService.disableMfa(mfaDisableCode.trim()); + setMfaEnabled(false); + setMfaDisableModalOpen(false); + setMfaDisableCode(''); + showToast({ + alertType: 'success', + title: t('account.mfa.disabled', 'Two-factor authentication disabled.'), + }); + } catch (err) { + const axiosError = err as { response?: { data?: { error?: string } } }; + setMfaError( + axiosError.response?.data?.error || + t('account.mfa.disableFailed', 'Unable to disable two-factor authentication. Check the code and try again.') + ); + } finally { + setMfaLoading(false); + } + }, + [mfaDisableCode, t] + ); + + const handleCloseMfaSetupModal = useCallback(async () => { + setMfaSetupModalOpen(false); + setMfaSetupData(null); + setMfaSetupCode(''); + setMfaError(''); + try { + await accountService.cancelMfaSetup(); + } catch { + console.warn('Failed to clear pending MFA setup'); + } + }, []); + + const handleCloseMfaDisableModal = useCallback(() => { + setMfaDisableModalOpen(false); + setMfaDisableCode(''); + setMfaError(''); + }, []); + const handleUsernameSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -183,6 +315,50 @@ const AccountSection: React.FC = () => { + + + {t('account.mfa.title', 'Two-factor authentication')} + + {t('account.mfa.description', 'Add an extra layer of security to your account.')} + + {isSsoUser ? ( + } color="blue" variant="light"> + {t( + 'account.mfa.ssoManaged', + 'Two-factor authentication for this account is managed by your identity provider.' + )} + + ) : ( + + {!mfaEnabled ? ( + + ) : ( + + )} + + )} + + + setPasswordModalOpen(false)} @@ -238,6 +414,124 @@ const AccountSection: React.FC = () => { + +
+ + + {t( + 'account.mfa.setupDescription', + 'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.' + )} + + {mfaSetupData && ( + + + + + + {t('account.mfa.manualKey', 'Manual setup key')}: {mfaSetupData.secret} + + + {t( + 'account.mfa.secretWarning', + 'Keep this key private. Anyone with access can generate valid authentication codes.' + )} + + + )} + {mfaError && ( + } color="red" variant="light"> + {mfaError} + + )} + ) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))} + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + minLength={6} + autoComplete="one-time-code" + required + /> + + + + + +
+
+ + +
+ + + {t('account.mfa.disableDescription', 'Enter a valid authentication code to disable two-factor authentication.')} + + {mfaError && ( + } color="red" variant="light"> + {mfaError} + + )} + ) => setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))} + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + minLength={6} + autoComplete="one-time-code" + required + /> + + + + + +
+
+ setUsernameModalOpen(false)} @@ -261,7 +555,7 @@ const AccountSection: React.FC = () => { label={t('changeCreds.newUsername', 'New Username')} placeholder={t('changeCreds.newUsername', 'New Username')} value={newUsername} - onChange={(event) => setNewUsername(event.currentTarget.value)} + onChange={(event: React.ChangeEvent) => setNewUsername(event.currentTarget.value)} required /> @@ -269,7 +563,7 @@ const AccountSection: React.FC = () => { label={t('changeCreds.oldPassword', 'Current Password')} placeholder={t('changeCreds.oldPassword', 'Current Password')} value={currentPasswordForUsername} - onChange={(event) => setCurrentPasswordForUsername(event.currentTarget.value)} + onChange={(event: React.ChangeEvent) => setCurrentPasswordForUsername(event.currentTarget.value)} required /> diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 3e1482265..be65e4047 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -112,6 +112,7 @@ export default function PeopleSection() { ...user, isActive: adminData.userSessions[user.username] || false, lastRequest: adminData.userLastRequest[user.username] || undefined, + mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true', })); setUsers(enrichedUsers); @@ -200,7 +201,7 @@ export default function PeopleSection() { }); } } catch (error) { - console.error('Failed to fetch people data:', error); + console.error('[PeopleSection] Failed to fetch people data:', error); alert({ alertType: 'error', title: 'Failed to load people data' }); } finally { setLoading(false); @@ -221,7 +222,7 @@ export default function PeopleSection() { closeEditModal(); fetchData(); } catch (error: any) { - console.error('Failed to update user:', error); + console.error('[PeopleSection] Failed to update user:', error); const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || @@ -238,7 +239,7 @@ export default function PeopleSection() { alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') }); fetchData(); } catch (error: any) { - console.error('Failed to toggle user status:', error); + console.error('[PeopleSection] Failed to toggle user status:', error); const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || @@ -258,7 +259,7 @@ export default function PeopleSection() { alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') }); fetchData(); } catch (error: any) { - console.error('Failed to delete user:', error); + console.error('[PeopleSection] Failed to delete user:', error); const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || @@ -576,6 +577,7 @@ export default function PeopleSection() { {/* Actions menu */} + {!isCurrentUser(user) && ( @@ -589,9 +591,10 @@ export default function PeopleSection() { onClick={() => openEditModal(user)} disabled={!loginEnabled} > - {t('workspace.people.editRole')} + {t('workspace.people.editRole', 'Edit Role & Team')} )} + {!isCurrentUser(user) && ( } onClick={() => openChangePasswordModal(user)} @@ -599,6 +602,7 @@ export default function PeopleSection() { > {t('workspace.people.changePassword.action', 'Change password')} + )} {!isCurrentUser(user) && ( : } @@ -608,6 +612,31 @@ export default function PeopleSection() { {user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')} )} + {!isCurrentUser(user) && user.mfaEnabled && ( + <> + + } + 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')} + + + )} {!isCurrentUser(user) && ( <> @@ -618,6 +647,7 @@ export default function PeopleSection() { )} + )} diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 5de88239a..aea069ecf 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -33,6 +33,8 @@ export default function Login() { const [showEmailForm, setShowEmailForm] = useState(false); const [email, setEmail] = useState(() => searchParams.get('email') ?? ''); const [password, setPassword] = useState(''); + const [mfaCode, setMfaCode] = useState(''); + const [requiresMfa, setRequiresMfa] = useState(false); const [enabledProviders, setEnabledProviders] = useState([]); const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); @@ -273,6 +275,11 @@ export default function Login() { return; } + if (requiresMfa && !mfaCode.trim()) { + setError(t('login.mfaRequired', 'Two-factor code required')); + return; + } + try { setIsSigningIn(true); setError(null); @@ -281,14 +288,20 @@ export default function Login() { const { user, session, error } = await springAuth.signInWithPassword({ email: email.trim(), - password: password + password: password, + mfaCode: requiresMfa ? mfaCode.trim() : undefined, }); if (error) { console.error('[Login] Email sign in error:', error); setError(error.message); + if (error.mfaRequired || error.code === 'invalid_mfa_code') { + setRequiresMfa(true); + } } else if (user && session) { console.log('[Login] Email sign in successful'); + setRequiresMfa(false); + setMfaCode(''); // Auth state will update automatically and Landing will redirect to home // No need to navigate manually here } @@ -362,6 +375,10 @@ export default function Login() { password={password} setEmail={setEmail} setPassword={setPassword} + mfaCode={mfaCode} + setMfaCode={setMfaCode} + showMfaField={requiresMfa || Boolean(mfaCode)} + requiresMfa={requiresMfa} onSubmit={signInWithEmail} isSubmitting={isSigningIn} submitButtonText={isSigningIn ? (t('login.loggingIn') || 'Signing in...') : (t('login.login') || 'Sign in')} diff --git a/frontend/src/proprietary/routes/login/EmailPasswordForm.tsx b/frontend/src/proprietary/routes/login/EmailPasswordForm.tsx index 2b4ec96f2..d7338e18e 100644 --- a/frontend/src/proprietary/routes/login/EmailPasswordForm.tsx +++ b/frontend/src/proprietary/routes/login/EmailPasswordForm.tsx @@ -22,6 +22,10 @@ interface EmailPasswordFormProps { password: string setEmail: (email: string) => void setPassword: (password: string) => void + mfaCode?: string + setMfaCode?: (code: string) => void + showMfaField?: boolean + requiresMfa?: boolean onSubmit: () => void isSubmitting: boolean submitButtonText: string @@ -29,6 +33,7 @@ interface EmailPasswordFormProps { fieldErrors?: { email?: string password?: string + mfaCode?: string } } @@ -37,6 +42,10 @@ export default function EmailPasswordForm({ password, setEmail, setPassword, + mfaCode = '', + setMfaCode, + showMfaField = false, + requiresMfa = false, onSubmit, isSubmitting, submitButtonText, @@ -66,6 +75,7 @@ export default function EmailPasswordForm({ error={fieldErrors.email} classNames={{ label: 'auth-label' }} styles={authInputStyles} + autoFocus /> @@ -85,11 +95,32 @@ export default function EmailPasswordForm({ /> )} + {showMfaField && ( +
+ ) => 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} + /> +
+ )}