diff --git a/batch-files/keycloak-sso/start-keycloak.bat b/batch-files/keycloak-sso/start-keycloak.bat new file mode 100644 index 00000000..4e89b034 --- /dev/null +++ b/batch-files/keycloak-sso/start-keycloak.bat @@ -0,0 +1 @@ +docker run --name keycloak -p 7000:8080 -d -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:24.0.1 start-dev \ No newline at end of file diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationService.java b/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationService.java index d6c1e259..154c380d 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationService.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationService.java @@ -1,7 +1,6 @@ package io.github.gms.auth; import io.github.gms.auth.model.AuthenticationResponse; -import io.github.gms.common.dto.LoginVerificationRequestDto; /** * @author Peter Szrnka @@ -11,5 +10,5 @@ public interface AuthenticationService { AuthenticationResponse authenticate(String username, String credential); - AuthenticationResponse verify(LoginVerificationRequestDto dto); + void logout(); } \ No newline at end of file diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationServiceImpl.java index e9e888c8..4ab1da83 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationServiceImpl.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/AuthenticationServiceImpl.java @@ -1,19 +1,17 @@ package io.github.gms.auth; -import dev.samstevens.totp.code.CodeVerifier; import io.github.gms.auth.model.AuthenticationResponse; import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.service.TokenGeneratorService; import io.github.gms.auth.types.AuthResponsePhase; -import io.github.gms.common.abstraction.AbstractAuthService; -import io.github.gms.common.dto.LoginVerificationRequestDto; import io.github.gms.common.enums.JwtConfigType; import io.github.gms.common.enums.SystemProperty; -import io.github.gms.common.converter.GenerateJwtRequestConverter; -import io.github.gms.functions.user.UserConverter; -import io.github.gms.common.service.JwtService; import io.github.gms.functions.systemproperty.SystemPropertyService; -import io.github.gms.functions.user.UserService; +import io.github.gms.functions.user.UserConverter; +import io.github.gms.functions.user.UserLoginAttemptManagerService; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -21,39 +19,28 @@ import java.util.Map; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + /** * @author Peter Szrnka * @since 1.0 */ @Slf4j @Service -public class AuthenticationServiceImpl extends AbstractAuthService implements AuthenticationService { +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class AuthenticationServiceImpl implements AuthenticationService { + private final TokenGeneratorService tokenGeneratorService; + private final SystemPropertyService systemPropertyService; private final AuthenticationManager authenticationManager; private final UserConverter converter; - private final CodeVerifier verifier; - private final UserService userService; - - public AuthenticationServiceImpl( - AuthenticationManager authenticationManager, - JwtService jwtService, - SystemPropertyService systemPropertyService, - GenerateJwtRequestConverter generateJwtRequestConverter, - UserConverter converter, - UserAuthService userAuthService, - CodeVerifier verifier, - UserService userService) { - super(jwtService, systemPropertyService, generateJwtRequestConverter, userAuthService); - this.authenticationManager = authenticationManager; - this.converter = converter; - this.verifier = verifier; - this.userService = userService; - } + private final UserLoginAttemptManagerService userLoginAttemptManagerService; @Override public AuthenticationResponse authenticate(String username, String credential) { try { - if (userService.isBlocked(username)) { // User locked in DB + if (userLoginAttemptManagerService.isBlocked(username)) { // User locked in DB return AuthenticationResponse.builder() .phase(AuthResponsePhase.BLOCKED) .build(); @@ -76,8 +63,8 @@ public AuthenticationResponse authenticate(String username, String credential) { .build(); } - Map authenticationDetails = getAuthenticationDetails(user); - userService.resetLoginAttempt(username); + Map authenticationDetails = tokenGeneratorService.getAuthenticationDetails(user); + userLoginAttemptManagerService.resetLoginAttempt(username); return AuthenticationResponse.builder() .currentUser(converter.toUserInfoDto(user, false)) @@ -86,51 +73,18 @@ public AuthenticationResponse authenticate(String username, String credential) { .phase(AuthResponsePhase.COMPLETED) .build(); } catch (Exception ex) { - userService.updateLoginAttempt(username); + userLoginAttemptManagerService.updateLoginAttempt(username); log.warn("Login failed", ex); return new AuthenticationResponse(); } } @Override - public AuthenticationResponse verify(LoginVerificationRequestDto dto) { - try { - if (userService.isBlocked(dto.getUsername())) { - return AuthenticationResponse.builder() - .phase(AuthResponsePhase.BLOCKED) - .build(); - } - - GmsUserDetails userDetails = (GmsUserDetails) userAuthService.loadUserByUsername(dto.getUsername()); - - if (Boolean.FALSE.equals(userDetails.getAccountNonLocked())) { // User locked in LDAP - return AuthenticationResponse.builder() - .phase(AuthResponsePhase.BLOCKED) - .build(); - } - - if (!verifier.isValidCode(userDetails.getMfaSecret(), dto.getVerificationCode())) { - userService.updateLoginAttempt(dto.getUsername()); - return AuthenticationResponse.builder().phase(AuthResponsePhase.FAILED).build(); - } - - Map authenticationDetails = getAuthenticationDetails(userDetails); - - // Verify codes - return AuthenticationResponse.builder() - .currentUser(converter.toUserInfoDto(userDetails, false)) - .phase(AuthResponsePhase.COMPLETED) - .token(authenticationDetails.get(JwtConfigType.ACCESS_JWT)) - .refreshToken(authenticationDetails.get(JwtConfigType.REFRESH_JWT)) - .build(); - } catch (Exception e) { - return AuthenticationResponse.builder() - .phase(AuthResponsePhase.FAILED) - .build(); - } + public void logout() { + log.info("User logged out"); } - private boolean isMfaEnabled(GmsUserDetails userDetails) { + private boolean isMfaEnabled(GmsUserDetails userDetails) { return systemPropertyService.getBoolean(SystemProperty.ENABLE_GLOBAL_MFA) || (systemPropertyService.getBoolean(SystemProperty.ENABLE_MFA) && userDetails.isMfaEnabled()); } diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/AuthorizationServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/AuthorizationServiceImpl.java index 4275559b..cc926ce8 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/AuthorizationServiceImpl.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/AuthorizationServiceImpl.java @@ -2,17 +2,18 @@ import io.github.gms.auth.model.AuthorizationResponse; import io.github.gms.auth.model.GmsUserDetails; -import io.github.gms.common.abstraction.AbstractAuthService; +import io.github.gms.auth.service.TokenGeneratorService; import io.github.gms.common.enums.MdcParameter; import io.github.gms.common.enums.SystemProperty; -import io.github.gms.common.converter.GenerateJwtRequestConverter; import io.github.gms.common.service.JwtService; import io.github.gms.functions.systemproperty.SystemPropertyService; import io.jsonwebtoken.Claims; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; import org.springframework.data.util.Pair; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -24,6 +25,7 @@ import java.util.stream.Stream; import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; /** * @author Peter Szrnka @@ -31,15 +33,14 @@ */ @Slf4j @Service -public class AuthorizationServiceImpl extends AbstractAuthService implements AuthorizationService { +@RequiredArgsConstructor +@Profile(value = { CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO }) +public class AuthorizationServiceImpl implements AuthorizationService { - AuthorizationServiceImpl( - JwtService jwtService, - SystemPropertyService systemPropertyService, - GenerateJwtRequestConverter generateJwtRequestConverter, - UserAuthService userAuthService) { - super(jwtService, systemPropertyService, generateJwtRequestConverter, userAuthService); - } + private final JwtService jwtService; + private final TokenGeneratorService tokenGeneratorService; + private final SystemPropertyService systemPropertyService; + private final UserAuthService userAuthService; @Override public AuthorizationResponse authorize(HttpServletRequest request) { @@ -86,7 +87,7 @@ public AuthorizationResponse authorize(HttpServletRequest request) { return AuthorizationResponse.builder() .authentication(authentication) - .jwtPair(getAuthenticationDetails(userDetails)) + .jwtPair(tokenGeneratorService.getAuthenticationDetails(userDetails)) .build(); } catch (Exception e) { log.warn("Authorization failed: {}", e.getMessage()); diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/VerificationService.java b/code/gms-backend/src/main/java/io/github/gms/auth/VerificationService.java new file mode 100644 index 00000000..f4edeacd --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/VerificationService.java @@ -0,0 +1,13 @@ +package io.github.gms.auth; + +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.common.dto.LoginVerificationRequestDto; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface VerificationService { + + AuthenticationResponse verify(LoginVerificationRequestDto dto); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/VerificationServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/VerificationServiceImpl.java new file mode 100644 index 00000000..6fb5144f --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/VerificationServiceImpl.java @@ -0,0 +1,72 @@ +package io.github.gms.auth; + +import dev.samstevens.totp.code.CodeVerifier; +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.service.TokenGeneratorService; +import io.github.gms.auth.types.AuthResponsePhase; +import io.github.gms.common.dto.LoginVerificationRequestDto; +import io.github.gms.common.enums.JwtConfigType; +import io.github.gms.functions.user.UserConverter; +import io.github.gms.functions.user.UserLoginAttemptManagerService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.Map; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Service +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class VerificationServiceImpl implements VerificationService { + + private final UserAuthService userAuthService; + private final UserConverter converter; + private final CodeVerifier verifier; + private final UserLoginAttemptManagerService userLoginAttemptManagerService; + private final TokenGeneratorService tokenGeneratorService; + + @Override + public AuthenticationResponse verify(LoginVerificationRequestDto dto) { + try { + if (userLoginAttemptManagerService.isBlocked(dto.getUsername())) { + return AuthenticationResponse.builder() + .phase(AuthResponsePhase.BLOCKED) + .build(); + } + + GmsUserDetails userDetails = (GmsUserDetails) userAuthService.loadUserByUsername(dto.getUsername()); + + if (Boolean.FALSE.equals(userDetails.getAccountNonLocked())) { // User locked in LDAP + return AuthenticationResponse.builder() + .phase(AuthResponsePhase.BLOCKED) + .build(); + } + + if (!verifier.isValidCode(userDetails.getMfaSecret(), dto.getVerificationCode())) { + userLoginAttemptManagerService.updateLoginAttempt(dto.getUsername()); + return AuthenticationResponse.builder().phase(AuthResponsePhase.FAILED).build(); + } + + Map authenticationDetails = tokenGeneratorService.getAuthenticationDetails(userDetails); + + // Verify codes + return AuthenticationResponse.builder() + .currentUser(converter.toUserInfoDto(userDetails, false)) + .phase(AuthResponsePhase.COMPLETED) + .token(authenticationDetails.get(JwtConfigType.ACCESS_JWT)) + .refreshToken(authenticationDetails.get(JwtConfigType.REFRESH_JWT)) + .build(); + } catch (Exception e) { + return AuthenticationResponse.builder() + .phase(AuthResponsePhase.FAILED) + .build(); + } + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/config/AbstractSecurityConfig.java b/code/gms-backend/src/main/java/io/github/gms/auth/config/AbstractSecurityConfig.java new file mode 100644 index 00000000..4ea8f36a --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/config/AbstractSecurityConfig.java @@ -0,0 +1,67 @@ +package io.github.gms.auth.config; + +import io.github.gms.common.filter.SecureHeaderInitializerFilter; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public abstract class AbstractSecurityConfig { + + private static final String[] FILTER_URL = new String[]{"/", "/system/status", "/healthcheck", "/setup/**", + "/login", "/authenticate", "/verify", "/logoutUser", "/api/**", "/info/me", "/actuator/**", + "/gms-app/**", "/favicon.ico", "/assets/**", "/index.html**", "/*.js**", "/*.css**", "/*.json**", + "/manifest.webmanifest", "/reset_password"}; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, + AuthenticationEntryPoint authenticationEntryPoint, + SecureHeaderInitializerFilter secureHeaderInitializerFilter) throws Exception { + http + .cors(cors -> Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .exceptionHandling(e -> e.authenticationEntryPoint(authenticationEntryPoint)) + .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorizeHttpRequest -> + authorizeHttpRequest.requestMatchers(FILTER_URL).permitAll() + .requestMatchers(FILTER_URL).permitAll() + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll().anyRequest() + .authenticated() + ); + + http.addFilterBefore(secureHeaderInitializerFilter, UsernamePasswordAuthenticationFilter.class); + http.formLogin(FormLoginConfigurer::disable); + http.httpBasic(HttpBasicConfigurer::disable); + + return http.build(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:4200")); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/config/SecurityConfig.java b/code/gms-backend/src/main/java/io/github/gms/auth/config/SecurityConfig.java index b25880be..25d82533 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/config/SecurityConfig.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/config/SecurityConfig.java @@ -1,112 +1,27 @@ package io.github.gms.auth.config; -import dev.samstevens.totp.code.CodeGenerator; -import dev.samstevens.totp.code.CodeVerifier; -import dev.samstevens.totp.code.DefaultCodeGenerator; -import dev.samstevens.totp.code.DefaultCodeVerifier; -import dev.samstevens.totp.time.SystemTimeProvider; -import dev.samstevens.totp.time.TimeProvider; -import io.github.gms.common.filter.SecureHeaderInitializerFilter; -import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; +import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -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.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; -import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; - -import static io.github.gms.common.util.Constants.PASSWORD_ENCODER; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; /** * @author Peter Szrnka * @since 1.0 */ -@DependsOn(PASSWORD_ENCODER) @Configuration @EnableWebSecurity @EnableMethodSecurity -public class SecurityConfig { - - private static final String[] FILTER_URL = new String[] { "/", "/system/status", "/healthcheck", "/setup/**", - "/login", "/authenticate", "/verify", "/logoutUser", "/api/**", "/info/me", "/actuator/**", - "/gms-app/**", "/favicon.ico", "/assets/**", "/index.html**", "/*.js**", "/*.css**", "/*.json**", - "/manifest.webmanifest", "/reset_password", "/api/**" }; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http, - DaoAuthenticationProvider authenticationProvider, - AuthenticationEntryPoint authenticationEntryPoint, - SecureHeaderInitializerFilter secureHeaderInitializerFilter) throws Exception { - http - .cors(cors -> Customizer.withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .exceptionHandling(e -> e.authenticationEntryPoint(authenticationEntryPoint)) - .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authorizeHttpRequest -> - authorizeHttpRequest.requestMatchers(FILTER_URL).permitAll() - .requestMatchers(FILTER_URL).permitAll() - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll().anyRequest() - .authenticated() - ); - - http.authenticationProvider(authenticationProvider); - http.addFilterBefore(secureHeaderInitializerFilter, UsernamePasswordAuthenticationFilter.class); - http.formLogin(FormLoginConfigurer::disable); - http.httpBasic(HttpBasicConfigurer::disable); - - return http.build(); - } - - @Bean - public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - - authProvider.setUserDetailsService(userDetailsService); - authProvider.setPasswordEncoder(passwordEncoder); - - return authProvider; - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { - return authConfig.getAuthenticationManager(); - } - - @Bean - CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("http://localhost:4200")); - configuration.setAllowedMethods(List.of("*")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class SecurityConfig extends AbstractSecurityConfig { - @Bean - public CodeVerifier codeVerifier() { - TimeProvider timeProvider = new SystemTimeProvider(); - CodeGenerator codeGenerator = new DefaultCodeGenerator(); - return new DefaultCodeVerifier(codeGenerator, timeProvider); - } + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } } \ No newline at end of file diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/db/DbUserAuthServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/db/DbUserAuthServiceImpl.java index 1e16419f..8c03da0f 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/db/DbUserAuthServiceImpl.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/db/DbUserAuthServiceImpl.java @@ -12,11 +12,8 @@ import org.springframework.stereotype.Service; import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_DB; -import static org.springframework.util.StringUtils.tokenizeToStringArray; /** * @author Peter Szrnka @@ -36,7 +33,7 @@ public DbUserAuthServiceImpl(UserRepository repository) { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { UserEntity user = repository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("User not found!")); - Set authorities = Stream.of(tokenizeToStringArray(user.getRoles(), ";")).map(UserRole::valueOf).collect(Collectors.toSet()); + Set authorities = Set.of(user.getRole()); return GmsUserDetails.builder() .userId(user.getId()) .username(user.getUsername()) diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/ldap/LdapSyncServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/ldap/LdapSyncServiceImpl.java index 153f4c18..419ef535 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/ldap/LdapSyncServiceImpl.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/ldap/LdapSyncServiceImpl.java @@ -1,7 +1,7 @@ package io.github.gms.auth.ldap; +import io.github.gms.auth.ldap.converter.LdapUserConverter; import io.github.gms.auth.model.GmsUserDetails; -import io.github.gms.functions.user.UserConverter; import io.github.gms.functions.user.UserEntity; import io.github.gms.functions.user.UserRepository; import lombok.extern.slf4j.Slf4j; @@ -32,13 +32,13 @@ public class LdapSyncServiceImpl implements LdapSyncService { private final LdapTemplate ldapTemplate; private final UserRepository repository; - private final UserConverter converter; + private final LdapUserConverter converter; private final String authType; public LdapSyncServiceImpl( LdapTemplate ldapTemplate, UserRepository repository, - UserConverter converter, + LdapUserConverter converter, @Value("${config.auth.type}") String authType ) { this.ldapTemplate = ldapTemplate; @@ -60,13 +60,13 @@ public Pair synchronizeUsers() { AtomicInteger counter = new AtomicInteger(0); result.forEach(item -> saveOrUpdateUser(item, counter)); - AtomicInteger deletedCounter = new AtomicInteger(0); + AtomicInteger toBeDeletedCounter = new AtomicInteger(0); List usernamesFromLdap = result.stream().map(GmsUserDetails::getUsername).toList(); repository.getAllUserNames().stream() .filter(username -> !usernamesFromLdap.contains(username)) - .forEach(username -> blockUser(username, deletedCounter)); + .forEach(username -> blockUser(username, toBeDeletedCounter)); - return Pair.of(counter.get(), deletedCounter.get()); + return Pair.of(counter.get(), toBeDeletedCounter.get()); } private void blockUser(String username, AtomicInteger deletedCounter) { diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/ldap/converter/LdapUserConverter.java b/code/gms-backend/src/main/java/io/github/gms/auth/ldap/converter/LdapUserConverter.java new file mode 100644 index 00000000..7bcca81a --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/ldap/converter/LdapUserConverter.java @@ -0,0 +1,13 @@ +package io.github.gms.auth.ldap.converter; + +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.functions.user.UserEntity; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface LdapUserConverter { + + UserEntity toEntity(GmsUserDetails foundUser, UserEntity existingEntity); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/ldap/converter/impl/LdapUserConverterImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/ldap/converter/impl/LdapUserConverterImpl.java new file mode 100644 index 00000000..dd165e6b --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/ldap/converter/impl/LdapUserConverterImpl.java @@ -0,0 +1,55 @@ +package io.github.gms.auth.ldap.converter.impl; + +import dev.samstevens.totp.secret.SecretGenerator; +import io.github.gms.auth.ldap.converter.LdapUserConverter; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.common.abstraction.AbstractUserConverter; +import io.github.gms.functions.user.UserEntity; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.ZonedDateTime; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_LDAP; +import static io.github.gms.common.util.Constants.LDAP_CRYPT_PREFIX; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Component +@RequiredArgsConstructor +@Profile(value = { CONFIG_AUTH_TYPE_LDAP }) +public class LdapUserConverterImpl extends AbstractUserConverter implements LdapUserConverter { + + private final Clock clock; + private final SecretGenerator secretGenerator; + @Setter + @Value("${config.store.ldap.credential:false}") + private boolean storeLdapCredential; + + @Override + public UserEntity toEntity(GmsUserDetails foundUser, UserEntity existingEntity) { + UserEntity entity = existingEntity == null ? new UserEntity() : existingEntity; + + entity.setStatus(foundUser.getStatus()); + entity.setName(foundUser.getName()); + entity.setUsername(foundUser.getUsername()); + entity.setCredential(getCredential(foundUser)); + entity.setCreationDate(ZonedDateTime.now(clock)); + entity.setEmail(foundUser.getEmail()); + entity.setRole(getFirstRole(foundUser.getAuthorities())); + entity.setMfaEnabled(foundUser.isMfaEnabled()); + entity.setMfaSecret(secretGenerator.generate()); + return entity; + } + + private String getCredential(GmsUserDetails foundUser) { + return storeLdapCredential ? foundUser.getCredential().replace(LDAP_CRYPT_PREFIX, "") + : "*PROVIDED_BY_LDAP*"; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/service/TokenGeneratorService.java b/code/gms-backend/src/main/java/io/github/gms/auth/service/TokenGeneratorService.java new file mode 100644 index 00000000..6084d92b --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/service/TokenGeneratorService.java @@ -0,0 +1,15 @@ +package io.github.gms.auth.service; + +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.common.enums.JwtConfigType; + +import java.util.Map; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface TokenGeneratorService { + + Map getAuthenticationDetails(GmsUserDetails user); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/service/impl/TokenGeneratorServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/service/impl/TokenGeneratorServiceImpl.java new file mode 100644 index 00000000..8cb59f0c --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/service/impl/TokenGeneratorServiceImpl.java @@ -0,0 +1,60 @@ +package io.github.gms.auth.service.impl; + +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.service.TokenGeneratorService; +import io.github.gms.common.converter.GenerateJwtRequestConverter; +import io.github.gms.common.enums.JwtConfigType; +import io.github.gms.common.enums.MdcParameter; +import io.github.gms.common.enums.UserRole; +import io.github.gms.common.model.GenerateJwtRequest; +import io.github.gms.common.service.JwtService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Service +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class TokenGeneratorServiceImpl implements TokenGeneratorService { + + private final JwtService jwtService; + private final GenerateJwtRequestConverter generateJwtRequestConverter; + + @Override + public Map getAuthenticationDetails(GmsUserDetails user) { + Map input = Map.of( + JwtConfigType.ACCESS_JWT, buildAccessJwtRequest(user.getUserId(), user.getUsername(), + user.getAuthorities().stream().map(authority -> UserRole.getByName(authority.getAuthority())).collect(Collectors.toSet())), + JwtConfigType.REFRESH_JWT, buildRefreshTokenRequest(user.getUsername()) + ); + + return jwtService.generateJwts(input); + } + + private GenerateJwtRequest buildRefreshTokenRequest(String userName) { + Map claims = Map.of( + MdcParameter.USER_NAME.getDisplayName(), userName + ); + return generateJwtRequestConverter.toRequest(JwtConfigType.REFRESH_JWT, userName, claims); + } + + private GenerateJwtRequest buildAccessJwtRequest(Long userId, String userName, Set roles) { + Map claims = Map.of( + MdcParameter.USER_ID.getDisplayName(), userId, + MdcParameter.USER_NAME.getDisplayName(), userName, + "roles", roles + ); + + return generateJwtRequestConverter.toRequest(JwtConfigType.ACCESS_JWT, userName, claims); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/config/KeycloakSecurityConfig.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/config/KeycloakSecurityConfig.java new file mode 100644 index 00000000..592c2fdb --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/config/KeycloakSecurityConfig.java @@ -0,0 +1,35 @@ +package io.github.gms.auth.sso.keycloak.config; + +import io.github.gms.auth.config.AbstractSecurityConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; +import static io.github.gms.common.util.Constants.PASSWORD_ENCODER; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@Profile(CONFIG_AUTH_TYPE_KEYCLOAK_SSO) +public class KeycloakSecurityConfig extends AbstractSecurityConfig { + + @Bean(PASSWORD_ENCODER) + public PasswordEncoder passwordEncoder() { + return new PasswordEncoder() { + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return rawPassword.toString().equals(encodedPassword); + } + + @Override + public String encode(CharSequence rawPassword) { + return rawPassword.toString(); + } + }; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/config/KeycloakSettings.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/config/KeycloakSettings.java new file mode 100644 index 00000000..90e6160c --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/config/KeycloakSettings.java @@ -0,0 +1,29 @@ +package io.github.gms.auth.sso.keycloak.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; + +@Getter +@Setter +@Component +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +public class KeycloakSettings { + + @Value("${config.keycloak.tokenUrl}") + private String keycloakTokenUrl; + @Value("${config.keycloak.introspectUrl}") + private String introspectUrl; + @Value("${config.keycloak.logoutUrl}") + private String logoutUrl; + @Value("${config.keycloak.clientId}") + private String clientId; + @Value("${config.keycloak.clientSecret}") + private String clientSecret; + @Value("${config.keycloak.realm}") + private String realm; +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverter.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverter.java new file mode 100644 index 00000000..148ee183 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverter.java @@ -0,0 +1,15 @@ +package io.github.gms.auth.sso.keycloak.converter; + +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.functions.user.UserEntity; + +public interface KeycloakConverter { + + GmsUserDetails toUserDetails(IntrospectResponse response); + + UserInfoDto toUserInfoDto(IntrospectResponse response); + + UserEntity toEntity(UserEntity entity, UserInfoDto dto); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverterImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverterImpl.java new file mode 100644 index 00000000..12317e2d --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverterImpl.java @@ -0,0 +1,78 @@ +package io.github.gms.auth.sso.keycloak.converter; + +import dev.samstevens.totp.secret.SecretGenerator; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.EntityStatus; +import io.github.gms.common.enums.UserRole; +import io.github.gms.functions.user.UserEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.Objects; +import java.util.Set; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; + +@Component +@RequiredArgsConstructor +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +public class KeycloakConverterImpl implements KeycloakConverter { + + private final Clock clock; + private final SecretGenerator secretGenerator; + + @Override + public GmsUserDetails toUserDetails(IntrospectResponse response) { + return GmsUserDetails.builder() + .username(response.getUsername()) + .email(response.getEmail()) + .name(response.getName()) + .status(getStatus(response)) + .authorities(Set.of(getRoles(response))) + .build(); + } + + @Override + public UserInfoDto toUserInfoDto(IntrospectResponse response) { + return UserInfoDto.builder() + .name(response.getName()) + .username(response.getUsername()) + .email(response.getEmail()) + .role(getRoles(response)) + .status(getStatus(response)) + .failedAttempts(response.getFailedAttempts()) + .build(); + } + + public UserEntity toEntity(UserEntity entity, UserInfoDto dto) { + if (entity.getId() == null) { + entity.setStatus(EntityStatus.ACTIVE); + entity.setCreationDate(ZonedDateTime.now(clock)); + entity.setMfaSecret(secretGenerator.generate()); + } else { + entity.setStatus(dto.getStatus()); + } + + entity.setName(dto.getName()); + entity.setUsername(dto.getUsername()); + entity.setEmail(dto.getEmail()); + entity.setRole(dto.getRole()); + entity.setFailedAttempts(dto.getFailedAttempts()); + return entity; + } + + private static UserRole getRoles(IntrospectResponse response) { + return response.getRealmAccess().getRoles().stream().map(UserRole::getByName) + .filter(Objects::nonNull) + .toList().getFirst(); + } + + private static EntityStatus getStatus(IntrospectResponse response) { + return "true".equals(response.getActive()) ? EntityStatus.ACTIVE : EntityStatus.DISABLED; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/IntrospectResponse.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/IntrospectResponse.java new file mode 100644 index 00000000..5a3d1216 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/IntrospectResponse.java @@ -0,0 +1,40 @@ +package io.github.gms.auth.sso.keycloak.model; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class IntrospectResponse implements Serializable { + + @Serial + private static final long serialVersionUID = 6099923075106178108L; + + @JsonAlias("username") + private String username; + @JsonAlias("name") + private String name; + @JsonAlias("email") + private String email; + @JsonAlias("active") + private String active; + @JsonAlias("realm_access") + private RealmAccess realmAccess; + @JsonAlias("failed_attempts") + private Integer failedAttempts; + + @JsonAlias("error") + private String error; + @JsonAlias("error_description") + private String errorDescription; +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/LoginResponse.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/LoginResponse.java new file mode 100644 index 00000000..d662ad48 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/LoginResponse.java @@ -0,0 +1,26 @@ +package io.github.gms.auth.sso.keycloak.model; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class LoginResponse { + + @JsonAlias("access_token") + private String accessToken; + @JsonAlias("refresh_token") + private String refreshToken; + + @JsonAlias("error") + private String error; + @JsonAlias("error_description") + private String errorDescription; +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/RealmAccess.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/RealmAccess.java new file mode 100644 index 00000000..92b8e06f --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/model/RealmAccess.java @@ -0,0 +1,24 @@ +package io.github.gms.auth.sso.keycloak.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class RealmAccess implements Serializable { + + @Serial + private static final long serialVersionUID = 7416296792671241228L; + + private List roles; +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/KeycloakIntrospectService.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/KeycloakIntrospectService.java new file mode 100644 index 00000000..5b643218 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/KeycloakIntrospectService.java @@ -0,0 +1,13 @@ +package io.github.gms.auth.sso.keycloak.service; + +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import org.springframework.http.ResponseEntity; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface KeycloakIntrospectService { + + ResponseEntity getUserDetails(String accessToken, String refreshToken); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/KeycloakLoginService.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/KeycloakLoginService.java new file mode 100644 index 00000000..b55eacc5 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/KeycloakLoginService.java @@ -0,0 +1,15 @@ +package io.github.gms.auth.sso.keycloak.service; + +import io.github.gms.auth.sso.keycloak.model.LoginResponse; +import org.springframework.http.ResponseEntity; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface KeycloakLoginService { + + ResponseEntity login(String username, String credential); + + void logout(); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/OAuthService.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/OAuthService.java new file mode 100644 index 00000000..53a0207c --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/OAuthService.java @@ -0,0 +1,13 @@ +package io.github.gms.auth.sso.keycloak.service; + +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface OAuthService { + + ResponseEntity callPostEndpoint(String url, MultiValueMap requestBody, Class responseClass); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthenticationServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthenticationServiceImpl.java new file mode 100644 index 00000000..bf18b77f --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthenticationServiceImpl.java @@ -0,0 +1,149 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.AuthenticationService; +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.auth.sso.keycloak.converter.KeycloakConverter; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.model.LoginResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.auth.sso.keycloak.service.KeycloakLoginService; +import io.github.gms.auth.types.AuthResponsePhase; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.functions.user.UserEntity; +import io.github.gms.functions.user.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.web.util.WebUtils; + +import static io.github.gms.auth.types.AuthResponsePhase.BLOCKED; +import static io.github.gms.auth.types.AuthResponsePhase.COMPLETED; +import static io.github.gms.auth.types.AuthResponsePhase.FAILED; +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +public class KeycloakAuthenticationServiceImpl implements AuthenticationService { + + private final KeycloakLoginService keycloakLoginService; + private final KeycloakIntrospectService keycloakIntrospectService; + private final KeycloakConverter converter; + private final UserRepository userRepository; + private final HttpServletRequest httpServletRequest; + + @Override + public AuthenticationResponse authenticate(String username, String credential) { + Cookie accessJwtCookie = WebUtils.getCookie(httpServletRequest, ACCESS_JWT_TOKEN); + Cookie refreshJwtCookie = WebUtils.getCookie(httpServletRequest, REFRESH_JWT_TOKEN); + + if (accessJwtCookie != null && refreshJwtCookie != null) { + UserInfoDto userInfoDto = getUserDetails(accessJwtCookie.getValue(), refreshJwtCookie.getValue()); + + // Remove unnecessary properties + removeNonPublicAttributes(userInfoDto); + + return AuthenticationResponse.builder() + .currentUser(userInfoDto) + .phase(AuthResponsePhase.ALREADY_LOGGED_IN) + .token(accessJwtCookie.getValue()) + .refreshToken(refreshJwtCookie.getValue()) + .build(); + } + + // Login with Keycloak + try { + ResponseEntity response = keycloakLoginService.login(username, credential); + LoginResponse payload = response.getBody(); + + if (payload == null) { + log.warn("Response body missing!"); + return AuthenticationResponse.builder().build(); + } + + if (!response.getStatusCode().is2xxSuccessful()) { + log.warn("Login failed! Status code={}", response.getStatusCode()); + return AuthenticationResponse.builder() + .phase(getPhaseByResponse(payload)) + .build(); + } + + // Get user data + UserInfoDto userInfoDto = getUserDetails(payload.getAccessToken(), payload.getRefreshToken()); + + if (userInfoDto == null) { + return AuthenticationResponse.builder().build(); + } + + refreshUserData(userInfoDto); + + return AuthenticationResponse.builder() + .currentUser(userInfoDto) + .phase(COMPLETED) + .token(payload.getAccessToken()) + .refreshToken(payload.getRefreshToken()) + .build(); + } catch (Exception e) { + log.error("Unexpected error occurred during authentication!", e); + return AuthenticationResponse.builder().build(); + } + } + + @Override + public void logout() { + keycloakLoginService.logout(); + } + + private void refreshUserData(UserInfoDto userInfoDto) { + UserEntity entity = userRepository.findByUsername(userInfoDto.getUsername()) + .orElse(new UserEntity()); + + entity = userRepository.save(converter.toEntity(entity, userInfoDto)); + userInfoDto.setId(entity.getId()); + + // Remove unnecessary properties + removeNonPublicAttributes(userInfoDto); + } + + private void removeNonPublicAttributes(@Nullable UserInfoDto userInfoDto) { + if (userInfoDto == null) { + return; + } + + userInfoDto.setStatus(null); + userInfoDto.setFailedAttempts(null); + } + + private UserInfoDto getUserDetails(String accessToken, String refreshToken) { + ResponseEntity response = keycloakIntrospectService.getUserDetails(accessToken, refreshToken); + IntrospectResponse payload = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful() || payload == null) { + log.warn("Retrieving user details failed! Status code={}", response.getStatusCode()); + return null; + } + + if (!"true".equals(payload.getActive())) { + // TODO Handle this case, might need to query required user actions via admin REST API + return null; + } + + return converter.toUserInfoDto(payload); + } + + private static AuthResponsePhase getPhaseByResponse(LoginResponse response) { + return "Account disabled".equals(response.getErrorDescription()) ? BLOCKED : FAILED; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthorizationServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthorizationServiceImpl.java new file mode 100644 index 00000000..13c72429 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthorizationServiceImpl.java @@ -0,0 +1,78 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.AuthorizationService; +import io.github.gms.auth.model.AuthorizationResponse; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.sso.keycloak.converter.KeycloakConverter; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.common.enums.JwtConfigType; +import io.github.gms.common.util.Constants; +import io.github.gms.functions.user.UserEntity; +import io.github.gms.functions.user.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Service; +import org.springframework.web.util.WebUtils; + +import java.util.Map; +import java.util.Optional; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Service +@RequiredArgsConstructor +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +public class KeycloakAuthorizationServiceImpl implements AuthorizationService { + + private final KeycloakConverter converter; + private final KeycloakIntrospectService keycloakIntrospectService; + private final UserRepository userRepository; + + @Override + public AuthorizationResponse authorize(HttpServletRequest request) { + Cookie accessJwtCookie = WebUtils.getCookie(request, ACCESS_JWT_TOKEN); + Cookie refreshJwtCookie = WebUtils.getCookie(request, REFRESH_JWT_TOKEN); + + if (accessJwtCookie == null || refreshJwtCookie == null) { + return AuthorizationResponse.builder().responseStatus(HttpStatus.FORBIDDEN).errorMessage(Constants.ACCESS_DENIED).build(); + } + + ResponseEntity response = keycloakIntrospectService.getUserDetails(accessJwtCookie.getValue(), refreshJwtCookie.getValue()); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + return AuthorizationResponse.builder().responseStatus(HttpStatus.FORBIDDEN).errorMessage(Constants.ACCESS_DENIED).build(); + } + + GmsUserDetails userDetails = converter.toUserDetails(response.getBody()); + Optional userResult = userRepository.findByUsername(userDetails.getUsername()); + + if (userResult.isEmpty()) { + return AuthorizationResponse.builder().responseStatus(HttpStatus.FORBIDDEN).errorMessage(Constants.ACCESS_DENIED).build(); + } + + userDetails.setUserId(userResult.get().getId()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + return AuthorizationResponse.builder() + .authentication(authentication) + .jwtPair(Map.of( + JwtConfigType.ACCESS_JWT, accessJwtCookie.getValue(), + JwtConfigType.REFRESH_JWT, refreshJwtCookie.getValue() + )) + .build(); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakIntrospectServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakIntrospectServiceImpl.java new file mode 100644 index 00000000..05e8402f --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakIntrospectServiceImpl.java @@ -0,0 +1,50 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.config.KeycloakSettings; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.auth.sso.keycloak.service.OAuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static io.github.gms.common.util.Constants.CACHE_KEYCLOAK_SSO_GENERATOR; +import static io.github.gms.common.util.Constants.CACHE_SSO_USER; +import static io.github.gms.common.util.Constants.CLIENT_ID; +import static io.github.gms.common.util.Constants.CLIENT_SECRET; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; +import static io.github.gms.common.util.Constants.REFRESH_TOKEN; +import static io.github.gms.common.util.Constants.TOKEN; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@CacheConfig(cacheNames = { CACHE_SSO_USER }, keyGenerator = CACHE_KEYCLOAK_SSO_GENERATOR) +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +public class KeycloakIntrospectServiceImpl implements KeycloakIntrospectService { + + private final OAuthService oAuthService; + private final KeycloakSettings keycloakSettings; + + @Override + @Cacheable + public ResponseEntity getUserDetails(String accessToken, String refreshToken) { + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add(CLIENT_ID, keycloakSettings.getClientId()); + requestBody.add(CLIENT_SECRET, keycloakSettings.getClientSecret()); + requestBody.add(TOKEN, accessToken); + requestBody.add(REFRESH_TOKEN, refreshToken); + + return oAuthService.callPostEndpoint(keycloakSettings.getIntrospectUrl(), requestBody, IntrospectResponse.class); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakLoginServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakLoginServiceImpl.java new file mode 100644 index 00000000..bc7de962 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakLoginServiceImpl.java @@ -0,0 +1,80 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.config.KeycloakSettings; +import io.github.gms.auth.sso.keycloak.model.LoginResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakLoginService; +import io.github.gms.auth.sso.keycloak.service.OAuthService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.WebUtils; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.AUDIENCE; +import static io.github.gms.common.util.Constants.CACHE_KEYCLOAK_SSO_GENERATOR; +import static io.github.gms.common.util.Constants.CACHE_SSO_USER; +import static io.github.gms.common.util.Constants.CLIENT_ID; +import static io.github.gms.common.util.Constants.CLIENT_SECRET; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; +import static io.github.gms.common.util.Constants.CREDENTIAL; +import static io.github.gms.common.util.Constants.GRANT_TYPE; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; +import static io.github.gms.common.util.Constants.REFRESH_TOKEN; +import static io.github.gms.common.util.Constants.SCOPE; +import static io.github.gms.common.util.Constants.SCOPE_GMS; +import static io.github.gms.common.util.Constants.TOKEN; +import static io.github.gms.common.util.Constants.USERNAME; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Service +@RequiredArgsConstructor +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +@CacheConfig(cacheNames = { CACHE_SSO_USER }, keyGenerator = CACHE_KEYCLOAK_SSO_GENERATOR) +public class KeycloakLoginServiceImpl implements KeycloakLoginService { + + private final OAuthService oAuthService; + private final HttpServletRequest httpServletRequest; + private final KeycloakSettings keycloakSettings; + + @Override + public ResponseEntity login(String username, String credential) { + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add(GRANT_TYPE, CREDENTIAL); + requestBody.add(AUDIENCE, keycloakSettings.getRealm()); + requestBody.add(USERNAME, username); + requestBody.add(CREDENTIAL, credential); + requestBody.add(CLIENT_ID, keycloakSettings.getClientId()); + requestBody.add(CLIENT_SECRET, keycloakSettings.getClientSecret()); + requestBody.add(SCOPE, SCOPE_GMS); + + return oAuthService.callPostEndpoint(keycloakSettings.getKeycloakTokenUrl(), requestBody, LoginResponse.class); + } + + @Override + @CacheEvict + public void logout() { + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + Cookie accessJwtCookie = WebUtils.getCookie(httpServletRequest, ACCESS_JWT_TOKEN); + Cookie refreshJwtCookie = WebUtils.getCookie(httpServletRequest, REFRESH_JWT_TOKEN); + + if (accessJwtCookie == null || refreshJwtCookie == null) { + return; + } + + requestBody.add(CLIENT_ID, keycloakSettings.getClientId()); + requestBody.add(CLIENT_SECRET, keycloakSettings.getClientSecret()); + requestBody.add(TOKEN, accessJwtCookie.getValue()); + requestBody.add(REFRESH_TOKEN, refreshJwtCookie.getValue()); + oAuthService.callPostEndpoint(keycloakSettings.getLogoutUrl(), requestBody, Void.class); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakOAuthServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakOAuthServiceImpl.java new file mode 100644 index 00000000..5de1f6ea --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakOAuthServiceImpl.java @@ -0,0 +1,37 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.service.OAuthService; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Service +@Profile(value = { CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) +public class KeycloakOAuthServiceImpl implements OAuthService { + + private final RestTemplate restTemplate; + + public KeycloakOAuthServiceImpl(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @Override + public ResponseEntity callPostEndpoint(String url, MultiValueMap requestBody, Class responseClass) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + return restTemplate.postForEntity(url, requestEntity, responseClass); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserInfoServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserInfoServiceImpl.java new file mode 100644 index 00000000..d1bfb200 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserInfoServiceImpl.java @@ -0,0 +1,66 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.UserRole; +import io.github.gms.functions.user.UserInfoService; +import io.github.gms.functions.user.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.util.WebUtils; + +import java.util.Objects; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Service +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_KEYCLOAK_SSO) +public class KeycloakUserInfoServiceImpl implements UserInfoService { + + private final KeycloakIntrospectService keycloakIntrospectService; + private final UserRepository userRepository; + + @Override + public UserInfoDto getUserInfo(HttpServletRequest request) { + Cookie accessJwtCookie = WebUtils.getCookie(request, ACCESS_JWT_TOKEN); + Cookie refreshJwtCookie = WebUtils.getCookie(request, REFRESH_JWT_TOKEN); + + if (accessJwtCookie == null || refreshJwtCookie == null) { + return UserInfoDto.builder().build(); + } + + ResponseEntity response = keycloakIntrospectService.getUserDetails(accessJwtCookie.getValue(), refreshJwtCookie.getValue()); + IntrospectResponse payload = response.getBody(); + if (!response.getStatusCode().is2xxSuccessful() || payload == null) { + return null; + } + + Long userId = userRepository.getIdByUsername(payload.getUsername()).orElse(-1L); + + if (userId.equals(-1L)) { + return null; + } + + return UserInfoDto.builder() + .id(userId) + .email(payload.getEmail()) + .name(payload.getName()) + .username(payload.getUsername()) + .role(payload.getRealmAccess().getRoles().stream().map(UserRole::getByName) + .filter(Objects::nonNull) + .toList().getFirst()) + .build(); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserLoginAttemptManagerServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserLoginAttemptManagerServiceImpl.java new file mode 100644 index 00000000..da2fb3f0 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserLoginAttemptManagerServiceImpl.java @@ -0,0 +1,35 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.functions.user.UserLoginAttemptManagerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_KEYCLOAK_SSO) +public class KeycloakUserLoginAttemptManagerServiceImpl implements UserLoginAttemptManagerService { + + @Override + public void updateLoginAttempt(String username) { + log.info("updateLoginAttempt method will be ignored when Keycloak SSO based security is active"); + } + + @Override + public void resetLoginAttempt(String username) { + log.info("resetLoginAttempt method will be ignored when Keycloak SSO based security is active"); + } + + @Override + public boolean isBlocked(String username) { + return false; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/auth/types/AuthResponsePhase.java b/code/gms-backend/src/main/java/io/github/gms/auth/types/AuthResponsePhase.java index 65fc95fc..a48f5820 100644 --- a/code/gms-backend/src/main/java/io/github/gms/auth/types/AuthResponsePhase.java +++ b/code/gms-backend/src/main/java/io/github/gms/auth/types/AuthResponsePhase.java @@ -5,6 +5,7 @@ * @since 1.0 */ public enum AuthResponsePhase { + ALREADY_LOGGED_IN, BLOCKED, FAILED, MFA_REQUIRED, diff --git a/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractAuthService.java b/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractAuthService.java deleted file mode 100644 index 463ccabc..00000000 --- a/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractAuthService.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.github.gms.common.abstraction; - -import io.github.gms.auth.UserAuthService; -import io.github.gms.auth.model.GmsUserDetails; -import io.github.gms.common.converter.GenerateJwtRequestConverter; -import io.github.gms.common.enums.JwtConfigType; -import io.github.gms.common.enums.MdcParameter; -import io.github.gms.common.enums.UserRole; -import io.github.gms.common.model.GenerateJwtRequest; -import io.github.gms.common.service.JwtService; -import io.github.gms.functions.systemproperty.SystemPropertyService; -import lombok.RequiredArgsConstructor; - -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * @author Peter Szrnka - * @since 1.0 - */ -@RequiredArgsConstructor -public abstract class AbstractAuthService { - - protected final JwtService jwtService; - protected final SystemPropertyService systemPropertyService; - protected final GenerateJwtRequestConverter generateJwtRequestConverter; - protected final UserAuthService userAuthService; - - protected Map getAuthenticationDetails(GmsUserDetails user) { - Map input = Map.of( - JwtConfigType.ACCESS_JWT, buildAccessJwtRequest(user.getUserId(), user.getUsername(), - user.getAuthorities().stream().map(authority -> UserRole.getByName(authority.getAuthority())).collect(Collectors.toSet())), - JwtConfigType.REFRESH_JWT, buildRefreshTokenRequest(user.getUsername()) - ); - - return jwtService.generateJwts(input); - } - - private GenerateJwtRequest buildRefreshTokenRequest(String userName) { - Map claims = Map.of( - MdcParameter.USER_NAME.getDisplayName(), userName - ); - return generateJwtRequestConverter.toRequest(JwtConfigType.REFRESH_JWT, userName, claims); - } - - private GenerateJwtRequest buildAccessJwtRequest(Long userId, String userName, Set roles) { - Map claims = Map.of( - MdcParameter.USER_ID.getDisplayName(), userId, - MdcParameter.USER_NAME.getDisplayName(), userName, - "roles", roles - ); - - return generateJwtRequestConverter.toRequest(JwtConfigType.ACCESS_JWT, userName, claims); - } -} diff --git a/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractLoginController.java b/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractLoginController.java new file mode 100644 index 00000000..07b2a290 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractLoginController.java @@ -0,0 +1,41 @@ +package io.github.gms.common.abstraction; + +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.common.enums.SystemProperty; +import io.github.gms.common.util.CookieUtils; +import io.github.gms.functions.systemproperty.SystemPropertyService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; +import static io.github.gms.common.util.Constants.SET_COOKIE; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@RequiredArgsConstructor +public abstract class AbstractLoginController { + + protected final SystemPropertyService systemPropertyService; + protected final boolean secure; + + protected HttpHeaders addHeaders(AuthenticationResponse authenticateResult) { + HttpHeaders headers = new HttpHeaders(); + + addHeader(headers, ACCESS_JWT_TOKEN, authenticateResult.getToken(), SystemProperty.ACCESS_JWT_EXPIRATION_TIME_SECONDS); + addHeader(headers, REFRESH_JWT_TOKEN, authenticateResult.getRefreshToken(), SystemProperty.REFRESH_JWT_EXPIRATION_TIME_SECONDS); + + return headers; + } + + private void addHeader(HttpHeaders headers, String tokenName, String tokenValue, SystemProperty property) { + if (tokenValue == null) { + return; + } + + headers.add(SET_COOKIE, CookieUtils.createCookie(tokenName, tokenValue, + systemPropertyService.getLong(property), secure).toString()); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractUserConverter.java b/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractUserConverter.java new file mode 100644 index 00000000..40520979 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/common/abstraction/AbstractUserConverter.java @@ -0,0 +1,18 @@ +package io.github.gms.common.abstraction; + +import io.github.gms.common.enums.UserRole; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.Objects; + +public abstract class AbstractUserConverter { + + protected static UserRole getFirstRole(Collection authorities) { + return authorities.stream() + .map(authority -> UserRole.getByName(authority.getAuthority())) + .filter(Objects::nonNull) + .toList() + .getFirst(); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/common/config/ApplicationConfig.java b/code/gms-backend/src/main/java/io/github/gms/common/config/ApplicationConfig.java index d9d77c1f..a8137e53 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/config/ApplicationConfig.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/config/ApplicationConfig.java @@ -2,6 +2,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import io.github.gms.common.interceptor.HttpClientResponseLoggingInterceptor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.jackson.JsonComponentModule; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -10,6 +21,8 @@ import org.springframework.core.io.Resource; import org.springframework.core.task.TaskExecutor; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.lang.NonNull; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @@ -47,13 +60,16 @@ public TaskExecutor getAsyncExecutor() { ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); - + mapper.registerModule(new JsonComponentModule()); return mapper; } @Bean - RestTemplate restTemplate() { - return new RestTemplateBuilder().build(); + RestTemplate restTemplate(@Value("${config.logging.httpClient.enabled}") boolean httpClientLoggingEnabled) { + return new RestTemplateBuilder() + .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory())) + .additionalInterceptors(new HttpClientResponseLoggingInterceptor(httpClientLoggingEnabled)) + .build(); } @Bean @@ -76,4 +92,16 @@ protected Resource getResource(@NonNull String resourcePath, @NonNull Resource l } }); } + + @Bean + public CodeVerifier codeVerifier() { + TimeProvider timeProvider = new SystemTimeProvider(); + CodeGenerator codeGenerator = new DefaultCodeGenerator(); + return new DefaultCodeVerifier(codeGenerator, timeProvider); + } + + @Bean + public SecretGenerator secretGenerator() { + return new DefaultSecretGenerator(); + } } diff --git a/code/gms-backend/src/main/java/io/github/gms/common/config/CacheConfig.java b/code/gms-backend/src/main/java/io/github/gms/common/config/CacheConfig.java index 55dc6005..ca109122 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/config/CacheConfig.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/config/CacheConfig.java @@ -1,6 +1,7 @@ package io.github.gms.common.config; import io.github.gms.common.config.cache.ApiCacheKeyGenerator; +import io.github.gms.common.config.cache.KeycloakSsoKeyGenerator; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CachingConfigurer; @@ -16,6 +17,8 @@ import static io.github.gms.common.util.Constants.CACHE_API_GENERATOR; import static io.github.gms.common.util.Constants.CACHE_GLOBAL_IP_RESTRICTION; import static io.github.gms.common.util.Constants.CACHE_IP_RESTRICTION; +import static io.github.gms.common.util.Constants.CACHE_KEYCLOAK_SSO_GENERATOR; +import static io.github.gms.common.util.Constants.CACHE_SSO_USER; import static io.github.gms.common.util.Constants.CACHE_SYSTEM_PROPERTY; import static io.github.gms.common.util.Constants.CACHE_USER; @@ -36,7 +39,8 @@ public CacheManager cacheManager() { CACHE_SYSTEM_PROPERTY, CACHE_API, CACHE_GLOBAL_IP_RESTRICTION, - CACHE_IP_RESTRICTION); + CACHE_IP_RESTRICTION, + CACHE_SSO_USER); manager.setAllowNullValues(false); return manager; } @@ -52,4 +56,9 @@ public KeyGenerator keyGenerator() { public KeyGenerator apiCacheKeyGenerator() { return new ApiCacheKeyGenerator(); } + + @Bean(CACHE_KEYCLOAK_SSO_GENERATOR) + public KeyGenerator keycloakSsoKeyGenerator() { + return new KeycloakSsoKeyGenerator(); + } } \ No newline at end of file diff --git a/code/gms-backend/src/main/java/io/github/gms/common/config/RedisCacheConfig.java b/code/gms-backend/src/main/java/io/github/gms/common/config/RedisCacheConfig.java index f73b30eb..07698383 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/config/RedisCacheConfig.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/config/RedisCacheConfig.java @@ -1,6 +1,7 @@ package io.github.gms.common.config; import io.github.gms.common.config.cache.ApiCacheKeyGenerator; +import io.github.gms.common.config.cache.KeycloakSsoKeyGenerator; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -24,6 +25,8 @@ import static io.github.gms.common.util.Constants.CACHE_API_GENERATOR; import static io.github.gms.common.util.Constants.CACHE_GLOBAL_IP_RESTRICTION; import static io.github.gms.common.util.Constants.CACHE_IP_RESTRICTION; +import static io.github.gms.common.util.Constants.CACHE_KEYCLOAK_SSO_GENERATOR; +import static io.github.gms.common.util.Constants.CACHE_SSO_USER; import static io.github.gms.common.util.Constants.CACHE_SYSTEM_PROPERTY; import static io.github.gms.common.util.Constants.CACHE_USER; @@ -57,7 +60,7 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec } @Bean - public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisCacheManagerBuilderCustomizer customizer) { return RedisCacheManager.RedisCacheManagerBuilder .fromConnectionFactory(connectionFactory) .withCacheConfiguration(CACHE_USER, minutesCacheConfig(10)) @@ -65,17 +68,13 @@ public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { .withCacheConfiguration(CACHE_API, minutesCacheConfig(10)) .withCacheConfiguration(CACHE_GLOBAL_IP_RESTRICTION, minutesCacheConfig(10)) .withCacheConfiguration(CACHE_IP_RESTRICTION, minutesCacheConfig(10)) + .withCacheConfiguration(CACHE_SSO_USER, minutesCacheConfig(5)) .build(); } @Bean public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() { - return (builder) -> builder - .withCacheConfiguration(CACHE_USER, minutesCacheConfig(10)) - .withCacheConfiguration(CACHE_SYSTEM_PROPERTY, minutesCacheConfig(10)) - .withCacheConfiguration(CACHE_API, minutesCacheConfig(5)) - .withCacheConfiguration(CACHE_GLOBAL_IP_RESTRICTION, minutesCacheConfig(10)) - .withCacheConfiguration(CACHE_IP_RESTRICTION, minutesCacheConfig(10)); + return RedisCacheManager.RedisCacheManagerBuilder::cacheDefaults; } @Bean @@ -89,6 +88,11 @@ public KeyGenerator apiCacheKeyGenerator() { return new ApiCacheKeyGenerator(); } + @Bean(CACHE_KEYCLOAK_SSO_GENERATOR) + public KeyGenerator keycloakSsoKeyGenerator() { + return new KeycloakSsoKeyGenerator(); + } + private static RedisCacheConfiguration minutesCacheConfig(int minutes) { return RedisCacheConfiguration .defaultCacheConfig(Thread.currentThread().getContextClassLoader()) diff --git a/code/gms-backend/src/main/java/io/github/gms/common/config/cache/KeycloakSsoKeyGenerator.java b/code/gms-backend/src/main/java/io/github/gms/common/config/cache/KeycloakSsoKeyGenerator.java new file mode 100644 index 00000000..982e5837 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/common/config/cache/KeycloakSsoKeyGenerator.java @@ -0,0 +1,21 @@ +package io.github.gms.common.config.cache; + +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.lang.NonNull; + +import java.lang.reflect.Method; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public class KeycloakSsoKeyGenerator implements KeyGenerator { + + @Override + public @NonNull Object generate(@NonNull Object target, @NonNull Method method, Object... params) { + String accessToken = (String) params[0]; + String refreshToken = (String) params[1]; + String hash = accessToken.concat(refreshToken); + return hash.hashCode(); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/common/controller/InformationController.java b/code/gms-backend/src/main/java/io/github/gms/common/controller/InformationController.java index c8c47aa9..29ac44d7 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/controller/InformationController.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/controller/InformationController.java @@ -1,7 +1,7 @@ package io.github.gms.common.controller; import io.github.gms.common.dto.UserInfoDto; -import io.github.gms.functions.user.UserService; +import io.github.gms.functions.user.UserInfoService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -17,10 +17,10 @@ @RequiredArgsConstructor public class InformationController { - private final UserService userService; + private final UserInfoService userInfoService; @GetMapping("/me") public UserInfoDto getUserInfo(HttpServletRequest request) { - return userService.getUserInfo(request); + return userInfoService.getUserInfo(request); } } \ No newline at end of file diff --git a/code/gms-backend/src/main/java/io/github/gms/common/controller/LoginController.java b/code/gms-backend/src/main/java/io/github/gms/common/controller/LoginController.java index 9db0327d..2e0e18df 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/controller/LoginController.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/controller/LoginController.java @@ -5,8 +5,7 @@ import io.github.gms.auth.dto.AuthenticateResponseDto; import io.github.gms.auth.model.AuthenticationResponse; import io.github.gms.auth.types.AuthResponsePhase; -import io.github.gms.common.dto.LoginVerificationRequestDto; -import io.github.gms.common.enums.SystemProperty; +import io.github.gms.common.abstraction.AbstractLoginController; import io.github.gms.common.util.CookieUtils; import io.github.gms.functions.systemproperty.SystemPropertyService; import org.springframework.beans.factory.annotation.Value; @@ -28,28 +27,29 @@ */ @RestController @RequestMapping("/") -public class LoginController { +public class LoginController extends AbstractLoginController { public static final String LOGIN_PATH = "authenticate"; public static final String LOGOUT_PATH = "logoutUser"; - private final AuthenticationService service; - private final SystemPropertyService systemPropertyService; - private final boolean secure; + private final AuthenticationService authenticationService; - public LoginController(AuthenticationService service, SystemPropertyService systemPropertyService, + public LoginController(AuthenticationService authenticationService, + SystemPropertyService systemPropertyService, @Value("${config.cookie.secure}") boolean secure) { - this.service = service; - this.systemPropertyService = systemPropertyService; - this.secure = secure; + super(systemPropertyService, secure); + this.authenticationService = authenticationService; } @PostMapping(LOGIN_PATH) public ResponseEntity loginAuthentication(@RequestBody AuthenticateRequestDto dto) { - AuthenticationResponse authenticateResult = service.authenticate(dto.getUsername(), dto.getCredential()); + AuthenticationResponse authenticateResult = authenticationService.authenticate(dto.getUsername(), dto.getCredential()); if (AuthResponsePhase.FAILED == authenticateResult.getPhase()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + HttpHeaders headers = new HttpHeaders(); + headers.add(SET_COOKIE, CookieUtils.createCookie(ACCESS_JWT_TOKEN, null, 0, secure).toString()); + headers.add(SET_COOKIE, CookieUtils.createCookie(REFRESH_JWT_TOKEN, null, 0, secure).toString()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).headers(headers).body(null); } return ResponseEntity.ok().headers(addHeaders(authenticateResult)) @@ -58,46 +58,16 @@ public ResponseEntity loginAuthentication(@RequestBody .phase(authenticateResult.getPhase()) .build()); } - - @PostMapping("verify") - public ResponseEntity verify(@RequestBody LoginVerificationRequestDto dto) { - AuthenticationResponse authenticateResult = service.verify(dto); - - if (AuthResponsePhase.FAILED == authenticateResult.getPhase()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); - } - - return ResponseEntity.ok().headers(addHeaders(authenticateResult)).body(AuthenticateResponseDto.builder() - .currentUser(authenticateResult.getCurrentUser()) - .phase(authenticateResult.getPhase()) - .build()); - } @PostMapping(LOGOUT_PATH) public ResponseEntity logout() { HttpHeaders headers = new HttpHeaders(); + + authenticationService.logout(); headers.add(SET_COOKIE, CookieUtils.createCookie(ACCESS_JWT_TOKEN, null, 0, secure).toString()); headers.add(SET_COOKIE, CookieUtils.createCookie(REFRESH_JWT_TOKEN, null, 0, secure).toString()); return ResponseEntity.ok().headers(headers).build(); } - - private HttpHeaders addHeaders(AuthenticationResponse authenticateResult) { - HttpHeaders headers = new HttpHeaders(); - - addHeader(headers, ACCESS_JWT_TOKEN, authenticateResult.getToken(), SystemProperty.ACCESS_JWT_EXPIRATION_TIME_SECONDS); - addHeader(headers, REFRESH_JWT_TOKEN, authenticateResult.getRefreshToken(), SystemProperty.REFRESH_JWT_EXPIRATION_TIME_SECONDS); - - return headers; - } - - private void addHeader(HttpHeaders headers, String tokenName, String tokenValue, SystemProperty property) { - if (tokenValue == null) { - return; - } - - headers.add(SET_COOKIE, CookieUtils.createCookie(tokenName, tokenValue, - systemPropertyService.getLong(property), secure).toString()); - } } \ No newline at end of file diff --git a/code/gms-backend/src/main/java/io/github/gms/common/controller/SetupController.java b/code/gms-backend/src/main/java/io/github/gms/common/controller/SetupController.java index 154a272d..16401c76 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/controller/SetupController.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/controller/SetupController.java @@ -1,7 +1,6 @@ package io.github.gms.common.controller; -import com.google.common.collect.Sets; import io.github.gms.common.dto.SaveEntityResponseDto; import io.github.gms.common.enums.EventOperation; import io.github.gms.common.enums.EventTarget; @@ -13,11 +12,14 @@ import io.github.gms.functions.user.UserService; import lombok.RequiredArgsConstructor; import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + /** * @author Peter Szrnka * @since 1.0 @@ -26,6 +28,7 @@ @RequiredArgsConstructor @RequestMapping("/setup") @AuditTarget(EventTarget.ADMIN_USER) +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) public class SetupController { private final UserService userService; @@ -36,8 +39,8 @@ public SaveEntityResponseDto saveAdminUser(@RequestBody SaveUserRequestDto dto) MDC.put(MdcParameter.USER_NAME.getDisplayName(), "setup"); MDC.put(MdcParameter.USER_ID.getDisplayName(), "0"); - if (dto.getRoles().isEmpty()) { - dto.setRoles(Sets.newHashSet(UserRole.ROLE_ADMIN)); + if (dto.getRole() != null) { + dto.setRole(UserRole.ROLE_ADMIN); } return userService.saveAdminUser(dto); diff --git a/code/gms-backend/src/main/java/io/github/gms/common/controller/VerificationController.java b/code/gms-backend/src/main/java/io/github/gms/common/controller/VerificationController.java new file mode 100644 index 00000000..53a3b76b --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/common/controller/VerificationController.java @@ -0,0 +1,53 @@ +package io.github.gms.common.controller; + +import io.github.gms.auth.VerificationService; +import io.github.gms.auth.dto.AuthenticateResponseDto; +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.auth.types.AuthResponsePhase; +import io.github.gms.common.abstraction.AbstractLoginController; +import io.github.gms.common.dto.LoginVerificationRequestDto; +import io.github.gms.functions.systemproperty.SystemPropertyService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@RestController +@RequestMapping("/") +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class VerificationController extends AbstractLoginController { + + private final VerificationService service; + + public VerificationController( + VerificationService verificationService, + SystemPropertyService systemPropertyService, + @Value("${config.cookie.secure}") boolean secure) { + super(systemPropertyService, secure); + this.service = verificationService; + } + + @PostMapping("verify") + public ResponseEntity verify(@RequestBody LoginVerificationRequestDto dto) { + AuthenticationResponse authenticateResult = service.verify(dto); + + if (AuthResponsePhase.FAILED == authenticateResult.getPhase()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } + + return ResponseEntity.ok().headers(addHeaders(authenticateResult)).body(AuthenticateResponseDto.builder() + .currentUser(authenticateResult.getCurrentUser()) + .phase(authenticateResult.getPhase()) + .build()); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/common/dto/UserInfoDto.java b/code/gms-backend/src/main/java/io/github/gms/common/dto/UserInfoDto.java index 6ab7e898..7522a198 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/dto/UserInfoDto.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/dto/UserInfoDto.java @@ -1,6 +1,8 @@ package io.github.gms.common.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import io.github.gms.common.enums.EntityStatus; import io.github.gms.common.enums.UserRole; import lombok.AllArgsConstructor; import lombok.Builder; @@ -9,8 +11,6 @@ import java.io.Serial; import java.io.Serializable; -import java.util.HashSet; -import java.util.Set; /** * @author Peter Szrnka @@ -30,6 +30,9 @@ public class UserInfoDto implements Serializable { private String name; private String username; private String email; - @Builder.Default - private Set roles = new HashSet<>(); + private UserRole role; + @JsonIgnore + private EntityStatus status; + @JsonIgnore + private Integer failedAttempts; } diff --git a/code/gms-backend/src/main/java/io/github/gms/common/enums/EntityStatus.java b/code/gms-backend/src/main/java/io/github/gms/common/enums/EntityStatus.java index 1f365087..a7ceb264 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/enums/EntityStatus.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/enums/EntityStatus.java @@ -11,6 +11,7 @@ public enum EntityStatus { ACTIVE, BLOCKED, DISABLED, + INITIAL, TO_BE_DELETED; public static EntityStatus getByName(String name) { diff --git a/code/gms-backend/src/main/java/io/github/gms/common/filter/SecureHeaderInitializerFilter.java b/code/gms-backend/src/main/java/io/github/gms/common/filter/SecureHeaderInitializerFilter.java index 455cf005..dfef90c1 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/filter/SecureHeaderInitializerFilter.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/filter/SecureHeaderInitializerFilter.java @@ -3,6 +3,7 @@ import com.google.common.collect.Sets; import io.github.gms.auth.AuthorizationService; import io.github.gms.auth.model.AuthorizationResponse; +import io.github.gms.auth.model.GmsUserDetails; import io.github.gms.common.enums.JwtConfigType; import io.github.gms.common.enums.MdcParameter; import io.github.gms.common.enums.SystemProperty; @@ -69,7 +70,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.sendError(authenticationResponse.getResponseStatus().value(), authenticationResponse.getErrorMessage()); return; } - + + GmsUserDetails userDetails = (GmsUserDetails) authenticationResponse.getAuthentication().getPrincipal(); + MDC.put(MdcParameter.USER_ID.getDisplayName(), String.valueOf(userDetails.getUserId())); String accessCookie = CookieUtils.createCookie(Constants.ACCESS_JWT_TOKEN, authenticationResponse.getJwtPair().get(JwtConfigType.ACCESS_JWT), systemPropertyService.getLong(SystemProperty.ACCESS_JWT_EXPIRATION_TIME_SECONDS), secure).toString(); String refreshCookie = CookieUtils.createCookie(Constants.REFRESH_JWT_TOKEN, authenticationResponse.getJwtPair().get(JwtConfigType.REFRESH_JWT), diff --git a/code/gms-backend/src/main/java/io/github/gms/common/interceptor/HttpClientResponseLoggingInterceptor.java b/code/gms-backend/src/main/java/io/github/gms/common/interceptor/HttpClientResponseLoggingInterceptor.java new file mode 100644 index 00000000..c608fba0 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/common/interceptor/HttpClientResponseLoggingInterceptor.java @@ -0,0 +1,38 @@ +package io.github.gms.common.interceptor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; + +import java.io.IOException; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Slf4j +@RequiredArgsConstructor +public class HttpClientResponseLoggingInterceptor implements ClientHttpRequestInterceptor { + + private final boolean httpClientLoggingEnabled; + + @NonNull + @Override + public ClientHttpResponse intercept(@NonNull HttpRequest request, @NonNull byte[] body, @NonNull ClientHttpRequestExecution execution) + throws IOException { + ClientHttpResponse response = execution.execute(request, body); + + if (!httpClientLoggingEnabled) { + return response; + } + + log.info("Requested URL: {}", request.getURI()); + String responseString = new String(response.getBody().readAllBytes()); + log.info("Response status: {} body:\r\n{}", response.getStatusCode().value(), responseString); + return response; + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/common/util/Constants.java b/code/gms-backend/src/main/java/io/github/gms/common/util/Constants.java index bae8aeb0..d006181c 100644 --- a/code/gms-backend/src/main/java/io/github/gms/common/util/Constants.java +++ b/code/gms-backend/src/main/java/io/github/gms/common/util/Constants.java @@ -5,8 +5,8 @@ * @since 1.0 */ public final class Constants { - - private Constants() {} + + private Constants() {} public static final String SLASH = "/"; public static final String OK = "OK"; @@ -28,6 +28,8 @@ private Constants() {} // Properties public static final String CONFIG_AUTH_TYPE_DB = "db"; public static final String CONFIG_AUTH_TYPE_LDAP = "ldap"; + public static final String CONFIG_AUTH_TYPE_KEYCLOAK_SSO = "keycloak-sso"; + public static final String CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO = "!keycloak-sso"; public static final String CONFIG_LDAP_PASSWORD_ENCODER = "config.ldap.passwordencoder"; // LDAP @@ -41,9 +43,9 @@ private Constants() {} public static final String PASSWORD_ENCODER = "passwordEncoder"; // Environment properties - public static final String SELECTED_AUTH = "SELECTED_AUTH"; public static final String SELECTED_AUTH_DB = "db"; public static final String SELECTED_AUTH_LDAP = "ldap"; + public static final String SELECTED_AUTH_SSO = "sso"; // Headers public static final String ACCESS_JWT_TOKEN = "jwt"; @@ -54,10 +56,12 @@ private Constants() {} // Cache public static final String CACHE_API = "apiCache"; public static final String CACHE_API_GENERATOR = "apiCacheKeyGenerator"; + public static final String CACHE_KEYCLOAK_SSO_GENERATOR = "keycloakSsoKeyGenerator"; public static final String CACHE_USER = "userCache"; public static final String CACHE_SYSTEM_PROPERTY = "systemPropertyCache"; public static final String CACHE_IP_RESTRICTION = "ipRestrictionCache"; public static final String CACHE_GLOBAL_IP_RESTRICTION = "globalIpRestrictionCache"; + public static final String CACHE_SSO_USER = "ssoUserCache"; // Formats public static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"; @@ -68,4 +72,17 @@ private Constants() {} public static final String PATH_LIST_NAMES = "/list_names"; public static final String PATH_ENABLED = "enabled"; public static final String ID = "id"; + + public static final String GRANT_TYPE = "grant_type"; + public static final String AUDIENCE = "audience"; + public static final String USERNAME = "username"; + public static final String CREDENTIAL = "password"; + public static final String SCOPE = "scope"; + public static final String SCOPE_GMS = "profile email"; + public static final String CLIENT_ID = "client_id"; + public static final String CLIENT_SECRET = "client_secret"; + public static final String TOKEN = "token"; + public static final String REFRESH_TOKEN = "refresh_token"; + + public static final String ACCESS_DENIED = "Access denied!"; } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/system/SystemServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/functions/system/SystemServiceImpl.java index fca1a6c7..c1b4900d 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/system/SystemServiceImpl.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/system/SystemServiceImpl.java @@ -14,7 +14,7 @@ import java.time.format.DateTimeFormatter; import static io.github.gms.common.util.Constants.OK; -import static io.github.gms.common.util.Constants.SELECTED_AUTH_LDAP; +import static io.github.gms.common.util.Constants.SELECTED_AUTH_DB; /** * @author Peter Szrnka @@ -43,7 +43,7 @@ public SystemStatusDto getSystemStatus() { builder.version(getVersion()); builder.built(getBuildTime().format(DateTimeFormatter.ofPattern(Constants.DATE_FORMAT))); - if (SELECTED_AUTH_LDAP.equals(authType)) { + if (!SELECTED_AUTH_DB.equals(authType)) { return builder.status(OK).build(); } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/LdapUserController.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/LdapUserController.java new file mode 100644 index 00000000..03f82e12 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/LdapUserController.java @@ -0,0 +1,49 @@ +package io.github.gms.functions.user; + +import io.github.gms.auth.ldap.LdapSyncService; +import io.github.gms.common.enums.EventOperation; +import io.github.gms.common.types.Audited; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_LDAP; +import static io.github.gms.common.util.Constants.ROLE_ADMIN; +import static io.github.gms.common.util.Constants.SELECTED_AUTH_LDAP; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@RestController +@RequestMapping("/secure/user") +@Profile(value = { CONFIG_AUTH_TYPE_LDAP }) +public class LdapUserController { + + private final LdapSyncService ldapSyncService; + private final String authType; + + public LdapUserController( + LdapSyncService ldapSyncService, + @Value("${config.auth.type}") String authType) { + this.ldapSyncService = ldapSyncService; + this.authType = authType; + } + + @GetMapping("/sync_ldap_users") + @PreAuthorize(ROLE_ADMIN) + @Audited(operation = EventOperation.SYNC_LDAP_USERS_MANUALLY) + public ResponseEntity synchronizeUsers() { + if (!SELECTED_AUTH_LDAP.equals(authType)) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + ldapSyncService.synchronizeUsers(); + return new ResponseEntity<>(HttpStatus.OK); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/SaveUserRequestDto.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/SaveUserRequestDto.java index 5f80a2a3..7562c278 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/SaveUserRequestDto.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/SaveUserRequestDto.java @@ -1,6 +1,7 @@ package io.github.gms.functions.user; import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import io.github.gms.common.enums.EntityStatus; import io.github.gms.common.enums.UserRole; @@ -9,8 +10,6 @@ import java.io.Serial; import java.io.Serializable; import java.time.ZonedDateTime; -import java.util.HashSet; -import java.util.Set; import static io.github.gms.common.util.Constants.DATE_FORMAT; @@ -20,6 +19,7 @@ */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) public class SaveUserRequestDto implements Serializable { @Serial @@ -29,9 +29,10 @@ public class SaveUserRequestDto implements Serializable { private String name; private String username; private String email; - private EntityStatus status; + @JsonFormat(shape = JsonFormat.Shape.STRING) + private EntityStatus status = EntityStatus.INITIAL; private String credential; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_FORMAT) private ZonedDateTime creationDate; - private Set roles = new HashSet<>(); + private UserRole role; } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserController.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserController.java index 52159909..ae1fab4c 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserController.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserController.java @@ -1,6 +1,5 @@ package io.github.gms.functions.user; -import io.github.gms.auth.ldap.LdapSyncService; import io.github.gms.common.abstraction.AbstractAdminController; import io.github.gms.common.dto.PagingDto; import io.github.gms.common.dto.SaveEntityResponseDto; @@ -8,8 +7,7 @@ import io.github.gms.common.enums.EventTarget; import io.github.gms.common.types.AuditTarget; import io.github.gms.common.types.Audited; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -23,13 +21,14 @@ import org.springframework.web.bind.annotation.RestController; import static io.github.gms.common.util.Constants.ALL_ROLE; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_DB; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_KEYCLOAK_SSO; import static io.github.gms.common.util.Constants.ID; import static io.github.gms.common.util.Constants.PATH_ENABLED; import static io.github.gms.common.util.Constants.PATH_LIST; import static io.github.gms.common.util.Constants.PATH_VARIABLE_ID; import static io.github.gms.common.util.Constants.ROLE_ADMIN; import static io.github.gms.common.util.Constants.ROLE_ADMIN_OR_USER; -import static io.github.gms.common.util.Constants.SELECTED_AUTH_LDAP; /** * @author Peter Szrnka @@ -38,18 +37,11 @@ @RestController @RequestMapping("/secure/user") @AuditTarget(EventTarget.USER) +@Profile(value = { CONFIG_AUTH_TYPE_DB, CONFIG_AUTH_TYPE_KEYCLOAK_SSO }) public class UserController extends AbstractAdminController { - private final LdapSyncService ldapSyncService; - private final String authType; - - public UserController( - UserService service, - @Autowired(required = false) LdapSyncService ldapSyncService, - @Value("${config.auth.type}") String authType) { + public UserController(UserService service) { super(service); - this.ldapSyncService = ldapSyncService; - this.authType = authType; } @PostMapping @@ -101,16 +93,4 @@ public ResponseEntity toggleMfa(@RequestParam(PATH_ENABLED) boolean enable public ResponseEntity isMfaActive() { return new ResponseEntity<>(service.isMfaActive(), HttpStatus.OK); } - - @GetMapping("/sync_ldap_users") - @PreAuthorize(ROLE_ADMIN) - @Audited(operation = EventOperation.SYNC_LDAP_USERS_MANUALLY) - public ResponseEntity synchronizeUsers() { - if (!SELECTED_AUTH_LDAP.equals(authType)) { - return new ResponseEntity<>(HttpStatus.NOT_FOUND); - } - - ldapSyncService.synchronizeUsers(); - return new ResponseEntity<>(HttpStatus.OK); - } } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverter.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverter.java index b98a9aac..0d6234c5 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverter.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverter.java @@ -19,6 +19,4 @@ public interface UserConverter extends GmsConverter { UserInfoDto toUserInfoDto(GmsUserDetails user, boolean mfaRequired); GmsUserDetails addIdToUserDetails(GmsUserDetails first, Long id); - - UserEntity toEntity(GmsUserDetails foundUser, UserEntity existingEntity); } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverterImpl.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverterImpl.java index 36e8fb95..7814a2f3 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverterImpl.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserConverterImpl.java @@ -1,27 +1,17 @@ package io.github.gms.functions.user; -import com.google.common.collect.Sets; -import dev.samstevens.totp.secret.DefaultSecretGenerator; -import dev.samstevens.totp.secret.SecretGenerator; import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.common.abstraction.AbstractUserConverter; import io.github.gms.common.dto.UserInfoDto; import io.github.gms.common.enums.EntityStatus; -import io.github.gms.common.enums.UserRole; import lombok.RequiredArgsConstructor; -import lombok.Setter; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import java.time.Clock; import java.time.ZonedDateTime; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static io.github.gms.common.util.Constants.LDAP_CRYPT_PREFIX; /** * @author Peter Szrnka @@ -29,13 +19,10 @@ */ @Component @RequiredArgsConstructor -public class UserConverterImpl implements UserConverter { +public class UserConverterImpl extends AbstractUserConverter implements UserConverter { private final Clock clock; private final PasswordEncoder passwordEncoder; - @Setter - @Value("${config.store.ldap.credential:false}") - private boolean storeLdapCredential; @Override public UserEntity toNewEntity(SaveUserRequestDto dto, boolean roleChangeEnabled) { @@ -49,7 +36,7 @@ public UserEntity toNewEntity(SaveUserRequestDto dto, boolean roleChangeEnabled) entity.setStatus(EntityStatus.ACTIVE); if (roleChangeEnabled) { - entity.setRoles(dto.getRoles().stream().map(Enum::name).collect(Collectors.joining(";"))); + entity.setRole(dto.getRole()); } return entity; @@ -68,7 +55,7 @@ public UserEntity toEntity(UserEntity entity, SaveUserRequestDto dto, boolean ro entity.setStatus(dto.getStatus()); if (roleChangeEnabled) { - entity.setRoles(dto.getRoles().stream().map(Enum::name).collect(Collectors.joining(";"))); + entity.setRole(dto.getRole()); } return entity; @@ -82,7 +69,7 @@ public UserDto toDto(UserEntity entity) { dto.setUsername(entity.getUsername()); dto.setEmail(entity.getEmail()); dto.setStatus(entity.getStatus()); - dto.setRoles(Sets.newHashSet(Stream.of(entity.getRoles().split(";")).map(UserRole::getByName).collect(Collectors.toSet()))); + dto.setRole(entity.getRole()); dto.setCreationDate(entity.getCreationDate()); return dto; @@ -106,7 +93,7 @@ public UserInfoDto toUserInfoDto(GmsUserDetails user, boolean mfaRequired) { dto.setId(user.getUserId()); dto.setName(user.getName()); dto.setEmail(user.getEmail()); - dto.setRoles(Sets.newHashSet(user.getAuthorities().stream().map(authority -> UserRole.getByName(authority.getAuthority())).collect(Collectors.toSet()))); + dto.setRole(getFirstRole(user.getAuthorities())); return dto; } @@ -116,27 +103,4 @@ public GmsUserDetails addIdToUserDetails(GmsUserDetails foundUser, Long id) { foundUser.setUserId(id); return foundUser; } - - @Override - public UserEntity toEntity(GmsUserDetails foundUser, UserEntity existingEntity) { - UserEntity entity = existingEntity == null ? new UserEntity() : existingEntity; - - entity.setStatus(foundUser.getStatus()); - entity.setName(foundUser.getName()); - entity.setUsername(foundUser.getUsername()); - entity.setCredential(getCredential(foundUser)); - entity.setCreationDate(ZonedDateTime.now(clock)); - entity.setEmail(foundUser.getEmail()); - entity.setRoles(foundUser.getAuthorities().stream().map(GrantedAuthority::getAuthority) - .collect(Collectors.joining(","))); - entity.setMfaEnabled(foundUser.isMfaEnabled()); - SecretGenerator secretGenerator = new DefaultSecretGenerator(); - entity.setMfaSecret(secretGenerator.generate()); - return entity; - } - - private String getCredential(GmsUserDetails foundUser) { - return storeLdapCredential ? foundUser.getCredential().replace(LDAP_CRYPT_PREFIX, "") - : "*PROVIDED_BY_LDAP*"; - } } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserDto.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserDto.java index e956c2d9..a7ed443e 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserDto.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserDto.java @@ -9,8 +9,6 @@ import java.io.Serial; import java.io.Serializable; import java.time.ZonedDateTime; -import java.util.HashSet; -import java.util.Set; import static io.github.gms.common.util.Constants.DATE_FORMAT; @@ -33,5 +31,5 @@ public class UserDto implements Serializable { private String credential; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_FORMAT) private ZonedDateTime creationDate; - private Set roles = new HashSet<>(); + private UserRole role; } diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserEntity.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserEntity.java index 7454715e..3ddb2e19 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserEntity.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserEntity.java @@ -2,6 +2,7 @@ import io.github.gms.common.abstraction.AbstractGmsEntity; import io.github.gms.common.enums.EntityStatus; +import io.github.gms.common.enums.UserRole; import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; @@ -50,18 +51,19 @@ public class UserEntity extends AbstractGmsEntity { @Enumerated(EnumType.STRING) private EntityStatus status; - @Column(name = "credential", nullable = true) + @Column(name = "credential") private String credential; @Column(name = "creation_date") private ZonedDateTime creationDate; - @Column(name = "roles") - private String roles; + @Column(name = "role") + @Enumerated(EnumType.STRING) + private UserRole role; @Column(name = "mfa_enabled") @Convert(converter = org.hibernate.type.NumericBooleanConverter.class) - private boolean mfaEnabled; + private boolean mfaEnabled = false; @Column(name = "mfa_secret") private String mfaSecret; diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserInfoService.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserInfoService.java new file mode 100644 index 00000000..49a63431 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserInfoService.java @@ -0,0 +1,13 @@ +package io.github.gms.functions.user; + +import io.github.gms.common.dto.UserInfoDto; +import jakarta.servlet.http.HttpServletRequest; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface UserInfoService { + + UserInfoDto getUserInfo(HttpServletRequest request); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserInfoServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserInfoServiceImpl.java new file mode 100644 index 00000000..1eef0e39 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserInfoServiceImpl.java @@ -0,0 +1,59 @@ +package io.github.gms.functions.user; + +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.MdcParameter; +import io.github.gms.common.service.JwtClaimService; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.web.util.WebUtils; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class UserInfoServiceImpl implements UserInfoService { + + private final UserRepository repository; + private final JwtClaimService jwtClaimService; + + @Override + public UserInfoDto getUserInfo(HttpServletRequest request) { + Cookie jwtTokenCookie = WebUtils.getCookie(request, ACCESS_JWT_TOKEN); + + if (jwtTokenCookie == null) { + // We should not return an error, just simply return nothing + return null; + } + + Claims claims = jwtClaimService.getClaims(jwtTokenCookie.getValue()); + UserEntity entity = validateAndReturnUser(claims.get(MdcParameter.USER_ID.getDisplayName(), Long.class)); + + if (entity == null) { + return null; + } + + return UserInfoDto.builder() + .id(entity.getId()) + .name(entity.getName()) + .username(entity.getUsername()) + .role(entity.getRole()) + .email(entity.getEmail()) + .build(); + } + + private UserEntity validateAndReturnUser(Long userId) { + return repository.findById(userId).orElse(null); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserLoginAttemptManagerService.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserLoginAttemptManagerService.java new file mode 100644 index 00000000..3585b51a --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserLoginAttemptManagerService.java @@ -0,0 +1,14 @@ +package io.github.gms.functions.user; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public interface UserLoginAttemptManagerService { + + void updateLoginAttempt(String username); + + void resetLoginAttempt(String username); + + boolean isBlocked(String username); +} diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserLoginAttemptManagerServiceImpl.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserLoginAttemptManagerServiceImpl.java new file mode 100644 index 00000000..7ac34556 --- /dev/null +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserLoginAttemptManagerServiceImpl.java @@ -0,0 +1,72 @@ +package io.github.gms.functions.user; + +import io.github.gms.common.enums.EntityStatus; +import io.github.gms.common.enums.SystemProperty; +import io.github.gms.functions.systemproperty.SystemPropertyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +import static io.github.gms.common.util.Constants.CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Profile(CONFIG_AUTH_TYPE_NOT_KEYCLOAK_SSO) +public class UserLoginAttemptManagerServiceImpl implements UserLoginAttemptManagerService { + + private final UserRepository repository; + private final SystemPropertyService systemPropertyService; + + @Override + public void updateLoginAttempt(String username) { + UserEntity user = getByUsername(username); + + if (user == null) { + return; + } + + if (EntityStatus.BLOCKED == user.getStatus()) { + log.info("User already blocked"); + return; + } + + Integer attemptsLimit = systemPropertyService.getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); + Integer failedAttempts = user.getFailedAttempts() + 1; + if (Objects.equals(attemptsLimit, failedAttempts)) { + user.setStatus(EntityStatus.BLOCKED); + } + + user.setFailedAttempts(failedAttempts); + repository.save(user); + } + + @Override + public void resetLoginAttempt(String username) { + UserEntity user = getByUsername(username); + + if (user == null) { + return; + } + + user.setFailedAttempts(0); + repository.save(user); + } + + @Override + public boolean isBlocked(String username) { + UserEntity user = getByUsername(username); + return user != null && EntityStatus.BLOCKED == user.getStatus(); + } + + private UserEntity getByUsername(String username) { + return repository.findByUsername(username).orElse(null); + } +} diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserRepository.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserRepository.java index cd957767..ff745259 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserRepository.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserRepository.java @@ -23,17 +23,17 @@ @CacheConfig(cacheNames = CACHE_USER) public interface UserRepository extends JpaRepository { - @Query("SELECT COUNT(u) from UserEntity u where u.roles LIKE '%ADMIN%'") + @Query("SELECT COUNT(u) from UserEntity u where u.role = 'ROLE_ADMIN'") long countExistingAdmins(); - @Query("SELECT u from UserEntity u where u.roles LIKE '%ADMIN%'") + @Query("SELECT u from UserEntity u where u.role = 'ROLE_ADMIN'") List getAllAdmins(); Optional findByUsernameOrEmail(String username, String email); Optional findByUsername(String username); - @Query("SELECT COUNT(u) from UserEntity u where u.roles like '%ROLE_USER%' or u.roles like '%ROLE_VIEWER%'") + @Query("SELECT COUNT(u) from UserEntity u where u.role = 'ROLE_USER' or u.role = 'ROLE_VIEWER'") long countNormalUsers(); @Cacheable diff --git a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserService.java b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserService.java index 7ca85b93..2071f089 100644 --- a/code/gms-backend/src/main/java/io/github/gms/functions/user/UserService.java +++ b/code/gms-backend/src/main/java/io/github/gms/functions/user/UserService.java @@ -28,10 +28,4 @@ public interface UserService extends AbstractCrudService result = service.synchronizeUsers(); - log.info("{} user(s) synchronized and {} of them {} been removed from the database.", + log.info("{} user(s) synchronized and {} of them {} been marked as deletable.", result.getFirst(), result.getSecond(), result.getSecond() > 1 ? "have" : "has"); } } diff --git a/code/gms-backend/src/main/resources/application-keycloak-sso.properties b/code/gms-backend/src/main/resources/application-keycloak-sso.properties new file mode 100644 index 00000000..7d9fb2ec --- /dev/null +++ b/code/gms-backend/src/main/resources/application-keycloak-sso.properties @@ -0,0 +1,8 @@ +config.keycloak.loginUrl=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/account +config.keycloak.tokenUrl=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token +config.keycloak.introspectUrl=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token/introspect +config.keycloak.logoutUrl=${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout +config.keycloak.realm=${KEYCLOAK_REALM} +config.keycloak.clientId=${KEYCLOAK_CLIENT_ID} +config.keycloak.clientSecret=${KEYCLOAK_CLIENT_SECRET} +config.auth.type=sso \ No newline at end of file diff --git a/code/gms-backend/src/main/resources/application.properties b/code/gms-backend/src/main/resources/application.properties index 0909d34f..a9026220 100644 --- a/code/gms-backend/src/main/resources/application.properties +++ b/code/gms-backend/src/main/resources/application.properties @@ -29,6 +29,7 @@ config.log.type=${LOG_TYPE:console} config.log.folder=${LOG_FOLDER:./gms-logs/} config.log.archived.folder=${ARCHIVED_LOG_FOLDER:./gms-logs/archived} config.logstash.url=${LOGSTASH_URL:.} +config.logging.httpClient.enabled=${HTTP_CLIENT_LOGGING_ENABLED:false} # Application related properties config.enable.test.data=false diff --git a/code/gms-backend/src/main/resources/db/mariadb/migration/V1.0.0__initial_script.sql b/code/gms-backend/src/main/resources/db/mariadb/migration/V1.0.0__initial_script.sql index 75265490..c04e8863 100644 --- a/code/gms-backend/src/main/resources/db/mariadb/migration/V1.0.0__initial_script.sql +++ b/code/gms-backend/src/main/resources/db/mariadb/migration/V1.0.0__initial_script.sql @@ -4,7 +4,7 @@ CREATE TABLE gms_user ( credential VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci', email VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', name VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', - roles VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', + role VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', status VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', user_name VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', mfa_enabled TINYINT(1) NOT NULL DEFAULT 0, diff --git a/code/gms-backend/src/main/resources/db/mssql/migration/V1.0.0__initial_script.sql b/code/gms-backend/src/main/resources/db/mssql/migration/V1.0.0__initial_script.sql index 01545b13..7d710d4a 100644 --- a/code/gms-backend/src/main/resources/db/mssql/migration/V1.0.0__initial_script.sql +++ b/code/gms-backend/src/main/resources/db/mssql/migration/V1.0.0__initial_script.sql @@ -4,7 +4,7 @@ CREATE TABLE gms_user ( credential VARCHAR(255) NULL DEFAULT NULL, email VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, - roles VARCHAR(255) NOT NULL, + role VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, user_name VARCHAR(255) NOT NULL, mfa_enabled TINYINT NOT NULL DEFAULT 0, diff --git a/code/gms-backend/src/main/resources/db/mysql/migration/V1.0.0__initial_script.sql b/code/gms-backend/src/main/resources/db/mysql/migration/V1.0.0__initial_script.sql index 13de8104..31b406d4 100644 --- a/code/gms-backend/src/main/resources/db/mysql/migration/V1.0.0__initial_script.sql +++ b/code/gms-backend/src/main/resources/db/mysql/migration/V1.0.0__initial_script.sql @@ -4,7 +4,7 @@ CREATE TABLE gms_user ( credential VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_general_ci', email VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', name VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', - roles VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', + role VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', status VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', user_name VARCHAR(255) NOT NULL COLLATE 'utf8mb4_general_ci', mfa_enabled TINYINT NOT NULL DEFAULT 0, diff --git a/code/gms-backend/src/main/resources/db/postgresql/migration/V1.0.0__initial_script.sql b/code/gms-backend/src/main/resources/db/postgresql/migration/V1.0.0__initial_script.sql index a7b7ca29..6d9aba99 100644 --- a/code/gms-backend/src/main/resources/db/postgresql/migration/V1.0.0__initial_script.sql +++ b/code/gms-backend/src/main/resources/db/postgresql/migration/V1.0.0__initial_script.sql @@ -4,7 +4,7 @@ CREATE TABLE gms_user ( credential VARCHAR(255) NULL DEFAULT NULL, email VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, - roles VARCHAR(255) NOT NULL, + role VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, user_name VARCHAR(255) NOT NULL, mfa_enabled SMALLINT NOT NULL DEFAULT 0, diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/AuthenticationServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/AuthenticationServiceImplTest.java index 5d78073b..f3b38e59 100644 --- a/code/gms-backend/src/test/java/io/github/gms/auth/AuthenticationServiceImplTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/auth/AuthenticationServiceImplTest.java @@ -1,26 +1,21 @@ package io.github.gms.auth; import ch.qos.logback.classic.Logger; -import dev.samstevens.totp.code.CodeVerifier; import io.github.gms.abstraction.AbstractLoggingUnitTest; import io.github.gms.auth.model.AuthenticationResponse; import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.service.TokenGeneratorService; import io.github.gms.auth.types.AuthResponsePhase; -import io.github.gms.common.dto.LoginVerificationRequestDto; import io.github.gms.common.enums.JwtConfigType; import io.github.gms.common.enums.SystemProperty; -import io.github.gms.common.model.GenerateJwtRequest; -import io.github.gms.common.converter.GenerateJwtRequestConverter; -import io.github.gms.functions.user.UserConverter; -import io.github.gms.common.service.JwtService; import io.github.gms.functions.systemproperty.SystemPropertyService; -import io.github.gms.functions.user.UserService; +import io.github.gms.functions.user.UserConverter; +import io.github.gms.functions.user.UserLoginAttemptManagerService; import io.github.gms.util.TestUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.TestingAuthenticationToken; @@ -34,8 +29,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -49,14 +42,11 @@ */ class AuthenticationServiceImplTest extends AbstractLoggingUnitTest { + private TokenGeneratorService tokenGeneratorService; private AuthenticationManager authenticationManager; - private JwtService jwtService; private SystemPropertyService systemPropertyService; - private GenerateJwtRequestConverter generateJwtRequestConverter; private UserConverter userConverter; - private UserAuthService userAuthService; - private CodeVerifier verifier; - private UserService userService; + private UserLoginAttemptManagerService userLoginAttemptManagerService; private AuthenticationServiceImpl service; @Override @@ -65,16 +55,13 @@ public void setup() { super.setup(); // init + tokenGeneratorService = mock(TokenGeneratorService.class); authenticationManager = mock(AuthenticationManager.class); - jwtService = mock(JwtService.class); systemPropertyService = mock(SystemPropertyService.class); - generateJwtRequestConverter = mock(GenerateJwtRequestConverter.class); userConverter = mock(UserConverter.class); - userAuthService = mock(UserAuthService.class); - verifier = mock(CodeVerifier.class); - userService = mock(UserService.class); - service = new AuthenticationServiceImpl(authenticationManager, jwtService, - systemPropertyService, generateJwtRequestConverter, userConverter, userAuthService, verifier, userService); + userLoginAttemptManagerService = mock(UserLoginAttemptManagerService.class); + service = new AuthenticationServiceImpl(tokenGeneratorService, + systemPropertyService, authenticationManager, userConverter, userLoginAttemptManagerService); ((Logger) LoggerFactory.getLogger(AuthenticationServiceImpl.class)).addAppender(logAppender); } @@ -82,7 +69,7 @@ public void setup() { @Test void shouldAuthenticateFailWhenUserIsBlocked() { // arrange - when(userService.isBlocked("user")).thenReturn(true); + when(userLoginAttemptManagerService.isBlocked("user")).thenReturn(true); // act AuthenticationResponse response = service.authenticate("user", "credential"); @@ -90,7 +77,7 @@ void shouldAuthenticateFailWhenUserIsBlocked() { // assert assertNotNull(response); assertEquals(AuthResponsePhase.BLOCKED, response.getPhase()); - verify(userService).isBlocked("user"); + verify(userLoginAttemptManagerService).isBlocked("user"); } @Test @@ -98,7 +85,7 @@ void shouldAuthenticateFailWhenUserIsLockedInLdap() { // arrange GmsUserDetails userDetails = TestUtils.createGmsUser(); userDetails.setAccountNonLocked(false); - when(userService.isBlocked("user")).thenReturn(false); + when(userLoginAttemptManagerService.isBlocked("user")).thenReturn(false); when(authenticationManager.authenticate(any())) .thenReturn(new TestingAuthenticationToken(userDetails, "cred", List.of(new SimpleGrantedAuthority("ROLE_USER")))); @@ -108,13 +95,13 @@ void shouldAuthenticateFailWhenUserIsLockedInLdap() { // assert assertNotNull(response); assertEquals(AuthResponsePhase.BLOCKED, response.getPhase()); - verify(userService).isBlocked("user"); + verify(userLoginAttemptManagerService).isBlocked("user"); } @Test void shouldAuthenticateFail() { // arrange - when(userService.isBlocked("user")).thenReturn(false); + when(userLoginAttemptManagerService.isBlocked("user")).thenReturn(false); when(authenticationManager.authenticate(any())).thenThrow(IllegalArgumentException.class); // act @@ -124,29 +111,25 @@ void shouldAuthenticateFail() { assertNotNull(response); assertEquals(AuthResponsePhase.FAILED, response.getPhase()); assertTrue(logAppender.list.stream().anyMatch(event -> event.getFormattedMessage().equalsIgnoreCase("Login failed"))); - verify(userService).isBlocked("user"); + verify(userLoginAttemptManagerService).isBlocked("user"); } @ParameterizedTest @MethodSource("nonMfaTestData") void shouldAuthenticate(boolean systemLevelMfaEnabled, boolean userMfaEnabled) { // arrange - when(userService.isBlocked("user")).thenReturn(false); + when(userLoginAttemptManagerService.isBlocked("user")).thenReturn(false); GmsUserDetails userDetails = TestUtils.createGmsUser(); userDetails.setMfaEnabled(userMfaEnabled); when(authenticationManager.authenticate(any())) .thenReturn(new TestingAuthenticationToken(userDetails, "cred", List.of(new SimpleGrantedAuthority("ROLE_USER")))); - when(generateJwtRequestConverter.toRequest(eq(JwtConfigType.ACCESS_JWT), anyString(), anyMap())) - .thenReturn(GenerateJwtRequest.builder().algorithm("HS512").claims(Map.of("role","ROLE_USER")).expirationDateInSeconds(900L).build()); - when(generateJwtRequestConverter.toRequest(eq(JwtConfigType.REFRESH_JWT), anyString(), anyMap())) - .thenReturn(GenerateJwtRequest.builder().algorithm("HS512").claims(Map.of("role","ROLE_USER")).expirationDateInSeconds(900L).build()); - when(jwtService.generateJwts(anyMap())).thenReturn(Map.of( - JwtConfigType.ACCESS_JWT, "ACCESS_JWT", - JwtConfigType.REFRESH_JWT, "REFRESH_JWT" - )); when(userConverter.toUserInfoDto(any(GmsUserDetails.class), eq(false))).thenReturn(TestUtils.createUserInfoDto()); when(systemPropertyService.getBoolean(SystemProperty.ENABLE_GLOBAL_MFA)).thenReturn(false); when(systemPropertyService.getBoolean(SystemProperty.ENABLE_MFA)).thenReturn(systemLevelMfaEnabled); + when(tokenGeneratorService.getAuthenticationDetails(any(GmsUserDetails.class))).thenReturn(Map.of( + JwtConfigType.ACCESS_JWT, "ACCESS_JWT", + JwtConfigType.REFRESH_JWT, "REFRESH_JWT" + )); //act AuthenticationResponse response = service.authenticate("user", "credential"); @@ -157,19 +140,10 @@ void shouldAuthenticate(boolean systemLevelMfaEnabled, boolean userMfaEnabled) { assertEquals("REFRESH_JWT", response.getRefreshToken()); assertEquals(AuthResponsePhase.COMPLETED, response.getPhase()); - verify(userService).isBlocked("user"); - verify(userService).resetLoginAttempt("user"); + verify(userLoginAttemptManagerService).isBlocked("user"); + verify(userLoginAttemptManagerService).resetLoginAttempt("user"); verify(authenticationManager).authenticate(any()); - verify(generateJwtRequestConverter).toRequest(eq(JwtConfigType.ACCESS_JWT), anyString(), anyMap()); - verify(generateJwtRequestConverter).toRequest(eq(JwtConfigType.REFRESH_JWT), anyString(), anyMap()); - ArgumentCaptor> argumentCaptor = ArgumentCaptor.forClass(Map.class); - verify(jwtService).generateJwts(argumentCaptor.capture()); - Map capturedMap = argumentCaptor.getValue(); - assertNotNull(capturedMap.get(JwtConfigType.ACCESS_JWT)); - assertNotNull(capturedMap.get(JwtConfigType.REFRESH_JWT)); - assertEquals(1, capturedMap.get(JwtConfigType.ACCESS_JWT).getClaims().size()); - assertEquals(1, capturedMap.get(JwtConfigType.REFRESH_JWT).getClaims().size()); - + verify(tokenGeneratorService).getAuthenticationDetails(any(GmsUserDetails.class)); verify(userConverter).toUserInfoDto(any(GmsUserDetails.class), eq(false)); verify(systemPropertyService).getBoolean(SystemProperty.ENABLE_GLOBAL_MFA); verify(systemPropertyService).getBoolean(SystemProperty.ENABLE_MFA); @@ -179,7 +153,7 @@ void shouldAuthenticate(boolean systemLevelMfaEnabled, boolean userMfaEnabled) { @MethodSource("mfaTestData") void shouldExpectMfa(boolean enableGlobalMfa, boolean enableMfa) { // arrange - when(userService.isBlocked("user")).thenReturn(false); + when(userLoginAttemptManagerService.isBlocked("user")).thenReturn(false); GmsUserDetails userDetails = TestUtils.createGmsUser(); userDetails.setMfaEnabled(true); when(authenticationManager.authenticate(any())) @@ -199,113 +173,13 @@ void shouldExpectMfa(boolean enableGlobalMfa, boolean enableMfa) { assertNull(response.getRefreshToken()); assertEquals(AuthResponsePhase.MFA_REQUIRED, response.getPhase()); - verify(userService).isBlocked("user"); + verify(userLoginAttemptManagerService).isBlocked("user"); verify(authenticationManager).authenticate(any()); verify(userConverter).toUserInfoDto(any(GmsUserDetails.class), eq(true)); verify(systemPropertyService).getBoolean(SystemProperty.ENABLE_GLOBAL_MFA); verify(systemPropertyService, enableGlobalMfa ? never() : times(1)).getBoolean(SystemProperty.ENABLE_MFA); } - @Test - void shouldVerifyThrowsAnError() { - // arrange - when(userService.isBlocked("user1")).thenReturn(false); - LoginVerificationRequestDto dto = TestUtils.createLoginVerificationRequestDto(); - - // act - AuthenticationResponse response = service.verify(dto); - - // assert - assertNotNull(response); - assertEquals(AuthResponsePhase.FAILED, response.getPhase()); - verify(userService).isBlocked("user1"); - } - - @Test - void shouldVerifyFailWhenUserIsBlocked() { - // arrange - when(userService.isBlocked("user1")).thenReturn(true); - - // act - AuthenticationResponse response = service.verify(TestUtils.createLoginVerificationRequestDto()); - - // assert - assertNotNull(response); - assertEquals(AuthResponsePhase.BLOCKED, response.getPhase()); - verify(userService).isBlocked("user1"); - } - - @Test - void shouldVerifyFailWhenUserIsLockedInLdap() { - // arrange - GmsUserDetails userDetails = TestUtils.createGmsUser(); - userDetails.setAccountNonLocked(false); - when(userService.isBlocked("user1")).thenReturn(false); - when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); - - // act - AuthenticationResponse response = service.verify(TestUtils.createLoginVerificationRequestDto()); - - // assert - assertNotNull(response); - assertEquals(AuthResponsePhase.BLOCKED, response.getPhase()); - verify(userService).isBlocked("user1"); - } - - @Test - void shouldVerifyFail() { - // arrange - when(userService.isBlocked("user1")).thenReturn(false); - LoginVerificationRequestDto dto = TestUtils.createLoginVerificationRequestDto(); - GmsUserDetails userDetails = TestUtils.createGmsUser(); - when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); - when(verifier.isValidCode(anyString(), anyString())).thenReturn(false); - - // act - AuthenticationResponse response = service.verify(dto); - - // assert - assertNotNull(response); - assertEquals(AuthResponsePhase.FAILED, response.getPhase()); - verify(userService).isBlocked("user1"); - verify(userService).updateLoginAttempt("user1"); - } - - @Test - void shouldVerify() { - // arrange - when(userService.isBlocked("user1")).thenReturn(false); - LoginVerificationRequestDto dto = TestUtils.createLoginVerificationRequestDto(); - GmsUserDetails userDetails = TestUtils.createGmsUser(); - when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); - when(verifier.isValidCode(anyString(), anyString())).thenReturn(true); - when(generateJwtRequestConverter.toRequest(eq(JwtConfigType.ACCESS_JWT), anyString(), anyMap())) - .thenReturn(GenerateJwtRequest.builder().algorithm("HS512").claims(Map.of()).expirationDateInSeconds(900L).build()); - when(generateJwtRequestConverter.toRequest(eq(JwtConfigType.REFRESH_JWT), anyString(), anyMap())) - .thenReturn(GenerateJwtRequest.builder().algorithm("HS512").claims(Map.of()).expirationDateInSeconds(900L).build()); - when(jwtService.generateJwts(anyMap())).thenReturn(Map.of( - JwtConfigType.ACCESS_JWT, "ACCESS_JWT", - JwtConfigType.REFRESH_JWT, "REFRESH_JWT" - )); - when(userConverter.toUserInfoDto(any(GmsUserDetails.class), eq(false))).thenReturn(TestUtils.createUserInfoDto()); - - // act - AuthenticationResponse response = service.verify(dto); - - // assert - assertNotNull(response); - assertEquals(AuthResponsePhase.COMPLETED, response.getPhase()); - assertEquals("ACCESS_JWT", response.getToken()); - assertEquals("REFRESH_JWT", response.getRefreshToken()); - - verify(userService).isBlocked("user1"); - verify(userAuthService).loadUserByUsername(anyString()); - verify(generateJwtRequestConverter).toRequest(eq(JwtConfigType.ACCESS_JWT), anyString(), anyMap()); - verify(generateJwtRequestConverter).toRequest(eq(JwtConfigType.REFRESH_JWT), anyString(), anyMap()); - verify(jwtService).generateJwts(anyMap()); - verify(userConverter).toUserInfoDto(any(GmsUserDetails.class), eq(false)); - } - private static Object[][] nonMfaTestData() { return new Object[][] { { true, false }, diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/AuthorizationServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/AuthorizationServiceImplTest.java index 9765bd57..d5a5056d 100644 --- a/code/gms-backend/src/test/java/io/github/gms/auth/AuthorizationServiceImplTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/auth/AuthorizationServiceImplTest.java @@ -1,44 +1,40 @@ package io.github.gms.auth; -import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.ZonedDateTime; -import java.util.Date; -import java.util.Map; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.web.authentication.WebAuthenticationDetails; - import ch.qos.logback.classic.Logger; import io.github.gms.abstraction.AbstractLoggingUnitTest; import io.github.gms.auth.model.AuthorizationResponse; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.service.TokenGeneratorService; import io.github.gms.common.enums.JwtConfigType; import io.github.gms.common.enums.SystemProperty; -import io.github.gms.common.model.GenerateJwtRequest; -import io.github.gms.common.converter.GenerateJwtRequestConverter; import io.github.gms.common.service.JwtService; import io.github.gms.functions.systemproperty.SystemPropertyService; import io.github.gms.util.TestUtils; import io.jsonwebtoken.Claims; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.Map; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * @author Peter Szrnka @@ -46,10 +42,10 @@ */ class AuthorizationServiceImplTest extends AbstractLoggingUnitTest { + private TokenGeneratorService tokenGeneratorService; private JwtService jwtService; private UserAuthService userAuthService; private SystemPropertyService systemPropertyService; - private GenerateJwtRequestConverter generateJwtRequestConverter; private AuthorizationServiceImpl service; @Override @@ -58,12 +54,11 @@ public void setup() { super.setup(); // init + tokenGeneratorService = mock(TokenGeneratorService.class); jwtService = mock(JwtService.class); userAuthService = mock(UserAuthService.class); systemPropertyService = mock(SystemPropertyService.class); - generateJwtRequestConverter = mock(GenerateJwtRequestConverter.class); - service = new AuthorizationServiceImpl(jwtService, - systemPropertyService, generateJwtRequestConverter, userAuthService); + service = new AuthorizationServiceImpl(jwtService, tokenGeneratorService, systemPropertyService, userAuthService); ((Logger) LoggerFactory.getLogger(AuthorizationServiceImpl.class)).addAppender(logAppender); } @@ -167,13 +162,8 @@ void jwtIsValid() { when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); when(claims.get(anyString(), any())).thenReturn(userDetails.getUsername()); when(systemPropertyService.get(SystemProperty.ACCESS_JWT_ALGORITHM)).thenReturn("HS512"); - - when(generateJwtRequestConverter.toRequest(eq(JwtConfigType.ACCESS_JWT), anyString(), anyMap())) - .thenReturn(GenerateJwtRequest.builder().algorithm("HS512").claims(Map.of("k1", "v1", "k2", "v2", "k3", "v3")).expirationDateInSeconds(900L).build()); - when(generateJwtRequestConverter.toRequest(eq(JwtConfigType.REFRESH_JWT), anyString(), anyMap())) - .thenReturn(GenerateJwtRequest.builder().algorithm("HS512").claims(Map.of()).expirationDateInSeconds(900L).build()); - - when(jwtService.generateJwts(anyMap())).thenReturn(Map.of( + + when(tokenGeneratorService.getAuthenticationDetails(any(GmsUserDetails.class))).thenReturn(Map.of( JwtConfigType.ACCESS_JWT, "ACCESS_JWT", JwtConfigType.REFRESH_JWT, "REFRESH_JWT" )); @@ -198,14 +188,7 @@ void jwtIsValid() { verify(jwtService).parseJwt(anyString(), anyString()); verify(systemPropertyService).get(SystemProperty.ACCESS_JWT_ALGORITHM); - - verify(generateJwtRequestConverter).toRequest(eq(JwtConfigType.ACCESS_JWT), anyString(), anyMap()); - verify(generateJwtRequestConverter).toRequest(eq(JwtConfigType.REFRESH_JWT), anyString(), anyMap()); - - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - verify(jwtService).generateJwts(mapCaptor.capture()); - Map capturedMap = mapCaptor.getValue(); - assertEquals(3, capturedMap.get(JwtConfigType.ACCESS_JWT).getClaims().size()); + verify(tokenGeneratorService).getAuthenticationDetails(any(GmsUserDetails.class)); } } diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/VerificationServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/VerificationServiceImplTest.java new file mode 100644 index 00000000..4a9c3926 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/VerificationServiceImplTest.java @@ -0,0 +1,153 @@ +package io.github.gms.auth; + +import ch.qos.logback.classic.Logger; +import dev.samstevens.totp.code.CodeVerifier; +import io.github.gms.abstraction.AbstractLoggingUnitTest; +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.service.TokenGeneratorService; +import io.github.gms.auth.types.AuthResponsePhase; +import io.github.gms.common.dto.LoginVerificationRequestDto; +import io.github.gms.common.enums.JwtConfigType; +import io.github.gms.functions.user.UserConverter; +import io.github.gms.functions.user.UserLoginAttemptManagerService; +import io.github.gms.util.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class VerificationServiceImplTest extends AbstractLoggingUnitTest { + + private UserAuthService userAuthService; + private UserConverter userConverter; + private CodeVerifier verifier; + private UserLoginAttemptManagerService userLoginAttemptManagerService; + private TokenGeneratorService tokenGeneratorService; + private VerificationServiceImpl service; + + @Override + @BeforeEach + public void setup() { + super.setup(); + + // init + tokenGeneratorService = mock(TokenGeneratorService.class); + userAuthService = mock(UserAuthService.class); + verifier = mock(CodeVerifier.class); + userLoginAttemptManagerService = mock(UserLoginAttemptManagerService.class); + userConverter = mock(UserConverter.class); + service = new VerificationServiceImpl(userAuthService, userConverter, verifier, userLoginAttemptManagerService, tokenGeneratorService); + + ((Logger) LoggerFactory.getLogger(AuthenticationServiceImpl.class)).addAppender(logAppender); + } + + @Test + void shouldVerifyThrowsAnError() { + // arrange + when(userLoginAttemptManagerService.isBlocked("user1")).thenReturn(false); + LoginVerificationRequestDto dto = TestUtils.createLoginVerificationRequestDto(); + + // act + AuthenticationResponse response = service.verify(dto); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(userLoginAttemptManagerService).isBlocked("user1"); + } + + @Test + void shouldVerifyFailWhenUserIsBlocked() { + // arrange + when(userLoginAttemptManagerService.isBlocked("user1")).thenReturn(true); + + // act + AuthenticationResponse response = service.verify(TestUtils.createLoginVerificationRequestDto()); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.BLOCKED, response.getPhase()); + verify(userLoginAttemptManagerService).isBlocked("user1"); + } + + @Test + void shouldVerifyFailWhenUserIsLockedInLdap() { + // arrange + GmsUserDetails userDetails = TestUtils.createGmsUser(); + userDetails.setAccountNonLocked(false); + when(userLoginAttemptManagerService.isBlocked("user1")).thenReturn(false); + when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); + + // act + AuthenticationResponse response = service.verify(TestUtils.createLoginVerificationRequestDto()); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.BLOCKED, response.getPhase()); + verify(userLoginAttemptManagerService).isBlocked("user1"); + } + + @Test + void shouldVerifyFail() { + // arrange + when(userLoginAttemptManagerService.isBlocked("user1")).thenReturn(false); + LoginVerificationRequestDto dto = TestUtils.createLoginVerificationRequestDto(); + GmsUserDetails userDetails = TestUtils.createGmsUser(); + when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); + when(verifier.isValidCode(anyString(), anyString())).thenReturn(false); + + // act + AuthenticationResponse response = service.verify(dto); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(userLoginAttemptManagerService).isBlocked("user1"); + verify(userLoginAttemptManagerService).updateLoginAttempt("user1"); + } + + @Test + void shouldVerify() { + // arrange + when(userLoginAttemptManagerService.isBlocked("user1")).thenReturn(false); + LoginVerificationRequestDto dto = TestUtils.createLoginVerificationRequestDto(); + GmsUserDetails userDetails = TestUtils.createGmsUser(); + when(userAuthService.loadUserByUsername(anyString())).thenReturn(userDetails); + when(verifier.isValidCode(anyString(), anyString())).thenReturn(true); + when(tokenGeneratorService.getAuthenticationDetails(any(GmsUserDetails.class))) + .thenReturn(Map.of( + JwtConfigType.ACCESS_JWT, "ACCESS_JWT", + JwtConfigType.REFRESH_JWT, "REFRESH_JWT" + )); + when(userConverter.toUserInfoDto(any(GmsUserDetails.class), eq(false))).thenReturn(TestUtils.createUserInfoDto()); + + // act + AuthenticationResponse response = service.verify(dto); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.COMPLETED, response.getPhase()); + assertEquals("ACCESS_JWT", response.getToken()); + assertEquals("REFRESH_JWT", response.getRefreshToken()); + + verify(userLoginAttemptManagerService).isBlocked("user1"); + verify(userAuthService).loadUserByUsername(anyString()); + verify(userConverter).toUserInfoDto(any(GmsUserDetails.class), eq(false)); + verify(tokenGeneratorService).getAuthenticationDetails(any(GmsUserDetails.class)); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/ldap/LdapSyncServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/ldap/LdapSyncServiceImplTest.java index c4d51091..2181852f 100644 --- a/code/gms-backend/src/test/java/io/github/gms/auth/ldap/LdapSyncServiceImplTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/auth/ldap/LdapSyncServiceImplTest.java @@ -4,8 +4,8 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import io.github.gms.abstraction.AbstractUnitTest; +import io.github.gms.auth.ldap.converter.LdapUserConverter; import io.github.gms.auth.model.GmsUserDetails; -import io.github.gms.functions.user.UserConverter; import io.github.gms.functions.user.UserEntity; import io.github.gms.functions.user.UserRepository; import io.github.gms.util.TestUtils; @@ -43,7 +43,7 @@ class LdapSyncServiceImplTest extends AbstractUnitTest { private ListAppender logAppender; private LdapTemplate ldapTemplate; private UserRepository repository; - private UserConverter converter; + private LdapUserConverter converter; private LdapSyncServiceImpl service; @BeforeEach @@ -53,7 +53,7 @@ void beforeEach() { ldapTemplate = mock(LdapTemplate.class); repository = mock(UserRepository.class); - converter = mock(UserConverter.class); + converter = mock(LdapUserConverter.class); service = new LdapSyncServiceImpl(ldapTemplate, repository, converter, "db"); ((Logger) LoggerFactory.getLogger(LdapSyncServiceImpl.class)).addAppender(logAppender); } diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/ldap/converter/impl/LdapUserConverterImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/ldap/converter/impl/LdapUserConverterImplTest.java new file mode 100644 index 00000000..f38138ff --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/ldap/converter/impl/LdapUserConverterImplTest.java @@ -0,0 +1,80 @@ +package io.github.gms.auth.ldap.converter.impl; + +import dev.samstevens.totp.secret.SecretGenerator; +import io.github.gms.abstraction.AbstractUnitTest; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.functions.user.UserEntity; +import io.github.gms.util.DemoData; +import io.github.gms.util.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class LdapUserConverterImplTest extends AbstractUnitTest { + + private Clock clock; + private SecretGenerator secretGenerator; + private LdapUserConverterImpl converter; + + @BeforeEach + void beforeEach() { + clock = mock(Clock.class); + secretGenerator = mock(SecretGenerator.class); + converter = new LdapUserConverterImpl(clock, secretGenerator); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void shouldConvertUserDetailsWithNewUser(boolean storeLdapCredential) { + // arrange + when(clock.instant()).thenReturn(Instant.parse("2023-06-29T00:00:00Z")); + when(clock.getZone()).thenReturn(ZoneOffset.UTC); + converter.setStoreLdapCredential(storeLdapCredential); + GmsUserDetails testUser = TestUtils.createGmsUser(); + when(secretGenerator.generate()).thenReturn("secret!"); + + // act + UserEntity response = converter.toEntity(testUser, null); + + // assert + assertNotNull(response); + assertEquals("secret!", response.getMfaSecret()); + assertEquals(storeLdapCredential ? DemoData.CREDENTIAL_TEST : "*PROVIDED_BY_LDAP*", response.getCredential()); + verify(secretGenerator).generate(); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void shouldConvertUserDetailsWithExistingUser(boolean storeLdapCredential) { + // arrange + when(clock.instant()).thenReturn(Instant.parse("2023-06-29T00:00:00Z")); + when(clock.getZone()).thenReturn(ZoneOffset.UTC); + converter.setStoreLdapCredential(storeLdapCredential); + GmsUserDetails testUser = TestUtils.createGmsUser(); + UserEntity existingEntity = TestUtils.createUser(); + when(secretGenerator.generate()).thenReturn("secret!"); + + // act + UserEntity response = converter.toEntity(testUser, existingEntity); + + // assert + assertNotNull(response); + assertEquals("secret!", response.getMfaSecret()); + assertEquals(storeLdapCredential ? DemoData.CREDENTIAL_TEST : "*PROVIDED_BY_LDAP*", response.getCredential()); + verify(secretGenerator).generate(); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/Input.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/Input.java new file mode 100644 index 00000000..79b92dfc --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/Input.java @@ -0,0 +1,15 @@ +package io.github.gms.auth.sso.keycloak; + +import jakarta.servlet.http.Cookie; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +@Getter +@AllArgsConstructor +public class Input { + private Cookie[] cookies; +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverterImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverterImplTest.java new file mode 100644 index 00000000..340cbebc --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/converter/KeycloakConverterImplTest.java @@ -0,0 +1,141 @@ +package io.github.gms.auth.sso.keycloak.converter; + +import dev.samstevens.totp.secret.SecretGenerator; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.model.RealmAccess; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.EntityStatus; +import io.github.gms.common.enums.UserRole; +import io.github.gms.functions.user.UserEntity; +import io.github.gms.util.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +import static io.github.gms.common.enums.UserRole.ROLE_USER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakConverterImplTest { + + private static final ZonedDateTime zonedDateTime = + ZonedDateTime.ofInstant(Instant.parse("2023-06-29T00:00:00Z"), ZoneId.systemDefault()); + + private Clock clock; + private SecretGenerator secretGenerator; + private KeycloakConverterImpl converter; + + @BeforeEach + void setup() { + clock = mock(Clock.class); + secretGenerator = mock(SecretGenerator.class); + converter = new KeycloakConverterImpl(clock, secretGenerator); + } + + @ParameterizedTest + @MethodSource("toUserDetailsData") + void shouldReturnUserDetails(String active, EntityStatus expectedStatus) { + GmsUserDetails userDetails = converter.toUserDetails(IntrospectResponse.builder() + .email("email@email") + .name("My Name") + .username("user1") + .active(active) + .realmAccess(RealmAccess.builder().roles(List.of("ROLE_USER")).build()) + .build()); + + // assert + assertNotNull(userDetails); + assertEquals("user1", userDetails.getUsername()); + assertEquals(expectedStatus, userDetails.getStatus()); + } + + @Test + void shouldReturnUserInfo() { + // act + UserInfoDto response = converter.toUserInfoDto(IntrospectResponse.builder() + .email("email@email") + .name("My Name") + .username("user1") + .realmAccess(RealmAccess.builder().roles(List.of("ROLE_USER")).build()) + .build()); + + // assert + assertNotNull(response); + assertEquals("email@email", response.getEmail()); + assertEquals("My Name", response.getName()); + assertEquals("user1", response.getUsername()); + assertEquals(UserRole.ROLE_USER, response.getRole()); + } + + @Test + void shouldReturnNewEntity() { + try (MockedStatic mockedZonedDateTime = mockStatic(ZonedDateTime.class)) { + mockedZonedDateTime.when(() -> ZonedDateTime.now(clock)).thenReturn(zonedDateTime); + // arrange + UserEntity mockEntity = TestUtils.createAdminUser(); + mockEntity.setId(null); + UserInfoDto mockUserInfoDto = TestUtils.createUserInfoDto(); + when(secretGenerator.generate()).thenReturn("secret!"); + + // act + UserEntity response = converter.toEntity(mockEntity, mockUserInfoDto); + + // assert + assertNotNull(response); + assertNull(response.getCredential()); + assertNull(response.getId()); + assertEquals("secret!", response.getMfaSecret()); + assertEquals("a@b.com", response.getEmail()); + assertEquals(ROLE_USER, response.getRole()); + assertEquals("name", response.getName()); + assertEquals("user", response.getUsername()); + assertEquals(EntityStatus.ACTIVE, response.getStatus()); + mockedZonedDateTime.verify(() -> ZonedDateTime.now(clock)); + verify(secretGenerator).generate(); + } + } + + @Test + void shouldReturnExistingEntity() { + // arrange + UserEntity mockEntity = TestUtils.createAdminUser(); + UserInfoDto mockUserInfoDto = TestUtils.createUserInfoDto(); + + // act + UserEntity response = converter.toEntity(mockEntity, mockUserInfoDto); + + // assert + assertNotNull(response); + assertNotNull(response.getId()); + assertEquals("a@b.com", response.getEmail()); + assertEquals(ROLE_USER, response.getRole()); + assertEquals("name", response.getName()); + assertEquals("user", response.getUsername()); + assertEquals(EntityStatus.ACTIVE, response.getStatus()); + } + + private static Object[][] toUserDetailsData() { + return new Object[][]{ + {"true", EntityStatus.ACTIVE}, + {"false", EntityStatus.DISABLED} + }; + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthenticationServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthenticationServiceImplTest.java new file mode 100644 index 00000000..65309c4c --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthenticationServiceImplTest.java @@ -0,0 +1,352 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import ch.qos.logback.classic.Logger; +import io.github.gms.abstraction.AbstractLoggingUnitTest; +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.auth.sso.keycloak.converter.KeycloakConverter; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.model.LoginResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.auth.sso.keycloak.service.KeycloakLoginService; +import io.github.gms.auth.types.AuthResponsePhase; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.functions.user.UserEntity; +import io.github.gms.functions.user.UserRepository; +import io.github.gms.util.DemoData; +import io.github.gms.util.TestUtils; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; +import static io.github.gms.util.TestUtils.MOCK_ACCESS_TOKEN; +import static io.github.gms.util.TestUtils.MOCK_REFRESH_TOKEN; +import static io.github.gms.util.TestUtils.assertLogContains; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakAuthenticationServiceImplTest extends AbstractLoggingUnitTest { + + private KeycloakLoginService keycloakLoginService; + private KeycloakIntrospectService keycloakIntrospectService; + private KeycloakConverter converter; + private UserRepository userRepository; + private HttpServletRequest httpServletRequest; + private KeycloakAuthenticationServiceImpl service; + + @Override + @BeforeEach + public void setup() { + super.setup(); + keycloakLoginService = mock(KeycloakLoginService.class); + keycloakIntrospectService = mock(KeycloakIntrospectService.class); + converter = mock(KeycloakConverter.class); + userRepository = mock(UserRepository.class); + httpServletRequest = mock(HttpServletRequest.class); + service = new KeycloakAuthenticationServiceImpl(keycloakLoginService, keycloakIntrospectService, converter, userRepository, httpServletRequest); + ((Logger) LoggerFactory.getLogger(KeycloakAuthenticationServiceImpl.class)).addAppender(logAppender); + } + + @Test + void shouldLoginSkippedWhenIntrospectFailed() { + // arrange + when(httpServletRequest.getCookies()).thenReturn(new Cookie[] { + new Cookie(ACCESS_JWT_TOKEN, MOCK_ACCESS_TOKEN), + new Cookie(REFRESH_JWT_TOKEN, MOCK_REFRESH_TOKEN) + }); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.status(400).body(IntrospectResponse.builder().errorDescription("Account disabled").build())); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.ALREADY_LOGGED_IN, response.getPhase()); + verify(keycloakLoginService, never()).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(converter, never()).toUserInfoDto(any(IntrospectResponse.class)); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + verify(httpServletRequest, times(2)).getCookies(); + } + + @Test + void shouldLoginSkipped() { + // arrange + when(httpServletRequest.getCookies()).thenReturn(new Cookie[] { + new Cookie(ACCESS_JWT_TOKEN, MOCK_ACCESS_TOKEN), + new Cookie(REFRESH_JWT_TOKEN, MOCK_REFRESH_TOKEN) + }); + UserInfoDto mockUserInfo = TestUtils.createUserInfoDto(); + when(converter.toUserInfoDto(any(IntrospectResponse.class))).thenReturn(mockUserInfo); + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder() + .email("email@email") + .name("name") + .active("true") + .build(); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.ALREADY_LOGGED_IN, response.getPhase()); + verify(keycloakLoginService, never()).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(converter).toUserInfoDto(any(IntrospectResponse.class)); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + verify(httpServletRequest, times(2)).getCookies(); + } + + @Test + void shouldLoginFail() { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenThrow(new RuntimeException("Oops!")); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(converter, never()).toUserInfoDto(any(IntrospectResponse.class)); + } + + @ParameterizedTest + @MethodSource("not2xxInputData") + void shouldLoginFailWhenResponseIsNot2xx(String errorDescription, AuthResponsePhase authResponsePhaseExpected) { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.status(400).body(LoginResponse.builder() + .error("error").errorDescription(errorDescription).build())); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(authResponsePhaseExpected, response.getPhase()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(converter, never()).toUserInfoDto(any(IntrospectResponse.class)); + verify(keycloakIntrospectService, never()).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + assertLogContains(logAppender, "Login failed! Status code=400"); + } + + private static Object[][] not2xxInputData() { + return new Object[][] { + { "Account disabled", AuthResponsePhase.BLOCKED }, + { "Other issue", AuthResponsePhase.FAILED } + }; + } + + @Test + void shouldLoginFailedWhenIntrospectFailed() { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.ok(LoginResponse.builder() + .accessToken(MOCK_ACCESS_TOKEN) + .refreshToken(MOCK_REFRESH_TOKEN) + .build())); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.badRequest().build()); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + } + + @Test + void shouldLoginFailedWhenResponseBodyMissing() { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.ok().build()); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + } + + @Test + void shouldLoginFailedWhenIntrospectReturnsInactive() { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.ok(LoginResponse.builder() + .accessToken(MOCK_ACCESS_TOKEN) + .refreshToken(MOCK_REFRESH_TOKEN) + .build())); + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder().active("false") + .build(); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(converter, never()).toUserInfoDto(any(IntrospectResponse.class)); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + } + + @Test + void shouldLoginFailedWhenIntrospectReturnsEmptyBody() { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.ok(LoginResponse.builder() + .accessToken(MOCK_ACCESS_TOKEN) + .refreshToken(MOCK_REFRESH_TOKEN) + .build())); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.ok().build()); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.FAILED, response.getPhase()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + verify(converter, never()).toUserInfoDto(any(IntrospectResponse.class)); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + } + + @Test + void shouldLoginSucceedWhenUserNotFound() { + // arrange + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.ok(LoginResponse.builder() + .accessToken(MOCK_ACCESS_TOKEN) + .refreshToken(MOCK_REFRESH_TOKEN) + .build())); + UserInfoDto mockUserInfo = TestUtils.createUserInfoDto(); + when(converter.toUserInfoDto(any(IntrospectResponse.class))).thenReturn(mockUserInfo); + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder() + .email("email@email") + .name("name") + .active("true") + .build(); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + UserEntity userEntity = TestUtils.createUser(); + when(converter.toEntity(any(UserEntity.class), eq(mockUserInfo))).thenReturn(userEntity); + when(userRepository.save(userEntity)).thenReturn(userEntity); + when(userRepository.findByUsername(anyString())).thenReturn(Optional.empty()); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.COMPLETED, response.getPhase()); + assertEquals(MOCK_ACCESS_TOKEN, response.getToken()); + assertEquals(MOCK_REFRESH_TOKEN, response.getRefreshToken()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + ArgumentCaptor introspectResponseArgumentCaptor = ArgumentCaptor.forClass(IntrospectResponse.class); + verify(converter).toUserInfoDto(introspectResponseArgumentCaptor.capture()); + assertEquals(mockIntrospectResponse, introspectResponseArgumentCaptor.getValue()); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + verify(userRepository).save(userEntity); + verify(converter).toEntity(any(UserEntity.class), eq(mockUserInfo)); + } + + @ParameterizedTest + @MethodSource("tokenInputData") + void shouldLoginSucceedWhenUserFound(String accessToken, String refreshToken) { + List cookies = new ArrayList<>(); + if (accessToken != null) { + cookies.add(new Cookie(ACCESS_JWT_TOKEN, accessToken)); + } + if (refreshToken != null) { + cookies.add(new Cookie(REFRESH_JWT_TOKEN, refreshToken)); + } + // arrange + when(httpServletRequest.getCookies()).thenReturn(cookies.toArray(new Cookie[]{})); + when(keycloakLoginService.login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST)) + .thenReturn(ResponseEntity.ok(LoginResponse.builder() + .accessToken(MOCK_ACCESS_TOKEN) + .refreshToken(MOCK_REFRESH_TOKEN) + .build())); + UserInfoDto mockUserInfo = TestUtils.createUserInfoDto(); + when(converter.toUserInfoDto(any(IntrospectResponse.class))).thenReturn(mockUserInfo); + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder() + .email("email@email") + .name("name") + .active("true") + .build(); + when(keycloakIntrospectService.getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN)) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + UserEntity userEntity = TestUtils.createUser(); + when(userRepository.findByUsername(anyString())).thenReturn(Optional.of(userEntity)); + when(converter.toEntity(any(UserEntity.class), eq(mockUserInfo))).thenReturn(userEntity); + when(userRepository.save(userEntity)).thenReturn(userEntity); + + // act + AuthenticationResponse response = service.authenticate(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertEquals(AuthResponsePhase.COMPLETED, response.getPhase()); + assertEquals(MOCK_ACCESS_TOKEN, response.getToken()); + assertEquals(MOCK_REFRESH_TOKEN, response.getRefreshToken()); + verify(keycloakLoginService).login(DemoData.USERNAME1, DemoData.CREDENTIAL_TEST); + ArgumentCaptor introspectResponseArgumentCaptor = ArgumentCaptor.forClass(IntrospectResponse.class); + verify(converter).toUserInfoDto(introspectResponseArgumentCaptor.capture()); + assertEquals(mockIntrospectResponse, introspectResponseArgumentCaptor.getValue()); + verify(keycloakIntrospectService).getUserDetails(MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN); + verify(userRepository).save(userEntity); + verify(converter).toEntity(any(UserEntity.class), eq(mockUserInfo)); + } + + @Test + void shouldLogout() { + // act + service.logout(); + + // assert + verify(keycloakLoginService).logout(); + } + + private static Object[][] tokenInputData () { + return new Object[][] { + { null, null }, + { null, MOCK_REFRESH_TOKEN }, + { MOCK_ACCESS_TOKEN, null } + }; + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthorizationServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthorizationServiceImplTest.java new file mode 100644 index 00000000..d3311ef3 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakAuthorizationServiceImplTest.java @@ -0,0 +1,165 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.model.AuthorizationResponse; +import io.github.gms.auth.model.GmsUserDetails; +import io.github.gms.auth.sso.keycloak.Input; +import io.github.gms.auth.sso.keycloak.converter.KeycloakConverter; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.functions.user.UserRepository; +import io.github.gms.util.TestUtils; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Optional; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakAuthorizationServiceImplTest { + + private KeycloakConverter converter; + private KeycloakIntrospectService keycloakIntrospectService; + private UserRepository userRepository; + private KeycloakAuthorizationServiceImpl service; + + @BeforeEach + public void setup() { + converter = mock(KeycloakConverter.class); + keycloakIntrospectService = mock(KeycloakIntrospectService.class); + userRepository = mock(UserRepository.class); + service = new KeycloakAuthorizationServiceImpl(converter, keycloakIntrospectService, userRepository); + } + + @ParameterizedTest + @MethodSource("emptyInputData") + void shouldReturnEmptyInfo(Input input) { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(input.getCookies()); + + // act + AuthorizationResponse response = service.authorize(httpServletRequest); + + // assert + assertNotNull(response); + assertEquals(HttpStatus.FORBIDDEN, response.getResponseStatus()); + assertEquals("Access denied!", response.getErrorMessage()); + } + + @Test + void shouldNotReturnInfoWhenIntrospectFailed() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.badRequest().build()); + + // act + AuthorizationResponse response = service.authorize(httpServletRequest); + + // assert + assertNotNull(response); + assertEquals(HttpStatus.FORBIDDEN, response.getResponseStatus()); + assertEquals("Access denied!", response.getErrorMessage()); + verify(keycloakIntrospectService).getUserDetails("access", "refresh"); + } + + @Test + void shouldNotReturnInfoWhenResponseBodyIsEmpty() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.ok().build()); + + // act + AuthorizationResponse response = service.authorize(httpServletRequest); + + // assert + assertNotNull(response); + assertEquals(HttpStatus.FORBIDDEN, response.getResponseStatus()); + assertEquals("Access denied!", response.getErrorMessage()); + verify(keycloakIntrospectService).getUserDetails("access", "refresh"); + } + + @Test + void shouldNotReturnInfoWhenUserIsMissing() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder().build(); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + GmsUserDetails mockUser = TestUtils.createGmsAdminUser(); + when(converter.toUserDetails(mockIntrospectResponse)).thenReturn(mockUser); + when(userRepository.findByUsername(anyString())).thenReturn(Optional.empty()); + + // act + AuthorizationResponse response = service.authorize(httpServletRequest); + + // assert + assertNotNull(response); + assertEquals(HttpStatus.FORBIDDEN, response.getResponseStatus()); + assertEquals("Access denied!", response.getErrorMessage()); + verify(converter).toUserDetails(mockIntrospectResponse); + } + + @Test + void shouldReturnInfo() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder().build(); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + GmsUserDetails mockUser = TestUtils.createGmsAdminUser(); + when(converter.toUserDetails(mockIntrospectResponse)).thenReturn(mockUser); + when(userRepository.findByUsername(anyString())).thenReturn(Optional.of(TestUtils.createAdminUser())); + + // act + AuthorizationResponse response = service.authorize(httpServletRequest); + + // assert + assertNotNull(response); + assertNotNull(response.getAuthentication()); + assertEquals(HttpStatus.OK, response.getResponseStatus()); + verify(converter).toUserDetails(mockIntrospectResponse); + } + + private static Object[] emptyInputData() { + return new Object[]{ + new Input(new Cookie[]{}), + new Input(new Cookie[]{ new Cookie(ACCESS_JWT_TOKEN, "access") }), + new Input(new Cookie[]{ new Cookie(REFRESH_JWT_TOKEN, "refresh") }) + }; + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakIntrospectServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakIntrospectServiceImplTest.java new file mode 100644 index 00000000..157c2b92 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakIntrospectServiceImplTest.java @@ -0,0 +1,74 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.config.KeycloakSettings; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.service.OAuthService; +import io.github.gms.util.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; + +import static io.github.gms.common.util.Constants.CLIENT_ID; +import static io.github.gms.common.util.Constants.CLIENT_SECRET; +import static io.github.gms.common.util.Constants.REFRESH_TOKEN; +import static io.github.gms.common.util.Constants.TOKEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakIntrospectServiceImplTest { + + private OAuthService oAuthService; + private KeycloakSettings keycloakSettings; + private KeycloakIntrospectServiceImpl service; + + @BeforeEach + public void setup() { + oAuthService = mock(OAuthService.class); + keycloakSettings = mock(KeycloakSettings.class); + service = new KeycloakIntrospectServiceImpl(oAuthService, keycloakSettings); + } + + @Test + void test() { + // arrange + when(keycloakSettings.getClientId()).thenReturn("clientId"); + when(keycloakSettings.getClientSecret()).thenReturn("clientSecret"); + when(keycloakSettings.getIntrospectUrl()).thenReturn(TestUtils.LOCALHOST_8080); + + IntrospectResponse mockIntrospectResponse = IntrospectResponse.builder() + .email("email@email") + .name("name") + .active("true") + .build(); + when(oAuthService.callPostEndpoint(eq(TestUtils.LOCALHOST_8080), any(MultiValueMap.class), eq(IntrospectResponse.class))) + .thenReturn(ResponseEntity.ok(mockIntrospectResponse)); + + // act + ResponseEntity response = service.getUserDetails("accessToken", "refreshToken"); + + // assert + assertNotNull(response); + verify(keycloakSettings).getClientId(); + verify(keycloakSettings).getClientSecret(); + verify(keycloakSettings).getIntrospectUrl(); + ArgumentCaptor> argumentCaptor = ArgumentCaptor.forClass(MultiValueMap.class); + verify(oAuthService).callPostEndpoint(eq(TestUtils.LOCALHOST_8080), argumentCaptor.capture(), eq(IntrospectResponse.class)); + + MultiValueMap captured = argumentCaptor.getValue(); + assertEquals("clientId", captured.get(CLIENT_ID).getFirst()); + assertEquals("clientSecret", captured.get(CLIENT_SECRET).getFirst()); + assertEquals("accessToken", captured.get(TOKEN).getFirst()); + assertEquals("refreshToken", captured.get(REFRESH_TOKEN).getFirst()); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakLoginServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakLoginServiceImplTest.java new file mode 100644 index 00000000..01553e03 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakLoginServiceImplTest.java @@ -0,0 +1,148 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.Input; +import io.github.gms.auth.sso.keycloak.config.KeycloakSettings; +import io.github.gms.auth.sso.keycloak.model.LoginResponse; +import io.github.gms.auth.sso.keycloak.service.OAuthService; +import io.github.gms.util.TestUtils; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.AUDIENCE; +import static io.github.gms.common.util.Constants.CLIENT_ID; +import static io.github.gms.common.util.Constants.CLIENT_SECRET; +import static io.github.gms.common.util.Constants.CREDENTIAL; +import static io.github.gms.common.util.Constants.GRANT_TYPE; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; +import static io.github.gms.common.util.Constants.SCOPE; +import static io.github.gms.common.util.Constants.SCOPE_GMS; +import static io.github.gms.common.util.Constants.USERNAME; +import static io.github.gms.util.DemoData.CREDENTIAL_TEST; +import static io.github.gms.util.DemoData.USERNAME1; +import static io.github.gms.util.TestUtils.MOCK_ACCESS_TOKEN; +import static io.github.gms.util.TestUtils.MOCK_REFRESH_TOKEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakLoginServiceImplTest { + + private OAuthService oAuthService; + private HttpServletRequest httpServletRequest; + private KeycloakSettings keycloakSettings; + private KeycloakLoginServiceImpl service; + + @BeforeEach + public void setup() { + oAuthService = mock(OAuthService.class); + httpServletRequest = mock(HttpServletRequest.class); + keycloakSettings = mock(KeycloakSettings.class); + + service = new KeycloakLoginServiceImpl(oAuthService, httpServletRequest, keycloakSettings); + } + + @Test + void shouldLogin() { + // arrange + when(keycloakSettings.getRealm()).thenReturn("gms"); + when(keycloakSettings.getKeycloakTokenUrl()).thenReturn(TestUtils.LOCALHOST_8080); + when(keycloakSettings.getClientId()).thenReturn("clientId"); + when(keycloakSettings.getClientSecret()).thenReturn("clientSecret"); + when(oAuthService.callPostEndpoint(eq(TestUtils.LOCALHOST_8080), any(MultiValueMap.class), eq(LoginResponse.class))) + .thenReturn(ResponseEntity.ok(LoginResponse.builder().accessToken(MOCK_ACCESS_TOKEN).refreshToken(MOCK_REFRESH_TOKEN).build())); + + // act + ResponseEntity response = service.login(USERNAME1, CREDENTIAL_TEST); + + // assert + assertNotNull(response); + assertNotNull(response.getBody()); + LoginResponse payload = response.getBody(); + assertEquals(MOCK_ACCESS_TOKEN, payload.getAccessToken()); + assertEquals(MOCK_REFRESH_TOKEN, payload.getRefreshToken()); + ArgumentCaptor> argumentCaptor = ArgumentCaptor.forClass(MultiValueMap.class); + verify(oAuthService).callPostEndpoint(eq(TestUtils.LOCALHOST_8080), argumentCaptor.capture(), eq(LoginResponse.class)); + verify(keycloakSettings).getRealm(); + verify(keycloakSettings).getKeycloakTokenUrl(); + verify(keycloakSettings).getClientId(); + verify(keycloakSettings).getClientSecret(); + MultiValueMap captured = argumentCaptor.getValue(); + assertEquals(CREDENTIAL, captured.get(GRANT_TYPE).getFirst()); + assertEquals("gms", captured.get(AUDIENCE).getFirst()); + assertEquals(USERNAME1, captured.get(USERNAME).getFirst()); + assertEquals(CREDENTIAL_TEST, captured.get(CREDENTIAL).getFirst()); + assertEquals(SCOPE_GMS, captured.get(SCOPE).getFirst()); + assertEquals("clientId", captured.get(CLIENT_ID).getFirst()); + assertEquals("clientSecret", captured.get(CLIENT_SECRET).getFirst()); + } + + @Test + void shouldLogout() { + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakSettings.getClientId()).thenReturn("clientId"); + when(keycloakSettings.getClientSecret()).thenReturn("clientSecret"); + when(keycloakSettings.getLogoutUrl()).thenReturn(TestUtils.LOCALHOST_8080); + + // act + service.logout(); + + // assert + verify(httpServletRequest, times(2)).getCookies(); + verify(keycloakSettings).getClientId(); + verify(keycloakSettings).getClientSecret(); + verify(keycloakSettings).getLogoutUrl(); + ArgumentCaptor> argumentCaptor = ArgumentCaptor.forClass(MultiValueMap.class); + verify(oAuthService).callPostEndpoint(eq(TestUtils.LOCALHOST_8080), argumentCaptor.capture(), eq(Void.class)); + MultiValueMap captured = argumentCaptor.getValue(); + assertEquals("clientId", captured.get(CLIENT_ID).getFirst()); + assertEquals("clientSecret", captured.get(CLIENT_SECRET).getFirst()); + } + + @ParameterizedTest + @MethodSource("emptyInputData") + void shouldReturnEmptyInfo(Input input) { + // arrange + when(httpServletRequest.getCookies()).thenReturn(input.getCookies()); + when(keycloakSettings.getClientId()).thenReturn("clientId"); + when(keycloakSettings.getClientSecret()).thenReturn("clientSecret"); + when(keycloakSettings.getLogoutUrl()).thenReturn(TestUtils.LOCALHOST_8080); + + // act + service.logout(); + + // assert + verify(keycloakSettings, never()).getClientId(); + verify(keycloakSettings, never()).getClientSecret(); + verify(keycloakSettings, never()).getLogoutUrl(); + verify(oAuthService, never()).callPostEndpoint(eq(TestUtils.LOCALHOST_8080), any(MultiValueMap.class), eq(Void.class)); + } + + private static Object[] emptyInputData() { + return new Object[]{ + new Input(new Cookie[]{}), + new Input(new Cookie[]{ new Cookie(ACCESS_JWT_TOKEN, "access") }), + new Input(new Cookie[]{ new Cookie(REFRESH_JWT_TOKEN, "refresh") }) + }; + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakOAuthServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakOAuthServiceImplTest.java new file mode 100644 index 00000000..28f6e4a2 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakOAuthServiceImplTest.java @@ -0,0 +1,61 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.abstraction.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import static io.github.gms.common.util.Constants.CLIENT_ID; +import static io.github.gms.common.util.Constants.CLIENT_SECRET; +import static io.github.gms.common.util.Constants.REFRESH_TOKEN; +import static io.github.gms.common.util.Constants.TOKEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +public class KeycloakOAuthServiceImplTest extends AbstractUnitTest { + + public static final String URL = "http://localhost"; + private RestTemplate restTemplate; + private KeycloakOAuthServiceImpl service; + + @BeforeEach + public void setup() { + restTemplate = mock(RestTemplate.class); + service = new KeycloakOAuthServiceImpl(restTemplate); + } + + @Test + void test() { + // arrange + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add(CLIENT_ID, "client-id"); + requestBody.add(CLIENT_SECRET, "client-secret"); + requestBody.add(TOKEN, "accessToken"); + requestBody.add(REFRESH_TOKEN, "refreshToken"); + ResponseEntity mockResponseEntity = ResponseEntity.ok("ok"); + when(restTemplate.postForEntity(eq(URL), any(HttpEntity.class), eq(String.class))) + .thenReturn(mockResponseEntity); + + // act + ResponseEntity response = service.callPostEndpoint(URL, requestBody, String.class); + + // assert + assertNotNull(response); + assertNotNull(response.getBody()); + assertEquals("ok", response.getBody()); + verify(restTemplate).postForEntity(eq(URL), any(HttpEntity.class), eq(String.class)); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserInfoServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserInfoServiceImplTest.java new file mode 100644 index 00000000..1bc773ec --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserInfoServiceImplTest.java @@ -0,0 +1,178 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import io.github.gms.auth.sso.keycloak.Input; +import io.github.gms.auth.sso.keycloak.model.IntrospectResponse; +import io.github.gms.auth.sso.keycloak.model.RealmAccess; +import io.github.gms.auth.sso.keycloak.service.KeycloakIntrospectService; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.UserRole; +import io.github.gms.functions.user.UserRepository; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Optional; + +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakUserInfoServiceImplTest { + + private KeycloakIntrospectService keycloakIntrospectService; + private UserRepository userRepository; + private KeycloakUserInfoServiceImpl service; + + @BeforeEach + public void setup() { + keycloakIntrospectService = mock(KeycloakIntrospectService.class); + userRepository = mock(UserRepository.class); + service = new KeycloakUserInfoServiceImpl(keycloakIntrospectService, userRepository); + } + + @ParameterizedTest + @MethodSource("emptyInputData") + void shouldReturnEmptyInfo(Input input) { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(input.getCookies()); + + // act + UserInfoDto response = service.getUserInfo(httpServletRequest); + + // assert + assertNotNull(response); + assertNull(response.getEmail()); + assertNull(response.getName()); + assertNull(response.getUsername()); + assertNull(response.getRole()); + verify(httpServletRequest, times(2)).getCookies(); + verify(keycloakIntrospectService, never()).getUserDetails("access", "refresh"); + } + + @Test + void shouldNotFoundUserWhenResponseBodyMissing() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.ok().build()); + + // act + UserInfoDto response = service.getUserInfo(httpServletRequest); + + // assert + assertNull(response); + verify(httpServletRequest, times(2)).getCookies(); + verify(keycloakIntrospectService).getUserDetails("access", "refresh"); + verify(userRepository, never()).getIdByUsername("user1"); + } + + @Test + void shouldNotFoundUserWhenIntrospectFailed() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.badRequest().build()); + + // act + UserInfoDto response = service.getUserInfo(httpServletRequest); + + // assert + assertNull(response); + verify(httpServletRequest, times(2)).getCookies(); + verify(keycloakIntrospectService).getUserDetails("access", "refresh"); + verify(userRepository, never()).getIdByUsername("user1"); + } + + @Test + void shouldNotFoundUserInDb() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.ok(IntrospectResponse.builder() + .name("My Name") + .username("user1") + .active("active") + .email("email@email") + .realmAccess(RealmAccess.builder().roles(List.of("ROLE_USER")).build()) + .build())); + when(userRepository.getIdByUsername("user1")).thenReturn(Optional.empty()); + + // act + UserInfoDto response = service.getUserInfo(httpServletRequest); + + // assert + assertNull(response); + verify(httpServletRequest, times(2)).getCookies(); + verify(keycloakIntrospectService).getUserDetails("access", "refresh"); + verify(userRepository).getIdByUsername("user1"); + } + + @Test + void shouldReturnUserInfo() { + // arrange + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + when(httpServletRequest.getCookies()).thenReturn(new Cookie[]{ + new Cookie(ACCESS_JWT_TOKEN, "access"), + new Cookie(REFRESH_JWT_TOKEN, "refresh") + }); + when(keycloakIntrospectService.getUserDetails("access", "refresh")) + .thenReturn(ResponseEntity.ok(IntrospectResponse.builder() + .name("My Name") + .username("user1") + .active("active") + .email("email@email") + .realmAccess(RealmAccess.builder().roles(List.of("ROLE_USER")).build()) + .build())); + when(userRepository.getIdByUsername("user1")).thenReturn(Optional.of(1L)); + + // act + UserInfoDto response = service.getUserInfo(httpServletRequest); + + // assert + assertNotNull(response); + assertEquals("email@email", response.getEmail()); + assertEquals("My Name", response.getName()); + assertEquals("user1", response.getUsername()); + assertEquals(UserRole.ROLE_USER, response.getRole()); + verify(httpServletRequest, times(2)).getCookies(); + verify(keycloakIntrospectService).getUserDetails("access", "refresh"); + verify(userRepository).getIdByUsername("user1"); + } + + private static Object[] emptyInputData() { + return new Object[]{ + new Input(new Cookie[]{}), + new Input(new Cookie[]{ new Cookie(ACCESS_JWT_TOKEN, "access") }), + new Input(new Cookie[]{ new Cookie(REFRESH_JWT_TOKEN, "refresh") }) + }; + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserLoginAttemptManagerServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserLoginAttemptManagerServiceImplTest.java new file mode 100644 index 00000000..f593a93d --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/auth/sso/keycloak/service/impl/KeycloakUserLoginAttemptManagerServiceImplTest.java @@ -0,0 +1,49 @@ +package io.github.gms.auth.sso.keycloak.service.impl; + +import ch.qos.logback.classic.Logger; +import io.github.gms.abstraction.AbstractLoggingUnitTest; +import io.github.gms.util.DemoData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static io.github.gms.util.TestUtils.assertLogContains; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class KeycloakUserLoginAttemptManagerServiceImplTest extends AbstractLoggingUnitTest { + + private KeycloakUserLoginAttemptManagerServiceImpl service; + + @Override + @BeforeEach + public void setup() { + super.setup(); + service = new KeycloakUserLoginAttemptManagerServiceImpl(); + ((Logger) LoggerFactory.getLogger(KeycloakUserLoginAttemptManagerServiceImpl.class)).addAppender(logAppender); + } + + @Test + void shouldNotUpdateLoginAttempt() { + service.updateLoginAttempt(DemoData.USERNAME1); + + // assert + assertLogContains(logAppender, "updateLoginAttempt method will be ignored when Keycloak SSO based security is active"); + } + + @Test + void shouldNotResetLoginAttempt() { + service.resetLoginAttempt(DemoData.USERNAME1); + + // assert + assertLogContains(logAppender, "resetLoginAttempt method will be ignored when Keycloak SSO based security is active"); + } + + @Test + void shouldNotBlocked() { + assertFalse(service.isBlocked(DemoData.USERNAME1)); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerIntegrationTest.java b/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerIntegrationTest.java index 0ec9ef85..b26431bd 100644 --- a/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerIntegrationTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerIntegrationTest.java @@ -1,8 +1,8 @@ package io.github.gms.common.controller; import io.github.gms.abstraction.AbstractIntegrationTest; -import io.github.gms.common.enums.UserRole; import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.UserRole; import io.github.gms.util.DemoData; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -11,8 +11,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import java.util.Set; - import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; import static io.github.gms.util.TestConstants.TAG_INTEGRATION_TEST; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -53,6 +51,6 @@ void shouldReturnHttp200() { assertEquals("a@b.hu", result.getEmail()); assertEquals(DemoData.USERNAME1, result.getName()); assertEquals(DemoData.USERNAME1, result.getUsername()); - assertEquals(Set.of(UserRole.ROLE_USER), result.getRoles()); + assertEquals(UserRole.ROLE_USER, result.getRole()); } } diff --git a/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerTest.java b/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerTest.java index 9b8ed28b..8b62d582 100644 --- a/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/common/controller/InformationControllerTest.java @@ -1,19 +1,18 @@ package io.github.gms.common.controller; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.functions.user.UserInfoService; +import io.github.gms.util.TestUtils; +import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import io.github.gms.common.dto.UserInfoDto; -import io.github.gms.functions.user.UserService; -import io.github.gms.util.TestUtils; -import jakarta.servlet.http.HttpServletRequest; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * @author Peter Szrnka @@ -22,26 +21,26 @@ @ExtendWith(MockitoExtension.class) class InformationControllerTest { - private UserService userService; + private UserInfoService userInfoService; private InformationController controller; @BeforeEach() void setup() { - userService = mock(UserService.class); - controller = new InformationController(userService); + userInfoService = mock(UserInfoService.class); + controller = new InformationController(userInfoService); } @Test void shouldReturnHttp200() { // arrange HttpServletRequest request = mock(HttpServletRequest.class); - when(userService.getUserInfo(request)).thenReturn(TestUtils.createUserInfoDto()); + when(userInfoService.getUserInfo(request)).thenReturn(TestUtils.createUserInfoDto()); // act UserInfoDto response = controller.getUserInfo(request); // assert assertNotNull(response); - verify(userService).getUserInfo(request); + verify(userInfoService).getUserInfo(request); } } diff --git a/code/gms-backend/src/test/java/io/github/gms/common/controller/LoginControllerTest.java b/code/gms-backend/src/test/java/io/github/gms/common/controller/LoginControllerTest.java index 5372e82d..51d07d86 100644 --- a/code/gms-backend/src/test/java/io/github/gms/common/controller/LoginControllerTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/common/controller/LoginControllerTest.java @@ -5,8 +5,6 @@ import io.github.gms.auth.dto.AuthenticateResponseDto; import io.github.gms.auth.model.AuthenticationResponse; import io.github.gms.auth.types.AuthResponsePhase; -import io.github.gms.common.dto.LoginVerificationRequestDto; -import io.github.gms.common.dto.UserInfoDto; import io.github.gms.common.enums.SystemProperty; import io.github.gms.common.util.Constants; import io.github.gms.common.util.CookieUtils; @@ -22,11 +20,13 @@ import java.util.Objects; +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.common.util.Constants.REFRESH_JWT_TOKEN; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -54,18 +54,24 @@ void setup() { @Test void shouldLoginFail() { - // arrange - AuthenticateRequestDto dto = new AuthenticateRequestDto("user", "pass"); - AuthenticationResponse mockResponse = new AuthenticationResponse(); - mockResponse.setPhase(AuthResponsePhase.FAILED); - when(service.authenticate("user", "pass")).thenReturn(mockResponse); - - // act - ResponseEntity response = controller.loginAuthentication(dto); - - // assert - assertNotNull(response); - assertEquals(401, response.getStatusCode().value()); + try (MockedStatic cookieUtilsMockedStatic = mockStatic(CookieUtils.class)) { + // arrange + AuthenticateRequestDto dto = new AuthenticateRequestDto("user", "pass"); + AuthenticationResponse mockResponse = new AuthenticationResponse(); + mockResponse.setPhase(AuthResponsePhase.FAILED); + when(service.authenticate("user", "pass")).thenReturn(mockResponse); + cookieUtilsMockedStatic.when(() -> CookieUtils.createCookie(eq(ACCESS_JWT_TOKEN), isNull(), eq(0L), eq(false))) + .thenReturn(TestUtils.createResponseCookie(ACCESS_JWT_TOKEN)); + cookieUtilsMockedStatic.when(() -> CookieUtils.createCookie(eq(REFRESH_JWT_TOKEN), isNull(), eq(0L), eq(false))) + .thenReturn(TestUtils.createResponseCookie(REFRESH_JWT_TOKEN)); + + // act + ResponseEntity response = controller.loginAuthentication(dto); + + // assert + assertNotNull(response); + assertEquals(401, response.getStatusCode().value()); + } } @Test @@ -102,7 +108,7 @@ void shouldLogin() { assertEquals(1, response.getHeaders().size()); assertTrue(Objects.requireNonNull(response.getHeaders().get("Set-Cookie")).stream().anyMatch(item -> item.equals("mock-cookie1"))); assertTrue(Objects.requireNonNull(response.getHeaders().get("Set-Cookie")).stream().anyMatch(item -> item.equals("mock-cookie2"))); - assertEquals("AuthenticateResponseDto(currentUser=UserInfoDto(id=1, name=name, username=user, email=a@b.com, roles=[ROLE_USER]), phase=COMPLETED)", + assertEquals("AuthenticateResponseDto(currentUser=UserInfoDto(id=1, name=name, username=user, email=a@b.com, role=ROLE_USER, status=ACTIVE, failedAttempts=null), phase=COMPLETED)", Objects.requireNonNull(response.getBody()).toString()); verify(systemPropertyService).getLong(SystemProperty.ACCESS_JWT_EXPIRATION_TIME_SECONDS); @@ -136,47 +142,6 @@ void shouldLoginRequireMfa() { verify(systemPropertyService, never()).getLong(SystemProperty.REFRESH_JWT_EXPIRATION_TIME_SECONDS); } - @Test - void shouldVerify() { - // arrange - LoginVerificationRequestDto dto = new LoginVerificationRequestDto(); - UserInfoDto userInfoDto = TestUtils.createUserInfoDto(); - AuthenticationResponse mockResponse = new AuthenticationResponse(); - mockResponse.setCurrentUser(userInfoDto); - mockResponse.setPhase(AuthResponsePhase.COMPLETED); - when(service.verify(dto)).thenReturn(mockResponse); - - // act - ResponseEntity response = controller.verify(dto); - - // assert - assertNotNull(response); - assertEquals(200, response.getStatusCode().value()); - assertEquals(userInfoDto, Objects.requireNonNull(response.getBody()).getCurrentUser()); - assertEquals(AuthResponsePhase.COMPLETED, response.getBody().getPhase()); - verify(service).verify(dto); - } - - @Test - void shouldVerifyFail() { - // arrange - LoginVerificationRequestDto dto = new LoginVerificationRequestDto(); - UserInfoDto userInfoDto = TestUtils.createUserInfoDto(); - AuthenticationResponse mockResponse = new AuthenticationResponse(); - mockResponse.setCurrentUser(userInfoDto); - mockResponse.setPhase(AuthResponsePhase.FAILED); - when(service.verify(dto)).thenReturn(mockResponse); - - // act - ResponseEntity response = controller.verify(dto); - - // assert - assertNotNull(response); - assertEquals(401, response.getStatusCode().value()); - assertNull(response.getBody()); - verify(service).verify(dto); - } - @Test void shouldLogout() { // act diff --git a/code/gms-backend/src/test/java/io/github/gms/common/controller/SetupControllerTest.java b/code/gms-backend/src/test/java/io/github/gms/common/controller/SetupControllerTest.java index 9eec350e..cd10454e 100644 --- a/code/gms-backend/src/test/java/io/github/gms/common/controller/SetupControllerTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/common/controller/SetupControllerTest.java @@ -1,13 +1,10 @@ package io.github.gms.common.controller; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Set; - +import io.github.gms.common.dto.SaveEntityResponseDto; +import io.github.gms.common.enums.MdcParameter; +import io.github.gms.common.enums.UserRole; +import io.github.gms.functions.user.SaveUserRequestDto; +import io.github.gms.functions.user.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -15,11 +12,12 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.MDC; -import io.github.gms.common.enums.MdcParameter; -import io.github.gms.common.enums.UserRole; -import io.github.gms.common.dto.SaveEntityResponseDto; -import io.github.gms.functions.user.SaveUserRequestDto; -import io.github.gms.functions.user.UserService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Unit test of {@link SetupController} @@ -42,7 +40,7 @@ void setup() { void adminRoleMissing() { // arrange SaveUserRequestDto dto = new SaveUserRequestDto(); - dto.setRoles(Set.of()); + dto.setRole(null); SaveEntityResponseDto mockResponse = new SaveEntityResponseDto(1L); when(userService.saveAdminUser(dto)).thenReturn(mockResponse); @@ -57,14 +55,14 @@ void adminRoleMissing() { ArgumentCaptor argumentCaptorDto = ArgumentCaptor.forClass(SaveUserRequestDto.class); verify(userService).saveAdminUser(argumentCaptorDto.capture()); - assertEquals(UserRole.ROLE_ADMIN, argumentCaptorDto.getValue().getRoles().iterator().next()); + assertNull(argumentCaptorDto.getValue().getRole()); } @Test void shouldHaveRole() { // arrange SaveUserRequestDto dto = new SaveUserRequestDto(); - dto.setRoles(Set.of(UserRole.ROLE_USER)); + dto.setRole(UserRole.ROLE_USER); SaveEntityResponseDto mockResponse = new SaveEntityResponseDto(1L); when(userService.saveAdminUser(dto)).thenReturn(mockResponse); @@ -79,6 +77,6 @@ void shouldHaveRole() { ArgumentCaptor argumentCaptorDto = ArgumentCaptor.forClass(SaveUserRequestDto.class); verify(userService).saveAdminUser(argumentCaptorDto.capture()); - assertEquals(UserRole.ROLE_USER, argumentCaptorDto.getValue().getRoles().iterator().next()); + assertEquals(UserRole.ROLE_ADMIN, argumentCaptorDto.getValue().getRole()); } } \ No newline at end of file diff --git a/code/gms-backend/src/test/java/io/github/gms/common/controller/VerificationControllerTest.java b/code/gms-backend/src/test/java/io/github/gms/common/controller/VerificationControllerTest.java new file mode 100644 index 00000000..6be0d244 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/common/controller/VerificationControllerTest.java @@ -0,0 +1,90 @@ +package io.github.gms.common.controller; + +import io.github.gms.auth.VerificationService; +import io.github.gms.auth.dto.AuthenticateResponseDto; +import io.github.gms.auth.model.AuthenticationResponse; +import io.github.gms.auth.types.AuthResponsePhase; +import io.github.gms.common.dto.LoginVerificationRequestDto; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.enums.SystemProperty; +import io.github.gms.functions.systemproperty.SystemPropertyService; +import io.github.gms.util.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit test of {@link VerificationController} + * + * @author Peter Szrnka + */ +@ExtendWith(MockitoExtension.class) +class VerificationControllerTest { + + private VerificationService service; + private SystemPropertyService systemPropertyService; + private VerificationController controller; + + @BeforeEach + void setup() { + service = mock(VerificationService.class); + systemPropertyService = mock(SystemPropertyService.class); + controller = new VerificationController(service, systemPropertyService, false); + } + + + @Test + void shouldVerify() { + // arrange + LoginVerificationRequestDto dto = new LoginVerificationRequestDto(); + UserInfoDto userInfoDto = TestUtils.createUserInfoDto(); + AuthenticationResponse mockResponse = new AuthenticationResponse(); + mockResponse.setCurrentUser(userInfoDto); + mockResponse.setPhase(AuthResponsePhase.COMPLETED); + mockResponse.setToken("token"); + when(service.verify(dto)).thenReturn(mockResponse); + when(systemPropertyService.getLong(SystemProperty.ACCESS_JWT_EXPIRATION_TIME_SECONDS)).thenReturn(2L); + + // act + ResponseEntity response = controller.verify(dto); + + // assert + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertEquals(userInfoDto, Objects.requireNonNull(response.getBody()).getCurrentUser()); + assertEquals(AuthResponsePhase.COMPLETED, response.getBody().getPhase()); + verify(service).verify(dto); + verify(systemPropertyService).getLong(SystemProperty.ACCESS_JWT_EXPIRATION_TIME_SECONDS); + } + + @Test + void shouldVerifyFail() { + // arrange + LoginVerificationRequestDto dto = new LoginVerificationRequestDto(); + UserInfoDto userInfoDto = TestUtils.createUserInfoDto(); + AuthenticationResponse mockResponse = new AuthenticationResponse(); + mockResponse.setCurrentUser(userInfoDto); + mockResponse.setPhase(AuthResponsePhase.FAILED); + when(service.verify(dto)).thenReturn(mockResponse); + + // act + ResponseEntity response = controller.verify(dto); + + // assert + assertNotNull(response); + assertEquals(401, response.getStatusCode().value()); + assertNull(response.getBody()); + verify(service).verify(dto); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/common/filter/SecureHeaderInitializerFilterTest.java b/code/gms-backend/src/test/java/io/github/gms/common/filter/SecureHeaderInitializerFilterTest.java index 3afdf28a..c9623b54 100644 --- a/code/gms-backend/src/test/java/io/github/gms/common/filter/SecureHeaderInitializerFilterTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/common/filter/SecureHeaderInitializerFilterTest.java @@ -11,6 +11,7 @@ import io.github.gms.common.util.Constants; import io.github.gms.common.util.CookieUtils; import io.github.gms.functions.systemproperty.SystemPropertyService; +import io.github.gms.util.TestUtils; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -102,7 +103,7 @@ void shouldSendError() { verify(response).sendError(HttpStatus.BAD_REQUEST.value(), ERROR_MESSAGE); } - @SuppressWarnings({ "unchecked", "rawtypes" }) + @SuppressWarnings({ "rawtypes" }) @ParameterizedTest @MethodSource("testData") @SneakyThrows @@ -114,6 +115,7 @@ void shouldPass(UserRole role, boolean admin) { HttpServletResponse response = mock(HttpServletResponse.class); FilterChain filterChain = mock(FilterChain.class); Authentication mockAuthentication = mock(Authentication.class); + when(mockAuthentication.getPrincipal()).thenReturn(TestUtils.createGmsAdminUser()); when(authorizationService.authorize(any(HttpServletRequest.class))).thenReturn(AuthorizationResponse.builder() .responseStatus(HttpStatus.OK) diff --git a/code/gms-backend/src/test/java/io/github/gms/common/interceptor/HttpClientResponseLoggingInterceptorTest.java b/code/gms-backend/src/test/java/io/github/gms/common/interceptor/HttpClientResponseLoggingInterceptorTest.java new file mode 100644 index 00000000..25ac99ce --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/common/interceptor/HttpClientResponseLoggingInterceptorTest.java @@ -0,0 +1,71 @@ +package io.github.gms.common.interceptor; + +import ch.qos.logback.classic.Logger; +import io.github.gms.abstraction.AbstractLoggingUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.net.URI; + +import static io.github.gms.util.TestUtils.assertLogContains; +import static io.github.gms.util.TestUtils.assertLogMissing; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class HttpClientResponseLoggingInterceptorTest extends AbstractLoggingUnitTest { + + private static final String BODY = "body"; + private HttpClientResponseLoggingInterceptor interceptor; + + @Override + @BeforeEach + public void setup() { + super.setup(); + interceptor = new HttpClientResponseLoggingInterceptor(true); + ((Logger) LoggerFactory.getLogger(HttpClientResponseLoggingInterceptor.class)).addAppender(logAppender); + } + + @Test + void shouldNotLogResponse() throws IOException { + // arrange + interceptor = new HttpClientResponseLoggingInterceptor(false); + HttpRequest request = mock(HttpRequest.class); + ClientHttpRequestExecution execution = new MockClientHttpRequestExecution(); + + // act & assert + try (ClientHttpResponse response = interceptor.intercept(request, BODY.getBytes(), execution)) { + assertNotNull(response); + verify(request, never()).getURI(); + assertLogMissing(logAppender, "Requested URL: http://localhost:5555/api/get"); + assertLogMissing(logAppender, "Response status: 200"); + } + } + + @Test + void shouldLogHttpClientResponse() throws IOException { + // arrange + HttpRequest request = mock(HttpRequest.class); + when(request.getURI()).thenReturn(URI.create("http://localhost:5555/api/get")); + ClientHttpRequestExecution execution = new MockClientHttpRequestExecution(); + + // act & assert + try (ClientHttpResponse response = interceptor.intercept(request, BODY.getBytes(), execution)) { + assertNotNull(response); + verify(request).getURI(); + assertLogContains(logAppender, "Requested URL: http://localhost:5555/api/get"); + assertLogContains(logAppender, "Response status: 200"); + } + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/common/interceptor/MockClientHttpRequestExecution.java b/code/gms-backend/src/test/java/io/github/gms/common/interceptor/MockClientHttpRequestExecution.java new file mode 100644 index 00000000..ccedaf5f --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/common/interceptor/MockClientHttpRequestExecution.java @@ -0,0 +1,17 @@ +package io.github.gms.common.interceptor; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; + +import java.io.IOException; + +public class MockClientHttpRequestExecution implements ClientHttpRequestExecution { + + @NonNull + @Override + public ClientHttpResponse execute(@NonNull HttpRequest request, @NonNull byte[] body) throws IOException { + return new MockClientHttpResponse(); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/common/interceptor/MockClientHttpResponse.java b/code/gms-backend/src/test/java/io/github/gms/common/interceptor/MockClientHttpResponse.java new file mode 100644 index 00000000..4994cc35 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/common/interceptor/MockClientHttpResponse.java @@ -0,0 +1,42 @@ +package io.github.gms.common.interceptor; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.NonNull; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class MockClientHttpResponse implements ClientHttpResponse { + + @NonNull + @Override + public HttpStatusCode getStatusCode() throws IOException { + return HttpStatusCode.valueOf(200); + } + + @NonNull + @Override + public String getStatusText() throws IOException { + return "OK"; + } + + @Override + public void close() { + + } + + @NonNull + @Override + public InputStream getBody() throws IOException { + return new ByteArrayInputStream("body".getBytes()); + } + + @NonNull + @Override + public HttpHeaders getHeaders() { + return new HttpHeaders(); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/common/util/CookieUtilsTest.java b/code/gms-backend/src/test/java/io/github/gms/common/util/CookieUtilsTest.java index 8c029f36..3138df9d 100644 --- a/code/gms-backend/src/test/java/io/github/gms/common/util/CookieUtilsTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/common/util/CookieUtilsTest.java @@ -1,16 +1,15 @@ package io.github.gms.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.time.Duration; - +import io.github.gms.abstraction.AbstractUnitTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.ResponseCookie; -import io.github.gms.abstraction.AbstractUnitTest; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; /** * @author Peter Szrnka @@ -27,14 +26,23 @@ void shouldTestPrivateConstructor() { @ValueSource(booleans = { true, false }) void shouldReturnCookie(boolean secure) { // act - ResponseCookie cookie = CookieUtils.createCookie("the-cookie", "new-value", 60l, secure); + ResponseCookie cookie = CookieUtils.createCookie("the-cookie", "new-value", 60L, secure); // assert assertNotNull(cookie); assertEquals("the-cookie", cookie.getName()); assertEquals("new-value", cookie.getValue()); - assertEquals(Duration.ofSeconds(60l), cookie.getMaxAge()); + assertEquals(Duration.ofSeconds(60L), cookie.getMaxAge()); assertEquals(secure, cookie.isSecure()); assertEquals(secure ? "None" : "Lax", cookie.getSameSite()); } + + @Test + void shouldTestWithNullValues() { + //act + ResponseCookie cookie = CookieUtils.createCookie("the-cookie", null, 0L, true); + + // assert + assertNotNull(cookie); + } } \ No newline at end of file diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/keystore/KeystoreIntegrationTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/keystore/KeystoreIntegrationTest.java index 1d9659dc..b35ff598 100644 --- a/code/gms-backend/src/test/java/io/github/gms/functions/keystore/KeystoreIntegrationTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/functions/keystore/KeystoreIntegrationTest.java @@ -1,17 +1,18 @@ package io.github.gms.functions.keystore; -import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; -import static io.github.gms.util.TestConstants.TAG_INTEGRATION_TEST; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -import java.io.File; -import java.io.InputStream; -import java.util.List; -import java.util.UUID; - +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.io.Files; +import io.github.gms.abstraction.AbstractClientControllerIntegrationTest; +import io.github.gms.common.dto.IdNamePairListDto; +import io.github.gms.common.dto.PagingDto; +import io.github.gms.common.enums.EntityStatus; +import io.github.gms.common.enums.KeyStoreValueType; +import io.github.gms.common.filter.SecureHeaderInitializerFilter; +import io.github.gms.functions.secret.GetSecureValueDto; +import io.github.gms.util.DemoData; +import io.github.gms.util.TestUtils; +import io.github.gms.util.TestUtils.ValueHolder; +import lombok.SneakyThrows; import org.assertj.core.util.Lists; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -34,24 +35,17 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.WebApplicationContext; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.io.Files; +import java.io.File; +import java.io.InputStream; +import java.util.List; +import java.util.UUID; -import io.github.gms.abstraction.AbstractClientControllerIntegrationTest; -import io.github.gms.common.enums.EntityStatus; -import io.github.gms.common.enums.KeyStoreValueType; -import io.github.gms.common.filter.SecureHeaderInitializerFilter; -import io.github.gms.functions.keystore.KeystoreController; -import io.github.gms.functions.secret.GetSecureValueDto; -import io.github.gms.common.dto.IdNamePairListDto; -import io.github.gms.functions.keystore.KeystoreDto; -import io.github.gms.functions.keystore.KeystoreListDto; -import io.github.gms.common.dto.PagingDto; -import io.github.gms.functions.keystore.KeystoreEntity; -import io.github.gms.util.DemoData; -import io.github.gms.util.TestUtils; -import io.github.gms.util.TestUtils.ValueHolder; -import lombok.SneakyThrows; +import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; +import static io.github.gms.util.TestConstants.TAG_INTEGRATION_TEST; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; /** * @author Peter Szrnka @@ -77,27 +71,28 @@ class KeystoreIntegrationTest extends AbstractClientControllerIntegrationTest { @SneakyThrows void testSave() { ClassLoader classloader = Thread.currentThread().getContextClassLoader(); - InputStream jksFileStream = classloader.getResourceAsStream("test.jks"); + try (InputStream jksFileStream = classloader.getResourceAsStream("test.jks")) { + assert jksFileStream != null; + MockMultipartFile sampleFile = new MockMultipartFile(KeystoreController.MULTIPART_FILE, + "test-" + UUID.randomUUID() + ".jks", MediaType.APPLICATION_OCTET_STREAM_VALUE, + jksFileStream.readAllBytes()); - MockMultipartFile sampleFile = new MockMultipartFile(KeystoreController.MULTIPART_FILE, - "test-" + UUID.randomUUID().toString() + ".jks", MediaType.APPLICATION_OCTET_STREAM_VALUE, - jksFileStream.readAllBytes()); + String saveRequestJson = objectMapper.writeValueAsString(TestUtils.createSaveKeystoreRequestDto()); - String saveRequestJson = objectMapper.writeValueAsString(TestUtils.createSaveKeystoreRequestDto()); + MockMultipartHttpServletRequestBuilder multipartRequest = MockMvcRequestBuilders.multipart("/secure/keystore") + .file(sampleFile); - MockMultipartHttpServletRequestBuilder multipartRequest = MockMvcRequestBuilders.multipart("/secure/keystore") - .file(sampleFile); + multipartRequest.flashAttr("model", saveRequestJson); - multipartRequest.flashAttr("model", saveRequestJson); + multipartRequest.headers(TestUtils.getHttpHeaders(null)); + multipartRequest.cookie(TestUtils.getCookie(jwt)); - multipartRequest.headers(TestUtils.getHttpHeaders(null)); - multipartRequest.cookie(TestUtils.getCookie(jwt)); + MockMvc mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .addFilter(secureHeaderInitializerFilter, "/secure/keystore").build(); - MockMvc mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) - .addFilter(secureHeaderInitializerFilter, "/secure/keystore").build(); - - // act - mvc.perform(multipartRequest).andExpect(MockMvcResultMatchers.status().isOk()); + // act + mvc.perform(multipartRequest).andExpect(MockMvcResultMatchers.status().isOk()); + } } @Test diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/system/SystemServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/system/SystemServiceImplTest.java index 69343562..551515be 100644 --- a/code/gms-backend/src/test/java/io/github/gms/functions/system/SystemServiceImplTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/functions/system/SystemServiceImplTest.java @@ -21,6 +21,7 @@ import static io.github.gms.common.util.Constants.OK; import static io.github.gms.common.util.Constants.SELECTED_AUTH_DB; import static io.github.gms.common.util.Constants.SELECTED_AUTH_LDAP; +import static io.github.gms.common.util.Constants.SELECTED_AUTH_SSO; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -108,7 +109,7 @@ void shouldReturnOkWithDifferentAuthMethod(String selectedAuth, boolean hasBuild public static Object[][] inputData() { return new Object[][] { {SELECTED_AUTH_LDAP, false, "DevRuntime" }, - {SELECTED_AUTH_DB, true, "1.0.0" }, + {SELECTED_AUTH_SSO, true, "1.0.0" }, {SELECTED_AUTH_DB, true, "1.0.0" } }; } diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/user/LdapUserControllerTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/user/LdapUserControllerTest.java new file mode 100644 index 00000000..236a1d56 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/functions/user/LdapUserControllerTest.java @@ -0,0 +1,52 @@ +package io.github.gms.functions.user; + +import io.github.gms.auth.ldap.LdapSyncService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Unit test of {@link LdapUserController} + * + * @author Peter Szrnka + */ +class LdapUserControllerTest { + + private LdapSyncService ldapSyncService; + private LdapUserController controller; + + @BeforeEach + void setupTest() { + ldapSyncService = mock(LdapSyncService.class); + controller = new LdapUserController(ldapSyncService, "db"); + } + + @ParameterizedTest + @MethodSource("inputData") + void shouldSyncUsers(String authType, int expectedReturnCode) { + // arrange + controller = new LdapUserController(ldapSyncService, authType); + + // act + ResponseEntity response = controller.synchronizeUsers(); + + // assert + assertNotNull(response); + assertEquals(expectedReturnCode, response.getStatusCode().value()); + verify(ldapSyncService, times(expectedReturnCode==200 ? 1 : 0)).synchronizeUsers(); + } + + private static Object[][] inputData() { + return new Object[][] { + {"db", 404}, + {"ldap", 200} + }; + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserControllerTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserControllerTest.java index a094fea8..6b8ac56a 100644 --- a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserControllerTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserControllerTest.java @@ -2,7 +2,6 @@ import dev.samstevens.totp.exceptions.QrGenerationException; import io.github.gms.abstraction.AbstractClientControllerTest; -import io.github.gms.auth.ldap.LdapSyncService; import io.github.gms.common.dto.PagingDto; import io.github.gms.common.dto.SaveEntityResponseDto; import io.github.gms.util.TestUtils; @@ -10,7 +9,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.ResponseEntity; @@ -18,7 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -29,13 +26,10 @@ */ class UserControllerTest extends AbstractClientControllerTest { - private LdapSyncService ldapSyncService; - @BeforeEach void setupTest() { service = mock(UserService.class); - ldapSyncService = mock(LdapSyncService.class); - controller = new UserController(service, ldapSyncService, "db"); + controller = new UserController(service); } @Test @@ -187,26 +181,4 @@ void shouldReturnMfaIsActive() { assertEquals(true, response.getBody()); verify(service).isMfaActive(); } - - @ParameterizedTest - @MethodSource("inputData") - void shouldSyncUsers(String authType, int expectedReturnCode) { - // arrange - controller = new UserController(service, ldapSyncService, authType); - - // act - ResponseEntity response = controller.synchronizeUsers(); - - // assert - assertNotNull(response); - assertEquals(expectedReturnCode, response.getStatusCode().value()); - verify(ldapSyncService, times(expectedReturnCode==200 ? 1 : 0)).synchronizeUsers(); - } - - private static Object[][] inputData() { - return new Object[][] { - {"db", 404}, - {"ldap", 200} - }; - } } \ No newline at end of file diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserConverterImplTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserConverterImplTest.java index 4dbfabcc..0bed6241 100644 --- a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserConverterImplTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserConverterImplTest.java @@ -10,8 +10,6 @@ import io.github.gms.util.TestUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +19,7 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; +import static io.github.gms.common.enums.UserRole.ROLE_VIEWER; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.anyString; @@ -56,7 +55,7 @@ void checkToEntityWithRoleChange() { // assert assertNotNull(entity); - assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=null, roles=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); + assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=null, role=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); verify(passwordEncoder).encode(anyString()); } @@ -77,7 +76,7 @@ void checkToEntityWithoutCredential() { // assert assertNotNull(entity); - assertEquals("UserEntity(id=1, name=name, username=username, email=email@email.com, status=ACTIVE, credential=OldCredential, creationDate=2023-06-29T00:00Z, roles=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); + assertEquals("UserEntity(id=1, name=name, username=username, email=email@email.com, status=ACTIVE, credential=OldCredential, creationDate=2023-06-29T00:00Z, role=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); } @Test @@ -94,12 +93,12 @@ void checkToEntityWithParameters() { mockEntity.setUsername("my-user-1"); mockEntity.setCreationDate(ZonedDateTime.now(clock)); mockEntity.setStatus(EntityStatus.DISABLED); - mockEntity.setRoles("ROLE_VIEWER"); + mockEntity.setRole(ROLE_VIEWER); UserEntity entity = converter.toEntity(mockEntity, dto, true); // assert assertNotNull(entity); - assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=2023-06-29T00:00Z, roles=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); + assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=2023-06-29T00:00Z, role=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); verify(passwordEncoder).encode(anyString()); } @@ -118,7 +117,7 @@ void checkToNewEntity() { // assert assertNotNull(entity); - assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=2023-06-29T00:00Z, roles=null, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); + assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=2023-06-29T00:00Z, role=null, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); verify(passwordEncoder).encode(anyString()); } @@ -137,7 +136,7 @@ void checkToNewEntityWithRoleChange() { // assert assertNotNull(entity); - assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=2023-06-29T00:00Z, roles=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); + assertEquals("UserEntity(id=null, name=name, username=username, email=email@email.com, status=ACTIVE, credential=encoded, creationDate=2023-06-29T00:00Z, role=ROLE_USER, mfaEnabled=false, mfaSecret=null, failedAttempts=0)", entity.toString()); verify(passwordEncoder).encode(anyString()); } @@ -158,15 +157,14 @@ void checkToList() { assertEquals(1, resultList.getResultList().size()); assertEquals(1L, resultList.getTotalElements()); - UserDto dto = resultList.getResultList().get(0); + UserDto dto = resultList.getResultList().getFirst(); assertEquals(1L, dto.getId()); assertEquals("name", dto.getName()); assertEquals(TestUtils.USERNAME, dto.getUsername()); assertEquals("a@b.com", dto.getEmail()); assertEquals(EntityStatus.ACTIVE, dto.getStatus()); - assertEquals(1, dto.getRoles().size()); - assertEquals(UserRole.ROLE_USER, dto.getRoles().iterator().next()); + assertEquals(UserRole.ROLE_USER, dto.getRole()); assertEquals("2023-06-29T00:00Z", dto.getCreationDate().toString()); } @@ -181,8 +179,7 @@ void checkToUserInfoDto() { assertEquals(DemoData.USERNAME1, dto.getName()); assertEquals(DemoData.USERNAME1, dto.getUsername()); assertEquals("a@b.com", dto.getEmail()); - assertEquals(1, dto.getRoles().size()); - assertEquals(UserRole.ROLE_USER, dto.getRoles().iterator().next()); + assertEquals(UserRole.ROLE_USER, dto.getRole()); } @Test @@ -197,7 +194,7 @@ void checkToUserInfoDtoWithMfa() { // assert assertNotNull(dto); - assertEquals("UserInfoDto(id=null, name=null, username=username1, email=null, roles=[])", dto.toString()); + assertEquals("UserInfoDto(id=null, name=null, username=username1, email=null, role=null, status=null, failedAttempts=null)", dto.toString()); } @Test @@ -213,39 +210,4 @@ void shouldAddIdToUserDetails() { assertNotNull(response); assertEquals(1L, response.getUserId()); } - - @ParameterizedTest - @ValueSource(booleans = { true, false }) - void shouldConvertUserDetailsWithNewUser(boolean storeLdapCredential) { - // arrange - when(clock.instant()).thenReturn(Instant.parse("2023-06-29T00:00:00Z")); - when(clock.getZone()).thenReturn(ZoneOffset.UTC); - converter.setStoreLdapCredential(storeLdapCredential); - GmsUserDetails testUser = TestUtils.createGmsUser(); - - // act - UserEntity response = converter.toEntity(testUser, null); - - // assert - assertNotNull(response); - assertEquals(storeLdapCredential ? DemoData.CREDENTIAL_TEST : "*PROVIDED_BY_LDAP*", response.getCredential()); - } - - @ParameterizedTest - @ValueSource(booleans = { true, false }) - void shouldConvertUserDetailsWithExistingUser(boolean storeLdapCredential) { - // arrange - when(clock.instant()).thenReturn(Instant.parse("2023-06-29T00:00:00Z")); - when(clock.getZone()).thenReturn(ZoneOffset.UTC); - converter.setStoreLdapCredential(storeLdapCredential); - GmsUserDetails testUser = TestUtils.createGmsUser(); - UserEntity existingEntity = TestUtils.createUser(); - - // act - UserEntity response = converter.toEntity(testUser, existingEntity); - - // assert - assertNotNull(response); - assertEquals(storeLdapCredential ? DemoData.CREDENTIAL_TEST : "*PROVIDED_BY_LDAP*", response.getCredential()); - } } diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserInfoServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserInfoServiceImplTest.java new file mode 100644 index 00000000..79e1488f --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserInfoServiceImplTest.java @@ -0,0 +1,93 @@ +package io.github.gms.functions.user; + +import ch.qos.logback.classic.Logger; +import io.github.gms.abstraction.AbstractLoggingUnitTest; +import io.github.gms.common.dto.UserInfoDto; +import io.github.gms.common.service.JwtClaimService; +import io.github.gms.common.util.Constants; +import io.github.gms.util.TestUtils; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class UserInfoServiceImplTest extends AbstractLoggingUnitTest { + + private UserRepository repository; + private JwtClaimService jwtClaimService; + private UserInfoServiceImpl service; + + @Override + @BeforeEach + public void setup() { + super.setup(); + repository = mock(UserRepository.class); + jwtClaimService = mock(JwtClaimService.class); + service = new UserInfoServiceImpl(repository, jwtClaimService); + ((Logger) LoggerFactory.getLogger(UserInfoServiceImpl.class)).addAppender(logAppender); + } + + @Test + void jwtCookieIsMissing() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + + // act + UserInfoDto response = service.getUserInfo(request); + + // assert + assertNull(response); + verify(repository, never()).findById(anyLong()); + } + + @Test + void userNotFound() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getCookies()).thenReturn(new Cookie[] { new Cookie("jwt", "value") }); + Claims mockClaims = mock(Claims.class); + when(mockClaims.get(Constants.USER_ID, Long.class)).thenReturn(1L); + when(jwtClaimService.getClaims("value")).thenReturn(mockClaims); + + // act + UserInfoDto response = service.getUserInfo(request); + + // assert + assertNull(response); + verify(repository).findById(anyLong()); + } + + @Test + void shouldReturnUserInfo() { + // arrange + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getCookies()).thenReturn(new Cookie[] { new Cookie("jwt", "value") }); + Claims mockClaims = mock(Claims.class); + when(mockClaims.get(Constants.USER_ID, Long.class)).thenReturn(1L); + when(jwtClaimService.getClaims("value")).thenReturn(mockClaims); + when(repository.findById(anyLong())).thenReturn(Optional.of(TestUtils.createUser())); + + // act + UserInfoDto response = service.getUserInfo(request); + + // assert + assertNotNull(response); + verify(repository).findById(anyLong()); + } +} diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserLoginAttemptManagerServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserLoginAttemptManagerServiceImplTest.java new file mode 100644 index 00000000..ac9f3757 --- /dev/null +++ b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserLoginAttemptManagerServiceImplTest.java @@ -0,0 +1,174 @@ +package io.github.gms.functions.user; + +import ch.qos.logback.classic.Logger; +import io.github.gms.abstraction.AbstractLoggingUnitTest; +import io.github.gms.common.enums.EntityStatus; +import io.github.gms.common.enums.SystemProperty; +import io.github.gms.functions.systemproperty.SystemPropertyService; +import io.github.gms.util.TestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +import static io.github.gms.util.TestUtils.assertLogContains; +import static io.github.gms.util.TestUtils.assertLogMissing; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Peter Szrnka + * @since 1.0 + */ +class UserLoginAttemptManagerServiceImplTest extends AbstractLoggingUnitTest { + + private UserRepository repository; + private SystemPropertyService systemPropertyService; + private UserLoginAttemptManagerServiceImpl service; + + @Override + @BeforeEach + public void setup() { + super.setup(); + repository = mock(UserRepository.class); + systemPropertyService = mock(SystemPropertyService.class); + service = new UserLoginAttemptManagerServiceImpl(repository, systemPropertyService); + ((Logger) LoggerFactory.getLogger(UserLoginAttemptManagerServiceImpl.class)).addAppender(logAppender); + } + + @Test + void shouldNotUpdateLoginAttemptWhenUserIsNotFound() { + // arrange + when(repository.findByUsername("user1")).thenReturn(Optional.empty()); + + // act + service.updateLoginAttempt("user1"); + + // assert + verify(repository).findByUsername("user1"); + assertLogMissing(logAppender, "User already blocked"); + verify(repository, never()).save(any(UserEntity.class)); + } + + @Test + void shouldNotUpdateLoginAttemptWhenUserIsAlreadyBlocked() { + // arrange + UserEntity mockEntity = TestUtils.createUser(); + mockEntity.setStatus(EntityStatus.BLOCKED); + when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); + + // act + service.updateLoginAttempt("user1"); + + // assert + assertLogContains(logAppender, "User already blocked"); + verify(systemPropertyService, never()).getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); + } + + @Test + void shouldUpdateLoginAttempt() { + // arrange + UserEntity mockEntity = TestUtils.createUser(); + when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); + when(systemPropertyService.getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT)).thenReturn(3); + + // act + service.updateLoginAttempt("user1"); + + // assert + verify(systemPropertyService).getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); + verify(repository).save(any(UserEntity.class)); + } + + @Test + void shouldUpdateLoginAttemptAndBlockUser() { + // arrange + UserEntity mockEntity = TestUtils.createUser(); + mockEntity.setFailedAttempts(2); + when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); + when(systemPropertyService.getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT)).thenReturn(3); + + // act + service.updateLoginAttempt("user1"); + + // assert + verify(systemPropertyService).getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); + ArgumentCaptor userEntityArgumentCaptor = ArgumentCaptor.forClass(UserEntity.class); + verify(repository).save(userEntityArgumentCaptor.capture()); + assertEquals(EntityStatus.BLOCKED, userEntityArgumentCaptor.getValue().getStatus()); + } + + @Test + void shouldResetLoginAttempt() { + // arrange + UserEntity mockEntity = TestUtils.createUser(); + mockEntity.setFailedAttempts(3); + mockEntity.setStatus(EntityStatus.BLOCKED); + when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); + + // act + service.resetLoginAttempt("user1"); + + // assert + ArgumentCaptor userEntityArgumentCaptor = ArgumentCaptor.forClass(UserEntity.class); + verify(repository).save(userEntityArgumentCaptor.capture()); + assertEquals(0, userEntityArgumentCaptor.getValue().getFailedAttempts()); + } + + @Test + void shouldSkipResetLoginAttempt() { + // arrange + when(repository.findByUsername("user1")).thenReturn(Optional.empty()); + + // act + service.resetLoginAttempt("user1"); + + // assert + verify(repository, never()).save(any(UserEntity.class)); + verify(repository).findByUsername("user1"); + } + + @ParameterizedTest + @MethodSource("isBlockedTestData") + void isUserBlocked(EntityStatus inputStatus, boolean expectedResult) { + // arrange + UserEntity mockEntity = TestUtils.createUser(); + mockEntity.setFailedAttempts(3); + mockEntity.setStatus(inputStatus); + when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); + + // act + boolean response = service.isBlocked("user1"); + + // assert + assertEquals(expectedResult, response); + } + + @Test + void isUserNotBlocked() { + // arrange + when(repository.findByUsername("user1")).thenReturn(Optional.empty()); + + // act + boolean response = service.isBlocked("user1"); + + // assert + assertFalse(response); + } + + private static Object[][] isBlockedTestData() { + return new Object[][]{ + {EntityStatus.BLOCKED, true}, + {EntityStatus.ACTIVE, false} + }; + }; +} diff --git a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserServiceImplTest.java b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserServiceImplTest.java index b7cefceb..95b8065a 100644 --- a/code/gms-backend/src/test/java/io/github/gms/functions/user/UserServiceImplTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/functions/user/UserServiceImplTest.java @@ -1,6 +1,7 @@ package io.github.gms.functions.user; import ch.qos.logback.classic.Logger; +import dev.samstevens.totp.secret.SecretGenerator; import io.github.gms.abstraction.AbstractLoggingUnitTest; import io.github.gms.common.dto.LongValueDto; import io.github.gms.common.dto.PagingDto; @@ -8,10 +9,8 @@ import io.github.gms.common.dto.UserInfoDto; import io.github.gms.common.enums.EntityStatus; import io.github.gms.common.enums.MdcParameter; -import io.github.gms.common.enums.SystemProperty; import io.github.gms.common.service.JwtClaimService; import io.github.gms.common.types.GmsException; -import io.github.gms.functions.systemproperty.SystemPropertyService; import io.github.gms.util.TestUtils; import io.jsonwebtoken.Claims; import jakarta.servlet.http.Cookie; @@ -22,7 +21,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.slf4j.LoggerFactory; @@ -35,11 +33,8 @@ import java.util.Optional; import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; -import static io.github.gms.util.TestUtils.assertLogContains; -import static io.github.gms.util.TestUtils.assertLogMissing; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -65,7 +60,8 @@ class UserServiceImplTest extends AbstractLoggingUnitTest { private UserConverter converter; private PasswordEncoder passwordEncoder; private JwtClaimService jwtClaimService; - private SystemPropertyService systemPropertyService; + private SecretGenerator secretGenerator; + private UserServiceImpl service; @Override @@ -76,8 +72,8 @@ public void setup() { converter = mock(UserConverter.class); passwordEncoder = mock(PasswordEncoder.class); jwtClaimService = mock(JwtClaimService.class); - systemPropertyService = mock(SystemPropertyService.class); - service = new UserServiceImpl(repository, converter, passwordEncoder, jwtClaimService, systemPropertyService); + secretGenerator = mock(SecretGenerator.class); + service = new UserServiceImpl(repository, converter, passwordEncoder, jwtClaimService, secretGenerator); ((Logger) LoggerFactory.getLogger(UserServiceImpl.class)).addAppender(logAppender); } @@ -85,6 +81,7 @@ public void setup() { void shouldSaveAdminUser() { // arrange when(converter.toNewEntity(any(SaveUserRequestDto.class), anyBoolean())).thenReturn(TestUtils.createUser()); + when(secretGenerator.generate()).thenReturn("secret!"); when(repository.save(any(UserEntity.class))).thenReturn(TestUtils.createUser()); // act @@ -97,7 +94,8 @@ void shouldSaveAdminUser() { verify(converter).toNewEntity(any(SaveUserRequestDto.class), eq(true)); ArgumentCaptor userEntityArgumentCaptor = ArgumentCaptor.forClass(UserEntity.class); verify(repository).save(userEntityArgumentCaptor.capture()); - assertNotNull(userEntityArgumentCaptor.getValue().getMfaSecret()); + assertEquals("secret!", userEntityArgumentCaptor.getValue().getMfaSecret()); + verify(secretGenerator).generate(); } @Test @@ -407,131 +405,4 @@ void shouldGetUserInfo() { verify(jwtClaimService).getClaims(anyString()); verify(repository).findById(1L); } - - @Test - void shouldNotUpdateLoginAttemptWhenUserIsNotFound() { - // arrange - when(repository.findByUsername("user1")).thenReturn(Optional.empty()); - - // act - service.updateLoginAttempt("user1"); - - // assert - verify(repository).findByUsername("user1"); - assertLogMissing(logAppender, "User already blocked"); - verify(repository, never()).save(any(UserEntity.class)); - } - - @Test - void shouldNotUpdateLoginAttemptWhenUserIsAlreadyBlocked() { - // arrange - UserEntity mockEntity = TestUtils.createUser(); - mockEntity.setStatus(EntityStatus.BLOCKED); - when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); - - // act - service.updateLoginAttempt("user1"); - - // assert - assertLogContains(logAppender, "User already blocked"); - verify(systemPropertyService, never()).getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); - } - - @Test - void shouldUpdateLoginAttempt() { - // arrange - UserEntity mockEntity = TestUtils.createUser(); - when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); - when(systemPropertyService.getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT)).thenReturn(3); - - // act - service.updateLoginAttempt("user1"); - - // assert - verify(systemPropertyService).getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); - verify(repository).save(any(UserEntity.class)); - } - - @Test - void shouldUpdateLoginAttemptAndBlockUser() { - // arrange - UserEntity mockEntity = TestUtils.createUser(); - mockEntity.setFailedAttempts(2); - when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); - when(systemPropertyService.getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT)).thenReturn(3); - - // act - service.updateLoginAttempt("user1"); - - // assert - verify(systemPropertyService).getInteger(SystemProperty.FAILED_ATTEMPTS_LIMIT); - ArgumentCaptor userEntityArgumentCaptor = ArgumentCaptor.forClass(UserEntity.class); - verify(repository).save(userEntityArgumentCaptor.capture()); - assertEquals(EntityStatus.BLOCKED, userEntityArgumentCaptor.getValue().getStatus()); - } - - @Test - void shouldResetLoginAttempt() { - // arrange - UserEntity mockEntity = TestUtils.createUser(); - mockEntity.setFailedAttempts(3); - mockEntity.setStatus(EntityStatus.BLOCKED); - when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); - - // act - service.resetLoginAttempt("user1"); - - // assert - ArgumentCaptor userEntityArgumentCaptor = ArgumentCaptor.forClass(UserEntity.class); - verify(repository).save(userEntityArgumentCaptor.capture()); - assertEquals(0, userEntityArgumentCaptor.getValue().getFailedAttempts()); - } - - @Test - void shouldSkipResetLoginAttempt() { - // arrange - when(repository.findByUsername("user1")).thenReturn(Optional.empty()); - - // act - service.resetLoginAttempt("user1"); - - // assert - verify(repository, never()).save(any(UserEntity.class)); - verify(repository).findByUsername("user1"); - } - - @ParameterizedTest - @MethodSource("isBlockedTestData") - void isUserBlocked(EntityStatus inputStatus, boolean expectedResult) { - // arrange - UserEntity mockEntity = TestUtils.createUser(); - mockEntity.setFailedAttempts(3); - mockEntity.setStatus(inputStatus); - when(repository.findByUsername("user1")).thenReturn(Optional.of(mockEntity)); - - // act - boolean response = service.isBlocked("user1"); - - // assert - assertEquals(expectedResult, response); - } - - @Test - void isUserNotBlocked() { - // arrange - when(repository.findByUsername("user1")).thenReturn(Optional.empty()); - - // act - boolean response = service.isBlocked("user1"); - - // assert - assertFalse(response); - } - - private static Object[][] isBlockedTestData() { - return new Object[][]{ - {EntityStatus.BLOCKED, true}, - {EntityStatus.ACTIVE, false} - }; - }; } diff --git a/code/gms-backend/src/test/java/io/github/gms/job/LdapUserSyncJobTest.java b/code/gms-backend/src/test/java/io/github/gms/job/LdapUserSyncJobTest.java index 4607d9ec..2721281b 100644 --- a/code/gms-backend/src/test/java/io/github/gms/job/LdapUserSyncJobTest.java +++ b/code/gms-backend/src/test/java/io/github/gms/job/LdapUserSyncJobTest.java @@ -48,7 +48,7 @@ void shouldProcess(int deletedUserCount) { // assert assertFalse(logAppender.list.isEmpty()); assertLogContains(logAppender, "2 user(s) synchronized and " + deletedUserCount + " of them ha" + (deletedUserCount == 1 ? "s" : "ve") - + " been removed from the database."); + + " been marked as deletable."); verify(service).synchronizeUsers(); } } diff --git a/code/gms-backend/src/test/java/io/github/gms/util/DemoDataManagerService.java b/code/gms-backend/src/test/java/io/github/gms/util/DemoDataManagerService.java index 4d236f90..10ddef74 100644 --- a/code/gms-backend/src/test/java/io/github/gms/util/DemoDataManagerService.java +++ b/code/gms-backend/src/test/java/io/github/gms/util/DemoDataManagerService.java @@ -4,6 +4,7 @@ import io.github.gms.common.enums.KeystoreType; import io.github.gms.common.enums.RotationPeriod; import io.github.gms.common.enums.SecretType; +import io.github.gms.common.enums.UserRole; import io.github.gms.functions.announcement.AnnouncementEntity; import io.github.gms.functions.apikey.ApiKeyEntity; import io.github.gms.functions.iprestriction.IpRestrictionEntity; @@ -142,7 +143,7 @@ private UserEntity createUser(Long id, String userId, String role) { entity.setStatus(EntityStatus.ACTIVE); entity.setUsername(userId); entity.setCredential(passwordEncoder.encode(CREDENTIAL_TEST)); - entity.setRoles(role); + entity.setRole(UserRole.getByName(role)); entity.setName(userId); entity.setEmail("a@b.hu"); diff --git a/code/gms-backend/src/test/java/io/github/gms/util/TestUtils.java b/code/gms-backend/src/test/java/io/github/gms/util/TestUtils.java index 0be1da73..6b7f6e39 100644 --- a/code/gms-backend/src/test/java/io/github/gms/util/TestUtils.java +++ b/code/gms-backend/src/test/java/io/github/gms/util/TestUtils.java @@ -7,6 +7,7 @@ import com.google.common.collect.Sets; import io.github.gms.auth.model.GmsUserDetails; import io.github.gms.common.dto.LoginVerificationRequestDto; +import io.github.gms.common.dto.UserInfoDto; import io.github.gms.common.enums.AliasOperation; import io.github.gms.common.enums.EntityStatus; import io.github.gms.common.enums.EventOperation; @@ -18,45 +19,44 @@ import io.github.gms.common.enums.SecretType; import io.github.gms.common.enums.SystemProperty; import io.github.gms.common.enums.UserRole; -import io.github.gms.common.types.GmsException; import io.github.gms.common.model.GenerateJwtRequest; +import io.github.gms.common.types.GmsException; import io.github.gms.functions.announcement.AnnouncementDto; +import io.github.gms.functions.announcement.AnnouncementEntity; import io.github.gms.functions.announcement.AnnouncementListDto; +import io.github.gms.functions.announcement.SaveAnnouncementDto; import io.github.gms.functions.apikey.ApiKeyDto; +import io.github.gms.functions.apikey.ApiKeyEntity; import io.github.gms.functions.apikey.ApiKeyListDto; +import io.github.gms.functions.apikey.SaveApiKeyRequestDto; +import io.github.gms.functions.event.EventDto; +import io.github.gms.functions.event.EventEntity; +import io.github.gms.functions.event.EventListDto; import io.github.gms.functions.iprestriction.IpRestrictionDto; import io.github.gms.functions.iprestriction.IpRestrictionEntity; import io.github.gms.functions.iprestriction.IpRestrictionListDto; -import io.github.gms.functions.user.ChangePasswordRequestDto; -import io.github.gms.functions.event.EventDto; -import io.github.gms.functions.event.EventListDto; import io.github.gms.functions.keystore.KeystoreAliasDto; +import io.github.gms.functions.keystore.KeystoreAliasEntity; import io.github.gms.functions.keystore.KeystoreDto; +import io.github.gms.functions.keystore.KeystoreEntity; import io.github.gms.functions.keystore.KeystoreListDto; +import io.github.gms.functions.keystore.SaveKeystoreRequestDto; import io.github.gms.functions.message.MessageDto; +import io.github.gms.functions.message.MessageEntity; import io.github.gms.functions.message.MessageListDto; -import io.github.gms.functions.announcement.SaveAnnouncementDto; -import io.github.gms.functions.apikey.SaveApiKeyRequestDto; -import io.github.gms.functions.keystore.SaveKeystoreRequestDto; +import io.github.gms.functions.secret.ApiKeyRestrictionEntity; import io.github.gms.functions.secret.SaveSecretRequestDto; -import io.github.gms.functions.user.SaveUserRequestDto; import io.github.gms.functions.secret.SecretDto; +import io.github.gms.functions.secret.SecretEntity; import io.github.gms.functions.secret.SecretListDto; import io.github.gms.functions.systemproperty.SystemPropertyDto; +import io.github.gms.functions.systemproperty.SystemPropertyEntity; import io.github.gms.functions.systemproperty.SystemPropertyListDto; +import io.github.gms.functions.user.ChangePasswordRequestDto; +import io.github.gms.functions.user.SaveUserRequestDto; import io.github.gms.functions.user.UserDto; -import io.github.gms.common.dto.UserInfoDto; -import io.github.gms.functions.user.UserListDto; -import io.github.gms.functions.announcement.AnnouncementEntity; -import io.github.gms.functions.apikey.ApiKeyEntity; -import io.github.gms.functions.secret.ApiKeyRestrictionEntity; -import io.github.gms.functions.event.EventEntity; -import io.github.gms.functions.keystore.KeystoreAliasEntity; -import io.github.gms.functions.keystore.KeystoreEntity; -import io.github.gms.functions.message.MessageEntity; -import io.github.gms.functions.secret.SecretEntity; -import io.github.gms.functions.systemproperty.SystemPropertyEntity; import io.github.gms.functions.user.UserEntity; +import io.github.gms.functions.user.UserListDto; import jakarta.servlet.http.Cookie; import lombok.AllArgsConstructor; import lombok.Data; @@ -64,10 +64,9 @@ import lombok.extern.slf4j.Slf4j; import org.assertj.core.util.Lists; import org.junit.jupiter.api.function.Executable; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; import org.springframework.security.core.GrantedAuthority; import org.springframework.util.StringUtils; @@ -82,6 +81,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static io.github.gms.common.enums.UserRole.ROLE_ADMIN; +import static io.github.gms.common.enums.UserRole.ROLE_USER; import static io.github.gms.common.util.Constants.ACCESS_JWT_TOKEN; import static io.github.gms.common.util.Constants.API_KEY_HEADER; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -97,6 +98,9 @@ public class TestUtils { public static final String NEW_CREDENTIAL = "MyComplexPassword1!"; public static final String EMAIL = "email@email.com"; public static final String USERNAME = "username"; + public static final String MOCK_ACCESS_TOKEN = "accessToken"; + public static final String MOCK_REFRESH_TOKEN = "refreshToken"; + public static final String LOCALHOST_8080 = "http://localhost:8080"; public static ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); @@ -155,7 +159,7 @@ public static GmsUserDetails createGmsUser() { .userId(DemoData.USER_1_ID) .name(DemoData.USERNAME1) .username(DemoData.USERNAME1) - .authorities(Sets.newHashSet(UserRole.ROLE_USER)) + .authorities(Sets.newHashSet(ROLE_USER)) .mfaSecret("MFA_SECRET") .build(); } @@ -167,7 +171,7 @@ public static GmsUserDetails createBlockedGmsUser() { .credential(DemoData.CREDENTIAL_TEST) .userId(DemoData.USER_1_ID) .username(DemoData.USERNAME1) - .authorities(Sets.newHashSet(UserRole.ROLE_USER)) + .authorities(Sets.newHashSet(ROLE_USER)) .build(); } @@ -177,7 +181,7 @@ public static GmsUserDetails createGmsAdminUser() { .credential(DemoData.CREDENTIAL_TEST) .userId(DemoData.USER_2_ID) .username(DemoData.USERNAME2) - .authorities(Sets.newHashSet(UserRole.ROLE_ADMIN)) + .authorities(Sets.newHashSet(ROLE_ADMIN)) .build(); } @@ -208,7 +212,7 @@ public static UserEntity createUser() { user.setUsername(USERNAME); user.setName("name"); user.setEmail("a@b.com"); - user.setRoles("ROLE_USER"); + user.setRole(ROLE_USER); user.setStatus(EntityStatus.ACTIVE); user.setCredential(OLD_CREDENTIAL); user.setFailedAttempts(0); @@ -220,7 +224,7 @@ public static UserEntity createAdminUser() { user.setId(1L); user.setUsername(USERNAME); user.setName("name"); - user.setRoles("ROLE_ADMIN"); + user.setRole(ROLE_ADMIN); user.setStatus(EntityStatus.ACTIVE); return user; } @@ -232,7 +236,7 @@ public static UserEntity createUserWithStatus(EntityStatus status) { user.setCredential(NEW_CREDENTIAL); user.setEmail("test@email.hu"); user.setName("name"); - user.setRoles("ROLE_USER"); + user.setRole(ROLE_USER); user.setStatus(status); return user; } @@ -431,6 +435,10 @@ public static IpRestrictionListDto createIpRestrictionListDto() { .build(); } + public static ResponseCookie createResponseCookie(String cookieName) { + return ResponseCookie.from(cookieName).value(null).build(); + } + @Data @AllArgsConstructor public static class ValueHolder { @@ -445,7 +453,7 @@ public static SaveUserRequestDto createSaveUserRequestDto(Long id, String userna dto.setUsername(username); dto.setName("name"); dto.setEmail(email); - dto.setRoles(Sets.newHashSet(UserRole.ROLE_USER)); + dto.setRole(ROLE_USER); dto.setCredential(DemoData.CREDENTIAL_TEST); dto.setStatus(EntityStatus.ACTIVE); return dto; @@ -475,7 +483,7 @@ public static UserDto createUserDto() { user.setId(1L); user.setUsername(USERNAME); user.setName("name"); - user.setRoles(Sets.newHashSet(UserRole.ROLE_USER)); + user.setRole(ROLE_USER); user.setStatus(EntityStatus.ACTIVE); return user; } @@ -533,16 +541,6 @@ public static void deleteDirectoryWithContent(String dir) { }); } - public static Page createAnnouncementEntityList() { - AnnouncementEntity entity1 = new AnnouncementEntity(); - entity1.setId(1L); - entity1.setAuthorId(1L); - entity1.setTitle("Maintenance at 2022-01-01"); - entity1.setDescription("Test"); - entity1.setAnnouncementDate(ZonedDateTime.now().minusDays(1)); - return new PageImpl<>(Lists.newArrayList(entity1)); - } - public static AnnouncementEntity createAnnouncementEntity(Long id) { AnnouncementEntity entity = new AnnouncementEntity(); entity.setId(id); @@ -637,7 +635,7 @@ public static GenerateJwtRequest createJwtAdminRequest() { return createJwtAdminRequest(GmsUserDetails.builder() .userId(DemoData.USER_1_ID) .username(DemoData.USERNAME1) - .authorities(Set.of(UserRole.ROLE_ADMIN)) + .authorities(Set.of(ROLE_ADMIN)) .build()); } @@ -745,8 +743,9 @@ public static UserInfoDto createUserInfoDto() { dto.setId(1L); dto.setUsername("user"); dto.setName("name"); - dto.setRoles(Set.of(UserRole.ROLE_USER)); + dto.setRole(ROLE_USER); dto.setEmail("a@b.com"); + dto.setStatus(EntityStatus.ACTIVE); return dto; } diff --git a/code/gms-backend/src/test/resources/application.properties b/code/gms-backend/src/test/resources/application.properties index 3e9ea92f..b3a573c1 100644 --- a/code/gms-backend/src/test/resources/application.properties +++ b/code/gms-backend/src/test/resources/application.properties @@ -41,4 +41,6 @@ config.job.messageCleanup.enabled=false # Redis spring.data.redis.repositories.enabled=false config.cache.redis.host=localhost -config.cache.redis.port=6379 \ No newline at end of file +config.cache.redis.port=6379 + +config.logging.httpClient.enabled=false \ No newline at end of file diff --git a/code/gms-frontend/package-lock.json b/code/gms-frontend/package-lock.json index 5417f5be..d633a649 100644 --- a/code/gms-frontend/package-lock.json +++ b/code/gms-frontend/package-lock.json @@ -22,23 +22,20 @@ "crypto-js": "^4.2.0", "follow-redirects": "1.15.6", "jest-environment-jsdom": "^29.7.0", - "jsonwebtoken": "^9.0.2", - "jwt-decode": "3.1.2", "moment": "^2.29.4", "randomstring": "^1.3.0", "rxjs": "7.5.7", "ts-jest": "^29.1.2", "tslib": "^2.6.2", - "webpack-dev-middleware": ">=6.1.2", + "webpack-dev-middleware": "7.1.1", "zone.js": "^0.14.4" }, "devDependencies": { - "@angular-devkit/build-angular": "17.3.1", + "@angular-devkit/build-angular": "17.3.2", "@angular/cli": "17.3.1", "@angular/compiler-cli": "17.3.1", "@types/crypto-js": "^4.1.3", "@types/jest": "^29.5.6", - "@types/jsonwebtoken": "^8.5.9", "@types/node": "^16.18.59", "@types/randomstring": "^1.1.10", "@typescript-eslint/eslint-plugin": "5.59.8", @@ -55,7 +52,7 @@ "jest-junit": "16.0.0", "jest-preset-angular": "^14.0.3", "typescript": "^5.2.2", - "webpack-dev-middleware": ">=6.1.2" + "webpack-dev-middleware": "7.1.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -104,15 +101,15 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.1.tgz", - "integrity": "sha512-e+hZvLVH5AvHCFbVtKRd5oJeFsEmjg7kK1V6hsVxH4YE2f2x399TSr+AGxwV+R3jnjZ67ujIeXXd0Uuf1RwcSg==", + "version": "17.3.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.2.tgz", + "integrity": "sha512-muPCUyL0uHvRkLH4NLWiccER6P2vCm/Q5DDvqyN4XOzzY3tAHHLrKrpvY87sgd2oNJ6Ci8x7GPNcfzR5KELCnw==", "dev": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1703.1", - "@angular-devkit/build-webpack": "0.1703.1", - "@angular-devkit/core": "17.3.1", + "@angular-devkit/architect": "0.1703.2", + "@angular-devkit/build-webpack": "0.1703.2", + "@angular-devkit/core": "17.3.2", "@babel/core": "7.24.0", "@babel/generator": "7.23.6", "@babel/helper-annotate-as-pure": "7.22.5", @@ -123,7 +120,7 @@ "@babel/preset-env": "7.24.0", "@babel/runtime": "7.24.0", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.3.1", + "@ngtools/webpack": "17.3.2", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.18", @@ -168,7 +165,7 @@ "vite": "5.1.5", "watchpack": "2.4.0", "webpack": "5.90.3", - "webpack-dev-middleware": "6.1.1", + "webpack-dev-middleware": "6.1.2", "webpack-dev-server": "4.15.1", "webpack-merge": "5.10.0", "webpack-subresource-integrity": "5.1.0" @@ -232,6 +229,48 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { + "version": "0.1703.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.2.tgz", + "integrity": "sha512-fT5gSzwDHOyGv8zF97t8rjeoYSGSxXjWWstl3rN1nXdO0qgJ5m6Sv0fupON+HltdXDCBLRH+2khNpqx/Fh0Qww==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.2", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { + "version": "17.3.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.2.tgz", + "integrity": "sha512-1vxKo9+pdSwTOwqPDSYQh84gZYmCJo6OgR5+AZoGLGMZSeqvi9RG5RiUcOMLQYOnuYv0arlhlWxz0ZjyR8ApKw==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.1", + "picomatch": "4.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-angular/node_modules/@babel/core": { "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", @@ -1257,9 +1296,9 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-middleware": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.1.tgz", - "integrity": "sha512-y51HrHaFeeWir0YO4f0g+9GwZawuigzcAdRNon6jErXy/SqV/+O6eaVAzDqE6t3e3NpGeR5CS+cCDaTC+V3yEQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.2.tgz", + "integrity": "sha512-Wu+EHmX326YPYUpQLKmKbTyZZJIB8/n6R09pTmB03kJmnMsVPTo9COzHZFr01txwaCAuZvfBJE4ZCHRcKs5JaQ==", "dev": true, "dependencies": { "colorette": "^2.0.10", @@ -1291,12 +1330,12 @@ "dev": true }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1703.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.1.tgz", - "integrity": "sha512-nVUzewX8RCzaEPQZ1JQpE42wpsYchKQwfXUSCkoUsuCMB2c6zuEz0Jt94nzJg3UjSEEV4ZqCH8v5MDOvB49Rlw==", + "version": "0.1703.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.2.tgz", + "integrity": "sha512-w7rVFQcZK4iTCd/MLfQWIkDkwBOfAs++txNQyS9qYID8KvLs1V+oWYd2qDBRelRv1u3YtaCIS1pQx3GFKBC3OA==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1703.1", + "@angular-devkit/architect": "0.1703.2", "rxjs": "7.8.1" }, "engines": { @@ -1309,6 +1348,60 @@ "webpack-dev-server": "^4.0.0" } }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/architect": { + "version": "0.1703.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.2.tgz", + "integrity": "sha512-fT5gSzwDHOyGv8zF97t8rjeoYSGSxXjWWstl3rN1nXdO0qgJ5m6Sv0fupON+HltdXDCBLRH+2khNpqx/Fh0Qww==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "17.3.2", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/@angular-devkit/core": { + "version": "17.3.2", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.2.tgz", + "integrity": "sha512-1vxKo9+pdSwTOwqPDSYQh84gZYmCJo6OgR5+AZoGLGMZSeqvi9RG5RiUcOMLQYOnuYv0arlhlWxz0ZjyR8ApKw==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-formats": "2.1.1", + "jsonc-parser": "3.2.1", + "picomatch": "4.0.1", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.13.0 || >=20.9.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^3.5.2" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/picomatch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", + "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -5613,9 +5706,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.1.tgz", - "integrity": "sha512-6qRYFN6DqogZK0ZFrSlhg1OsIWm3lL3m+/Ixoj6/MLLjDBrTtHqmI93vg6P1EKYTH4fWChL7jtv7iS/LSZubgw==", + "version": "17.3.2", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.2.tgz", + "integrity": "sha512-E8zejFF4aJ8l2XcF+GgnE/1IqsZepnPT1xzulLB4LXtjVuXLFLoF9xkHQwxs7cJWWZsxd/SlNsCIcn/ezrYBcQ==", "dev": true, "engines": { "node": "^18.13.0 || >=20.9.0", @@ -6493,15 +6586,6 @@ "dev": true, "peer": true }, - "node_modules/@types/jsonwebtoken": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", - "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7921,11 +8005,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -9185,14 +9264,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -10337,9 +10408,9 @@ "dev": true }, "node_modules/express": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.1.tgz", - "integrity": "sha512-K4w1/Bp7y8iSiVObmCrtq8Cs79XjJc/RU2YYkZQ7wpUu5ZyZ7MtPHkqoMz4pf+mgXfNvo2qft8D9OnrH2ABk9w==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dev": true, "dependencies": { "accepts": "~1.3.8", @@ -10393,12 +10464,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "dev": true - }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -13988,51 +14053,6 @@ "node >= 0.2.0" ] }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" - }, "node_modules/karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", @@ -14251,36 +14271,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "dev": true }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -14292,11 +14282,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -15761,6 +15746,12 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -16775,6 +16766,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -18556,19 +18548,20 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.2.tgz", - "integrity": "sha512-Wu+EHmX326YPYUpQLKmKbTyZZJIB8/n6R09pTmB03kJmnMsVPTo9COzHZFr01txwaCAuZvfBJE4ZCHRcKs5JaQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.1.1.tgz", + "integrity": "sha512-NmRVq4AvRQs66dFWyDR4GsFDJggtSi2Yn38MXLk0nffgF9n/AIP4TFBg2TQKYaRAN4sHuKOTiz9BnNCENDLEVA==", "dev": true, "dependencies": { "colorette": "^2.0.10", - "memfs": "^3.4.12", + "memfs": "^4.6.0", "mime-types": "^2.1.31", + "on-finished": "^2.4.1", "range-parser": "^1.2.1", "schema-utils": "^4.0.0" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", @@ -18583,6 +18576,22 @@ } } }, + "node_modules/webpack-dev-middleware/node_modules/memfs": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.8.0.tgz", + "integrity": "sha512-fcs7trFxZlOMadmTw5nyfOwS3il9pr3y+6xzLfXNwmuR/D0i4wz6rJURxArAbcJDGalbpbMvQ/IFI0NojRZgRg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, "node_modules/webpack-dev-server": { "version": "4.15.1", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", diff --git a/code/gms-frontend/package.json b/code/gms-frontend/package.json index 14adfbee..1a5f7e56 100644 --- a/code/gms-frontend/package.json +++ b/code/gms-frontend/package.json @@ -30,8 +30,6 @@ "crypto-js": "^4.2.0", "follow-redirects": "1.15.6", "jest-environment-jsdom": "^29.7.0", - "jsonwebtoken": "^9.0.2", - "jwt-decode": "3.1.2", "moment": "^2.29.4", "randomstring": "^1.3.0", "rxjs": "7.5.7", @@ -41,12 +39,11 @@ "zone.js": "^0.14.4" }, "devDependencies": { - "@angular-devkit/build-angular": "17.3.1", + "@angular-devkit/build-angular": "17.3.2", "@angular/cli": "17.3.1", "@angular/compiler-cli": "17.3.1", "@types/crypto-js": "^4.1.3", "@types/jest": "^29.5.6", - "@types/jsonwebtoken": "^8.5.9", "@types/node": "^16.18.59", "@types/randomstring": "^1.1.10", "@typescript-eslint/eslint-plugin": "5.59.8", @@ -62,12 +59,12 @@ "jest-html-reporter": "3.10.2", "jest-junit": "16.0.0", "jest-preset-angular": "^14.0.3", - "webpack-dev-middleware": "7.1.1", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "webpack-dev-middleware": "7.1.1" }, "author": { "name": "Peter Szrnka", "email": "szrnka.peter@gmail.com", "url": "https://github.com/peter-szrnka" } -} \ No newline at end of file +} diff --git a/code/gms-frontend/src/app/app.component.spec.ts b/code/gms-frontend/src/app/app.component.spec.ts index 37bffd22..21abcd73 100644 --- a/code/gms-frontend/src/app/app.component.spec.ts +++ b/code/gms-frontend/src/app/app.component.spec.ts @@ -91,7 +91,7 @@ describe('AppComponent', () => { it('User is admin', () => { currentUser = { - roles : ["ROLE_ADMIN"], + role : "ROLE_ADMIN", username : "test1", id : 1 }; @@ -111,7 +111,7 @@ describe('AppComponent', () => { ])('User is a normal user with nav setting=%s', (showTexts : boolean) => { localStorage.setItem('showTextsInSidevNav', showTexts.toString()); currentUser = { - roles : ["ROLE_USER"], + role : "ROLE_USER", username : "test1", id : 1 }; @@ -145,7 +145,6 @@ describe('AppComponent', () => { fixture.autoDetectChanges(); // act & assert - expect(router.navigate).toHaveBeenCalledTimes(1); expect(splashScreenStateService.start).toHaveBeenCalled(); expect(splashScreenStateService.stop).toHaveBeenCalled(); }); @@ -164,15 +163,32 @@ describe('AppComponent', () => { expect(splashScreenStateService.stop).toHaveBeenCalled(); }); - it('should log out', () => { + it('should not log out', () => { sharedDataService.getUserInfo = jest.fn().mockReturnValue(undefined); configureTestBed(); mockSubject.next(undefined); - mockSystemReadySubject.next({ ready: false, status: 403, authMode : 'db' }); + mockSystemReadySubject.next({ ready: false, status: 500, authMode : 'db' }); fixture.detectChanges(); // act & assert - expect(router.navigate).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledTimes(0); + expect(splashScreenStateService.start).toHaveBeenCalled(); + expect(splashScreenStateService.stop).toHaveBeenCalled(); + }); + + it('Should navigate to main page', () => { + currentUser = { + role : "ROLE_ADMIN", + username : "test1", + id : 1 + }; + router.url = '/login'; + mockSubject.next(currentUser); + mockSystemReadySubject.next({ ready: true, status: 200, authMode : 'db' }); + configureTestBed(); + + // act & assert + expect(component.isAdmin).toEqual(true); expect(splashScreenStateService.start).toHaveBeenCalled(); expect(splashScreenStateService.stop).toHaveBeenCalled(); }); diff --git a/code/gms-frontend/src/app/app.component.ts b/code/gms-frontend/src/app/app.component.ts index 3adf6b7a..de66fe37 100644 --- a/code/gms-frontend/src/app/app.component.ts +++ b/code/gms-frontend/src/app/app.component.ts @@ -39,7 +39,6 @@ export class AppComponent implements OnInit, OnDestroy { this.sharedDataService.systemReadySubject$.subscribe(readyData => { if (readyData.status !== 200) { - this.navigateToLogin(); return; } @@ -49,21 +48,29 @@ export class AppComponent implements OnInit, OnDestroy { } this.systemReady = readyData.ready; - }); - - this.sharedDataService.userSubject$.subscribe(user => { - if ((!user?.roles) && (!this.router.url.startsWith(LOGIN_CALLBACK_URL))) { - this.navigateToLogin(); - return; - } - this.currentUser = user; - this.isAdmin = this.currentUser!! && roleCheck(this.currentUser, 'ROLE_ADMIN'); + this.processUserSubject(); }); this.sharedDataService.check(); } + private processUserSubject() : void { + this.sharedDataService.userSubject$.asObservable().subscribe(user => { + this.currentUser = user; + this.isAdmin = !this.currentUser ? false : roleCheck(this.currentUser, 'ROLE_ADMIN'); + + if (!this.currentUser && (!this.router.url.startsWith(LOGIN_CALLBACK_URL))) { + this.navigateToLogin(); + return; + } + + if (this.currentUser && this.router.url.startsWith(LOGIN_CALLBACK_URL)) { + this.router.navigate(['']); + } + }); + } + ngOnDestroy(): void { this.unsubscribe.next(); } diff --git a/code/gms-frontend/src/app/common/components/abstractions/component/base-login.component.ts b/code/gms-frontend/src/app/common/components/abstractions/component/base-login.component.ts index 8ea188a7..8a74ffed 100644 --- a/code/gms-frontend/src/app/common/components/abstractions/component/base-login.component.ts +++ b/code/gms-frontend/src/app/common/components/abstractions/component/base-login.component.ts @@ -48,10 +48,10 @@ export abstract class BaseLoginComponent implements OnInit { protected finalizeSuccessfulLogin(currentUser : User) { this.splashScreenStateService.stop(); - this.sharedDataService.userSubject$.next(currentUser); + this.sharedDataService.refreshCurrentUserInfo(); const nextUrl: string = this.getPreviousUrl(); const expectedRoles = nextUrl ? ROLE_ROUTE_MAP[nextUrl.substring(1)] : []; - const canActivate = checker(expectedRoles, currentUser.roles); + const canActivate = checker(expectedRoles, currentUser.role); void this.router.navigate([canActivate ? nextUrl : '']); } diff --git a/code/gms-frontend/src/app/common/interceptor/auth-interceptor.spec.ts b/code/gms-frontend/src/app/common/interceptor/auth-interceptor.spec.ts index 16b69df8..921f8f23 100644 --- a/code/gms-frontend/src/app/common/interceptor/auth-interceptor.spec.ts +++ b/code/gms-frontend/src/app/common/interceptor/auth-interceptor.spec.ts @@ -36,7 +36,7 @@ describe('AuthInterceptor', () => { interceptor.intercept(req, handler).subscribe(); // assert - expect(sharedData.logout).toBeCalledTimes(0); + expect(sharedData.clearData).toHaveBeenCalledTimes(0); }); it('should not proceed because of 0', () => { @@ -48,7 +48,6 @@ describe('AuthInterceptor', () => { // assert expect(sharedData.clearData).toHaveBeenCalled(); - expect(sharedData.logout).toHaveBeenCalled(); }); it('should not proceed because of 401', () => { @@ -59,7 +58,7 @@ describe('AuthInterceptor', () => { interceptor.intercept(req, handler).subscribe(); // assert - expect(sharedData.logout).toHaveBeenCalledTimes(1); + expect(sharedData.clearData).toHaveBeenCalled(); }); it('should not proceed because of 403', () => { @@ -70,7 +69,7 @@ describe('AuthInterceptor', () => { interceptor.intercept(req, handler).subscribe(); // assert - expect(sharedData.logout).toBeCalledTimes(1); + expect(sharedData.clearData).toHaveBeenCalled(); }); it('should not proceed because of 500', () => { @@ -81,7 +80,7 @@ describe('AuthInterceptor', () => { interceptor.intercept(req, handler).subscribe(); // assert - expect(sharedData.logout).toBeCalledTimes(0); + expect(sharedData.clearData).toHaveBeenCalledTimes(0); }); function createHandler(errorMessage : string, statusCode : number) : any { diff --git a/code/gms-frontend/src/app/common/interceptor/auth-interceptor.ts b/code/gms-frontend/src/app/common/interceptor/auth-interceptor.ts index 93486bf1..e5a1d542 100644 --- a/code/gms-frontend/src/app/common/interceptor/auth-interceptor.ts +++ b/code/gms-frontend/src/app/common/interceptor/auth-interceptor.ts @@ -23,7 +23,6 @@ export class AuthInterceptor implements HttpInterceptor { private handleAuthError(err: HttpErrorResponse): Observable { if (err.status === 0 || err.status === 401 || err.status === 403) { this.sharedData.clearData(); - this.sharedData.logout(); return of(err.message); } diff --git a/code/gms-frontend/src/app/common/interceptor/role-guard.spec.ts b/code/gms-frontend/src/app/common/interceptor/role-guard.spec.ts index f1aa0f90..ed392a53 100644 --- a/code/gms-frontend/src/app/common/interceptor/role-guard.spec.ts +++ b/code/gms-frontend/src/app/common/interceptor/role-guard.spec.ts @@ -13,7 +13,7 @@ describe('RoleGuard', () => { userId : "test", userName : "test-user", exp : 1, - roles : ["ROLE_USER"] + role : "ROLE_USER" }; const configureTestBed = () => { diff --git a/code/gms-frontend/src/app/common/interceptor/role-guard.ts b/code/gms-frontend/src/app/common/interceptor/role-guard.ts index 07bd4454..d4dc4354 100644 --- a/code/gms-frontend/src/app/common/interceptor/role-guard.ts +++ b/code/gms-frontend/src/app/common/interceptor/role-guard.ts @@ -3,17 +3,8 @@ import { ActivatedRouteSnapshot } from "@angular/router"; import { User } from "../../components/user/model/user.model"; import { SharedDataService } from "../service/shared-data-service"; -export const checker = (arr: string[], target: string[]) => { - let result = false; - arr.forEach(arrElement => { - target.forEach(targetElement => { - if (arrElement === targetElement) { - result = true; - } - }); - }); - - return result; +export const checker = (arr: string[], target: string) => { + return arr.filter(arrElement => arrElement === target).length === 1; }; /** @@ -29,5 +20,5 @@ export const ROLE_GUARD = async (route: ActivatedRouteSnapshot): Promise { const expectedUrl = environment.baseUrl + "authenticate"; const mockResponse: LoginResponse = { currentUser: { - roles: ["ROLE_USER"] + role: "ROLE_USER" }, phase: AuthenticationPhase.COMPLETED }; @@ -53,7 +53,7 @@ describe('AuthService', () => { const expectedUrl = environment.baseUrl + "verify"; const mockResponse: LoginResponse = { currentUser: { - roles: ["ROLE_USER"] + role: "ROLE_USER" }, phase: AuthenticationPhase.COMPLETED }; diff --git a/code/gms-frontend/src/app/common/service/info-service.spec.ts b/code/gms-frontend/src/app/common/service/info-service.spec.ts index 043f7b16..26f1a708 100644 --- a/code/gms-frontend/src/app/common/service/info-service.spec.ts +++ b/code/gms-frontend/src/app/common/service/info-service.spec.ts @@ -26,7 +26,7 @@ describe('InformationService', () => { const mockResponse: User = { id: 1, username: 'test', - roles: ["ROLE_USER"] + role: "ROLE_USER" }; diff --git a/code/gms-frontend/src/app/common/service/shared-data-service.spec.ts b/code/gms-frontend/src/app/common/service/shared-data-service.spec.ts index 4cd6f1ca..bf79ee84 100644 --- a/code/gms-frontend/src/app/common/service/shared-data-service.spec.ts +++ b/code/gms-frontend/src/app/common/service/shared-data-service.spec.ts @@ -95,7 +95,7 @@ describe('SharedDataService', () => { it.each([ [ { - roles: ["ROLE_ADMIN"], + role: 'ROLE_ADMIN', userName: "test1", userId: 1 } @@ -208,12 +208,16 @@ describe('SharedDataService', () => { it('should log out', () => { // act & assert + router.url = "/test"; configureTestBed(); mockSubject.next(currentUser); + + // act service.logout(); + // assert expect(authService.logout).toHaveBeenCalled(); - expect(router.navigate).toHaveBeenCalledWith(['/login']); + expect(router.navigate).toHaveBeenCalledTimes(0); }); it('should not log out again', () => { diff --git a/code/gms-frontend/src/app/common/service/shared-data-service.ts b/code/gms-frontend/src/app/common/service/shared-data-service.ts index a5e9bf84..00a34029 100644 --- a/code/gms-frontend/src/app/common/service/shared-data-service.ts +++ b/code/gms-frontend/src/app/common/service/shared-data-service.ts @@ -47,7 +47,6 @@ export class SharedDataService { return; } - void this.router.navigate(['/login']); this.authService.logout().subscribe(() => this.clearData()); } diff --git a/code/gms-frontend/src/app/common/utils/permission-utils.spec.ts b/code/gms-frontend/src/app/common/utils/permission-utils.spec.ts index ed5975f0..463ff053 100644 --- a/code/gms-frontend/src/app/common/utils/permission-utils.spec.ts +++ b/code/gms-frontend/src/app/common/utils/permission-utils.spec.ts @@ -18,25 +18,13 @@ describe("Permission utils", () => { expect(checkRights(undefined)).toBeTruthy(); }); - it('User role is undefined', () => { - const user : User = { - roles: [] - }; - expect(checkRights(user)).toBeFalsy(); - }); - - it('Admin rights are undefined', () => { - const user : User = { roles : ["ROLE_USER"] }; - expect(checkRights(user)).toBeFalsy(); - }); - it('User rights', () => { - const user : User = { roles : ["ROLE_USER"] }; + const user : User = { role : "ROLE_USER" }; expect(checkRights(user, true)).toBeTruthy(); }); it('Admin rights', () => { - const user : User = { roles : ["ROLE_ADMIN"] }; + const user : User = { role : "ROLE_ADMIN" }; expect(checkRights(user, false)).toBeTruthy(); }); }); \ No newline at end of file diff --git a/code/gms-frontend/src/app/common/utils/permission-utils.ts b/code/gms-frontend/src/app/common/utils/permission-utils.ts index 78c51e97..515b525b 100644 --- a/code/gms-frontend/src/app/common/utils/permission-utils.ts +++ b/code/gms-frontend/src/app/common/utils/permission-utils.ts @@ -4,7 +4,7 @@ import { User } from "../../components/user/model/user.model"; * @author Peter Szrnka */ export function checkRights(user?: User, requireAdminRights?: boolean): boolean { - if (user === undefined || user.roles === undefined) { + if (!user) { return true; } @@ -13,10 +13,10 @@ export function checkRights(user?: User, requireAdminRights?: boolean): boolean } if (requireAdminRights) { - return user.roles.filter((role) => role === 'ROLE_ADMIN').length === 0; + return user.role !== 'ROLE_ADMIN'; } - return user.roles.filter((role) => role === 'ROLE_ADMIN').length > 0; + return user.role === 'ROLE_ADMIN'; } export function isSpecificUser(roles: string[], requiredRole: string): boolean { @@ -24,5 +24,5 @@ export function isSpecificUser(roles: string[], requiredRole: string): boolean { } export function roleCheck(currentUser: User, roleName: string): boolean { - return currentUser.roles?.filter(role => role === roleName).length > 0; + return currentUser.role === roleName; } \ No newline at end of file diff --git a/code/gms-frontend/src/app/components/header/header.component.spec.ts b/code/gms-frontend/src/app/components/header/header.component.spec.ts index b2beeac8..f15c20d7 100644 --- a/code/gms-frontend/src/app/components/header/header.component.spec.ts +++ b/code/gms-frontend/src/app/components/header/header.component.spec.ts @@ -92,8 +92,8 @@ describe('HeaderComponent', () => { fixture = TestBed.createComponent(HeaderComponent); component = fixture.componentInstance; - component.currentUser = { - roles: ["ROLE_ADMIN"], + currentUser = { + role: "ROLE_ADMIN", username: "test1", id: 1 }; @@ -115,8 +115,8 @@ describe('HeaderComponent', () => { fixture = TestBed.createComponent(HeaderComponent); component = fixture.componentInstance; - component.currentUser = { - roles: ["ROLE_ADMIN"], + currentUser = { + role: "ROLE_ADMIN", username: "test1", id: 1 }; diff --git a/code/gms-frontend/src/app/components/header/header.component.ts b/code/gms-frontend/src/app/components/header/header.component.ts index 1899ea83..d5d3ef97 100644 --- a/code/gms-frontend/src/app/components/header/header.component.ts +++ b/code/gms-frontend/src/app/components/header/header.component.ts @@ -53,6 +53,8 @@ export class HeaderComponent implements OnInit, OnDestroy { logout() : void { this.currentUser = undefined; this.sharedDataService.logout(); + this.userSubscription.unsubscribe(); + this.unreadSubscription.unsubscribe(); } toggleMenu() : void { diff --git a/code/gms-frontend/src/app/components/home/home.component.spec.ts b/code/gms-frontend/src/app/components/home/home.component.spec.ts index 5368c15d..675c6ba1 100644 --- a/code/gms-frontend/src/app/components/home/home.component.spec.ts +++ b/code/gms-frontend/src/app/components/home/home.component.spec.ts @@ -1,15 +1,14 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterTestingModule } from "@angular/router/testing"; +import { ReplaySubject, Subscription, of } from "rxjs"; import { AngularMaterialModule } from "../../angular-material-module"; import { PipesModule } from "../../common/components/pipes/pipes.module"; -import { HomeComponent } from "./home.component"; -import { HomeService } from "./service/home.service"; import { SharedDataService } from "../../common/service/shared-data-service"; -import { ReplaySubject, Subscription, of, throwError } from "rxjs"; import { User } from "../user/model/user.model"; +import { HomeComponent } from "./home.component"; import { HomeData } from "./model/home-data.model"; -import { HttpErrorResponse } from "@angular/common/http"; +import { HomeService } from "./service/home.service"; /** * @author Peter Szrnka @@ -60,7 +59,7 @@ describe('HomeComponent', () => { it('should load component for admin', () => { const currentUser = { - roles: ["ROLE_ADMIN"], + role: "ROLE_ADMIN", username: "test1", id: 1 }; diff --git a/code/gms-frontend/src/app/components/home/home.component.ts b/code/gms-frontend/src/app/components/home/home.component.ts index 15a89c76..407deed8 100644 --- a/code/gms-frontend/src/app/components/home/home.component.ts +++ b/code/gms-frontend/src/app/components/home/home.component.ts @@ -71,7 +71,7 @@ export class HomeComponent implements OnInit, OnDestroy { ...this.data }; - this.data.role = user?.roles[0]; + this.data.role = user?.role; }); this.authModeSubcription = this.sharedData.authModeSubject$.subscribe(authMode => this.editEnabled = authMode === 'db'); } diff --git a/code/gms-frontend/src/app/components/keystore/keystore-list.component.spec.ts b/code/gms-frontend/src/app/components/keystore/keystore-list.component.spec.ts index ce440a92..21a20fbb 100644 --- a/code/gms-frontend/src/app/components/keystore/keystore-list.component.spec.ts +++ b/code/gms-frontend/src/app/components/keystore/keystore-list.component.spec.ts @@ -17,7 +17,7 @@ import { KeystoreListComponent } from "./keystore-list.component"; describe('KeystoreListComponent', () => { let component : KeystoreListComponent; const currentUser : User = { - roles : ["ROLE_USER" ] + role: 'ROLE_USER' }; // Injected services let service : any; diff --git a/code/gms-frontend/src/app/components/login/login.component.spec.ts b/code/gms-frontend/src/app/components/login/login.component.spec.ts index 03ed9ffe..482fd872 100644 --- a/code/gms-frontend/src/app/components/login/login.component.spec.ts +++ b/code/gms-frontend/src/app/components/login/login.component.spec.ts @@ -1,18 +1,18 @@ import { HttpErrorResponse } from "@angular/common/http"; +import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormsModule } from "@angular/forms"; import { MatDialog } from "@angular/material/dialog"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; -import { ReplaySubject, of, throwError } from "rxjs"; +import { of, throwError } from "rxjs"; import { AngularMaterialModule } from "../../angular-material-module"; import { AuthenticationPhase, Login, LoginResponse } from "../../common/model/login.model"; import { AuthService } from "../../common/service/auth-service"; import { SharedDataService } from "../../common/service/shared-data-service"; import { SplashScreenStateService } from "../../common/service/splash-screen-service"; -import { EMPTY_USER, User } from "../user/model/user.model"; +import { EMPTY_USER } from "../user/model/user.model"; import { LoginComponent } from "./login.component"; -import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from "@angular/core"; /** * @author Peter Szrnka @@ -20,7 +20,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from "@angular/core"; describe('LoginComponent', () => { let component : LoginComponent; let fixture : ComponentFixture; - let mockSubject : ReplaySubject; // Injected services let router : any; let authService : any; @@ -56,10 +55,8 @@ describe('LoginComponent', () => { navigate : jest.fn().mockReturnValue(of(true)) }; - mockSubject = new ReplaySubject(); sharedDataService = { refreshCurrentUserInfo : jest.fn(), - userSubject$ : mockSubject, systemReady: true }; @@ -76,7 +73,7 @@ describe('LoginComponent', () => { login : jest.fn().mockImplementation(() => { return of({ currentUser: { - roles: [] + role: "ROLE_USER" }, phase: AuthenticationPhase.COMPLETED }); @@ -96,29 +93,23 @@ describe('LoginComponent', () => { }); it.each([ - [undefined, []], + [undefined, "ROLE_USER"], [{ previousUrl: '/' - }, ['ROLE_USER']], - [{ - previousUrl: '/secret/list' - }, []], - [{ - previousUrl: '/users/list' - }, []], + }, 'ROLE_USER'], [{ previousUrl: '/secret/list' - }, ['ROLE_ADMIN']], + }, 'ROLE_ADMIN'], [{ previousUrl: '/users/list' - }, ['ROLE_ADMIN']], + }, 'ROLE_ADMIN'], [{ previousUrl: '/secret/list' - }, ['ROLE_USER']], + }, 'ROLE_USER'], [{ previousUrl: '/users/list' - }, ['ROLE_USER']] - ])('Should create component and login with redirect', (inputQueryParam: any, inputRoles: string[]) => { + }, 'ROLE_USER'] + ])('Should create component and login with redirect', (inputQueryParam: any, inputRole: string) => { // arrange activatedRoute = { snapshot : { @@ -129,7 +120,7 @@ describe('LoginComponent', () => { login : jest.fn().mockImplementation(() => { return of({ currentUser: { - roles: inputRoles + role: inputRole }, phase: AuthenticationPhase.COMPLETED }); @@ -137,7 +128,6 @@ describe('LoginComponent', () => { }; configTestBed(); - mockSubject.next(undefined); component.formModel = { username: "user-1", credential : "myPassword1" }; // act @@ -160,7 +150,6 @@ describe('LoginComponent', () => { } }; configTestBed(); - mockSubject.next(undefined); component.formModel = { username: "user-1", credential : "myPassword1" }; // act @@ -174,24 +163,6 @@ describe('LoginComponent', () => { expect(component.showPassword).toBeTruthy(); }); - it('Should redirect to main page', () => { - // arrange - activatedRoute = { - snapshot : { - queryParams: { - } - } - }; - configTestBed(); - - // act - mockSubject.next({ id: 1, username: 'test', roles: ['ROLE_USER'] } as User); - - // assert - expect(component).toBeTruthy(); - expect(router.navigate).toHaveBeenCalled(); - }); - it('Should not redirect to main page when system is not ready', () => { // arrange activatedRoute = { @@ -203,7 +174,6 @@ describe('LoginComponent', () => { configTestBed(); // act - mockSubject.next(undefined); sharedDataService.systemReady = false; // assert @@ -221,7 +191,7 @@ describe('LoginComponent', () => { } }; const mockResponse: LoginResponse = { - currentUser: { username: 'test', roles: [] }, + currentUser: { username: 'test', role: 'ROLE_USER' }, phase: AuthenticationPhase.MFA_REQUIRED }; authService = { @@ -263,7 +233,6 @@ describe('LoginComponent', () => { }) }; configTestBed(); - mockSubject.next(undefined); component.formModel = { username: "user-1", credential : "myPassword1" }; // act @@ -279,7 +248,7 @@ describe('LoginComponent', () => { it('Should require MFA', () => { // arrange const mockResponse: LoginResponse = { - currentUser: { username: 'test', roles: [] }, + currentUser: { username: 'test', role: 'ROLE_USER' }, phase: AuthenticationPhase.MFA_REQUIRED }; authService = { @@ -288,7 +257,6 @@ describe('LoginComponent', () => { }) }; configTestBed(); - mockSubject.next(undefined); component.formModel = { username: "user-1", credential : "myPassword1" }; // act @@ -309,7 +277,6 @@ describe('LoginComponent', () => { }; configTestBed(); - mockSubject.next(undefined); component.formModel = { username: "user-1", credential : "myPassword1" }; // act diff --git a/code/gms-frontend/src/app/components/login/login.component.ts b/code/gms-frontend/src/app/components/login/login.component.ts index ef7c83a7..7d4ef6ff 100644 --- a/code/gms-frontend/src/app/components/login/login.component.ts +++ b/code/gms-frontend/src/app/components/login/login.component.ts @@ -1,13 +1,12 @@ -import { Component, OnDestroy } from "@angular/core"; +import { Component } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; -import { Subscription, catchError } from "rxjs"; +import { catchError } from "rxjs"; import { BaseLoginComponent } from "../../common/components/abstractions/component/base-login.component"; import { AuthenticationPhase, Login, LoginResponse } from "../../common/model/login.model"; import { AuthService } from "../../common/service/auth-service"; import { SharedDataService } from "../../common/service/shared-data-service"; import { SplashScreenStateService } from "../../common/service/splash-screen-service"; -import { User } from "../user/model/user.model"; /** * @author Peter Szrnka @@ -17,14 +16,13 @@ import { User } from "../user/model/user.model"; templateUrl: './login.component.html', styleUrls: ['./login.component.scss'] }) -export class LoginComponent extends BaseLoginComponent implements OnDestroy { +export class LoginComponent extends BaseLoginComponent { formModel: Login = { username: undefined, credential: undefined }; showPassword: boolean = false; - userSubscription: Subscription; constructor( protected override route: ActivatedRoute, @@ -36,22 +34,6 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { super(route, router, sharedDataService, dialog, splashScreenStateService) } - override ngOnInit(): void { - super.ngOnInit(); - this.userSubscription = this.sharedDataService.userSubject$.subscribe((user: User | undefined) => { - if (!user || !this.sharedDataService.systemReady) { - return; - } - - this.router.navigate(['']); - }); - - } - - ngOnDestroy(): void { - this.userSubscription.unsubscribe(); - } - login(): void { this.splashScreenStateService.start(); diff --git a/code/gms-frontend/src/app/components/password_reset/request-password-reset.component.ts b/code/gms-frontend/src/app/components/password_reset/request-password-reset.component.ts index 28eb87aa..8a84243b 100644 --- a/code/gms-frontend/src/app/components/password_reset/request-password-reset.component.ts +++ b/code/gms-frontend/src/app/components/password_reset/request-password-reset.component.ts @@ -29,9 +29,8 @@ export class RequestPasswordResetComponent { this.splashScreenStateService.stop(); this.router.navigate(['/login']); }, - error: (err) => { + error: () => { this.splashScreenStateService.stop(); - console.error(err); this.showErrorModal(); }, }); diff --git a/code/gms-frontend/src/app/components/settings/settings-summary.component.html b/code/gms-frontend/src/app/components/settings/settings-summary.component.html index 4444e850..13e95fd0 100644 --- a/code/gms-frontend/src/app/components/settings/settings-summary.component.html +++ b/code/gms-frontend/src/app/components/settings/settings-summary.component.html @@ -3,10 +3,13 @@

Settings

-
+
Your password is managed in LDAP
- +
+ Your password is managed in SSO +
+ Password @@ -35,7 +38,7 @@

Settings

- + Multi-Factor Authentication diff --git a/code/gms-frontend/src/app/components/settings/settings-summary.component.ts b/code/gms-frontend/src/app/components/settings/settings-summary.component.ts index 4e087c13..31dd003b 100644 --- a/code/gms-frontend/src/app/components/settings/settings-summary.component.ts +++ b/code/gms-frontend/src/app/components/settings/settings-summary.component.ts @@ -30,7 +30,7 @@ export class SettingsSummaryComponent implements OnInit { newCredential1: undefined, newCredential2: undefined }; - passwordEnabled = true; + authMode = ''; mfaEnabled = false; showQrCode = false; @@ -41,7 +41,7 @@ export class SettingsSummaryComponent implements OnInit { private splashScreenService : SplashScreenStateService) { } ngOnInit(): void { - this.sharedData.authModeSubject$.subscribe(authMode => this.passwordEnabled = authMode === 'db'); + this.sharedData.authModeSubject$.subscribe(authMode => this.authMode = authMode); this.userService.isMfaActive().subscribe(response => this.mfaEnabled = response); } diff --git a/code/gms-frontend/src/app/components/setup/service/setup-service.spec.ts b/code/gms-frontend/src/app/components/setup/service/setup-service.spec.ts index 331f150b..f13ec781 100644 --- a/code/gms-frontend/src/app/components/setup/service/setup-service.spec.ts +++ b/code/gms-frontend/src/app/components/setup/service/setup-service.spec.ts @@ -47,7 +47,7 @@ describe('SetupService', () => { // act const request : UserData = { - id : 1, status : "ACTIVE", name : "test", roles : [] + id : 1, status : "ACTIVE", name : "test", role : 'USER_ADMIN' }; service.saveAdminUser(request).subscribe((res) => expect(res).toBe(mockResponse)); diff --git a/code/gms-frontend/src/app/components/setup/setup.component.spec.ts b/code/gms-frontend/src/app/components/setup/setup.component.spec.ts index 7443be25..a4a1b9fb 100644 --- a/code/gms-frontend/src/app/components/setup/setup.component.spec.ts +++ b/code/gms-frontend/src/app/components/setup/setup.component.spec.ts @@ -80,7 +80,7 @@ describe('SetupComponent', () => { component.userData = { username : "admin", credential : "testPassword", - roles : [] + role: 'ROLE_ADMIN' }; // act @@ -102,7 +102,7 @@ describe('SetupComponent', () => { component.userData = { username : "admin", credential : "testPassword", - roles : [] + role: 'ROLE_ADMIN' }; // act @@ -121,7 +121,7 @@ describe('SetupComponent', () => { component.userData = { username : "admin", credential : "testPassword", - roles : [] + role: 'ROLE_ADMIN' }; // act diff --git a/code/gms-frontend/src/app/components/setup/setup.component.ts b/code/gms-frontend/src/app/components/setup/setup.component.ts index 48e30cbe..b23847ff 100644 --- a/code/gms-frontend/src/app/components/setup/setup.component.ts +++ b/code/gms-frontend/src/app/components/setup/setup.component.ts @@ -27,6 +27,7 @@ export class SetupComponent { saveAdminUser() { this.splashScreenService.start(); + this.userData.role = 'ROLE_ADMIN'; this.setupService.saveAdminUser(this.userData) .subscribe({ next: () => { diff --git a/code/gms-frontend/src/app/components/user/model/user-data.model.ts b/code/gms-frontend/src/app/components/user/model/user-data.model.ts index 72cbd1f2..09aebb67 100644 --- a/code/gms-frontend/src/app/components/user/model/user-data.model.ts +++ b/code/gms-frontend/src/app/components/user/model/user-data.model.ts @@ -12,12 +12,12 @@ export interface UserData extends BaseDetail { id? : number, status? : string, creationDate? : Date, - roles : string[] + role : string } export const EMPTY_USER_DATA : UserData = { credential: undefined, - roles: [] + role: 'ROLE_USER' }; export const PAGE_CONFIG_USER : PageConfig = { diff --git a/code/gms-frontend/src/app/components/user/model/user.model.ts b/code/gms-frontend/src/app/components/user/model/user.model.ts index 5389cc67..c863dffb 100644 --- a/code/gms-frontend/src/app/components/user/model/user.model.ts +++ b/code/gms-frontend/src/app/components/user/model/user.model.ts @@ -4,9 +4,9 @@ export interface User { id? : number, username? : string, - roles : string[] + role : string } export const EMPTY_USER : User = { - roles : [] + role: 'EMPTY' }; \ No newline at end of file diff --git a/code/gms-frontend/src/app/components/user/resolver/user-detail.resolver.spec.ts b/code/gms-frontend/src/app/components/user/resolver/user-detail.resolver.spec.ts index b50a8384..5aa49a8d 100644 --- a/code/gms-frontend/src/app/components/user/resolver/user-detail.resolver.spec.ts +++ b/code/gms-frontend/src/app/components/user/resolver/user-detail.resolver.spec.ts @@ -19,7 +19,7 @@ describe('UserDetailResolver', () => { let sharedData: any; const mockResponse: UserData = { - roles: [] + role: 'ROLE_USER' }; beforeEach(async () => { diff --git a/code/gms-frontend/src/app/components/user/resolver/user-list.resolver.spec.ts b/code/gms-frontend/src/app/components/user/resolver/user-list.resolver.spec.ts index 0630bf91..4d967ca6 100644 --- a/code/gms-frontend/src/app/components/user/resolver/user-list.resolver.spec.ts +++ b/code/gms-frontend/src/app/components/user/resolver/user-list.resolver.spec.ts @@ -20,7 +20,7 @@ describe('UserListResolver', () => { const mockResponse: UserData[] = [{ id: 1, status: "ACTIVE", - roles: [] + role: 'ROLE_USER' }]; const configureTestBed = () => { diff --git a/code/gms-frontend/src/app/components/user/service/user-service.spec.ts b/code/gms-frontend/src/app/components/user/service/user-service.spec.ts index 61e81a26..72d30091 100644 --- a/code/gms-frontend/src/app/components/user/service/user-service.spec.ts +++ b/code/gms-frontend/src/app/components/user/service/user-service.spec.ts @@ -8,7 +8,7 @@ import { Paging } from "../../../common/model/paging.model"; const TEST_USER : User = { id: 1, - roles: [] + role: 'ROLE_USER' }; /** diff --git a/code/gms-frontend/src/app/components/user/user-detail.component.html b/code/gms-frontend/src/app/components/user/user-detail.component.html index 31bf0ce4..ba52ff62 100644 --- a/code/gms-frontend/src/app/components/user/user-detail.component.html +++ b/code/gms-frontend/src/app/components/user/user-detail.component.html @@ -8,8 +8,6 @@ - In order to make the application works, you must create an admin user. Please fill in the next - form. Full name @@ -37,23 +35,10 @@ - Allowed roles - - - {{role}} - - - - - - - {{role}} - - + Role + + {{role}} + diff --git a/code/gms-frontend/src/app/components/user/user-detail.component.spec.ts b/code/gms-frontend/src/app/components/user/user-detail.component.spec.ts index 8b36bec7..3026fb31 100644 --- a/code/gms-frontend/src/app/components/user/user-detail.component.spec.ts +++ b/code/gms-frontend/src/app/components/user/user-detail.component.spec.ts @@ -15,7 +15,6 @@ import { EventService } from "../event/service/event-service"; import { UserData } from "./model/user-data.model"; import { UserService } from "./service/user-service"; import { UserDetailComponent } from "./user-detail.component"; -import { MatChipInputEvent } from "@angular/material/chips"; /** * @author Peter Szrnka @@ -73,7 +72,7 @@ describe('UserDetailComponent', () => { entity : { id : 1, name : "Test User", - roles : [ "ROLE_USER" ], + role : "ROLE_USER", email : "test.email@mail.com", status : "ACTIVE", username : "user.name" @@ -97,16 +96,6 @@ describe('UserDetailComponent', () => { it('should save new entity', () => { // arrange - const chipInputMock : any = { - clear : jest.fn() - }; - - const matAutocompleteSelectedEvent : any = { - option : { - viewValue : "ROLE_ADMIN" - } - }; - eventServiceMock = { listByUserId : jest.fn().mockReturnValue(of(undefined)) }; @@ -115,7 +104,7 @@ describe('UserDetailComponent', () => { data : Data = of({ entity : { name : "Test User", - roles : [ "ROLE_USER" ], + role : "ROLE_USER" , email : "test.email@mail.com", status : "ACTIVE", username : "user.name" @@ -127,17 +116,6 @@ describe('UserDetailComponent', () => { // act authModeSubject.next("db"); - component.remove("ROLE_USER"); - - component.add({ value : " ROLE_ADMIN ", chipInput : chipInputMock } as MatChipInputEvent); - component.selected(matAutocompleteSelectedEvent); - component.remove("ROLE_ADMIN"); - - component.selected(matAutocompleteSelectedEvent); - component.remove("ROLE_ADMIN"); - - component.add({ value : ' ', chipInput : chipInputMock } as MatChipInputEvent); - component.save(); // assert @@ -147,32 +125,10 @@ describe('UserDetailComponent', () => { it('should save details', () => { // arrange - const chipInputMock : any = { - clear : jest.fn() - }; - - const matAutocompleteSelectedEvent : any = { - option : { - viewValue : "ROLE_ADMIN" - } - }; - configureTestBed(); // act authModeSubject.next("db"); - component.remove("ROLE_USER"); - - component.add({ value : "ROLE_ADMIN", chipInput : chipInputMock } as MatChipInputEvent); - component.add({ value : "ROLE_USER", chipInput : chipInputMock } as MatChipInputEvent); - component.selected(matAutocompleteSelectedEvent); - component.remove("ROLE_ADMIN"); - - component.selected(matAutocompleteSelectedEvent); - component.remove("ROLE_ADMIN"); - - component.add({ value : ' ', chipInput : chipInputMock } as MatChipInputEvent); - component.save(); // assert diff --git a/code/gms-frontend/src/app/components/user/user-detail.component.ts b/code/gms-frontend/src/app/components/user/user-detail.component.ts index 2d340282..77abadb8 100644 --- a/code/gms-frontend/src/app/components/user/user-detail.component.ts +++ b/code/gms-frontend/src/app/components/user/user-detail.component.ts @@ -1,12 +1,8 @@ import { ArrayDataSource } from "@angular/cdk/collections"; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; -import { Component, ElementRef, ViewChild } from "@angular/core"; -import { MatAutocompleteSelectedEvent } from "@angular/material/autocomplete"; -import { MatChipInputEvent } from '@angular/material/chips'; +import { Component } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { ActivatedRoute, Router } from "@angular/router"; import { BaseSaveableDetailComponent } from "../../common/components/abstractions/component/base-saveable-detail.component"; -import { InfoDialog } from "../../common/components/info-dialog/info-dialog.component"; import { PageConfig } from "../../common/model/common.model"; import { SharedDataService } from "../../common/service/shared-data-service"; import { SplashScreenStateService } from "../../common/service/splash-screen-service"; @@ -33,9 +29,6 @@ export class UserDetailComponent extends BaseSaveableDetailComponent; - addOnBlur = true; auto = true; selectableRoles = ALL_ROLES; eventList : Event[] = []; @@ -65,7 +58,6 @@ export class UserDetailComponent extends BaseSaveableDetailComponent { - return !this.data.roles.includes(item); - }); - } - getPageConfig(): PageConfig { return PAGE_CONFIG_USER; } - - selected(event: MatAutocompleteSelectedEvent): void { - if (this.data.roles.length === 1) { - this.showTooManyElementsDialog(); - return; - } - - this.data.roles.push(event.option.viewValue); - this.roleInput.nativeElement.value = ''; - this.refreshSelectableRoles(); - } - - add(event: MatChipInputEvent): void { - if (this.data.roles.length === 1) { - this.showTooManyElementsDialog(); - return; - } - - const value = event.value.trim(); - - if (value) { - this.data.roles.push(value); - } - - this.refreshSelectableRoles(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - event.chipInput.clear(); - } - - remove(role: string): void { - if (this.data.roles.includes(role)) { - this.data.roles.splice(this.data.roles.indexOf(role), 1); - } - - this.refreshSelectableRoles(); - } - - private showTooManyElementsDialog() { - this.dialog.open(InfoDialog, { data: { text: "Only one role can be added to the user!", type : 'warning' },}); - } } \ No newline at end of file diff --git a/code/gms-frontend/src/app/components/user/user-list.component.html b/code/gms-frontend/src/app/components/user/user-list.component.html index 3c72ae0b..00066ea1 100644 --- a/code/gms-frontend/src/app/components/user/user-list.component.html +++ b/code/gms-frontend/src/app/components/user/user-list.component.html @@ -1,10 +1,10 @@

Users

- - + + -
+
Users are managed in LDAP
@@ -37,7 +37,7 @@

Users

Roles - {{role}} + {{element.role}} @@ -47,13 +47,13 @@

Users

Operations   - - -