From dfcc9a6b2bc6a2691ac95188b25040c780ca713d Mon Sep 17 00:00:00 2001 From: Jens Kutzsche Date: Mon, 23 May 2022 15:45:21 +0200 Subject: [PATCH 1/4] feat: Users can now use two-factor authentication with time-based one-time password (TOTP). If it is enabled, a TOTP is expected and verified by a corresponding app after the conventional login. To set up the app, the user is displayed a QR code by IRIS. It is also possible for the admin to activate this mandatorily via environment variable. If a 2FA is expected but has not yet been finally configured for a user with a successful verification, the QR code is displayed after the successful conventional login and the verification is performed. A secret for the MFA has been added to `UserAccount` and a flag indicating whether the transfer of the secret was completed successfully with verification. Furthermore, the `UserService` was supplemented with methods for working with the secret. The `security.auth.db.mfa.option` property was added to specify how the MFA should be used. Possible values are: `ALWAYS, OPTIONAL_DEFAULT_TRUE, OPTIONAL_DEFAULT_FALSE, DISABLED`. The new `MfAuthenticationConfigController` allows querying the configuration for the MFA via the `/mfa/config` path. Thus the FE can be adapted accordingly. An optional step (depending on the MFA configuration) has been added to the authentication process. If the conventional login was successful, with active MFA a token is returned which only allows access to `/mfa/otp`. This is used to check an OTP to complete the authentication. Only after that a general JWT is returned. The responses to authentication requests now contain a body with the property `authenticationStatus`, which contains the status of the authentication and can take the following values: `AUTHENTICATED, PRE_AUTHENTICATED_MFA_REQUIRED, PRE_AUTHENTICATED_ENROLLMENT_REQUIRED`. The new annotation `@WithMockIrisUser` has been added to create a user with `authorities = "USER"`. This is necessary because an authority is now required for the accesses. Refs iris-connect/iris-backlog#251 --- iris-client-bff/pom.xml | 10 +- .../auth/db/AuthenticationStatus.java | 5 + .../auth/db/DbAuthSecurityConfig.java | 12 +- .../auth/db/JwtAuthenticationToken.java | 25 +- .../auth/db/MfAuthenticationProperties.java | 28 ++ .../auth/db/OtpAuthenticationProvider.java | 54 ++++ .../auth/db/OtpAuthenticationToken.java | 28 ++ .../client_bff/auth/db/jwt/JWTService.java | 75 +++-- .../client_bff/auth/db/jwt/JwtConstants.java | 4 +- .../login_attempts/LoginAttemptsService.java | 5 +- .../auth/db/web/AuthenticationController.java | 117 +++++-- .../web/MfAuthenticationConfigController.java | 28 ++ .../iris/client_bff/cases/model/Contact.java | 11 +- .../client_bff/config/SecurityConfig.java | 7 + .../GlobalControllerExceptionHandler.java | 15 +- .../client_bff/iris_messages/IrisMessage.java | 12 +- .../users/AuthenticatedUserAware.java | 2 + .../iris/client_bff/users/UserAccount.java | 46 +++ .../iris/client_bff/users/UserService.java | 134 +++++++- .../client_bff/users/web/UserController.java | 12 +- .../iris/client_bff/users/web/UserDtos.java | 10 +- .../iris/client_bff/users/web/UserMapper.java | 3 + .../users/web/UserProfileController.java | 60 +++- .../vaccination_info/VaccinationInfo.java | 12 +- .../resources/application-dev_auth.properties | 1 + .../src/main/resources/application.properties | 2 + .../resources/db/migration/V1015__add_mfa.sql | 2 + .../db/migration_mssql/V1015__add_mfa.sql | 2 + .../db/migration_mysql/V1015__add_mfa.sql | 2 + .../iris/client_bff/WithMockIrisUser.java | 17 + .../db/MfAuthenticationIntegrationTest.java | 299 ++++++++++++++++++ .../auth/db/RefreshTokenIntegrationTest.java | 12 - .../cases/web/IndexCaseControllerTest.java | 16 +- ...rollerExceptionHandlerIntegrationTest.java | 4 +- .../web/EventDataRequestControllerTest.java | 13 +- .../IrisMessageControllerIntegrationTest.java | 4 +- .../web/IrisMessageControllerTest.java | 19 +- .../web/IrisMessageDataControllerTest.java | 9 +- .../web/LocationSearchControllerTests.java | 4 +- .../web/StatisticsControllerTest.java | 4 +- .../status/web/AppStatusControllerTest.java | 10 +- .../client_bff/users/UserServiceTests.java | 187 ++++++++++- .../web/UserControllerIntegrationTests.java | 22 +- .../users/web/UserDtoMappingTests.java | 3 +- ...cinationInfoControllerIntegrationTest.java | 4 +- ...pplicationRequestSizeLimitFilterTests.java | 6 +- 46 files changed, 1172 insertions(+), 185 deletions(-) create mode 100644 iris-client-bff/src/main/java/iris/client_bff/auth/db/AuthenticationStatus.java create mode 100644 iris-client-bff/src/main/java/iris/client_bff/auth/db/MfAuthenticationProperties.java create mode 100644 iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationProvider.java create mode 100644 iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationToken.java create mode 100644 iris-client-bff/src/main/java/iris/client_bff/auth/db/web/MfAuthenticationConfigController.java create mode 100644 iris-client-bff/src/main/resources/db/migration/V1015__add_mfa.sql create mode 100644 iris-client-bff/src/main/resources/db/migration_mssql/V1015__add_mfa.sql create mode 100644 iris-client-bff/src/main/resources/db/migration_mysql/V1015__add_mfa.sql create mode 100644 iris-client-bff/src/test/java/iris/client_bff/WithMockIrisUser.java create mode 100644 iris-client-bff/src/test/java/iris/client_bff/auth/db/MfAuthenticationIntegrationTest.java diff --git a/iris-client-bff/pom.xml b/iris-client-bff/pom.xml index a3138e45c..294597ee8 100644 --- a/iris-client-bff/pom.xml +++ b/iris-client-bff/pom.xml @@ -32,9 +32,10 @@ 1.5.2.Final 0.2.0 0.1.2 + 6.1.5.Final + 1.7.1 1.6.9 1.17.2 - 6.1.5.Final @@ -308,6 +309,13 @@ 1.0.2.3 + + + dev.samstevens.totp + totp-spring-boot-starter + ${totp.version} + + org.springframework.boot diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/AuthenticationStatus.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/AuthenticationStatus.java new file mode 100644 index 000000000..db02f8f39 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/AuthenticationStatus.java @@ -0,0 +1,5 @@ +package iris.client_bff.auth.db; + +public enum AuthenticationStatus { + AUTHENTICATED, PRE_AUTHENTICATED_MFA_REQUIRED, PRE_AUTHENTICATED_ENROLLMENT_REQUIRED +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/DbAuthSecurityConfig.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/DbAuthSecurityConfig.java index 3c0fcddad..f4214bd79 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/auth/db/DbAuthSecurityConfig.java +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/DbAuthSecurityConfig.java @@ -22,6 +22,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; @@ -56,14 +57,11 @@ public class DbAuthSecurityConfig extends WebSecurityConfigurerAdapter { }; private final Environment env; - private final UserService userService; - private final JWTService jwtService; - private final AuthenticationEntryPoint authenticationEntryPoint; - private final AccessDeniedHandler accessDeniedHandler; + private final AuthenticationManagerBuilder authenticationManagerBuilder; @Override protected void configure(HttpSecurity http) throws Exception { @@ -91,13 +89,17 @@ protected void configure(HttpSecurity http) throws Exception { .antMatchers(HttpMethod.POST, DATA_SUBMISSION_ENDPOINT).permitAll() .antMatchers(HttpMethod.POST, DATA_SUBMISSION_ENDPOINT_WITH_SLASH).permitAll() .antMatchers(LOGIN, "/refreshtoken", "/error").permitAll() - .anyRequest().authenticated()) + .antMatchers("/mfa/otp").hasAnyAuthority(AuthenticationStatus.PRE_AUTHENTICATED_MFA_REQUIRED.name(), + AuthenticationStatus.PRE_AUTHENTICATED_ENROLLMENT_REQUIRED.name()) + .anyRequest().hasAnyAuthority(UserRole.ADMIN.name(), UserRole.USER.name())) .logout(it -> it .logoutUrl("/user/logout") .addLogoutHandler(logoutHandler()) .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT)) .permitAll()) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); + + authenticationManagerBuilder.authenticationProvider(new OtpAuthenticationProvider(userService)); } @Bean diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/JwtAuthenticationToken.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/JwtAuthenticationToken.java index 519724990..7d702ef9c 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/auth/db/JwtAuthenticationToken.java +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/JwtAuthenticationToken.java @@ -1,35 +1,35 @@ package iris.client_bff.auth.db; -import static iris.client_bff.users.UserRole.*; +import static java.util.Optional.*; +import static org.apache.commons.collections4.CollectionUtils.*; +import iris.client_bff.auth.db.jwt.JwtConstants; import iris.client_bff.users.UserAccount; -import iris.client_bff.users.UserRole; +import lombok.EqualsAndHashCode; import lombok.NonNull; -import java.util.EnumSet; +import java.util.List; import java.util.Map; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.Transient; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.AbstractOAuth2TokenAuthenticationToken; @Transient +@EqualsAndHashCode(callSuper = true) public class JwtAuthenticationToken extends AbstractOAuth2TokenAuthenticationToken { private static final long serialVersionUID = -2363012036109039609L; - private static final EnumSet AUTHENTICATION_ROLES = EnumSet.of(ADMIN, USER); - private final String name; public JwtAuthenticationToken(@NonNull Jwt jwt, @NonNull UserAccount principal) { - // By convention we expect that there exists only one authority and it represents the role - super(jwt, principal, null, - AuthorityUtils.createAuthorityList(principal.getRole().name())); + super(jwt, principal, null, determineAuthority(jwt, principal)); - this.setAuthenticated(isAuthenticated(principal)); + this.setAuthenticated(isNotEmpty(getAuthorities())); this.name = jwt.getSubject(); } @@ -43,7 +43,10 @@ public String getName() { return this.name; } - private boolean isAuthenticated(@NonNull UserAccount principal) { - return AUTHENTICATION_ROLES.contains(principal.getRole()); + private static List determineAuthority(Jwt jwt, UserAccount principal) { + return ofNullable(jwt.getClaimAsString(JwtConstants.JWT_CLAIM_AUTH_STATUS)) + .map(AuthorityUtils::createAuthorityList) + // By convention we expect that there exists only one authority and it represents the role + .orElseGet(() -> AuthorityUtils.createAuthorityList(principal.getRole().name())); } } diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/MfAuthenticationProperties.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/MfAuthenticationProperties.java new file mode 100644 index 000000000..95462fac7 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/MfAuthenticationProperties.java @@ -0,0 +1,28 @@ +package iris.client_bff.auth.db; + +import static iris.client_bff.auth.db.MfAuthenticationProperties.MfAuthenticationOptions.*; + +import lombok.Value; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +@ConfigurationProperties(prefix = "security.auth.db.mfa") +@ConstructorBinding +@ConditionalOnProperty( + value = "security.auth", + havingValue = "db") +@Value +public class MfAuthenticationProperties { + + private MfAuthenticationOptions option; + + public boolean isMfaEnabled() { + return option != DISABLED; + } + + public enum MfAuthenticationOptions { + ALWAYS, OPTIONAL_DEFAULT_TRUE, OPTIONAL_DEFAULT_FALSE, DISABLED + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationProvider.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationProvider.java new file mode 100644 index 000000000..79231801d --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationProvider.java @@ -0,0 +1,54 @@ +package iris.client_bff.auth.db; + +import iris.client_bff.users.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +@ConditionalOnProperty( + value = "security.auth", + havingValue = "db") +@RequiredArgsConstructor +@Slf4j +public class OtpAuthenticationProvider implements AuthenticationProvider { + + private static final String USER_NOT_FOUND = "User: %s, not found"; + + private final UserService userService; + + @Override + public Authentication authenticate(Authentication auth) throws AuthenticationException { + + var username = auth.getName(); + var otp = Objects.toString(auth.getCredentials()); + var user = userService.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException(String.format(USER_NOT_FOUND, username))); + + var valid = user.isMfaSecretEnrolled() + ? userService.verifyOtp(user, otp) + : userService.finishEnrollment(user, otp); + + if (!valid) { + throw new BadCredentialsException("Invalid verification code"); + } + + var resultAuth = new OtpAuthenticationToken(user, null); + resultAuth.setDetails(auth.getDetails()); + log.debug("Authenticated user"); + + return resultAuth; + } + + @Override + public boolean supports(Class authentication) { + return OtpAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationToken.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationToken.java new file mode 100644 index 000000000..b18451337 --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/OtpAuthenticationToken.java @@ -0,0 +1,28 @@ +package iris.client_bff.auth.db; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.SpringSecurityCoreVersion; + +/** + * @author Jens Kutzsche + */ +@Value +@EqualsAndHashCode(callSuper = false) +public class OtpAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private Object principal; + private Object credentials; + + public OtpAuthenticationToken(Object principal, Object credentials) { + + super(null); + + this.principal = principal; + this.credentials = credentials; + } +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JWTService.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JWTService.java index 53cabe08b..bdae3c0b0 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JWTService.java +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JWTService.java @@ -6,6 +6,7 @@ import io.vavr.control.Option; import io.vavr.control.Try; +import iris.client_bff.auth.db.AuthenticationStatus; import iris.client_bff.core.setting.Setting; import iris.client_bff.core.setting.Setting.Name; import iris.client_bff.core.setting.SettingsRepository; @@ -41,7 +42,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConstructorBinding; import org.springframework.http.ResponseCookie; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; @@ -102,18 +102,18 @@ public JwtDecoder getJwtDecoder() { return decoder; } - public ResponseCookie createJwtCookie(UserDetails user) { + public ResponseCookie createJwtCookie(String username) { - var refreshExpirationTime = jwtProperties.getRefresh().getExpirationTime(); - var jwt = createToken(user, jwtProperties.getExpirationTime(), refreshExpirationTime, this::signToken); + return createJwtCookie(new JwtCookieData(username, null, jwtProperties.getCookieName(), + jwtProperties.getExpirationTime(), jwtProperties.getRefresh().getExpirationTime(), jwtProperties.isSetSecure(), + PATH, jwtProperties.getSameSiteStr(), this::signToken)); + } - return ResponseCookie.from(jwtProperties.getCookieName(), jwt) - .maxAge(refreshExpirationTime.toSeconds()) - .secure(jwtProperties.isSetSecure()) - .path(PATH) - .httpOnly(true) - .sameSite(jwtProperties.getSameSiteStr()) - .build(); + public ResponseCookie createPreAuthJwtCookie(String username, AuthenticationStatus authStatus) { + + var expirationTime = Duration.ofMinutes(5); + return createJwtCookie(new JwtCookieData(username, authStatus, jwtProperties.getCookieName(), expirationTime, + expirationTime, jwtProperties.isSetSecure(), PATH, jwtProperties.getSameSiteStr(), this::signToken)); } public ResponseCookie createCleanJwtCookie() { @@ -122,19 +122,13 @@ public ResponseCookie createCleanJwtCookie() { return ResponseCookie.from(jwtProperties.getCookieName(), null).maxAge(0).path(PATH).build(); } - public ResponseCookie createRefreshCookie(UserDetails user) { + public ResponseCookie createRefreshCookie(String username) { var refresh = jwtProperties.getRefresh(); var expirationTime = refresh.getExpirationTime(); - var jwt = createToken(user, expirationTime, expirationTime, this::signRefreshToken); - return ResponseCookie.from(refresh.getCookieName(), jwt) - .maxAge(expirationTime.toSeconds()) - .secure(jwtProperties.isSetSecure()) - .path(REFRESH_PATH) - .httpOnly(true) - .sameSite(jwtProperties.getSameSiteStr()) - .build(); + return createJwtCookie(new JwtCookieData(username, null, refresh.getCookieName(), expirationTime, expirationTime, + jwtProperties.isSetSecure(), REFRESH_PATH, jwtProperties.getSameSiteStr(), this::signRefreshToken)); } public ResponseCookie createCleanRefreshCookie() { @@ -210,20 +204,35 @@ private OAuth2TokenValidatorResult isTokenWhitelisted(Jwt jwt) { return OAuth2TokenValidatorResult.failure(error); } - private String createToken(UserDetails user, Duration tokenExpirationDuration, Duration entityExpirationDuration, - Function signFunction) { + private ResponseCookie createJwtCookie(JwtCookieData data) { - var username = user.getUsername(); + var jwt = createToken(data.username(), data.authStatus, data.tokenExpirationDuration(), + data.cookieExpirationDuration(), data.signFunction()); + + return ResponseCookie.from(data.cookieName(), jwt) + .maxAge(data.cookieExpirationDuration().toSeconds()) + .secure(data.secure()) + .path(data.path()) + .httpOnly(true) + .sameSite(data.sameSite()) + .build(); + } + + private String createToken(String username, AuthenticationStatus authStatus, Duration tokenExpirationDuration, + Duration entityExpirationDuration, Function signFunction) { - // By convention we expect that there exists only one authority and it represents the role - var role = user.getAuthorities().iterator().next().getAuthority(); var issuedAt = Instant.now(); - var token = signFunction.apply(JWT.create() + var jwtBuilder = JWT.create() .withSubject(username) - .withClaim(JWT_CLAIM_USER_ROLE, role) .withIssuedAt(Date.from(issuedAt)) - .withExpiresAt(Date.from(issuedAt.plus(tokenExpirationDuration)))); + .withExpiresAt(Date.from(issuedAt.plus(tokenExpirationDuration))); + + if (authStatus != null) { + jwtBuilder = jwtBuilder.withClaim(JWT_CLAIM_AUTH_STATUS, authStatus.toString()); + } + + var token = signFunction.apply(jwtBuilder); saveToken(token, username, issuedAt.plus(entityExpirationDuration), issuedAt); @@ -342,4 +351,14 @@ static class RefreshProperties { enum SameSite { Strict, Lax, None } + + static record JwtCookieData(String username, + AuthenticationStatus authStatus, + String cookieName, + Duration tokenExpirationDuration, + Duration cookieExpirationDuration, + boolean secure, + String path, + String sameSite, + Function signFunction) {} } diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JwtConstants.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JwtConstants.java index 08382db24..8244f6d74 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JwtConstants.java +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JwtConstants.java @@ -4,7 +4,7 @@ import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -class JwtConstants { +public class JwtConstants { - public static final String JWT_CLAIM_USER_ROLE = "role"; + public static final String JWT_CLAIM_AUTH_STATUS = "AUTH_STATUS"; } diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/login_attempts/LoginAttemptsService.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/login_attempts/LoginAttemptsService.java index 02b7ade57..0824a5405 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/auth/db/login_attempts/LoginAttemptsService.java +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/login_attempts/LoginAttemptsService.java @@ -48,10 +48,7 @@ public void loginFailed(String key) { var keyHash = DigestUtils.md5Hex(key); var attempt = loginAttempts.findById(keyHash) - .map(it -> { - it.setWaitingTime(it.getWaitingTime().multipliedBy(properties.getWaitingTimeMultiplier())); - return it; - }) + .map(it -> it.setWaitingTime(it.getWaitingTime().multipliedBy(properties.getWaitingTimeMultiplier()))) .orElseGet(() -> LoginAttempts.builder() .reference(keyHash) .nextWarningThreshold(properties.getFirstWarningThreshold()) diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/AuthenticationController.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/AuthenticationController.java index 309728fa5..679ae2662 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/AuthenticationController.java +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/AuthenticationController.java @@ -1,15 +1,26 @@ package iris.client_bff.auth.db.web; +import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.*; +import static org.springframework.security.core.authority.AuthorityUtils.*; + +import iris.client_bff.auth.db.AuthenticationStatus; +import iris.client_bff.auth.db.MfAuthenticationProperties; +import iris.client_bff.auth.db.MfAuthenticationProperties.MfAuthenticationOptions; +import iris.client_bff.auth.db.OtpAuthenticationToken; import iris.client_bff.auth.db.RefreshTokenException; import iris.client_bff.auth.db.jwt.JWTService; import iris.client_bff.auth.db.login_attempts.LoginAttemptsService; import iris.client_bff.core.log.LogHelper; +import iris.client_bff.users.UserAccount; +import iris.client_bff.users.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.security.Principal; import java.time.Instant; import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; import javax.validation.constraints.NotBlank; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -19,8 +30,7 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.LockedException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.web.bind.annotation.GetMapping; @@ -44,38 +54,51 @@ public class AuthenticationController { private final AuthenticationManager authManager; private final LoginAttemptsService loginAttempts; private final JWTService jwtService; - private final UserDetailsService userService; + private final UserService userService; private final MessageSourceAccessor messages; + private final MfAuthenticationProperties mfaProperties; @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequestDto login, HttpServletRequest req) { + ResponseEntity login(@RequestBody @Valid LoginDto login, HttpServletRequest req) { log.debug("Login request from remote address: " + LogHelper.obfuscateLastThree(req.getRemoteAddr())); var userName = login.userName(); - loginAttempts.getBlockedUntil(userName) - .ifPresent(it -> { - throw new LockedException(String.format("User blocked! (%s)", it)); - }); + checkPreviousLoginAttempts(userName); + var user = authenticate(login, req); - var authToken = new UsernamePasswordAuthenticationToken( - userName, - login.password()); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); + if (!useMfa(user)) { + return createAuthenticatedResponse(userName); + } - var user = (UserDetails) authManager.authenticate(authToken).getPrincipal(); + if (user.isMfaSecretEnrolled()) { + return createPreAuthResponse(userName, AuthenticationStatus.PRE_AUTHENTICATED_MFA_REQUIRED, null, + null); + } - var jwtCookie = jwtService.createJwtCookie(user); - var refreshCookie = jwtService.createRefreshCookie(user); + // Enables MFA if this is not already active. This could be the case if `security.auth.db.mfa.option` = `always` + // is set. + userService.updateUseMfa(user, true); + // Reset the secret to avoid that someone gets the secret and this goes unnoticed because the enrollment is not + // completed. + userService.resetMfaSecret(user); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, jwtCookie.toString(), refreshCookie.toString()) - .build(); + return createPreAuthResponse(userName, AuthenticationStatus.PRE_AUTHENTICATED_ENROLLMENT_REQUIRED, + userService.generateQrCodeImageUri(user), user.getMfaSecret()); + } + + @PostMapping("/mfa/otp") + ResponseEntity verifyOtp(@RequestBody @Valid MfaDto mfaRequest, Principal principal) { + + var authenticationToken = new OtpAuthenticationToken(principal.getName(), mfaRequest.otp()); + authManager.authenticate(authenticationToken); + + return createAuthenticatedResponse(principal.getName()); } @GetMapping(value = "/refreshtoken") - public ResponseEntity refreshtoken(HttpServletRequest req) { + ResponseEntity refreshtoken(HttpServletRequest req) { log.debug("Refresh token request from remote address: " + LogHelper.obfuscateLastThree(req.getRemoteAddr())); @@ -91,7 +114,6 @@ public ResponseEntity refreshtoken(HttpServletRequest req) { .toEither("AuthenticationController.missing_refresh_jwt") .map(Jwt::getSubject) .filterOrElse(username::equals, __ -> "AuthenticationController.subjects_dont_match") - .map(userService::loadUserByUsername) .map(jwtService::createJwtCookie) .mapLeft(messages::getMessage) .getOrElseThrow(this::createException); @@ -101,11 +123,62 @@ public ResponseEntity refreshtoken(HttpServletRequest req) { .build(); } + private void checkPreviousLoginAttempts(String userName) { + + loginAttempts.getBlockedUntil(userName) + .ifPresent(it -> { + throw new LockedException(String.format("User blocked! (%s)", it)); + }); + } + + private UserAccount authenticate(LoginDto login, HttpServletRequest req) { + + var authToken = new UsernamePasswordAuthenticationToken(login.userName(), login.password()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); + + var auth = authManager.authenticate(authToken); + + var user = userService.findByUsername(auth.getName()).get(); // must exist at this point + + SecurityContextHolder.getContext().setAuthentication( + authenticated(user, null, createAuthorityList(user.getRole().name()))); + + return user; + } + + private boolean useMfa(UserAccount user) { + return mfaProperties.getOption() == MfAuthenticationOptions.ALWAYS || + (mfaProperties.isMfaEnabled() && user.usesMfa()); + } + + private ResponseEntity createPreAuthResponse(String userName, AuthenticationStatus authStatus, + String qrCodeImageUri, String mfaSecret) { + + String[] cookies = { jwtService.createPreAuthJwtCookie(userName, authStatus).toString() }; + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookies) + .body(new ResponseDto(authStatus, qrCodeImageUri, mfaSecret)); + } + + ResponseEntity createAuthenticatedResponse(String userName) { + + String[] cookies = { jwtService.createJwtCookie(userName).toString(), + jwtService.createRefreshCookie(userName).toString() }; + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookies) + .body(new ResponseDto(AuthenticationStatus.AUTHENTICATED, null, null)); + } + private RuntimeException createException(String reason) { return new RefreshTokenException(reason); } - static record LoginRequestDto(@NotBlank String userName, @NotBlank String password) {} + static record LoginDto(@NotBlank String userName, @NotBlank String password) {} + + static record MfaDto(@NotBlank String otp) {} - static record LoginResponseDto(String token) {} + static record ResponseDto(AuthenticationStatus authenticationStatus, String qrCodeImageUri, + String mfaSecret) {} } diff --git a/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/MfAuthenticationConfigController.java b/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/MfAuthenticationConfigController.java new file mode 100644 index 000000000..adfa2273c --- /dev/null +++ b/iris-client-bff/src/main/java/iris/client_bff/auth/db/web/MfAuthenticationConfigController.java @@ -0,0 +1,28 @@ +package iris.client_bff.auth.db.web; + +import iris.client_bff.auth.db.MfAuthenticationProperties; +import iris.client_bff.auth.db.MfAuthenticationProperties.MfAuthenticationOptions; +import lombok.RequiredArgsConstructor; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/mfa/config") +@ConditionalOnProperty( + value = "security.auth", + havingValue = "db") +@RequiredArgsConstructor +class MfAuthenticationConfigController { + + private final MfAuthenticationProperties properties; + + @GetMapping + MfaConfigResponse getMfaConfiguration() { + return new MfaConfigResponse(properties.getOption()); + } + + static record MfaConfigResponse(MfAuthenticationOptions mfaOption) {} +} diff --git a/iris-client-bff/src/main/java/iris/client_bff/cases/model/Contact.java b/iris-client-bff/src/main/java/iris/client_bff/cases/model/Contact.java index 9e3a667b5..311c3f182 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/cases/model/Contact.java +++ b/iris-client-bff/src/main/java/iris/client_bff/cases/model/Contact.java @@ -13,7 +13,16 @@ import java.time.LocalDate; import java.util.UUID; -import javax.persistence.*; +import javax.persistence.AttributeOverride; +import javax.persistence.AttributeOverrides; +import javax.persistence.Column; +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; @Entity @Data diff --git a/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java b/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java index 812df8f2e..990fb2d83 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java +++ b/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java @@ -1,5 +1,7 @@ package iris.client_bff.config; +import dev.samstevens.totp.code.HashingAlgorithm; + import java.security.Security; import javax.annotation.PostConstruct; @@ -23,4 +25,9 @@ public void initBouncyCastle() { Security.addProvider(new BouncyCastleFipsProvider()); Security.setProperty("crypto.policy", "unlimited"); } + + @Bean + public HashingAlgorithm totpHashingAlgorithm() { + return HashingAlgorithm.SHA256; + } } diff --git a/iris-client-bff/src/main/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandler.java b/iris-client-bff/src/main/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandler.java index 56273bf0b..19385b428 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandler.java +++ b/iris-client-bff/src/main/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandler.java @@ -52,6 +52,9 @@ protected ResponseEntity handleExceptionInternal(Exception ex, Object bo body = errorAttributes.getErrorAttributes(request); } + if (headers == null) { + headers = new HttpHeaders(); + } return super.handleExceptionInternal(ex, body, headers, status, request); } @@ -76,7 +79,13 @@ void handleBlockLimitExceededException(BlockLimitExceededException ex) { @ExceptionHandler(ConstraintViolationException.class) ResponseEntity handleConstraintViolation(ConstraintViolationException ex, WebRequest request) { var status = HttpStatus.BAD_REQUEST; - return handleExceptionInternal(ex, null, new HttpHeaders(), status, request); + return handleExceptionInternal(ex, null, null, status, request); + } + + @ExceptionHandler(IllegalArgumentException.class) + ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) { + var status = HttpStatus.BAD_REQUEST; + return handleExceptionInternal(ex, null, null, status, request); } @ExceptionHandler(AuthenticationException.class) @@ -96,7 +105,7 @@ ResponseEntity handleAccessDeniedException(AuthenticationException ex, WebReq @ExceptionHandler(AccessDeniedException.class) ResponseEntity handleAccessDeniedException(AccessDeniedException ex, WebRequest request) throws Exception { var status = HttpStatus.FORBIDDEN; - return handleExceptionInternal(ex, null, new HttpHeaders(), status, request); + return handleExceptionInternal(ex, null, null, status, request); } @ExceptionHandler(Exception.class) @@ -109,7 +118,7 @@ ResponseEntity handleExceptionFallback(Exception ex, WebRequest request) thro log.warn("Unmapped exception occurred", ex); var status = HttpStatus.INTERNAL_SERVER_ERROR; - return handleExceptionInternal(ex, null, new HttpHeaders(), status, request); + return handleExceptionInternal(ex, null, null, status, request); } private String getInternalMessage(Exception ex) { diff --git a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java index fe366b2e5..4ad376398 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java +++ b/iris-client-bff/src/main/java/iris/client_bff/iris_messages/IrisMessage.java @@ -13,7 +13,17 @@ import java.util.List; import java.util.UUID; -import javax.persistence.*; +import javax.persistence.AttributeOverride; +import javax.persistence.AttributeOverrides; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.Table; import org.hibernate.search.engine.backend.types.Sortable; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/AuthenticatedUserAware.java b/iris-client-bff/src/main/java/iris/client_bff/users/AuthenticatedUserAware.java index 4df9a3fbc..777805576 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/AuthenticatedUserAware.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/AuthenticatedUserAware.java @@ -4,6 +4,7 @@ import lombok.NonNull; +import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; @@ -48,6 +49,7 @@ private static Stream getCurrentAuthorities() { return Stream.of(SecurityContextHolder.getContext()) .map(SecurityContext::getAuthentication) + .filter(Objects::nonNull) .flatMap(it -> it.getAuthorities().stream()) .map(GrantedAuthority::getAuthority); } diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/UserAccount.java b/iris-client-bff/src/main/java/iris/client_bff/users/UserAccount.java index 42e3e3dcf..ea3acfb56 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/UserAccount.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/UserAccount.java @@ -1,5 +1,6 @@ package iris.client_bff.users; +import dev.samstevens.totp.secret.SecretGenerator; import iris.client_bff.core.model.Aggregate; import iris.client_bff.core.model.IdWithUuid; import iris.client_bff.users.UserAccount.UserAccountIdentifier; @@ -10,11 +11,13 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.ToString; import lombok.Value; import java.time.Instant; import java.util.UUID; +import java.util.function.Supplier; import javax.persistence.Column; import javax.persistence.Entity; @@ -22,6 +25,8 @@ import javax.persistence.Enumerated; import javax.persistence.Table; +import org.apache.commons.lang3.StringUtils; + @Entity @Table(name = "user_accounts") @Data @@ -53,6 +58,10 @@ public class UserAccount extends Aggregate { private boolean locked; private Instant deletedAt; + private String mfaSecret; + @Setter(AccessLevel.PRIVATE) + private boolean mfaSecretEnrolled; + public boolean isEnabled() { return getDeletedAt() == null; } @@ -61,6 +70,14 @@ public boolean isAdmin() { return getRole() == UserRole.ADMIN; } + private UserAccount setMfaSecret(String secret) { + + mfaSecret = secret; + setMfaSecretEnrolled(false); + + return this; + } + public UserAccount markDeleted() { var name = getUserName(); @@ -80,6 +97,35 @@ public UserAccount markLoginIncompatiblyUpdated() { return this; } + /** + * Creates a secret with the given {@link SecretGenerator} and sets it to {@link #mfaSecret}. + * + * @param secretGenerator + * @return This {@link UserAccount} + */ + UserAccount createMfaSecret(Supplier secretGenerator) { + + String secret = secretGenerator.get(); + + if (StringUtils.isBlank(secret)) { + throw new RuntimeException("Generated secret for MFA is null or blank!"); + } + + return setMfaSecret(secret); + } + + UserAccount deleteMfaSecret() { + return setMfaSecret(null); + } + + public boolean usesMfa() { + return getMfaSecret() != null; + } + + public UserAccount markAsEnrolled() { + return setMfaSecretEnrolled(true); + } + @EqualsAndHashCode(callSuper = false) @RequiredArgsConstructor(staticName = "of") @NoArgsConstructor(force = true, access = AccessLevel.PRIVATE) // for JPA diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/UserService.java b/iris-client-bff/src/main/java/iris/client_bff/users/UserService.java index 369caf956..e13d4b88c 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/UserService.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/UserService.java @@ -1,27 +1,39 @@ package iris.client_bff.users; +import static dev.samstevens.totp.util.Utils.*; import static iris.client_bff.users.UserRole.*; import static java.util.Objects.*; import static org.apache.commons.lang3.StringUtils.*; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.exceptions.QrGenerationException; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrDataFactory; +import dev.samstevens.totp.qr.QrGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import io.vavr.control.Try; +import iris.client_bff.core.alert.AlertService; import iris.client_bff.users.UserAccount.UserAccountBuilder; import iris.client_bff.users.UserAccount.UserAccountIdentifier; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.function.Supplier; import java.util.function.UnaryOperator; import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.util.Assert; import org.springframework.web.server.ResponseStatusException; @Service @@ -29,9 +41,16 @@ @Slf4j public class UserService { + public static final String APP_NAME = "IRIS Client"; + private final UserAccountsRepository users; private final PasswordEncoder passwordEncoder; private final AuthenticatedUserAware authManager; + private final SecretGenerator secretGenerator; + private final QrDataFactory qrDataFactory; + private final QrGenerator qrGenerator; + private final AlertService alertService; + private final CodeVerifier codeVerifier; public Optional findByUuid(UUID id) { return users.findById(UserAccountIdentifier.of(id)); @@ -54,13 +73,19 @@ public List loadAll() { } public UserAccount create(UserAccount user) { + return create(user, false); + } + + public UserAccount create(UserAccount user, boolean useMfa) { + log.info("Create user {}", user.getUserName()); - return users.save(user); + + return users.save(updateUseMfa(user, useMfa)); } - public UserAccount update(UserAccountIdentifier userId, - @Nullable String lastName, @Nullable String firstName, - @Nullable String userName, @Nullable String password, @Nullable UserRole role, @Nullable Boolean locked) { + public UserAccount update(UserAccountIdentifier userId, @Nullable String lastName, @Nullable String firstName, + @Nullable String userName, @Nullable String password, @Nullable UserRole role, @Nullable Boolean locked, + Boolean useMfa) { log.info("Update user: {}", userId); @@ -121,6 +146,10 @@ public UserAccount update(UserAccountIdentifier userId, invalidateTokens = changed || invalidateTokens; } + if (useMfa != null) { + updateUseMfa(userAccount, useMfa); + } + if (invalidateTokens) { userAccount.markLoginIncompatiblyUpdated(); } @@ -163,7 +192,10 @@ public void deleteById(UserAccountIdentifier id) { } public boolean isItCurrentUser(UserAccountIdentifier userId) { - return Objects.equals(userId, getCurrentUser().getId()); + return authManager.getCurrentUser() + .map(UserAccount::getId) + .filter(it -> it.equals(userId)) + .isPresent(); } /** @@ -176,6 +208,7 @@ public boolean isItCurrentUser(UserAccountIdentifier userId) { *
  • `role` = `ANONYMOUS`
  • *
  • `locked` = `false`
  • *
  • `deletedAt` = null
  • + *
  • `useMfa` = `false`
  • * * * @param username @@ -194,12 +227,95 @@ public UserAccount findOrCreateUser(String username, UnaryOperator errorMessage = () -> "Can't generate the QR code image!"; + + // Generate the QR code image data as a base64 string which + // can be used in an tag: + return Try.of(() -> qrGenerator.generate(data)) + .map(it -> getDataUriForImage(it, qrGenerator.getImageMimeType())) + .onFailure(it -> alertService.createAlertMessage(errorMessage.get(), it.getMessage())) + .getOrElseThrow(it -> new QrGenerationException(errorMessage.get(), it.getCause())); // sets a better message + } + + /** + * If the parameter {@code useMfa} is {@code True}, a new secret will created for the {@link UserAccount} with + * {@link UserAccount#createMfaSecret(SecretGenerator)}. If the parameter is {@code False}, the secret will deleted + * for the {@link UserAccount}. However, a change is only made if {@code useMfa} is different from the result of + * {@link UserAccount#isUseMfa()}. + *

    + * This method will only run successful if the current user an admin or the same user as in parameter {@code user}! + * + * @param user + * @param useMfa + * @return The modified and saved {@link UserAccount}. + */ + @NonNull + public UserAccount updateUseMfa(@NonNull UserAccount user, boolean useMfa) { + + if (user.usesMfa() == useMfa) { + return user; + } + + checkLegitimacyOfCall(user.getId()); + + return users.save(useMfa + ? user.createMfaSecret(secretGenerator::generate) + : user.deleteMfaSecret()); + } + + public Optional resetMfaSecret(UserAccountIdentifier id) { + return users.findUserById(id).map(this::resetMfaSecret); + } + + public UserAccount resetMfaSecret(UserAccount user) { + + checkLegitimacyOfCall(user.getId()); + + Assert.isTrue(user.usesMfa(), "Only for a user with activated MFA a reset of the secret is possible!"); + + return users.save(user.createMfaSecret(secretGenerator::generate)); + } + + public boolean finishEnrollment(@NotNull UserAccount user, String otp) { + + Assert.isTrue(isItCurrentUser(user.getId()), "Only the authenticated user can verify an OTP for him self!"); + Assert.isTrue(user.usesMfa(), "Can't verify an OTP for a user without enabled MFA!"); + + var validCode = codeVerifier.isValidCode(user.getMfaSecret(), otp); + + if (validCode) { + users.save(user.markAsEnrolled()); + } + + return validCode; + } + + public boolean verifyOtp(@NotNull UserAccount user, String otp) { + + if (user.usesMfa()) { + return codeVerifier.isValidCode(user.getMfaSecret(), otp); + } + return false; + } + + private void checkLegitimacyOfCall(UserAccountIdentifier id) { + Assert.isTrue(authManager.isAdmin() || isItCurrentUser(id), "Only an admin user can change other users!"); } private UserAccount loadUser(UserAccountIdentifier userId) { diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserController.java b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserController.java index d57e2934d..d940a23a0 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserController.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserController.java @@ -46,7 +46,7 @@ public UserDtos.Output createUser(@RequestBody @Valid UserDtos.Insert userInsert checkUniqueUsername(userInsert.userName()); - return userMapper.toDto(userService.create(userMapper.fromDto(userInsert))); + return userMapper.toDto(userService.create(userMapper.fromDto(userInsert), userInsert.useMfa())); } @PatchMapping("/{id}") @@ -64,7 +64,15 @@ public UserDtos.Output updateUser(@PathVariable UserAccountIdentifier id, userUpdateDTO.userName(), userUpdateDTO.password(), userMapper.fromDto(userUpdateDTO.role()), - userUpdateDTO.locked())); + userUpdateDTO.locked(), + userUpdateDTO.useMfa())); + } + + @DeleteMapping("/{id}/mfa") + @PreAuthorize(AS_ADMIN) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteMfaSecret(@PathVariable UserAccountIdentifier id) { + userService.resetMfaSecret(id); } @DeleteMapping("/{id}") diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserDtos.java b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserDtos.java index dbd7e0176..e058c212a 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserDtos.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserDtos.java @@ -28,6 +28,8 @@ record Output( String userName, Role role, boolean locked, + boolean useMfa, + boolean mfaSecretEnrolled, Instant createdAt, Instant lastModifiedAt, @@ -48,7 +50,9 @@ record Insert( @NotNull Role role, - boolean locked) {} + boolean locked, + + boolean useMfa) {} @Builder record Update( @@ -65,7 +69,9 @@ record Update( @Nullable Role role, - @Nullable Boolean locked) {} + @Nullable Boolean locked, + + @Nullable Boolean useMfa) {} enum Role { ADMIN, USER, DELETED, ANONYMOUS diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java index d6122533c..fcc3d1eac 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java @@ -4,6 +4,7 @@ import iris.client_bff.core.mappers.MetadataMapper.UserNameMetadata; import iris.client_bff.users.UserAccount; import iris.client_bff.users.UserRole; +import iris.client_bff.users.UserService; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -15,6 +16,8 @@ abstract class UserMapper { @Autowired protected PasswordEncoder passwordEncoder; + @Autowired + protected UserService userService; @Mapping(target = "createdBy", qualifiedBy = UserNameMetadata.class) @Mapping(target = "lastModifiedBy", qualifiedBy = UserNameMetadata.class) diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserProfileController.java b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserProfileController.java index 067d34c5f..c43e99ba6 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserProfileController.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserProfileController.java @@ -1,22 +1,78 @@ package iris.client_bff.users.web; import iris.client_bff.users.UserAccount; -import lombok.AllArgsConstructor; +import iris.client_bff.users.UserService; +import lombok.RequiredArgsConstructor; +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController -@AllArgsConstructor @RequestMapping("/user-profile") +@RequiredArgsConstructor +@Validated class UserProfileController { private final UserMapper userMapper; + private final UserService userService; @GetMapping public UserDtos.Output getUserProfile(@AuthenticationPrincipal UserAccount principal) { return userMapper.toDto(principal); } + + @GetMapping("/mfa") + public ResponseEntity getMfaQrUri( + @AuthenticationPrincipal @NotNull(message = "There is no current user to interact with!") UserAccount principal) { + + return ResponseEntity.ok(new MfaResponse(userService.generateQrCodeImageUri(principal), principal.getMfaSecret())); + } + + @PostMapping("/mfa") + public ResponseEntity modifyUseMfa(@RequestParam("useMfa") final boolean useMfa, + @AuthenticationPrincipal @NotNull(message = "There is no current user to interact with!") UserAccount principal) { + + var updatedUser = userService.updateUseMfa(principal, useMfa); + + return useMfa + ? ResponseEntity + .ok(new MfaResponse(userService.generateQrCodeImageUri(updatedUser), updatedUser.getMfaSecret())) + : ResponseEntity.noContent().build(); + } + + @DeleteMapping("/mfa") + public ResponseEntity resetMfaSecret( + @AuthenticationPrincipal @NotNull(message = "There is no current user to interact with!") UserAccount principal) { + + userService.resetMfaSecret(principal); + + return ResponseEntity.ok(new MfaResponse(userService.generateQrCodeImageUri(principal), principal.getMfaSecret())); + } + + @PostMapping("/mfa/otp") + public ResponseEntity verifyOtp(@RequestBody @Valid OtpDto otpDto, + @AuthenticationPrincipal @NotNull(message = "There is no current user to interact with!") UserAccount principal) { + + var otpValid = userService.finishEnrollment(principal, otpDto.otp()); + + return otpValid + ? ResponseEntity.ok("The OTP is valid.") + : ResponseEntity.badRequest().body("The OTP isn't valid."); + } + + static record OtpDto(@NotBlank String otp) {} + + static record MfaResponse(String qrCodeImageUri, String mfaSecret) {} } diff --git a/iris-client-bff/src/main/java/iris/client_bff/vaccination_info/VaccinationInfo.java b/iris-client-bff/src/main/java/iris/client_bff/vaccination_info/VaccinationInfo.java index 8fb230c49..c0266b045 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/vaccination_info/VaccinationInfo.java +++ b/iris-client-bff/src/main/java/iris/client_bff/vaccination_info/VaccinationInfo.java @@ -21,7 +21,17 @@ import java.util.UUID; import java.util.stream.Collectors; -import javax.persistence.*; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.JoinColumn; +import javax.persistence.OneToMany; +import javax.persistence.PostLoad; +import javax.persistence.PrePersist; +import javax.persistence.Table; import org.hibernate.search.engine.backend.types.Sortable; import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField; diff --git a/iris-client-bff/src/main/resources/application-dev_auth.properties b/iris-client-bff/src/main/resources/application-dev_auth.properties index c7c055c8c..0f3151599 100644 --- a/iris-client-bff/src/main/resources/application-dev_auth.properties +++ b/iris-client-bff/src/main/resources/application-dev_auth.properties @@ -1,5 +1,6 @@ security.auth=db security.auth.db.admin-user-name=admin security.auth.db.admin-user-password=admin +security.auth.db.mfa.option=OPTIONAL_DEFAULT_FALSE security.jwt.set-secure=false diff --git a/iris-client-bff/src/main/resources/application.properties b/iris-client-bff/src/main/resources/application.properties index 741dd6f9e..2554eec2f 100644 --- a/iris-client-bff/src/main/resources/application.properties +++ b/iris-client-bff/src/main/resources/application.properties @@ -38,6 +38,8 @@ iris.suspiciously.request.event.data-blocking-threshold=10000 iris.suspiciously.request.case.data-warning-threshold=100 iris.suspiciously.request.case.data-blocking-threshold=1000 +security.auth.db.mfa.option=always + # Spring Mail #spring.mail.host=127.0.0.1 #spring.mail.port=3465 diff --git a/iris-client-bff/src/main/resources/db/migration/V1015__add_mfa.sql b/iris-client-bff/src/main/resources/db/migration/V1015__add_mfa.sql new file mode 100644 index 000000000..e9bd669af --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration/V1015__add_mfa.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_accounts ADD mfa_secret_enrolled BOOLEAN DEFAULT FALSE NOT NULL; +ALTER TABLE user_accounts ADD mfa_secret varchar(128) NULL; \ No newline at end of file diff --git a/iris-client-bff/src/main/resources/db/migration_mssql/V1015__add_mfa.sql b/iris-client-bff/src/main/resources/db/migration_mssql/V1015__add_mfa.sql new file mode 100644 index 000000000..83ccd28a5 --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration_mssql/V1015__add_mfa.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_accounts ADD mfa_secret_enrolled Bit DEFAULT 0 NOT NULL; +ALTER TABLE user_accounts ADD mfa_secret varchar(128) NULL; \ No newline at end of file diff --git a/iris-client-bff/src/main/resources/db/migration_mysql/V1015__add_mfa.sql b/iris-client-bff/src/main/resources/db/migration_mysql/V1015__add_mfa.sql new file mode 100644 index 000000000..45def8e92 --- /dev/null +++ b/iris-client-bff/src/main/resources/db/migration_mysql/V1015__add_mfa.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_accounts ADD mfa_secret_enrolled boolean DEFAULT false NOT NULL; +ALTER TABLE user_accounts ADD mfa_secret varchar(128) NULL; \ No newline at end of file diff --git a/iris-client-bff/src/test/java/iris/client_bff/WithMockIrisUser.java b/iris-client-bff/src/test/java/iris/client_bff/WithMockIrisUser.java new file mode 100644 index 000000000..644bbc2b9 --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/WithMockIrisUser.java @@ -0,0 +1,17 @@ +package iris.client_bff; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.security.test.context.support.WithMockUser; + +@WithMockUser(value = "user", authorities = "USER") +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface WithMockIrisUser {} diff --git a/iris-client-bff/src/test/java/iris/client_bff/auth/db/MfAuthenticationIntegrationTest.java b/iris-client-bff/src/test/java/iris/client_bff/auth/db/MfAuthenticationIntegrationTest.java new file mode 100644 index 000000000..e9262225c --- /dev/null +++ b/iris-client-bff/src/test/java/iris/client_bff/auth/db/MfAuthenticationIntegrationTest.java @@ -0,0 +1,299 @@ +package iris.client_bff.auth.db; + +import static io.restassured.http.ContentType.*; +import static io.restassured.matcher.RestAssuredMatchers.*; +import static io.restassured.module.mockmvc.RestAssuredMockMvc.*; +import static java.time.Instant.*; +import static org.assertj.core.api.Assertions.*; +import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.not; +import static org.springframework.http.HttpStatus.*; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.exceptions.CodeGenerationException; +import io.restassured.http.Cookie; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.restassured.module.mockmvc.specification.MockMvcRequestSpecBuilder; +import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockAdmin; +import iris.client_bff.users.UserService; +import lombok.RequiredArgsConstructor; + +import java.util.Date; +import java.util.Locale; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; + +@IrisWebIntegrationTest +@SpringBootTest(properties = { "security.auth.db.mfa.option=OPTIONAL_DEFAULT_FALSE" }) +@RequiredArgsConstructor +@Tag("security") +@DisplayName("IT of the MFA implementation") +class MfAuthenticationIntegrationTest { + + private static final String BASE_URL = "/login"; + private static final String MFA_URL = "/mfa/otp"; + private static final String IRIS_COOKIE = "IRIS_JWT"; + private static final String REFRESH_COOKIE = "IRIS_REFRESH_JWT"; + + final MockMvc mvc; + final UserService userService; + final CodeGenerator codeGenerator; + + @BeforeEach + void init() { + + Locale.setDefault(Locale.ENGLISH); + + RestAssuredMockMvc.requestSpecification = new MockMvcRequestSpecBuilder() + .setMockMvc(mvc) + .setContentType(JSON) + .build(); + } + + @AfterEach + void resetUser() { + + var user = userService.findByUsername("admin").get(); + + // Must be set after the login attempt to allow the subsequent change of data. + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken(user, null, "ADMIN")); + userService.updateUseMfa(user, false); + } + + @AfterAll + void cleanup() { + + // Must be done, otherwise other tests may break using RestAssured. + RestAssuredMockMvc.requestSpecification = null; + } + + @Test + @DisplayName("login: valid input + without MFA") + void login_WithoutMfa() throws Throwable { + given() + .body("{\"userName\":\"admin\", \"password\":\"admin\"}") + + .when() + .post(BASE_URL) + + .then() + .status(OK) + .body("authenticationStatus", equalTo("AUTHENTICATED"), + "qrCodeImageUri", blankOrNullString(), + "mfaSecret", blankOrNullString()) + .cookie(IRIS_COOKIE) + .cookie(REFRESH_COOKIE); + } + + @Test + @WithMockAdmin + @DisplayName("MFA: without enrolled secret") + void login_WithMfa_WithoutEnrolledSecret() throws Throwable { + + var user = userService.findByUsername("admin").get(); + userService.updateUseMfa(user, true); + + // the login request + var responce = given() + .body("{\"userName\":\"admin\", \"password\":\"admin\"}") + + .when() + .post(BASE_URL) + + .then() + .status(OK) + .body("authenticationStatus", equalTo("PRE_AUTHENTICATED_ENROLLMENT_REQUIRED"), + "qrCodeImageUri", startsWith("data:image/png;base64,"), + "mfaSecret", not(blankOrNullString())) + .cookie(IRIS_COOKIE, detailedCookie().expiryDate(lessThan(Date.from(now().plusSeconds(5 * 60))))) + .extract(); + + var jwtCookie = responce.detailedCookie(IRIS_COOKIE); + String secret = responce.path("mfaSecret"); + + // request to /user-profile is forbidden with this token + requestUserProfileIsForbidden(jwtCookie); + + // a code verification with a wrong OTP + verifyWrongOtp(jwtCookie); + + // a code verification with correct OTP + jwtCookie = verifyCorrectOtp(secret, jwtCookie); + + // request to /user-profile is ok after full MFA + requestUserProfileIsOk(jwtCookie); + + assertThat(userService.findByUsername("admin").get().isMfaSecretEnrolled()).isTrue(); + } + + @Test + @WithMockAdmin + @DisplayName("MFA: with enrolled secret") + void login_WithMfa_WithEnrolledSecret() throws Throwable { + + var user = userService.findByUsername("admin").get(); + userService.updateUseMfa(user, true); + var verify = userService.finishEnrollment(user, createOtp(user.getMfaSecret())); + + assertThat(verify).isTrue(); + assertThat(user.isMfaSecretEnrolled()).isTrue(); + + // the login request + var jwtCookie = given() + .body("{\"userName\":\"admin\", \"password\":\"admin\"}") + + .when() + .post(BASE_URL) + + .then() + .status(OK) + .body("authenticationStatus", equalTo("PRE_AUTHENTICATED_MFA_REQUIRED"), + "qrCodeImageUri", blankOrNullString(), + "mfaSecret", blankOrNullString()) + .cookie(IRIS_COOKIE, detailedCookie().expiryDate(lessThan(Date.from(now().plusSeconds(5 * 60))))) + .extract() + .detailedCookie(IRIS_COOKIE); + + // request to /user-profile is forbidden with this token + requestUserProfileIsForbidden(jwtCookie); + + // a code verification with a wrong OTP + verifyWrongOtp(jwtCookie); + + // a code verification with correct OTP + jwtCookie = verifyCorrectOtp(user.getMfaSecret(), jwtCookie); + + // request to /user-profile is ok after full MFA + requestUserProfileIsOk(jwtCookie); + } + + private String createOtp(String secret) throws CodeGenerationException { + return codeGenerator.generate(secret, Math.floorDiv(now().getEpochSecond(), 30)); + } + + private void requestUserProfileIsForbidden(Cookie jwtCookie) { + + given() + .cookie(jwtCookie) + + .when() + .get("/user-profile") + + .then() + .status(FORBIDDEN); + } + + private void verifyWrongOtp(Cookie jwtCookie) { + + given() + .cookie(jwtCookie) + .body("{\"otp\":\"000000\"}") + + .when() + .post(MFA_URL) + + .then() + .status(UNAUTHORIZED) + .body("message", equalTo("Invalid verification code")); + } + + private Cookie verifyCorrectOtp(String secret, Cookie jwtCookie) throws CodeGenerationException { + + return given() + .cookie(jwtCookie) + .body(String.format("{\"otp\":\"%s\"}", createOtp(secret))) + + .when() + .post(MFA_URL) + + .then() + .status(OK) + .body("authenticationStatus", equalTo("AUTHENTICATED"), + "qrCodeImageUri", blankOrNullString(), + "mfaSecret", blankOrNullString()) + .cookie(IRIS_COOKIE) + .cookie(REFRESH_COOKIE) + .extract() + .detailedCookie(IRIS_COOKIE); + } + + private void requestUserProfileIsOk(Cookie jwtCookie) { + given() + .cookie(jwtCookie) + + .when() + .get("/user-profile") + + .then() + .status(OK); + } + + @IrisWebIntegrationTest + @SpringBootTest(properties = { "security.auth.db.mfa.option=always" }) + @RequiredArgsConstructor + @Tag("security") + @DisplayName("IT of the MFA implementation with mfa.option=always") + static class MfaAlways { + + final MockMvc mvc; + + @BeforeEach + void init() { + + Locale.setDefault(Locale.ENGLISH); + + RestAssuredMockMvc.requestSpecification = new MockMvcRequestSpecBuilder() + .setMockMvc(mvc) + .setContentType(JSON) + .build(); + } + + @AfterAll + void cleanup() { + + // Must be done, otherwise other tests may break using RestAssured. + RestAssuredMockMvc.requestSpecification = null; + } + + @Test + @DisplayName("login: invalid input ⇒ 🔙 401") + void login_InvalidInput_Returns401() throws Throwable { + + given() + .body("{\"userName\":\"a\", \"password\":\"a\"}") + + .when() + .post(BASE_URL) + + .then() + .status(UNAUTHORIZED); + } + + @Test + @DisplayName("login: valid input + without enrolled secret ⇒ 🔙 200 + PRE_AUTHENTICATED_ENROLLMENT_REQUIRED") + void login_ValidInput_ReturnsEnrollmentRequired() throws Throwable { + given() + .body("{\"userName\":\"admin\", \"password\":\"admin\"}") + + .when() + .post(BASE_URL) + + .then() + .status(OK) + .body("authenticationStatus", equalTo("PRE_AUTHENTICATED_ENROLLMENT_REQUIRED"), + "qrCodeImageUri", startsWith("data:image/png;base64,"), + "mfaSecret", not(blankOrNullString())) + .cookie(IRIS_COOKIE, detailedCookie().expiryDate(lessThan(Date.from(now().plusSeconds(5 * 60))))); + } + } +} diff --git a/iris-client-bff/src/test/java/iris/client_bff/auth/db/RefreshTokenIntegrationTest.java b/iris-client-bff/src/test/java/iris/client_bff/auth/db/RefreshTokenIntegrationTest.java index 326e26ced..1f2a7cfaf 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/auth/db/RefreshTokenIntegrationTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/auth/db/RefreshTokenIntegrationTest.java @@ -160,16 +160,4 @@ void refreshtoken_ValidInput_NewJwt() throws Throwable { .status(NO_CONTENT) .cookie(IRIS_COOKIE, not(cookies.get(IRIS_COOKIE))); } - - // @Test - @DisplayName("create user: without CSRF Token ⇒ 🔙 403") - void createUser_WithoutCsrfToken_ReturnsForbidden() throws Throwable { - - } - - // @Test - @DisplayName("create user: with wrong CSRF Token ⇒ 🔙 403") - void createUser_WithWrongCsrfToken_ReturnsForbidden() throws Throwable { - - } } diff --git a/iris-client-bff/src/test/java/iris/client_bff/cases/web/IndexCaseControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/cases/web/IndexCaseControllerTest.java index a559ede01..9b09b3875 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/cases/web/IndexCaseControllerTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/cases/web/IndexCaseControllerTest.java @@ -9,6 +9,7 @@ import iris.client_bff.IrisWebIntegrationTest; import iris.client_bff.RestResponsePage; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.cases.CaseDataRequest; import iris.client_bff.cases.CaseDataRequest.DataRequestIdentifier; import iris.client_bff.cases.CaseDataRequest.Status; @@ -37,7 +38,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -46,6 +47,7 @@ @IrisWebIntegrationTest @TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@WithMockIrisUser class IndexCaseControllerTest { private final String baseUrl = "/data-requests-client/cases"; @@ -101,12 +103,12 @@ void setUp() throws IRISDataRequestException { @Test @Order(1) + @WithAnonymousUser void endpointShouldBeProtected() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(baseUrl)).andExpect(status().isUnauthorized()); } @Test - @WithMockUser() @Order(2) void getAll() throws Exception { @@ -118,7 +120,6 @@ void getAll() throws Exception { } @Test - @WithMockUser() @Order(3) void getAllWithStatus() throws Exception { @@ -131,7 +132,6 @@ void getAllWithStatus() throws Exception { } @Test - @WithMockUser() @Order(4) void getAllFiltered() throws Exception { @@ -144,7 +144,6 @@ void getAllFiltered() throws Exception { } @Test - @WithMockUser() @Order(5) void getAllWithStatusAndFiltered() throws Exception { @@ -157,7 +156,6 @@ void getAllWithStatusAndFiltered() throws Exception { } @Test - @WithMockUser() void create() throws Exception { var insert = om.writeValueAsString(IndexCaseInsertDTO.builder().start(Instant.now()).build()); @@ -171,7 +169,6 @@ void create() throws Exception { } @Test - @WithMockUser() void create_invalidStartDate() throws Exception { var insert = om.writeValueAsString(IndexCaseInsertDTO.builder().start(null).build()); @@ -185,7 +182,6 @@ void create_invalidStartDate() throws Exception { } @Test - @WithMockUser() void getDetails() throws Exception { var url = baseUrl + "/" + MOCK_CASE_ID.toString(); @@ -198,7 +194,6 @@ void getDetails() throws Exception { } @Test - @WithMockUser() void getDetails_notFound() throws Exception { var url_404 = baseUrl + "/" + UUID.randomUUID().toString(); @@ -207,7 +202,6 @@ void getDetails_notFound() throws Exception { } @Test - @WithMockUser() void update() throws Exception { String updatedComment = "This is an updated comment"; String updatedName = "CASE_UPDATED"; @@ -240,7 +234,6 @@ void update() throws Exception { } @Test - @WithMockUser() void update_invalidId() throws Exception { var url_404 = baseUrl + "/" + UUID.randomUUID().toString(); @@ -267,7 +260,6 @@ void update_invalidId() throws Exception { } @Test - @WithMockUser() void update_noBody() throws Exception { var url = baseUrl + "/" + MOCK_CASE_ID.toString(); diff --git a/iris-client-bff/src/test/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandlerIntegrationTest.java b/iris-client-bff/src/test/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandlerIntegrationTest.java index 0a3f34e98..f7e4b4d7c 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandlerIntegrationTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/core/api/web/error/GlobalControllerExceptionHandlerIntegrationTest.java @@ -10,6 +10,7 @@ import io.restassured.module.mockmvc.specification.MockMvcRequestSpecBuilder; import iris.client_bff.IrisWebIntegrationTest; import iris.client_bff.WithMockAdmin; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.core.api.web.error.TestController.TestException; import lombok.RequiredArgsConstructor; @@ -22,7 +23,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -328,7 +328,7 @@ void login_BlockedUser() throws Throwable { } @Test - @WithMockUser + @WithMockIrisUser @DisplayName("Test forbidden access") void getAllUsers_ForbiddenAccess() { diff --git a/iris-client-bff/src/test/java/iris/client_bff/events/web/EventDataRequestControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/events/web/EventDataRequestControllerTest.java index 412880de2..ddaf54606 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/events/web/EventDataRequestControllerTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/events/web/EventDataRequestControllerTest.java @@ -8,6 +8,7 @@ import iris.client_bff.IrisWebIntegrationTest; import iris.client_bff.RestResponsePage; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.events.EventDataRequest; import iris.client_bff.events.EventDataRequest.DataRequestIdentifier; import iris.client_bff.events.EventDataRequest.Status; @@ -31,7 +32,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @@ -40,6 +41,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @IrisWebIntegrationTest +@WithMockIrisUser class EventDataRequestControllerTest { private final String baseUrl = "/data-requests-client/events"; @@ -71,12 +73,12 @@ void setUp() { } @Test + @WithAnonymousUser void endpointShouldBeProtected() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(baseUrl)).andExpect(status().isUnauthorized()); } @Test - @WithMockUser() public void getDataRequests() throws Exception { var res = mockMvc.perform(MockMvcRequestBuilders.get(baseUrl)).andExpect(MockMvcResultMatchers.status().isOk()) .andReturn(); @@ -89,7 +91,6 @@ public void getDataRequests() throws Exception { } @Test - @WithMockUser() public void getAllWithStatus() throws Exception { var res = mockMvc.perform(MockMvcRequestBuilders.get(baseUrl + "?status=DATA_REQUESTED")) .andExpect(MockMvcResultMatchers.status().isOk()) @@ -103,7 +104,6 @@ public void getAllWithStatus() throws Exception { } @Test - @WithMockUser() public void getAllFiltered() throws Exception { var res = mockMvc.perform(MockMvcRequestBuilders.get(baseUrl + "?search=test")) .andExpect(MockMvcResultMatchers.status().isOk()).andReturn(); @@ -116,7 +116,6 @@ public void getAllFiltered() throws Exception { } @Test - @WithMockUser() public void getAllWithStatusAndFiltered() throws Exception { var res = mockMvc.perform(MockMvcRequestBuilders.get(baseUrl + "?search=test&status=DATA_REQUESTED")) .andExpect(MockMvcResultMatchers.status().isOk()) @@ -130,7 +129,6 @@ public void getAllWithStatusAndFiltered() throws Exception { } @Test - @WithMockUser() public void getDataRequestByCode() throws Exception { postNewDataRequest(); @@ -140,7 +138,6 @@ public void getDataRequestByCode() throws Exception { } @Test - @WithMockUser() public void updateDataRequestByCode() throws Exception { String REFID = "refId"; var eventDataRequest = EventDataRequest.builder() @@ -194,13 +191,11 @@ public void updateDataRequestByCode() throws Exception { } @Test - @WithMockUser() public void createDataRequest() throws Exception { postNewDataRequest(); } @Test - @WithMockUser() public void createDataRequestWithError() throws Exception { when(dataRequestManagement.createDataRequest(any())).thenThrow(new IRISDataRequestException("Data request failed")); diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java index 33c525550..03d70eea7 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerIntegrationTest.java @@ -5,13 +5,13 @@ import io.restassured.http.ContentType; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.iris_messages.IrisMessageDataInitializer; import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; /** @@ -27,7 +27,7 @@ class IrisMessageControllerIntegrationTest { private final IrisMessageDataInitializer initializer; @Test - @WithMockUser() + @WithMockIrisUser @DisplayName("Tests getMessage to search with folder and search string") void getMessage_WithFolderAndSearchString_ReturnsMessage() throws Exception { diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java index 1f4bb5ced..1f1a4306b 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageControllerTest.java @@ -10,6 +10,7 @@ import iris.client_bff.IrisWebIntegrationTest; import iris.client_bff.RestResponsePage; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.iris_messages.IrisMessage; import iris.client_bff.iris_messages.IrisMessage.IrisMessageIdentifier; import iris.client_bff.iris_messages.IrisMessageDataProcessor; @@ -30,7 +31,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @@ -39,6 +40,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @IrisWebIntegrationTest +@WithMockIrisUser @RequiredArgsConstructor class IrisMessageControllerTest { @@ -64,25 +66,23 @@ class IrisMessageControllerTest { IrisMessageDataProcessors irisMessageDataProcessors; @Test + @WithAnonymousUser void endpointShouldBeProtected() throws Exception { mockMvc.perform(get(baseUrl)) .andExpect(status().isUnauthorized()); } @Test - @WithMockUser() void getInboxMessages() throws Exception { this.getMessages(testData.MOCK_INBOX_MESSAGE, testData.MOCK_INBOX_MESSAGE.getFolder().getId()); } @Test - @WithMockUser() void getOutboxMessages() throws Exception { this.getMessages(testData.MOCK_OUTBOX_MESSAGE, testData.MOCK_OUTBOX_MESSAGE.getFolder().getId()); } @Test - @WithMockUser() void getMessages_shouldFail() throws Exception { mockMvc.perform(get(baseUrl)) .andExpect(MockMvcResultMatchers.status().is4xxClientError()) @@ -90,7 +90,6 @@ void getMessages_shouldFail() throws Exception { } @Test - @WithMockUser() public void createAndSendMessage() throws Exception { IrisMessage irisMessage = testData.MOCK_OUTBOX_MESSAGE; @@ -139,7 +138,6 @@ public void createAndSendMessage() throws Exception { } @Test - @WithMockUser() public void createMessage_shouldFail() throws Exception { IrisMessage irisMessage = testData.MOCK_OUTBOX_MESSAGE; mockMvc @@ -155,7 +153,6 @@ public void createMessage_shouldFail() throws Exception { } @Test - @WithMockUser() void getMessageDetails() throws Exception { IrisMessageIdentifier messageId = testData.MOCK_INBOX_MESSAGE.getId(); @@ -175,7 +172,6 @@ void getMessageDetails() throws Exception { } @Test - @WithMockUser() void getMessageDetails_shouldFail() throws Exception { IrisMessageIdentifier invalidId = IrisMessageIdentifier.of(UUID.randomUUID()); @@ -192,7 +188,6 @@ void getMessageDetails_shouldFail() throws Exception { } @Test - @WithMockUser() void updateMessage() throws Exception { IrisMessageUpdateDto messageUpdate = new IrisMessageUpdateDto(true); @@ -221,7 +216,6 @@ void updateMessage() throws Exception { } @Test - @WithMockUser() void updateMessage_shouldFail() throws Exception { UUID invalidId = UUID.randomUUID(); @@ -239,7 +233,6 @@ void updateMessage_shouldFail() throws Exception { } @Test - @WithMockUser() void getMessageFolders() throws Exception { List folderList = List.of(testData.MOCK_INBOX_FOLDER, testData.MOCK_OUTBOX_FOLDER); @@ -260,7 +253,6 @@ void getMessageFolders() throws Exception { } @Test - @WithMockUser() void getMessageHdContactsWithoutOwn() throws Exception { when(irisMessageService.getHdContacts(null)).thenReturn(List.of(testData.MOCK_CONTACT_OTHER)); @@ -283,7 +275,6 @@ void getMessageHdContactsWithoutOwn() throws Exception { } @Test - @WithMockUser() void getMessageHdContactsIncludingOwn() throws Exception { when(irisMessageService.getHdContacts(null)) @@ -305,7 +296,6 @@ void getMessageHdContactsIncludingOwn() throws Exception { } @Test - @WithMockUser() void getUnreadMessageCount() throws Exception { when(irisMessageService.getCountUnread()).thenReturn(2); @@ -322,7 +312,6 @@ void getUnreadMessageCount() throws Exception { } @Test - @WithMockUser() void getUnreadMessageCountByFolder() throws Exception { when(irisMessageService.getCountUnreadByFolderId(any())).thenReturn(1); diff --git a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageDataControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageDataControllerTest.java index 17ba26028..cf7df052b 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageDataControllerTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/iris_messages/web/IrisMessageDataControllerTest.java @@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.events.message.EventMessageTestData; import iris.client_bff.iris_messages.IrisMessageData; import iris.client_bff.iris_messages.IrisMessageDataProcessor; @@ -22,7 +23,7 @@ import org.mockito.Mock; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; @@ -30,6 +31,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @IrisWebIntegrationTest +@WithMockIrisUser @RequiredArgsConstructor class IrisMessageDataControllerTest { @@ -55,13 +57,13 @@ class IrisMessageDataControllerTest { IrisMessageDataProcessors irisMessageDataProcessors; @Test + @WithAnonymousUser void endpointShouldBeProtected() throws Exception { mockMvc.perform(get(baseUrl)) .andExpect(MockMvcResultMatchers.status().isUnauthorized()); } @Test - @WithMockUser() void importMessageDataAndAdd() throws Exception { IrisMessageData messageData = spy(messageTestData.MOCK_INBOX_MESSAGE.getDataAttachments().get(0)); @@ -85,7 +87,6 @@ void importMessageDataAndAdd() throws Exception { } @Test - @WithMockUser() void importMessageDataAndUpdate() throws Exception { IrisMessageData messageData = spy(messageTestData.MOCK_INBOX_MESSAGE.getDataAttachments().get(0)); @@ -125,7 +126,6 @@ void importMessageDataAndUpdate() throws Exception { } @Test - @WithMockUser() void getMessageDataImportSelectionViewData() throws Exception { var viewDataDto = messageDataTestData.MOCK_IMPORT_SELECTION_VIEW_DATA; @@ -149,7 +149,6 @@ void getMessageDataImportSelectionViewData() throws Exception { } @Test - @WithMockUser() void getMessageDataViewData() throws Exception { var viewDataDto = messageDataTestData.MOCK_DATA_VIEW_DATA; diff --git a/iris-client-bff/src/test/java/iris/client_bff/search_client/web/LocationSearchControllerTests.java b/iris-client-bff/src/test/java/iris/client_bff/search_client/web/LocationSearchControllerTests.java index 86960b2a4..2eb8e245b 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/search_client/web/LocationSearchControllerTests.java +++ b/iris-client-bff/src/test/java/iris/client_bff/search_client/web/LocationSearchControllerTests.java @@ -10,6 +10,7 @@ import io.restassured.module.mockmvc.RestAssuredMockMvc; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.search_client.SearchClient; import iris.client_bff.search_client.exceptions.IRISSearchException; import iris.client_bff.search_client.web.dto.LocationInformation; @@ -23,11 +24,10 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.Pageable; import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @IrisWebIntegrationTest -@WithMockUser +@WithMockIrisUser @RequiredArgsConstructor class LocationSearchControllerTests { diff --git a/iris-client-bff/src/test/java/iris/client_bff/statistics/web/StatisticsControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/statistics/web/StatisticsControllerTest.java index f25d49e97..8e1c4f97d 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/statistics/web/StatisticsControllerTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/statistics/web/StatisticsControllerTest.java @@ -6,6 +6,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.cases.CaseDataRequest; import iris.client_bff.cases.CaseDataRequestService; import iris.client_bff.events.EventDataRequest; @@ -18,7 +19,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -62,7 +62,7 @@ void endpointShouldBeProtected() throws Exception { } @Test - @WithMockUser() + @WithMockIrisUser void getWeeklyData() throws Exception { var res = mockMvc.perform(MockMvcRequestBuilders.get(baseUrl)) diff --git a/iris-client-bff/src/test/java/iris/client_bff/status/web/AppStatusControllerTest.java b/iris-client-bff/src/test/java/iris/client_bff/status/web/AppStatusControllerTest.java index 7ce8af9c2..c9c8a1cdb 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/status/web/AppStatusControllerTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/status/web/AppStatusControllerTest.java @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.status.AppInfo; import iris.client_bff.status.AppStatus; import iris.client_bff.status.Apps; @@ -23,7 +24,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.support.MessageSourceAccessor; import org.springframework.http.HttpStatus; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.web.server.ResponseStatusException; @@ -32,6 +33,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @IrisWebIntegrationTest +@WithMockIrisUser @RequiredArgsConstructor class AppStatusControllerTest { @@ -52,6 +54,7 @@ class AppStatusControllerTest { AppStatus.ACCESS_DENIED); @Test + @WithAnonymousUser void endpointShouldBeProtected() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(baseUrl)) @@ -59,7 +62,6 @@ void endpointShouldBeProtected() throws Exception { } @Test - @WithMockUser() void getApps() throws Exception { when(statusService.getApps()).thenReturn(MOCK_APPS); @@ -80,7 +82,6 @@ void getApps() throws Exception { } @Test - @WithMockUser() void getAppStatusInfo_shouldFail() throws Exception { when(statusService.getAppInfo(anyString())).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST)); @@ -92,7 +93,6 @@ void getAppStatusInfo_shouldFail() throws Exception { } @Test - @WithMockUser() void getAppStatusInfo_forbiddenCharacter_shouldFail() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(baseUrl + "/+invalid")) @@ -102,7 +102,6 @@ void getAppStatusInfo_forbiddenCharacter_shouldFail() throws Exception { } @Test - @WithMockUser() void getAppStatusInfo_statusOK() throws Exception { when(statusService.getAppInfo("test.checkin-app.ok")).thenReturn(MOCK_APP_INFO_OK); @@ -120,7 +119,6 @@ void getAppStatusInfo_statusOK() throws Exception { } @Test - @WithMockUser() void getAppStatusInfo_statusError() throws Exception { when(statusService.getAppInfo("test.checkin-app.error")).thenReturn(MOCK_APP_INFO_ERROR); diff --git a/iris-client-bff/src/test/java/iris/client_bff/users/UserServiceTests.java b/iris-client-bff/src/test/java/iris/client_bff/users/UserServiceTests.java index e4fed2aa9..b4628b830 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/users/UserServiceTests.java +++ b/iris-client-bff/src/test/java/iris/client_bff/users/UserServiceTests.java @@ -5,6 +5,14 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.code.HashingAlgorithm; +import dev.samstevens.totp.qr.QrDataFactory; +import dev.samstevens.totp.qr.ZxingPngQrGenerator; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import iris.client_bff.core.alert.AlertService; import iris.client_bff.users.UserAccount.UserAccountIdentifier; import java.util.Optional; @@ -32,18 +40,23 @@ class UserServiceTests { @Mock(lenient = true) AuthenticatedUserAware authenticationManager; + @Mock(lenient = true) + AlertService alertService; + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); UserService userDetailsService; UserAccountIdentifier notFound = UserAccountIdentifier.of(UUID.randomUUID()); - UserAccount admin = userAccountAdmin(); + UserAccount admin = spy(userAccountAdmin()); UserAccount user = spy(userAccountUser()); @BeforeEach public void init() { - userDetailsService = new UserService(userAccountsRepository, passwordEncoder, authenticationManager); + userDetailsService = new UserService(userAccountsRepository, passwordEncoder, authenticationManager, + new DefaultSecretGenerator(), new QrDataFactory(HashingAlgorithm.SHA256, 6, 30), new ZxingPngQrGenerator(), + alertService, new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider())); reset(user); } @@ -53,7 +66,7 @@ void fails_uuidNotFound() { mockUserNotFound(); assertThrows(RuntimeException.class, - () -> userDetailsService.update(notFound, null, null, null, null, null, null)); + () -> userDetailsService.update(notFound, null, null, null, null, null, null, null)); } @Test @@ -62,7 +75,7 @@ void fails_notAdmin_changeOtherProfil() { mockAdminFound(); assertThrows(RuntimeException.class, - () -> userDetailsService.update(admin.getId(), null, null, null, null, null, null)); + () -> userDetailsService.update(admin.getId(), null, null, null, null, null, null, null)); } @Test @@ -71,7 +84,7 @@ void fails_notAdmin_changeUserName() { mockUserFound(); assertThrows(RuntimeException.class, - () -> userDetailsService.update(user.getId(), null, null, "new", null, null, null)); + () -> userDetailsService.update(user.getId(), null, null, "new", null, null, null, null)); } @Test @@ -80,7 +93,7 @@ void fails_notAdmin_changeRole() { mockUserFound(); assertThrows(RuntimeException.class, - () -> userDetailsService.update(user.getId(), null, null, null, null, UserRole.ADMIN, null)); + () -> userDetailsService.update(user.getId(), null, null, null, null, UserRole.ADMIN, null, null)); } @Test @@ -91,7 +104,7 @@ void fails_lastAdmin_changeRole() { mockCountLastAdmin(); assertThrows(RuntimeException.class, - () -> userDetailsService.update(admin.getId(), null, null, null, null, UserRole.USER, null)); + () -> userDetailsService.update(admin.getId(), null, null, null, null, UserRole.USER, null, null)); } @Test @@ -101,7 +114,7 @@ void ok_admin_changeNothing() { mockUserFound(); mockSaveUser(); - userDetailsService.update(user.getId(), null, null, null, null, null, null); + userDetailsService.update(user.getId(), null, null, null, null, null, null, null); verify(userAccountsRepository).save(user); verify(user, times(2)).getId(); @@ -116,7 +129,7 @@ void ok_admin_changeAll() { mockUserFound(); mockSaveUser(); - var ret = userDetailsService.update(user.getId(), "ln", "fn", "un", "pw", UserRole.ADMIN, Boolean.TRUE); + var ret = userDetailsService.update(user.getId(), "ln", "fn", "un", "pw", UserRole.ADMIN, Boolean.TRUE, null); assertThat(ret).extracting("firstName", "lastName", "userName", "role") .containsExactly("fn", "ln", "un", UserRole.ADMIN); @@ -134,15 +147,15 @@ void ok_tokenInvalidateOnRigthChanges() { mockUserFound(); mockSaveUser(); - userDetailsService.update(user.getId(), null, null, "un", null, null, null); + userDetailsService.update(user.getId(), null, null, "un", null, null, null, null); verify(user).markLoginIncompatiblyUpdated(); - userDetailsService.update(user.getId(), null, null, null, "pw", null, null); + userDetailsService.update(user.getId(), null, null, null, "pw", null, null, null); verify(user, times(2)).markLoginIncompatiblyUpdated(); - userDetailsService.update(user.getId(), null, null, null, null, UserRole.ADMIN, null); + userDetailsService.update(user.getId(), null, null, null, null, UserRole.ADMIN, null, null); verify(user, times(3)).markLoginIncompatiblyUpdated(); } @@ -196,6 +209,156 @@ void ok_deleteById() { verifyNoMoreInteractions(userAccountsRepository); } + @Test // for iris-backlog#251 + void ok_generateQrCodeImageUri() { + + currentUserIsTheAdmin(); + + when(userAccountsRepository.save(any())).then(it -> it.getArgument(0)); + + var qr = userDetailsService.generateQrCodeImageUri(userDetailsService.updateUseMfa(admin, true)); + + assertThat(qr).startsWith("data:image/png;base64,"); + } + + @Test // for iris-backlog#251 + void fails_notCurrentUser_generateQrCodeImageUri() { + + currentUserIsTheAdmin(); + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.generateQrCodeImageUri(userDetailsService.updateUseMfa(user, true))); + } + + @Test // for iris-backlog#251 + void fails_mfaIsDeactivated_generateQrCodeImageUri() { + + currentUserIsTheAdmin(); + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.generateQrCodeImageUri(userDetailsService.updateUseMfa(admin, false))); + } + + @Test // for iris-backlog#251 + void ok_updateUseMfa_sameValue() { + + currentUserIsTheAdmin(); + + userDetailsService.updateUseMfa(admin, false); + + verify(admin, never()).deleteMfaSecret(); + verify(admin, never()).createMfaSecret(any()); + verify(userAccountsRepository, never()).save(any()); + } + + @Test // for iris-backlog#251 + void ok_updateUseMfa_changedValue() { + + currentUserIsTheAdmin(); + + userDetailsService.updateUseMfa(user, true); + + verify(user).createMfaSecret(any()); + verify(user, never()).deleteMfaSecret(); + reset(user); + + userDetailsService.updateUseMfa(user, false); + + verify(user).deleteMfaSecret(); + verify(user, never()).createMfaSecret(any()); + + verify(userAccountsRepository, times(2)).save(user); + } + + @Test // for iris-backlog#251 + void fails_notAdmin_updateUseMfa() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.updateUseMfa(user, true)); + } + + @Test // for iris-backlog#251 + void ok_resetMfaSecret() { + + currentUserIsTheAdmin(); + + userDetailsService.updateUseMfa(user, true); + + userDetailsService.resetMfaSecret(user); + + verify(user, times(2)).createMfaSecret(any()); + verify(userAccountsRepository, times(2)).save(user); + } + + @Test // for iris-backlog#251 + void fails_notAdmin_resetMfaSecret() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.resetMfaSecret(user)); + } + + @Test // for iris-backlog#251 + void fails_mfaIsDeactivated_resetMfaSecret() { + + currentUserIsTheAdmin(); + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.resetMfaSecret(user)); + } + + @Test // for iris-backlog#251 + void ok_finishEnrollment() throws Exception { + + currentUserIsTheAdmin(); + + when(userAccountsRepository.save(any())).then(it -> it.getArgument(0)); + + var u = userDetailsService.updateUseMfa(admin, true); + + reset(admin, userAccountsRepository); + + var code = new DefaultCodeGenerator().generate(u.getMfaSecret(), + Math.floorDiv(new SystemTimeProvider().getTime(), 30)); + + userDetailsService.finishEnrollment(admin, code); + + verify(admin).markAsEnrolled(); + verify(userAccountsRepository).save(admin); + } + + @Test // for iris-backlog#251 + void fails_notCurrentUser_finishEnrollment() { + + currentUserIsTheAdmin(); + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.finishEnrollment(user, "")); + } + + @Test // for iris-backlog#251 + void fails_mfaIsDeactivated_finishEnrollment() { + + currentUserIsTheAdmin(); + + assertThatIllegalArgumentException() + .isThrownBy(() -> userDetailsService.finishEnrollment(admin, "")); + } + + @Test // for iris-backlog#251 + void fails_wrongCode_finishEnrollment() { + + currentUserIsTheAdmin(); + + userDetailsService.updateUseMfa(admin, true); + reset(userAccountsRepository); + + var result = userDetailsService.finishEnrollment(admin, ""); + + assertThat(result).isFalse(); + verify(user, never()).markAsEnrolled(); + verify(userAccountsRepository, never()).save(any()); + } + private UserAccount userAccountAdmin() { var account = new UserAccount(); diff --git a/iris-client-bff/src/test/java/iris/client_bff/users/web/UserControllerIntegrationTests.java b/iris-client-bff/src/test/java/iris/client_bff/users/web/UserControllerIntegrationTests.java index b1f22daea..20026a296 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/users/web/UserControllerIntegrationTests.java +++ b/iris-client-bff/src/test/java/iris/client_bff/users/web/UserControllerIntegrationTests.java @@ -14,6 +14,7 @@ import io.restassured.module.mockmvc.specification.MockMvcRequestSpecBuilder; import iris.client_bff.IrisWebIntegrationTest; import iris.client_bff.WithMockAdmin; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.matchers.IsUuid; import iris.client_bff.users.UserAccountsRepositoryForTests; import lombok.RequiredArgsConstructor; @@ -31,7 +32,6 @@ import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.util.Assert; @@ -81,7 +81,7 @@ void cleanup() { } @Test - @WithMockUser + @WithMockIrisUser @DisplayName("getAllUsers: endpoint 🔒") void getAllUsers_EndpointsProtected() { @@ -93,7 +93,7 @@ void getAllUsers_EndpointsProtected() { } @Test - @WithMockUser + @WithMockIrisUser @DisplayName("createUser: endpoint 🔒") void createUser_EndpointProtected() { @@ -120,7 +120,7 @@ void updateUser_EndpointProtected() { } @Test - @WithMockUser + @WithMockIrisUser @DisplayName("deleteUser: endpoint 🔒") void deleteUser_EndpointProtected() { @@ -466,7 +466,7 @@ void createUser_userNameLength(int wordLength, HttpStatus expectation) throws Ex var userName = createWord(wordLength); - var dto = new UserDtos.Insert("fn", "ln", userName, "Password12A_", UserDtos.Role.USER, false); + var dto = new UserDtos.Insert("fn", "ln", userName, "Password12A_", UserDtos.Role.USER, false, false); given() .body(toJson(dto)) @@ -497,6 +497,18 @@ void updateUser_lastNameLength(int wordLength, HttpStatus expectation) throws Ex .status(expectation); } + @Test // for iris-backlog#251 + @WithMockIrisUser + @DisplayName("deleteMfaSecret: endpoint 🔒") + void deleteMfaSecret_EndpointsProtected() { + + when() + .delete(DETAILS_URL + "/mfa", UUID.randomUUID()) + + .then() + .status(FORBIDDEN); + } + private String createWord(int wordLength) { return fake.letterify("?".repeat(wordLength)); } diff --git a/iris-client-bff/src/test/java/iris/client_bff/users/web/UserDtoMappingTests.java b/iris-client-bff/src/test/java/iris/client_bff/users/web/UserDtoMappingTests.java index c5f395c30..5fb510dae 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/users/web/UserDtoMappingTests.java +++ b/iris-client-bff/src/test/java/iris/client_bff/users/web/UserDtoMappingTests.java @@ -46,7 +46,8 @@ class UserDtoMappingTests { void mapping_fromUserAccount_toUserDTO() { when(userService.findByUuid(any())) - .thenReturn(Optional.of(new UserAccount("admin", "admin", "admin", "admin", UserRole.ADMIN, false, null))); + .thenReturn( + Optional.of(new UserAccount("admin", "admin", "admin", "admin", UserRole.ADMIN, false, null, null, false))); when(passwordEncoder.encode(any())).thenAnswer(it -> it.getArgument(0)); var firstName = faker.name().firstName(); diff --git a/iris-client-bff/src/test/java/iris/client_bff/vaccination_info/web/VaccinationInfoControllerIntegrationTest.java b/iris-client-bff/src/test/java/iris/client_bff/vaccination_info/web/VaccinationInfoControllerIntegrationTest.java index 77ec3a7e3..6b99df2b0 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/vaccination_info/web/VaccinationInfoControllerIntegrationTest.java +++ b/iris-client-bff/src/test/java/iris/client_bff/vaccination_info/web/VaccinationInfoControllerIntegrationTest.java @@ -8,6 +8,7 @@ import io.restassured.module.mockmvc.RestAssuredMockMvc; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.vaccination_info.VaccinationInfo; import iris.client_bff.vaccination_info.VaccinationInfoDataInitializer; import iris.client_bff.vaccination_info.VaccinationInfoRepository; @@ -27,13 +28,12 @@ import org.junit.jupiter.api.TestInstance.Lifecycle; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.test.context.support.WithAnonymousUser; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.test.web.servlet.MockMvc; @IrisWebIntegrationTest -@WithMockUser +@WithMockIrisUser // removes saved entities before this test so that other tests not affected this one @DirtiesContext(classMode = ClassMode.BEFORE_CLASS) @RequiredArgsConstructor diff --git a/iris-client-bff/src/test/java/iris/client_bff/web/filter/ApplicationRequestSizeLimitFilterTests.java b/iris-client-bff/src/test/java/iris/client_bff/web/filter/ApplicationRequestSizeLimitFilterTests.java index 0e72e08ac..6b6f90e0e 100644 --- a/iris-client-bff/src/test/java/iris/client_bff/web/filter/ApplicationRequestSizeLimitFilterTests.java +++ b/iris-client-bff/src/test/java/iris/client_bff/web/filter/ApplicationRequestSizeLimitFilterTests.java @@ -22,6 +22,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import iris.client_bff.IrisWebIntegrationTest; +import iris.client_bff.WithMockIrisUser; import iris.client_bff.core.alert.AlertService; import iris.client_bff.core.messages.ErrorMessages; import lombok.RequiredArgsConstructor; @@ -29,7 +30,6 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; @@ -41,6 +41,7 @@ @IrisWebIntegrationTest @TestPropertySource(properties = { "iris.suspiciously.request.defaults.content-length-blocking-size=15B", "iris.suspiciously.request.for-uri.data-submission-rpc.content-length-blocking-size=15B" }) +@WithMockIrisUser @RequiredArgsConstructor class ApplicationRequestSizeLimitFilterTests { @@ -50,7 +51,6 @@ class ApplicationRequestSizeLimitFilterTests { AlertService alertService; @Test - @WithMockUser() void requestTooLarge_RestEndpoint() throws Exception { mvc.perform(post("/data-requests-client/events") @@ -63,7 +63,6 @@ void requestTooLarge_RestEndpoint() throws Exception { } @Test - @WithMockUser() void requestTooLarge_JsonRpc() throws Exception { var content = mvc.perform(post("/data-submission-rpc") @@ -77,7 +76,6 @@ void requestTooLarge_JsonRpc() throws Exception { } @Test - @WithMockUser() void wrongContentType_JsonRpc() throws Exception { var content = mvc.perform(post("/data-submission-rpc") From c04a68201671f5c9d2518f8694867cda188ad957 Mon Sep 17 00:00:00 2001 From: Jens Kutzsche Date: Tue, 28 Jun 2022 10:29:10 +0200 Subject: [PATCH 2/4] chore: switch from SHA256 to SHA1, to fix problems with Google and Microsoft apps --- .../main/java/iris/client_bff/config/SecurityConfig.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java b/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java index 990fb2d83..812df8f2e 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java +++ b/iris-client-bff/src/main/java/iris/client_bff/config/SecurityConfig.java @@ -1,7 +1,5 @@ package iris.client_bff.config; -import dev.samstevens.totp.code.HashingAlgorithm; - import java.security.Security; import javax.annotation.PostConstruct; @@ -25,9 +23,4 @@ public void initBouncyCastle() { Security.addProvider(new BouncyCastleFipsProvider()); Security.setProperty("crypto.policy", "unlimited"); } - - @Bean - public HashingAlgorithm totpHashingAlgorithm() { - return HashingAlgorithm.SHA256; - } } From 42c54ca4c4cd7334a7d075e641b5ca0150a0a3c7 Mon Sep 17 00:00:00 2001 From: "andreas.kausler" Date: Tue, 28 Jun 2022 15:32:24 +0200 Subject: [PATCH 3/4] feat: add 2 factor authentication ui to frontend --- .../iris/client_bff/users/web/UserMapper.java | 4 + iris-client-fe/src/App.vue | 1 + iris-client-fe/src/api/api.ts | 78 +++++++++- .../components/mfa-admin-user-fieldset.vue | 115 +++++++++++++++ .../mfa-admin-user-secret-reset.vue | 73 +++++++++ .../modules/mfa/components/mfa-enrollment.vue | 36 +++++ .../modules/mfa/components/mfa-otp-form.vue | 104 +++++++++++++ .../src/modules/mfa/services/api.ts | 25 ++++ .../src/modules/mfa/services/normalizer.ts | 15 ++ .../src/server/data/dummy-userlist.ts | 22 ++- .../admin-user-create.view.vue | 15 +- .../admin-user-edit/admin-user-edit.view.vue | 12 ++ .../admin-user-list/admin-user-list.view.vue | 76 ++++++++-- .../components/user-login-error.vue | 13 +- .../user-login/components/user-login-form.vue | 95 ++++++++++++ .../src/views/user-login/user-login.data.ts | 2 + .../src/views/user-login/user-login.store.ts | 58 ++++++-- .../src/views/user-login/user-login.view.vue | 138 ++++++++++-------- iris-client-fe/tests/e2e/support/commands.js | 3 +- 19 files changed, 780 insertions(+), 105 deletions(-) create mode 100644 iris-client-fe/src/modules/mfa/components/mfa-admin-user-fieldset.vue create mode 100644 iris-client-fe/src/modules/mfa/components/mfa-admin-user-secret-reset.vue create mode 100644 iris-client-fe/src/modules/mfa/components/mfa-enrollment.vue create mode 100644 iris-client-fe/src/modules/mfa/components/mfa-otp-form.vue create mode 100644 iris-client-fe/src/modules/mfa/services/api.ts create mode 100644 iris-client-fe/src/modules/mfa/services/normalizer.ts create mode 100644 iris-client-fe/src/views/user-login/components/user-login-form.vue diff --git a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java index fcc3d1eac..254fadf42 100644 --- a/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java +++ b/iris-client-bff/src/main/java/iris/client_bff/users/web/UserMapper.java @@ -21,7 +21,11 @@ abstract class UserMapper { @Mapping(target = "createdBy", qualifiedBy = UserNameMetadata.class) @Mapping(target = "lastModifiedBy", qualifiedBy = UserNameMetadata.class) + @Mapping(target = "useMfa", source = "source") public abstract UserDtos.Output toDto(UserAccount source); + boolean mapUseMfa(UserAccount source) { + return source.usesMfa(); + } @Mapping(target = "password", expression = "java(passwordEncoder.encode(source.password()))") public abstract UserAccount fromDto(UserDtos.Insert source); diff --git a/iris-client-fe/src/App.vue b/iris-client-fe/src/App.vue index a94e03ffd..3db8cbef1 100644 --- a/iris-client-fe/src/App.vue +++ b/iris-client-fe/src/App.vue @@ -136,6 +136,7 @@ export default Vue.extend({ // this is triggered if an existing session expires (caused by API response status codes 401 and 403). setInterceptRoute(this.$router.currentRoute); } + this.$store.commit("userLogin/setUser"); this.$router.push("/user/login"); } } diff --git a/iris-client-fe/src/api/api.ts b/iris-client-fe/src/api/api.ts index a2178843d..c1bc88bce 100644 --- a/iris-client-fe/src/api/api.ts +++ b/iris-client-fe/src/api/api.ts @@ -2,7 +2,6 @@ import { ApiResponse, assertParamExists, RequestOptions } from "./common"; import { BaseAPI } from "./base"; -import { UserSession } from "@/views/user-login/user-login.store"; /** * @@ -1530,6 +1529,8 @@ export interface User extends MetaData { */ role: UserRole; locked: boolean; + useMfa: boolean; + mfaSecretEnrolled: boolean; } /** * @@ -1568,6 +1569,7 @@ export interface UserInsert { */ role: UserRole; locked: boolean; + useMfa: boolean; } /** * @@ -1635,6 +1637,7 @@ export interface UserUpdate { */ role?: UserRole; locked?: boolean; + useMfa?: boolean; } /** * @@ -2001,6 +2004,33 @@ export interface VaccinationReportDetails extends VaccinationReport { employees?: VREmployee[]; } +export enum AuthenticationStatus { + AUTHENTICATED = "AUTHENTICATED", + PRE_AUTHENTICATED_MFA_REQUIRED = "PRE_AUTHENTICATED_MFA_REQUIRED", + PRE_AUTHENTICATED_ENROLLMENT_REQUIRED = "PRE_AUTHENTICATED_ENROLLMENT_REQUIRED", +} + +export enum MfaOption { + ALWAYS = "ALWAYS", + OPTIONAL_DEFAULT_TRUE = "OPTIONAL_DEFAULT_TRUE", + OPTIONAL_DEFAULT_FALSE = "OPTIONAL_DEFAULT_FALSE", + DISABLED = "DISABLED", +} + +export interface MfaConfig { + mfaOption: MfaOption; +} + +export interface MfaAuthentication { + authenticationStatus: AuthenticationStatus; + mfaSecret: string; + qrCodeImageUri: string; +} + +export interface MfaVerification { + otp: string; +} + /** * IrisClientFrontendApi - object-oriented interface * @export @@ -2169,7 +2199,7 @@ export class IrisClientFrontendApi extends BaseAPI { public login( credentials: Credentials, options?: RequestOptions - ): ApiResponse { + ): ApiResponse { assertParamExists("login", "credentials", credentials); return this.apiRequest("POST", "/login", credentials, options); } @@ -2181,7 +2211,7 @@ export class IrisClientFrontendApi extends BaseAPI { * @throws {RequiredError} * @memberof IrisClientFrontendApi */ - public refreshToken(options?: RequestOptions): ApiResponse { + public refreshToken(options?: RequestOptions): ApiResponse { return this.apiRequest("GET", "/refreshtoken", null, options); } @@ -2539,4 +2569,46 @@ export class IrisClientFrontendApi extends BaseAPI { const path = `/vaccination-reports/${encodeURIComponent(id)}`; return this.apiRequest("GET", path, null, options); } + + /** + * @summary Fetches two step authentication config + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public mfaConfigGet(options?: RequestOptions): ApiResponse { + return this.apiRequest("GET", "/mfa/config", null, options); + } + + /** + * @summary Verification of two step authentication one time password + * @param {string} otp time password. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public mfaOtpPost( + otp: string, + options?: RequestOptions + ): ApiResponse { + assertParamExists("mfaOtpPost", "otp", otp); + return this.apiRequest("POST", "/mfa/otp", { otp }, options); + } + + /** + * + * @summary Delete IRIS user MFA secret + * @param {string} id The ID of an IRIS Client user. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof IrisClientFrontendApi + */ + public usersMfaSecretDelete( + id: string, + options?: RequestOptions + ): ApiResponse { + assertParamExists("usersMfaSecretDelete", "userId", id); + const path = `/users/${encodeURIComponent(id)}/mfa`; + return this.apiRequest("DELETE", path, null, options); + } } diff --git a/iris-client-fe/src/modules/mfa/components/mfa-admin-user-fieldset.vue b/iris-client-fe/src/modules/mfa/components/mfa-admin-user-fieldset.vue new file mode 100644 index 000000000..5b32a21af --- /dev/null +++ b/iris-client-fe/src/modules/mfa/components/mfa-admin-user-fieldset.vue @@ -0,0 +1,115 @@ + + + diff --git a/iris-client-fe/src/modules/mfa/components/mfa-admin-user-secret-reset.vue b/iris-client-fe/src/modules/mfa/components/mfa-admin-user-secret-reset.vue new file mode 100644 index 000000000..9de576154 --- /dev/null +++ b/iris-client-fe/src/modules/mfa/components/mfa-admin-user-secret-reset.vue @@ -0,0 +1,73 @@ + + + diff --git a/iris-client-fe/src/modules/mfa/components/mfa-enrollment.vue b/iris-client-fe/src/modules/mfa/components/mfa-enrollment.vue new file mode 100644 index 000000000..9095c8c9b --- /dev/null +++ b/iris-client-fe/src/modules/mfa/components/mfa-enrollment.vue @@ -0,0 +1,36 @@ + + + diff --git a/iris-client-fe/src/modules/mfa/components/mfa-otp-form.vue b/iris-client-fe/src/modules/mfa/components/mfa-otp-form.vue new file mode 100644 index 000000000..0cb8cbd73 --- /dev/null +++ b/iris-client-fe/src/modules/mfa/components/mfa-otp-form.vue @@ -0,0 +1,104 @@ + + + diff --git a/iris-client-fe/src/modules/mfa/services/api.ts b/iris-client-fe/src/modules/mfa/services/api.ts new file mode 100644 index 000000000..2fb22f835 --- /dev/null +++ b/iris-client-fe/src/modules/mfa/services/api.ts @@ -0,0 +1,25 @@ +import asyncAction from "@/utils/asyncAction"; +import { apiBundleProvider } from "@/utils/api"; +import authClient from "@/api-client"; +import { normalizeMfaConfig } from "@/modules/mfa/services/normalizer"; + +const fetchMfaConfig = () => { + const action = async () => { + return normalizeMfaConfig((await authClient.mfaConfigGet()).data, true); + }; + return asyncAction(action); +}; + +const resetUsersMfaSecret = () => { + const action = async (userId: string) => { + return (await authClient.usersMfaSecretDelete(userId)).data; + }; + return asyncAction(action); +}; + +export const mfaApi = { + fetchMfaConfig, + resetUsersMfaSecret, +}; + +export const bundleMfaApi = apiBundleProvider(mfaApi); diff --git a/iris-client-fe/src/modules/mfa/services/normalizer.ts b/iris-client-fe/src/modules/mfa/services/normalizer.ts new file mode 100644 index 000000000..02a4517ed --- /dev/null +++ b/iris-client-fe/src/modules/mfa/services/normalizer.ts @@ -0,0 +1,15 @@ +import { MfaConfig, MfaOption } from "@/api"; +import { normalizeData } from "@/utils/data"; + +export const normalizeMfaConfig = (source?: MfaConfig, parse?: boolean) => { + return normalizeData( + source, + (normalizer) => { + return { + mfaOption: normalizer("mfaOption", MfaOption.DISABLED), + }; + }, + parse, + "MfaConfig" + ); +}; diff --git a/iris-client-fe/src/server/data/dummy-userlist.ts b/iris-client-fe/src/server/data/dummy-userlist.ts index 44e297099..a371d42fb 100644 --- a/iris-client-fe/src/server/data/dummy-userlist.ts +++ b/iris-client-fe/src/server/data/dummy-userlist.ts @@ -10,6 +10,8 @@ export const dummyUserList: UserList = { userName: "MaxMuster", role: UserRole.Admin, locked: false, + useMfa: false, + mfaSecretEnrolled: false, }, { id: "abcdef", @@ -18,6 +20,8 @@ export const dummyUserList: UserList = { userName: "LisaMuster", role: UserRole.User, locked: false, + useMfa: false, + mfaSecretEnrolled: false, }, { id: "67890", @@ -26,6 +30,8 @@ export const dummyUserList: UserList = { userName: "TestUser", role: UserRole.User, locked: true, + useMfa: false, + mfaSecretEnrolled: false, }, { id: "321654", @@ -34,6 +40,8 @@ export const dummyUserList: UserList = { userName: "E2ETestUser", role: UserRole.User, locked: false, + useMfa: false, + mfaSecretEnrolled: false, }, ], }; @@ -42,8 +50,16 @@ export const getDummyUserFromRequest = ( request: Request, id?: string ): User => { - const { firstName, lastName, userName, role, oldPassword, locked } = - JSON.parse(request.requestBody); + const { + firstName, + lastName, + userName, + role, + oldPassword, + locked, + useMfa, + mfaSecretEnrolled, + } = JSON.parse(request.requestBody); if (oldPassword === "p") { throw new Error("Das bisherige Passwort stimmt nicht!"); } @@ -54,5 +70,7 @@ export const getDummyUserFromRequest = ( userName, role, locked, + useMfa, + mfaSecretEnrolled, }; }; diff --git a/iris-client-fe/src/views/admin-user-create/admin-user-create.view.vue b/iris-client-fe/src/views/admin-user-create/admin-user-create.view.vue index f5e7e92d9..242f8df36 100644 --- a/iris-client-fe/src/views/admin-user-create/admin-user-create.view.vue +++ b/iris-client-fe/src/views/admin-user-create/admin-user-create.view.vue @@ -67,9 +67,13 @@ > - {{ - userCreationError - }} + + + {{ userCreationError }} + + @@ -164,6 +169,7 @@ import ConditionalField from "@/views/admin-user-edit/components/conditional-fie import _defaults from "lodash/defaults"; import _isEmpty from "lodash/isEmpty"; import EntryMetaData from "@/components/entry-meta-data.vue"; +import MfaAdminUserFieldset from "@/modules/mfa/components/mfa-admin-user-fieldset.vue"; type AdminUserEditForm = { model: UserUpdate; @@ -221,6 +227,7 @@ const fieldsConfigByRole: Record = { @Component({ components: { + MfaAdminUserFieldset, EntryMetaData, ConditionalField, PasswordInputField, @@ -239,6 +246,10 @@ export default class AdminUserEditView extends Vue { form: HTMLFormElement; }; + fetchUser(): void { + store.dispatch("adminUserEdit/fetchUser", this.userId); + } + get userLoading(): boolean { return store.state.adminUserEdit.userLoading; } @@ -308,6 +319,7 @@ export default class AdminUserEditView extends Vue { oldPassword: undefined, role: undefined, locked: undefined, + useMfa: undefined, }, valid: false, }; diff --git a/iris-client-fe/src/views/admin-user-list/admin-user-list.view.vue b/iris-client-fe/src/views/admin-user-list/admin-user-list.view.vue index 5cde627f8..e42186d31 100644 --- a/iris-client-fe/src/views/admin-user-list/admin-user-list.view.vue +++ b/iris-client-fe/src/views/admin-user-list/admin-user-list.view.vue @@ -30,13 +30,25 @@ > +