mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)
# Description of Changes This pull request introduces several improvements and new features across the authentication and admin data APIs, with a particular focus on multi-factor authentication (MFA) support and better handling of user settings. The changes include integrating MFA status into account data responses, masking sensitive user settings in admin views, and refactoring code to use more robust user creation methods. Additionally, there are minor code cleanups and consistency improvements. ### Multi-factor Authentication (MFA) Integration * Added `mfaEnabled` and `mfaRequired` fields to the `AccountData` response, populated using the new `MfaService`, to provide clients with MFA status information for users. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98) [[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575) ### User Settings Handling and Security * Admin settings API now returns user settings for each user, with the `mfaSecret` field masked to protect sensitive information. This is achieved by fetching settings via `findByIdWithSettings` and copying/masking the relevant field before returning. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563) ### Refactoring and Code Consistency * Refactored user creation in `InitialSecuritySetup` to use the `SaveUserRequest` builder and `saveUserCore` for better maintainability and clarity, replacing direct calls to `saveUser`. [[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22) [[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124) [[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144) [[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160) * Standardized checks for internal team membership by comparing with `TeamService.INTERNAL_TEAM_NAME` on the left side for consistency. [[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273) [[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351) [[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443) [[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471) [[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485) ### API and DTO Changes * Changed the login API to accept `UsernameAndPassMfa` instead of `UsernameAndPass`, paving the way for MFA code support in authentication requests. [[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41) [[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71) * Updated import statements and controller dependencies to include new DTOs and services related to MFA. [[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19) [[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57) These updates improve security, prepare the system for MFA rollout, and make admin and authentication APIs more robust and informative. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+137
@@ -0,0 +1,137 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ProprietaryUIDataControllerTest {
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private SessionRepository sessionRepository;
|
||||
@Mock private DatabaseService databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
@Mock private PersistentAuditEventRepository auditRepository;
|
||||
@Mock private MfaService mfaService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AuditConfigurationProperties auditConfig;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private ProprietaryUIDataController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getUi().setLanguages(List.of("en", "de"));
|
||||
applicationProperties.getSystem().setDefaultLocale("en");
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
applicationProperties.getSecurity().getOauth2().setEnabled(false);
|
||||
applicationProperties.getSecurity().getSaml2().setEnabled(false);
|
||||
|
||||
auditConfig = new AuditConfigurationProperties(applicationProperties);
|
||||
objectMapper = new ObjectMapper();
|
||||
|
||||
controller =
|
||||
new ProprietaryUIDataController(
|
||||
applicationProperties,
|
||||
auditConfig,
|
||||
sessionPersistentRegistry,
|
||||
userRepository,
|
||||
teamRepository,
|
||||
sessionRepository,
|
||||
databaseService,
|
||||
objectMapper,
|
||||
false,
|
||||
licenseSettingsService,
|
||||
auditRepository,
|
||||
mfaService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
|
||||
when(userRepository.findAll()).thenReturn(Collections.emptyList());
|
||||
|
||||
ResponseEntity<LoginData> response = controller.getLoginData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
LoginData body = response.getBody();
|
||||
assertThat(body.isFirstTimeSetup()).isTrue();
|
||||
assertThat(body.isShowDefaultCredentials()).isTrue();
|
||||
assertThat(body.getLanguages()).containsExactly("en", "de");
|
||||
assertThat(body.getDefaultLocale()).isEqualTo("en");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountDataReturnsUserSettingsAndMfaFlags() {
|
||||
User user = new User();
|
||||
user.setUsername("[email protected]");
|
||||
Authority authority = new Authority();
|
||||
authority.setAuthority(Role.USER.getRoleId());
|
||||
user.addAuthority(authority);
|
||||
user.setSettings(Map.of("theme", "dark"));
|
||||
|
||||
when(userRepository.findByUsernameIgnoreCaseWithSettings("[email protected]"))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
when(mfaService.isMfaRequired(user)).thenReturn(false);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
|
||||
ResponseEntity<AccountData> response = controller.getAccountData(authentication);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
AccountData data = response.getBody();
|
||||
assertThat(data.getUsername()).isEqualTo("[email protected]");
|
||||
assertThat(data.isMfaEnabled()).isTrue();
|
||||
assertThat(data.isMfaRequired()).isFalse();
|
||||
assertThat(data.getSettings()).contains("\"theme\":\"dark\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseDataMarksUnknownVersion() {
|
||||
when(databaseService.getBackupList()).thenReturn(List.of());
|
||||
when(databaseService.getH2Version()).thenReturn("Unknown");
|
||||
|
||||
ResponseEntity<DatabaseData> response = controller.getDatabaseData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
DatabaseData data = response.getBody();
|
||||
assertThat(data.getBackupFiles()).isEmpty();
|
||||
assertThat(data.isVersionUnknown()).isTrue();
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InitialSecuritySetupTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private TeamService teamService;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private InitialSecuritySetup initialSecuritySetup;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getSecurity().getInitialLogin().setUsername("admin");
|
||||
applicationProperties.getSecurity().getInitialLogin().setPassword("password");
|
||||
Team internalTeam = new Team();
|
||||
internalTeam.setName(TeamService.INTERNAL_TEAM_NAME);
|
||||
User internalUser = new User();
|
||||
internalUser.setUsername(Role.INTERNAL_API_USER.getRoleId());
|
||||
internalUser.setTeam(internalTeam);
|
||||
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(Optional.of(internalUser));
|
||||
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
|
||||
initialSecuritySetup =
|
||||
new InitialSecuritySetup(
|
||||
userService,
|
||||
teamService,
|
||||
applicationProperties,
|
||||
databaseService,
|
||||
licenseSettingsService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initImportsBackupWhenPresent() throws SQLException, UnsupportedProviderException {
|
||||
when(userService.hasUsers()).thenReturn(false);
|
||||
when(databaseService.hasBackup()).thenReturn(true);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
verify(databaseService).importDatabase();
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
verify(licenseSettingsService).initializeGrandfatheredCount();
|
||||
verify(licenseSettingsService).updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesConfiguredAdminWhenNoBackup()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
when(userService.hasUsers()).thenReturn(false);
|
||||
when(databaseService.hasBackup()).thenReturn(false);
|
||||
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.empty());
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(defaultTeam);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
ArgumentCaptor<SaveUserRequest> captor = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(captor.capture());
|
||||
SaveUserRequest saved = captor.getValue();
|
||||
assertThat(saved.getUsername()).isEqualTo("admin");
|
||||
assertThat(saved.getPassword()).isEqualTo("password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureJwtSettingsDisablesKeyCleanupWhenJwtDisabled() {
|
||||
ReflectionTestUtils.setField(initialSecuritySetup, "v2Enabled", true);
|
||||
applicationProperties.getSecurity().getJwt().setEnableKeystore(false);
|
||||
when(userService.hasUsers()).thenReturn(true);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(any())).thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
assertThat(applicationProperties.getSecurity().getJwt().isEnableKeyCleanup()).isFalse();
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerLoginTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private CustomUserDetailsService userDetailsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
userService,
|
||||
jwtService,
|
||||
userDetailsService,
|
||||
loginAttemptService,
|
||||
mfaService,
|
||||
totpService,
|
||||
securityProperties);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRejectsWhenUserPassDisabled() throws Exception {
|
||||
securityProperties.setLoginMethod(
|
||||
ApplicationProperties.Security.LoginMethods.OAUTH2.toString());
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(
|
||||
jsonPath("$.error")
|
||||
.value(
|
||||
"Username/password authentication is not enabled. Please use the configured authentication method."));
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginBlockedAccountReturnsUnauthorized() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
when(loginAttemptService.isBlocked("[email protected]")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(
|
||||
jsonPath("$.error")
|
||||
.value("Account is locked due to too many failed attempts"));
|
||||
|
||||
verify(loginAttemptService, never()).loginSucceeded(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRequiresMfaCodeWhenEnabled() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("mfa_required"));
|
||||
|
||||
verify(loginAttemptService, never()).loginSucceeded(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginFailsWhenPasswordIncorrect() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Invalid credentials"));
|
||||
|
||||
verify(loginAttemptService).loginFailed("[email protected]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginSucceedsAndGeneratesToken() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
|
||||
.thenReturn("token-123");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.session.access_token").value("token-123"))
|
||||
.andExpect(jsonPath("$.user.username").value("[email protected]"));
|
||||
|
||||
verify(loginAttemptService).loginSucceeded("[email protected]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsUnauthorizedWhenTokenMissing() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn(null);
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("No token found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("[email protected]");
|
||||
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.access_token").value("new-token"))
|
||||
.andExpect(jsonPath("$.expires_in").value(3600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Not authenticated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUserDetails() throws Exception {
|
||||
User user = buildUser();
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user.username").value("[email protected]"))
|
||||
.andExpect(
|
||||
jsonPath("$.user.authenticationType")
|
||||
.value(AuthenticationType.WEB.name().toLowerCase()));
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private User buildUser() {
|
||||
User user = new User();
|
||||
user.setUsername("[email protected]");
|
||||
user.setEnabled(true);
|
||||
user.setAuthenticationType(AuthenticationType.WEB);
|
||||
|
||||
Authority authority = new Authority();
|
||||
authority.setAuthority(Role.USER.getRoleId());
|
||||
user.addAuthorities(Set.of(authority));
|
||||
return user;
|
||||
}
|
||||
|
||||
private UsernameAndPassMfa buildPayload(String mfaCode) {
|
||||
UsernameAndPassMfa payload = new UsernameAndPassMfa();
|
||||
payload.setUsername("[email protected]");
|
||||
payload.setPassword("pw");
|
||||
payload.setMfaCode(mfaCode);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerMfaTest {
|
||||
|
||||
private static final String USERNAME = "[email protected]";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private Authentication authentication;
|
||||
private User user;
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private CustomUserDetailsService userDetailsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
|
||||
@InjectMocks private AuthController authController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
|
||||
authentication = new UsernamePasswordAuthenticationToken(USERNAME, "password", List.of());
|
||||
user = new User();
|
||||
user.setUsername(USERNAME);
|
||||
user.setAuthenticationType(AuthenticationType.WEB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaRequiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(content().json("{\"error\":\"Not authenticated\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsSecretAndUri() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(totpService.generateSecret()).thenReturn("SECRET");
|
||||
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.secret").value("SECRET"))
|
||||
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
|
||||
|
||||
verify(mfaService).setSecret(user, "SECRET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
|
||||
|
||||
verify(totpService, never()).generateSecret();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaRejectsNonWebAuthenticationType() throws Exception {
|
||||
user.setAuthenticationType(AuthenticationType.OAUTH2);
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(
|
||||
content()
|
||||
.json(
|
||||
"{\"error\":\"MFA settings are only available for web accounts\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaRejectsMissingCode() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/enable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().json("{\"error\":\"MFA code is required\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaCompletesWorkflow() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(42L);
|
||||
when(mfaService.isTotpStepUsable(user, 42L)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/enable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "123456"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(true));
|
||||
|
||||
verify(mfaService).enableMfa(user);
|
||||
verify(mfaService).markTotpStepUsed(user, 42L);
|
||||
verify(mfaService).setMfaRequired(user, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaCompletesWorkflow() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
when(totpService.getValidTimeStep("SECRET", "654321")).thenReturn(7L);
|
||||
when(mfaService.isTotpStepUsable(user, 7L)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/disable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false));
|
||||
|
||||
verify(mfaService).disableMfa(user);
|
||||
verify(mfaService).markTotpStepUsed(user, 7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaReturnsDisabledWhenNotEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/disable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false));
|
||||
|
||||
verify(mfaService, never()).getSecret(user);
|
||||
verifyNoInteractions(totpService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelMfaSetupClearsPendingSecret() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.cleared").value(true));
|
||||
|
||||
verify(mfaService).clearPendingSecret(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelMfaSetupReturnsConflictWhenEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
|
||||
|
||||
verify(mfaService, never()).clearPendingSecret(user);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.hamcrest.Matchers.startsWith;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InviteLinkControllerTest {
|
||||
|
||||
@Mock private InviteTokenRepository inviteTokenRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private EmailService emailService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
private Principal adminPrincipal;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getMail().setEnableInvites(true);
|
||||
applicationProperties.getMail().setInviteLinkExpiryHours(24);
|
||||
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com");
|
||||
|
||||
adminPrincipal = () -> "admin";
|
||||
|
||||
InviteLinkController controller =
|
||||
new InviteLinkController(
|
||||
inviteTokenRepository,
|
||||
teamRepository,
|
||||
userService,
|
||||
applicationProperties,
|
||||
Optional.of(emailService));
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkRejectsWhenInvitesDisabled() throws Exception {
|
||||
applicationProperties.getMail().setEnableInvites(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/invite/generate").principal(adminPrincipal))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Email invites are not enabled"));
|
||||
|
||||
verify(inviteTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkRejectsInvalidEmail() throws Exception {
|
||||
applicationProperties.getMail().setEnableInvites(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "not-an-email"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Invalid email address"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkBlocksOnLicenseLimit() throws Exception {
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
applicationProperties.getPremium().setMaxUsers(1);
|
||||
when(userService.getTotalUsersCount()).thenReturn(1L);
|
||||
when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "[email protected]"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value(startsWith("License limit reached")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkBuildsFrontendUrl() throws Exception {
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setId(5L);
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
|
||||
.thenReturn(Optional.of(defaultTeam));
|
||||
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
|
||||
when(inviteTokenRepository.findByEmail("[email protected]")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "[email protected]"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(
|
||||
jsonPath("$.inviteUrl")
|
||||
.value(startsWith("https://frontend.example.com/invite?token=")))
|
||||
.andExpect(jsonPath("$.email").value("[email protected]"));
|
||||
|
||||
verify(inviteTokenRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
|
||||
InviteToken expired = new InviteToken();
|
||||
expired.setToken("abc");
|
||||
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
|
||||
expired.setRole(Role.USER.getRoleId());
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
|
||||
|
||||
mockMvc.perform(get("/api/v1/invite/validate/abc"))
|
||||
.andExpect(status().isGone())
|
||||
.andExpect(jsonPath("$.error").value("This invite link has expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
|
||||
InviteToken invite = new InviteToken();
|
||||
invite.setToken("abc");
|
||||
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
|
||||
invite.setRole(Role.USER.getRoleId());
|
||||
invite.setUsed(false);
|
||||
invite.setEmail(null); // email required from request
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
|
||||
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/accept/abc")
|
||||
.param("email", "[email protected]")
|
||||
.param("password", "password123"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Account created successfully"))
|
||||
.andExpect(jsonPath("$.username").value("[email protected]"));
|
||||
|
||||
verify(userService).saveUserCore(any());
|
||||
verify(inviteTokenRepository).save(invite);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getPremium().setMaxUsers(10);
|
||||
applicationProperties.getMail().setEnabled(true);
|
||||
|
||||
UserController controller =
|
||||
new UserController(
|
||||
userService,
|
||||
sessionRegistry,
|
||||
applicationProperties,
|
||||
teamRepository,
|
||||
userRepository,
|
||||
Optional.of(emailService),
|
||||
licenseSettingsService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerRejectsExistingUser() throws Exception {
|
||||
UsernameAndPass payload = new UsernameAndPass();
|
||||
payload.setUsername("[email protected]");
|
||||
payload.setPassword("pw");
|
||||
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("User already exists"));
|
||||
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesUserWhenValid() throws Exception {
|
||||
UsernameAndPass payload = new UsernameAndPass();
|
||||
payload.setUsername("[email protected]");
|
||||
payload.setPassword("pw");
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
|
||||
when(userService.isUsernameValid("[email protected]")).thenReturn(true);
|
||||
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
|
||||
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
|
||||
.thenReturn(Optional.of(defaultTeam));
|
||||
|
||||
User savedUser = new User();
|
||||
savedUser.setUsername("[email protected]");
|
||||
savedUser.setEnabled(false);
|
||||
when(userService.saveUserCore(any())).thenReturn(savedUser);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.user.username").value("[email protected]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeUserEnabledPreventsSelfDisable() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("admin");
|
||||
when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true);
|
||||
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(user));
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "pw");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/admin/changeUserEnabled/admin")
|
||||
.param("enabled", "false")
|
||||
.principal(authentication))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Cannot disable your own account."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePasswordRejectsMissingUser() throws Exception {
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("ghost", "pw");
|
||||
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/user/admin/deleteUser/ghost").principal(authentication))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error").value("User not found."));
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
|
||||
class DatabaseServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private DatabaseNotificationServiceInterface notificationService;
|
||||
|
||||
private DatabaseService databaseService;
|
||||
private ApplicationProperties.Datasource datasourceProps;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
datasourceProps = new ApplicationProperties.Datasource();
|
||||
datasourceProps.setType(ApplicationProperties.Driver.H2.name());
|
||||
datasourceProps.setCustomDatabaseUrl("jdbc:h2:mem:test");
|
||||
datasourceProps.setEnableCustomDatabase(false);
|
||||
|
||||
DataSource dataSource =
|
||||
new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
|
||||
|
||||
databaseService = new DatabaseService(datasourceProps, dataSource, notificationService);
|
||||
ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasBackupReturnsFalseWhenEmpty() {
|
||||
assertThat(databaseService.hasBackup()).isFalse();
|
||||
assertThat(Files.exists(tempDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupListReturnsEntries() throws IOException {
|
||||
Path backup =
|
||||
tempDir.resolve(
|
||||
"backup_"
|
||||
+ LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
|
||||
+ ".sql");
|
||||
Files.writeString(backup, "CREATE TABLE TEST(ID INT);");
|
||||
|
||||
List<stirling.software.common.model.FileInfo> backups = databaseService.getBackupList();
|
||||
|
||||
assertThat(backups).hasSize(1);
|
||||
assertThat(backups.get(0).getFileName()).isEqualTo(backup.getFileName().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importDatabaseFromUICopiesBackupAndDeletesTemp() throws IOException {
|
||||
Path script = Files.createTempFile("script", ".sql");
|
||||
Files.writeString(script, "CREATE TABLE SAMPLE(ID INT PRIMARY KEY);\n");
|
||||
|
||||
boolean result = databaseService.importDatabaseFromUI(script);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
assertThat(Files.exists(script)).isFalse();
|
||||
try (var stream = Files.list(tempDir)) {
|
||||
assertThat(stream.toList()).isNotEmpty();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupFilePathPreventsTraversal() {
|
||||
assertThatThrownBy(() -> databaseService.getBackupFilePath("../evil.sql"))
|
||||
.isInstanceOf(SecurityException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteBackupFileRejectsInvalidName() throws Exception {
|
||||
boolean deleted = databaseService.deleteBackupFile("..bad.sql");
|
||||
assertThat(deleted).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAllBackupsRemovesFiles() throws Exception {
|
||||
Path first = tempDir.resolve("backup_first.sql");
|
||||
Path second = tempDir.resolve("backup_second.sql");
|
||||
Files.writeString(first, "SELECT 1;");
|
||||
Files.writeString(second, "SELECT 1;");
|
||||
|
||||
List<org.apache.commons.lang3.tuple.Pair<FileInfo, Boolean>> results =
|
||||
databaseService.deleteAllBackups();
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.stream().allMatch(org.apache.commons.lang3.tuple.Pair::getRight))
|
||||
.isTrue();
|
||||
assertThat(Files.exists(first)).isFalse();
|
||||
assertThat(Files.exists(second)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportDatabaseCreatesScript() {
|
||||
databaseService.exportDatabase();
|
||||
|
||||
assertThat(tempDir.toFile().list()).isNotEmpty();
|
||||
verify(notificationService)
|
||||
.notifyBackupsSuccess(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
}
|
||||
+146
@@ -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));
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
class TotpServiceTest {
|
||||
|
||||
private TotpService buildService(String appName) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
ui.setAppNameNavbar(appName);
|
||||
properties.setUi(ui);
|
||||
return new TotpService(properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSecretReturnsBase32String() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
String secret = service.generateSecret();
|
||||
|
||||
assertNotNull(secret);
|
||||
assertEquals(32, secret.length());
|
||||
assertTrue(secret.matches("[A-Z2-7]+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildOtpAuthUriIncludesIssuerAndUsername() {
|
||||
TotpService service = buildService("Stirling Test");
|
||||
|
||||
String uri = service.buildOtpAuthUri("[email protected]", "SECRET");
|
||||
|
||||
assertTrue(uri.contains("issuer=Stirling%20Test"));
|
||||
assertTrue(uri.contains("Stirling%20Test%3Auser%40example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeAcceptsCurrentAndAdjacentTimeSteps() throws Exception {
|
||||
TotpService service = buildService("Test App");
|
||||
byte[] secretBytes = "super-secret".getBytes(StandardCharsets.UTF_8);
|
||||
String secret = Base32Codec.encode(secretBytes);
|
||||
|
||||
long timeStep = Instant.now().getEpochSecond() / 30;
|
||||
String currentCode = generateCode(service, secretBytes, timeStep);
|
||||
String nextCode = generateCode(service, secretBytes, timeStep + 1);
|
||||
|
||||
assertTrue(service.isValidCode(secret, currentCode));
|
||||
assertEquals(timeStep, service.getValidTimeStep(secret, currentCode));
|
||||
assertTrue(service.isValidCode(secret, nextCode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeRejectsInvalidFormats() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
assertFalse(service.isValidCode("SECRET", "ABCDEF"));
|
||||
assertFalse(service.isValidCode("SECRET", "12345"));
|
||||
assertFalse(service.isValidCode(null, "123456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeRejectsInvalidSecrets() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
assertFalse(service.isValidCode("INVALID*", "123456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildOtpAuthUriUsesDefaultIssuerWhenMissing() {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
TotpService service = new TotpService(properties);
|
||||
|
||||
String uri = service.buildOtpAuthUri("[email protected]", "SECRET");
|
||||
|
||||
assertTrue(uri.contains("issuer=Stirling%20PDF"));
|
||||
}
|
||||
|
||||
private String generateCode(TotpService service, byte[] secretBytes, long timeStep)
|
||||
throws Exception {
|
||||
Method generateCode =
|
||||
TotpService.class.getDeclaredMethod("generateCode", byte[].class, long.class);
|
||||
generateCode.setAccessible(true);
|
||||
return (String) generateCode.invoke(service, secretBytes, timeStep);
|
||||
}
|
||||
}
|
||||
+118
-230
@@ -1,22 +1,26 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
@Mock private AuthorityRepository authorityRepository;
|
||||
|
||||
@Mock private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock private MessageSource messageSource;
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
private Team mockTeam;
|
||||
private User mockUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockTeam = new Team();
|
||||
mockTeam.setId(1L);
|
||||
mockTeam.setName("Test Team");
|
||||
|
||||
mockUser = new User();
|
||||
mockUser.setId(1L);
|
||||
mockUser.setUsername("testuser");
|
||||
mockUser.setEnabled(true);
|
||||
}
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
void saveUserCore_populatesFieldsAndPersists()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Long teamId = 42L;
|
||||
Team team = new Team();
|
||||
team.setId(teamId);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(team));
|
||||
when(passwordEncoder.encode("plain")).thenReturn("encoded");
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
SaveUserRequest request =
|
||||
SaveUserRequest.builder()
|
||||
.username("validUser")
|
||||
.password("plain")
|
||||
.ssoProviderId("sso-id")
|
||||
.ssoProvider("provider-x")
|
||||
.authenticationType(AuthenticationType.OAUTH2)
|
||||
.teamId(teamId)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true)
|
||||
.enabled(false)
|
||||
.requireMfa(true)
|
||||
.mfaEnabled(true)
|
||||
.mfaSecret("top-secret")
|
||||
.mfaLastUsedStep(5L)
|
||||
.build();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, authType);
|
||||
User saved = userService.saveUserCore(request);
|
||||
|
||||
// Then
|
||||
verify(userRepository).save(any(User.class));
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(userCaptor.capture());
|
||||
verify(databaseService).exportDatabase();
|
||||
|
||||
User persisted = userCaptor.getValue();
|
||||
assertEquals("validUser", persisted.getUsername());
|
||||
assertEquals("encoded", persisted.getPassword());
|
||||
assertEquals("provider-x", persisted.getSsoProvider());
|
||||
assertEquals("sso-id", persisted.getSsoProviderId());
|
||||
assertEquals(team, persisted.getTeam());
|
||||
assertEquals("oauth2", persisted.getAuthenticationType());
|
||||
assertFalse(persisted.isEnabled());
|
||||
assertTrue(persisted.isFirstLogin());
|
||||
assertTrue(
|
||||
persisted.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())));
|
||||
assertEquals(
|
||||
"true",
|
||||
persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY),
|
||||
"MFA requirement should be stored");
|
||||
assertEquals(
|
||||
"true",
|
||||
persisted.getSettings().get(MfaService.MFA_ENABLED_KEY),
|
||||
"MFA enabled flag should be stored");
|
||||
assertEquals(
|
||||
"top-secret",
|
||||
persisted.getSettings().get(MfaService.MFA_SECRET_KEY),
|
||||
"MFA secret should be stored");
|
||||
assertEquals(
|
||||
"5",
|
||||
persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY),
|
||||
"MFA last used step should be stored");
|
||||
assertSame(saved, persisted, "Returned user should be the persisted instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
void saveUserCore_withoutTeam_usesDefaultTeam()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName("Default");
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, password, teamId);
|
||||
User saved = userService.saveUserCore(request);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(teamRepository).findById(teamId);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(teamRepository).findByName("Default");
|
||||
verify(teamRepository, never()).findById(anyLong());
|
||||
verify(databaseService).exportDatabase();
|
||||
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithTeamAndRole_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
String role = Role.ADMIN.getRoleId();
|
||||
boolean firstLogin = true;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
void processSSOPostLogin_autoCreatesUserWhenMissing()
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
String username = "autoUser";
|
||||
doReturn(true).when(userService).isUsernameValid(username);
|
||||
when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
|
||||
User created = new User();
|
||||
doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class));
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
|
||||
ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(reqCaptor.capture());
|
||||
SaveUserRequest captured = reqCaptor.getValue();
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
assertEquals(username, captured.getUsername());
|
||||
assertEquals("id", captured.getSsoProviderId());
|
||||
assertEquals("prov", captured.getSsoProvider());
|
||||
assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String invalidUsername = "ab"; // Too short (less than 3 characters)
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
void addApiKeyToUserGeneratesAndPersists() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(invalidUsername, authType));
|
||||
User updated = userService.addApiKeyToUser("user");
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
assertNotNull(updated.getApiKey());
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithNullPassword_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
Long teamId = 1L;
|
||||
void getApiKeyForUserCreatesWhenMissing() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
String apiKey = userService.getApiKeyForUser("user");
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, null, teamId);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder, never()).encode(anyString());
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
assertNotNull(apiKey);
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithEmptyPassword_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String emptyPassword = "";
|
||||
Long teamId = 1L;
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, emptyPassword, teamId);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder, never()).encode(anyString());
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithValidEmail_Success() throws Exception {
|
||||
// Given
|
||||
String emailUsername = "[email protected]";
|
||||
AuthenticationType authType = AuthenticationType.OAUTH2;
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(emailUsername, authType);
|
||||
|
||||
// Then
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String reservedUsername = "all_users";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(reservedUsername, authType));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String anonymousUsername = "anonymoususer";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(anonymousUsername, authType));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
|
||||
|
||||
// When & Then
|
||||
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
|
||||
|
||||
// Verify user was still saved before the exception
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
boolean firstLogin = true;
|
||||
boolean enabled = false;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, password, teamId, firstLogin, enabled);
|
||||
|
||||
// Then
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithCustomRole_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String customRole = Role.LIMITED_API_USER.getRoleId();
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, password, teamId, customRole);
|
||||
|
||||
// Then
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
void isUsernameValidRejectsReservedAndAcceptsEmail() {
|
||||
assertFalse(userService.isUsernameValid("ALL_USERS"));
|
||||
assertTrue(userService.isUsernameValid("[email protected]"));
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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$"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user