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} + /> +
+ )}