diff --git a/.gitignore b/.gitignore index 549e00a..4e0b5cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +logs/ +*.log +secret.env ### STS ### .apt_generated diff --git a/docker-compose.yml b/docker-compose.yml index ccc55e4..a3d2810 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: backend: image: openjdk:25-ea-4-jdk-oraclelinux9 + container_name: auth ports: - "8080:8080" env_file: @@ -9,13 +10,15 @@ services: - auth_db environment: - SPRING_DATASOURCE_URL=jdbc:postgresql://auth_db:5432/authDB + - SPRING_DATA_REDIS_HOST=redis_cache volumes: - ./target:/app - ./logs:/logs command: [ "java", "-jar", "/app/auth-0.0.1-SNAPSHOT.jar" ] auth_db: - image: postgres:latest + image: postgres:14.17 + container_name: auth_db environment: POSTGRES_PASSWORD: 1234 POSTGRES_USER: postgres @@ -24,13 +27,15 @@ services: - "5432:5432" loki: - image: grafana/loki:latest + image: grafana/loki:3.5.0 + container_name: loki ports: - "3100:3100" command: -config.file=/etc/loki/local-config.yaml promtail: - image: grafana/promtail:latest + image: grafana/promtail:3.5.0 + container_name: promtail volumes: - ./promtail-config.yml:/etc/promtail/promtail-config.yaml - ./logs:/logs @@ -39,8 +44,23 @@ services: - loki grafana: - image: grafana/grafana:latest + image: grafana/grafana:11.6.1 + container_name: grafana ports: - "3000:3000" depends_on: - loki + + + redis_cache: + image: redis:7.4.3 + container_name: redisCache + ports: + - "6379:6379" + + redisinsight: + image: redis/redisinsight:2.68 + container_name: redisInsight + ports: + - "5540:5540" + restart: always \ No newline at end of file diff --git a/pom.xml b/pom.xml index d6c6bf7..84202a5 100644 --- a/pom.xml +++ b/pom.xml @@ -96,6 +96,18 @@ jakarta.validation-api 3.0.2 + + org.mockito + mockito-core + 5.14.2 + test + + + org.mockito + mockito-junit-jupiter + 5.14.2 + test + diff --git a/src/main/java/com/podzilla/auth/AuthApplication.java b/src/main/java/com/podzilla/auth/AuthApplication.java index cb6eb42..920bdd3 100644 --- a/src/main/java/com/podzilla/auth/AuthApplication.java +++ b/src/main/java/com/podzilla/auth/AuthApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching public class AuthApplication { public static void main(final String[] args) { diff --git a/src/main/java/com/podzilla/auth/dto/CustomGrantedAuthority.java b/src/main/java/com/podzilla/auth/dto/CustomGrantedAuthority.java new file mode 100644 index 0000000..541c48d --- /dev/null +++ b/src/main/java/com/podzilla/auth/dto/CustomGrantedAuthority.java @@ -0,0 +1,27 @@ +package com.podzilla.auth.dto; + +import org.springframework.security.core.GrantedAuthority; + +public class CustomGrantedAuthority implements GrantedAuthority { + private String authority; + + public CustomGrantedAuthority() { + // No-arg constructor required by Jackson + } + + public CustomGrantedAuthority(final String authority) { + this.authority = authority; + } + + @Override + public String getAuthority() { + return authority; + } + + @Override + public String toString() { + return "CustomGrantedAuthority{" + + "authority='" + authority + '\'' + + '}'; + } +} diff --git a/src/main/java/com/podzilla/auth/dto/CustomUserDetails.java b/src/main/java/com/podzilla/auth/dto/CustomUserDetails.java new file mode 100644 index 0000000..f5bed3d --- /dev/null +++ b/src/main/java/com/podzilla/auth/dto/CustomUserDetails.java @@ -0,0 +1,110 @@ +package com.podzilla.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Set; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CustomUserDetails implements UserDetails { + + private String username; + + @JsonIgnore + private String password; + + @JsonDeserialize(contentAs = CustomGrantedAuthority.class) + private Set authorities; + + @Getter + private final boolean accountNonExpired; + @Getter + private final boolean accountNonLocked; + @Getter + private final boolean credentialsNonExpired; + @Getter + private final boolean enabled; + + public CustomUserDetails() { + // No-arg constructor required by Jackson + this.accountNonExpired = true; + this.accountNonLocked = true; + this.credentialsNonExpired = true; + this.enabled = true; + } + + public CustomUserDetails(final String username, final String password, + final Set authorities) { + this.username = username; + this.password = password; + this.authorities = authorities; + this.accountNonExpired = true; + this.accountNonLocked = true; + this.credentialsNonExpired = true; + this.enabled = true; + } + + public CustomUserDetails(final String username, + final String password, + final boolean enabled, + final boolean accountNonExpired, + final boolean accountNonLocked, + final boolean credentialsNonExpired, + final Set authorities) { + this.username = username; + this.password = password; + this.authorities = authorities; + this.accountNonExpired = accountNonExpired; + this.accountNonLocked = accountNonLocked; + this.credentialsNonExpired = credentialsNonExpired; + this.enabled = enabled; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + public void eraseCredentials() { + this.password = null; + } + + public int hashCode() { + return this.username.hashCode(); + } + + public boolean equals(final Object obj) { + if (obj instanceof CustomUserDetails) { + return this.username + .equals(((CustomUserDetails) obj).getUsername()); + } else { + return false; + } + } + + public String toString() { + return this.getClass().getName() + " [" + + "Username=" + this.username + ", " + + "Password=[PROTECTED], " + + "Enabled=" + this.enabled + ", " + + "AccountNonExpired=" + this.accountNonExpired + ", " + + "CredentialsNonExpired=" + this.credentialsNonExpired + ", " + + "AccountNonLocked=" + this.accountNonLocked + ", " + + "Granted Authorities=" + this.authorities + "]"; + } +} diff --git a/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java b/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java index 527126e..19eb55d 100644 --- a/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/podzilla/auth/exception/GlobalExceptionHandler.java @@ -2,11 +2,31 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @RestControllerAdvice -public class GlobalExceptionHandler { +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException( + final AccessDeniedException exception) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.FORBIDDEN)); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException( + final AuthenticationException exception) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse(exception.getMessage(), + HttpStatus.UNAUTHORIZED)); + } @ExceptionHandler(NotFoundException.class) public ResponseEntity handleNotFoundException( diff --git a/src/main/java/com/podzilla/auth/model/RefreshToken.java b/src/main/java/com/podzilla/auth/model/RefreshToken.java index 7f7da62..3405a08 100644 --- a/src/main/java/com/podzilla/auth/model/RefreshToken.java +++ b/src/main/java/com/podzilla/auth/model/RefreshToken.java @@ -9,9 +9,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Column; import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -21,8 +23,10 @@ @Entity @Table(name = "refresh_tokens") @Getter +@Builder @Setter @NoArgsConstructor +@AllArgsConstructor @EntityListeners(AuditingEntityListener.class) public class RefreshToken { diff --git a/src/main/java/com/podzilla/auth/redis/RedisCacheConfig.java b/src/main/java/com/podzilla/auth/redis/RedisCacheConfig.java new file mode 100644 index 0000000..1a169bd --- /dev/null +++ b/src/main/java/com/podzilla/auth/redis/RedisCacheConfig.java @@ -0,0 +1,45 @@ +package com.podzilla.auth.redis; + +import com.podzilla.auth.dto.CustomUserDetails; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.support.NoOpCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +import java.time.Duration; + +@Configuration +public class RedisCacheConfig { + + private static final int CACHE_TTL = 60 * 60; + + @Bean + public CacheManager cacheManager( + final RedisConnectionFactory redisConnectionFactory, + @Value("${appconfig.cache.enabled}") final String cacheEnabled) { + if (!Boolean.parseBoolean(cacheEnabled)) { + return new NoOpCacheManager(); + } + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration + .defaultCacheConfig() + .entryTtl(Duration.ofMinutes(CACHE_TTL)) + .disableCachingNullValues() + .serializeValuesWith( + RedisSerializationContext. + SerializationPair. + fromSerializer( + new Jackson2JsonRedisSerializer<>( + CustomUserDetails.class))); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .build(); + } +} diff --git a/src/main/java/com/podzilla/auth/security/JWTAuthenticationFilter.java b/src/main/java/com/podzilla/auth/security/JWTAuthenticationFilter.java index 34d1297..3af4f3b 100644 --- a/src/main/java/com/podzilla/auth/security/JWTAuthenticationFilter.java +++ b/src/main/java/com/podzilla/auth/security/JWTAuthenticationFilter.java @@ -47,7 +47,8 @@ protected void doFilterInternal(final HttpServletRequest request, String userEmail = tokenService.extractEmail(); UserDetails userDetails = - customUserDetailsService.loadUserByUsername(userEmail); + customUserDetailsService + .loadUserByUsernameCached(userEmail); UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( @@ -62,7 +63,7 @@ protected void doFilterInternal(final HttpServletRequest request, context.setAuthentication(authToken); SecurityContextHolder.setContext(context); - + LOGGER.info("User {} authenticated", userEmail); } catch (Exception e) { LOGGER.error("Invalid JWT token: {}", e.getMessage()); } diff --git a/src/main/java/com/podzilla/auth/security/RestAuthenticationEntryPoint.java b/src/main/java/com/podzilla/auth/security/RestAuthenticationEntryPoint.java deleted file mode 100644 index 2351372..0000000 --- a/src/main/java/com/podzilla/auth/security/RestAuthenticationEntryPoint.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.podzilla.auth.security; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { - - @Override - public void commence(final HttpServletRequest request, - final HttpServletResponse response, - final AuthenticationException authException) - throws IOException { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, - "Authentication Failed"); - } -} diff --git a/src/main/java/com/podzilla/auth/security/SecurityConfig.java b/src/main/java/com/podzilla/auth/security/SecurityConfig.java index d8b83f0..1ae9147 100644 --- a/src/main/java/com/podzilla/auth/security/SecurityConfig.java +++ b/src/main/java/com/podzilla/auth/security/SecurityConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -35,22 +34,20 @@ SecurityFilterChain securityFilterChain(final HttpSecurity http) .csrf(AbstractHttpConfigurer::disable) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .exceptionHandling(exceptionHandling -> - exceptionHandling - .authenticationEntryPoint( - new RestAuthenticationEntryPoint()) - ) .authorizeHttpRequests((auth) -> - auth.requestMatchers( - HttpMethod.GET, "public_resource") + auth + .requestMatchers("/auth/login") + .permitAll() + .requestMatchers("/auth/register") + .permitAll() + .requestMatchers("/auth/refresh-token") .permitAll() - .requestMatchers("/auth/**").permitAll() .requestMatchers("/admin/**") - .hasRole("ADMIN") + .hasAuthority("ROLE_ADMIN") .requestMatchers("/swagger-ui/**", - "/v3/api-docs/**").permitAll() + "/v3/api-docs/**") + .permitAll() .anyRequest().authenticated() - ) .sessionManagement(s -> s .sessionCreationPolicy( diff --git a/src/main/java/com/podzilla/auth/service/AuthenticationService.java b/src/main/java/com/podzilla/auth/service/AuthenticationService.java index 406dd52..d4a4c58 100644 --- a/src/main/java/com/podzilla/auth/service/AuthenticationService.java +++ b/src/main/java/com/podzilla/auth/service/AuthenticationService.java @@ -2,6 +2,7 @@ import com.podzilla.auth.dto.LoginRequest; import com.podzilla.auth.dto.SignupRequest; +import com.podzilla.auth.exception.InvalidActionException; import com.podzilla.auth.exception.ValidationException; import com.podzilla.auth.model.ERole; import com.podzilla.auth.model.Role; @@ -10,6 +11,7 @@ import com.podzilla.auth.repository.UserRepository; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -46,6 +48,11 @@ public AuthenticationService( public String login(final LoginRequest loginRequest, final HttpServletResponse response) { + if (SecurityContextHolder.getContext().getAuthentication() + instanceof UsernamePasswordAuthenticationToken) { + throw new InvalidActionException("User already logged in."); + } + Authentication authenticationRequest = UsernamePasswordAuthenticationToken. unauthenticated( @@ -65,34 +72,75 @@ public String login(final LoginRequest loginRequest, } public void registerAccount(final SignupRequest signupRequest) { + checkNotNullValidationException(signupRequest, + "Signup request cannot be null."); + checkNotNullValidationException(signupRequest.getEmail(), + "Email cannot be null."); + checkNotNullValidationException(signupRequest.getPassword(), + "Password cannot be null."); + checkNotNullValidationException(signupRequest.getName(), + "Name cannot be null."); + if (userRepository.existsByEmail(signupRequest.getEmail())) { throw new ValidationException("Email already in use."); } - User account = new User( - signupRequest.getName(), - signupRequest.getEmail(), - passwordEncoder.encode(signupRequest.getPassword())); + User account = + User.builder() + .name(signupRequest.getName()) + .email(signupRequest.getEmail()) + .password( + passwordEncoder.encode( + signupRequest.getPassword())) + .build(); Role role = roleRepository.findByErole(ERole.ROLE_USER).orElse(null); + + checkNotNullValidationException(role, "Role_USER not found."); + account.setRoles(Collections.singleton(role)); userRepository.save(account); } - public void logoutUser(final HttpServletResponse response) { + public void logoutUser( + final HttpServletResponse response) { tokenService.removeAccessTokenFromCookie(response); - tokenService.removeRefreshTokenFromCookie(response); + tokenService.removeRefreshTokenFromCookieAndExpire(response); } public String refreshToken(final HttpServletRequest request, final HttpServletResponse response) { - String refreshToken = tokenService.getRefreshTokenFromCookie(request); try { + String refreshToken = + tokenService.getRefreshTokenFromCookie(request); + checkNotNullAccessDeniedException(refreshToken, + "Refresh token cannot be found."); String email = tokenService.renewRefreshToken(refreshToken, response); tokenService.generateAccessToken(email, response); return email; } catch (IllegalArgumentException e) { - throw new ValidationException("Invalid refresh token."); + throw new AccessDeniedException("Invalid refresh token."); + } + } + + private void checkNotNullValidationException(final String value, + final String message) { + if (value == null || value.isEmpty()) { + throw new ValidationException(message); + } + } + + private void checkNotNullValidationException(final Object value, + final String message) { + if (value == null) { + throw new ValidationException(message); + } + } + + private void checkNotNullAccessDeniedException(final Object value, + final String message) { + if (value == null) { + throw new AccessDeniedException(message); } } } diff --git a/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java b/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java index aa23490..1aaf662 100644 --- a/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java +++ b/src/main/java/com/podzilla/auth/service/CustomUserDetailsService.java @@ -1,11 +1,14 @@ package com.podzilla.auth.service; +import com.podzilla.auth.dto.CustomGrantedAuthority; +import com.podzilla.auth.dto.CustomUserDetails; import com.podzilla.auth.exception.NotFoundException; +import com.podzilla.auth.exception.ValidationException; import com.podzilla.auth.model.User; import com.podzilla.auth.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; @@ -30,17 +33,26 @@ public UserDetails loadUserByUsername(final String email) { new NotFoundException( email + " not found.")); + if (user.getRoles() == null || user.getRoles().isEmpty()) { + throw new ValidationException("User has no roles assigned."); + } + Set authorities = user .getRoles() .stream() - .map((role) -> new SimpleGrantedAuthority( + .map((role) -> new CustomGrantedAuthority( role.getErole().name())) .collect(Collectors.toSet()); - return new org.springframework.security.core.userdetails.User( + return new CustomUserDetails( user.getEmail(), user.getPassword(), authorities ); } + + @Cacheable(value = "userDetails", key = "#email") + public UserDetails loadUserByUsernameCached(final String email) { + return loadUserByUsername(email); + } } diff --git a/src/main/java/com/podzilla/auth/service/JWTService.java b/src/main/java/com/podzilla/auth/service/JWTService.java deleted file mode 100644 index e8577dc..0000000 --- a/src/main/java/com/podzilla/auth/service/JWTService.java +++ /dev/null @@ -1,185 +0,0 @@ -package com.podzilla.auth.service; - -import com.podzilla.auth.exception.ValidationException; -import com.podzilla.auth.model.RefreshToken; -import com.podzilla.auth.model.User; -import com.podzilla.auth.repository.RefreshTokenRepository; -import com.podzilla.auth.repository.UserRepository; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.web.util.WebUtils; - -import javax.crypto.SecretKey; -import java.time.Instant; -import java.time.temporal.TemporalAmount; -import java.util.Date; -import java.util.UUID; - -@Service -public class JWTService { - - // set in .env - @Value("${jwt.token.secret}") - private String secret; - - @Value("${jwt.token.expires}") - private Long jwtExpiresMinutes; - - private Claims claims; - - private static final Integer ACCESS_TOKEN_EXPIRATION_TIME = 60 * 1000; - private static final Integer ACCESS_TOKEN_COOKIE_EXPIRATION_TIME = 60 * 30; - private static final TemporalAmount REFRESH_TOKEN_EXPIRATION_TIME = - java.time.Duration.ofDays(10); - private static final Integer REFRESH_TOKEN_COOKIE_EXPIRATION_TIME = - 60 * 60 * 24 * 10; - - private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; - - public JWTService(final UserRepository userRepository, - final RefreshTokenRepository refreshTokenRepository) { - this.userRepository = userRepository; - this.refreshTokenRepository = refreshTokenRepository; - } - - public void generateAccessToken(final String email, - final HttpServletResponse response) { - String jwt = Jwts.builder() - .subject(email) - .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() - + jwtExpiresMinutes * ACCESS_TOKEN_EXPIRATION_TIME)) - .signWith(getSignInKey()) - .compact(); - - Cookie cookie = new Cookie("accessToken", jwt); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - cookie.setMaxAge(ACCESS_TOKEN_COOKIE_EXPIRATION_TIME); - response.addCookie(cookie); - } - - public void generateRefreshToken(final String email, - final HttpServletResponse response) { - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new ValidationException("User not found")); - RefreshToken userRefreshToken = - refreshTokenRepository.findByUserIdAndExpiresAtAfter( - user.getId(), Instant.now()).orElse(null); - - if (userRefreshToken == null) { - userRefreshToken = new RefreshToken(); - userRefreshToken.setUser(user); - userRefreshToken.setCreatedAt(Instant.now()); - userRefreshToken.setExpiresAt(Instant.now().plus( - REFRESH_TOKEN_EXPIRATION_TIME)); - refreshTokenRepository.save(userRefreshToken); - } - - String refreshTokenString = userRefreshToken.getId().toString(); - addRefreshTokenToCookie(refreshTokenString, response); - } - - public String renewRefreshToken(final String refreshToken, - final HttpServletResponse response) { - RefreshToken token = - refreshTokenRepository - .findByIdAndExpiresAtAfter( - UUID.fromString(refreshToken), Instant.now()) - .orElseThrow(() -> - new ValidationException( - "Invalid refresh token")); - - token.setExpiresAt(Instant.now()); - refreshTokenRepository.save(token); - - RefreshToken newRefreshToken = new RefreshToken(); - newRefreshToken.setUser(token.getUser()); - newRefreshToken.setCreatedAt(Instant.now()); - newRefreshToken.setExpiresAt(Instant.now().plus( - REFRESH_TOKEN_EXPIRATION_TIME)); - refreshTokenRepository.save(newRefreshToken); - - String newRefreshTokenString = newRefreshToken.getId().toString(); - addRefreshTokenToCookie(newRefreshTokenString, response); - - return token.getUser().getEmail(); - } - - private void addRefreshTokenToCookie(final String refreshToken, - final HttpServletResponse response) { - Cookie cookie = new Cookie("refreshToken", refreshToken); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/api/auth/refresh-token"); - cookie.setMaxAge(REFRESH_TOKEN_COOKIE_EXPIRATION_TIME); - response.addCookie(cookie); - } - - public String getAccessTokenFromCookie(final HttpServletRequest request) { - Cookie cookie = WebUtils.getCookie(request, "accessToken"); - if (cookie != null) { - return cookie.getValue(); - } - return null; - - } - - public String getRefreshTokenFromCookie(final HttpServletRequest request) { - Cookie cookie = WebUtils.getCookie(request, "refreshToken"); - if (cookie != null) { - return cookie.getValue(); - } - return null; - } - - public void validateAccessToken(final String token) { - try { - claims = Jwts.parser() - .verifyWith(getSignInKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - - - } catch (JwtException e) { - throw new ValidationException(e.getMessage()); - } - } - - public void removeAccessTokenFromCookie( - final HttpServletResponse response) { - Cookie cookie = new Cookie("JWT", null); - cookie.setPath("/"); - - response.addCookie(cookie); - } - - public void removeRefreshTokenFromCookie( - final HttpServletResponse response) { - Cookie cookie = new Cookie("refreshToken", null); - cookie.setPath("/api/auth/refresh-token"); - - response.addCookie(cookie); - } - - private SecretKey getSignInKey() { - byte[] keyBytes = Decoders.BASE64.decode(this.secret); - return Keys.hmacShaKeyFor(keyBytes); - } - - public String extractEmail() { - return claims.getSubject(); - } - -} diff --git a/src/main/java/com/podzilla/auth/service/TokenService.java b/src/main/java/com/podzilla/auth/service/TokenService.java index 9fb564c..bd186ca 100644 --- a/src/main/java/com/podzilla/auth/service/TokenService.java +++ b/src/main/java/com/podzilla/auth/service/TokenService.java @@ -1,5 +1,6 @@ package com.podzilla.auth.service; +import com.podzilla.auth.exception.ValidationException; import com.podzilla.auth.model.RefreshToken; import com.podzilla.auth.model.User; import com.podzilla.auth.repository.RefreshTokenRepository; @@ -9,11 +10,9 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import jakarta.persistence.EntityExistsException; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.ValidationException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.util.WebUtils; @@ -76,17 +75,18 @@ public void generateAccessToken(final String email, public void generateRefreshToken(final String email, final HttpServletResponse response) { User user = userRepository.findByEmail(email) - .orElseThrow(() -> new EntityExistsException("User not found")); + .orElseThrow(() -> new ValidationException("User not found")); RefreshToken userRefreshToken = refreshTokenRepository.findByUserIdAndExpiresAtAfter( user.getId(), Instant.now()).orElse(null); if (userRefreshToken == null) { - userRefreshToken = new RefreshToken(); - userRefreshToken.setUser(user); - userRefreshToken.setCreatedAt(Instant.now()); - userRefreshToken.setExpiresAt(Instant.now().plus( - REFRESH_TOKEN_EXPIRATION_TIME)); + userRefreshToken = + RefreshToken.builder() + .user(user) + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus( + REFRESH_TOKEN_EXPIRATION_TIME)).build(); refreshTokenRepository.save(userRefreshToken); } @@ -107,11 +107,12 @@ public String renewRefreshToken(final String refreshToken, token.setExpiresAt(Instant.now()); refreshTokenRepository.save(token); - RefreshToken newRefreshToken = new RefreshToken(); - newRefreshToken.setUser(token.getUser()); - newRefreshToken.setCreatedAt(Instant.now()); - newRefreshToken.setExpiresAt(Instant.now().plus( - REFRESH_TOKEN_EXPIRATION_TIME)); + RefreshToken newRefreshToken = + RefreshToken.builder() + .user(token.getUser()) + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus( + REFRESH_TOKEN_EXPIRATION_TIME)).build(); refreshTokenRepository.save(newRefreshToken); String newRefreshTokenString = newRefreshToken.getId().toString(); @@ -147,7 +148,7 @@ public String getRefreshTokenFromCookie(final HttpServletRequest request) { return null; } - public void validateAccessToken(final String token) throws JwtException { + public void validateAccessToken(final String token) { try { claims = Jwts.parser() .verifyWith(getSignInKey()) @@ -157,7 +158,7 @@ public void validateAccessToken(final String token) throws JwtException { } catch (JwtException e) { - throw new JwtException(e.getMessage()); + throw new ValidationException(e.getMessage()); } } @@ -169,14 +170,31 @@ public void removeAccessTokenFromCookie( response.addCookie(cookie); } - public void removeRefreshTokenFromCookie( + public void removeRefreshTokenFromCookieAndExpire( final HttpServletResponse response) { + String userEmail = extractEmail(); + User user = + userRepository.findByEmail(userEmail) + .orElseThrow(() -> new ValidationException( + "User not found")); + RefreshToken refreshToken = + refreshTokenRepository.findByUserIdAndExpiresAtAfter( + user.getId(), Instant.now()).orElseThrow( + () -> new ValidationException( + "Refresh token not found")); + expireRefreshToken(refreshToken); + Cookie cookie = new Cookie("refreshToken", null); cookie.setPath(REFRESH_TOKEN_COOKIE_PATH); response.addCookie(cookie); } + private void expireRefreshToken(final RefreshToken token) { + token.setExpiresAt(Instant.now()); + refreshTokenRepository.save(token); + } + private SecretKey getSignInKey() { byte[] keyBytes = Decoders.BASE64.decode(this.secret); return Keys.hmacShaKeyFor(keyBytes); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index be2b6ed..6d68851 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -8,13 +8,18 @@ logging.file.name=./logs/app.log logging.level.root=info logging.level.com.podzilla.auth=debug +spring.cache.type=redis + +spring.data.redis.host=localhost +spring.data.redis.port=6379 + spring.datasource.url=jdbc:postgresql://localhost:5432/authDB spring.datasource.username=postgres spring.datasource.password=1234 spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.hibernate.ddl-auto=update spring.jpa.generate-ddl=true spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true @@ -28,4 +33,6 @@ logging.level.org.springframework.security=DEBUG #jwt.token.secret jwt.token.secret=${SECRET_KEY} -jwt.token.expires=30 \ No newline at end of file +jwt.token.expires=30 + +appconfig.cache.enabled=true \ No newline at end of file diff --git a/src/test/java/com/podzilla/auth/controller/AdminControllerTest.java b/src/test/java/com/podzilla/auth/controller/AdminControllerTest.java new file mode 100644 index 0000000..a89afb5 --- /dev/null +++ b/src/test/java/com/podzilla/auth/controller/AdminControllerTest.java @@ -0,0 +1,117 @@ +package com.podzilla.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.podzilla.auth.model.ERole; +import com.podzilla.auth.model.Role; +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.RoleRepository; +import com.podzilla.auth.repository.UserRepository; +import io.jsonwebtoken.lang.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Arrays; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class AdminControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoleRepository roleRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ObjectMapper objectMapper; + + private User user1; + private User user2; + + @BeforeEach + void setUp() { + userRepository.deleteAll(); // Clean slate before each test + roleRepository.deleteAll(); + + Role adminRole = new Role(); + adminRole.setErole(ERole.ROLE_ADMIN); + roleRepository.save(adminRole); + + Role userRole = new Role(); + userRole.setErole(ERole.ROLE_USER); + roleRepository.save(userRole); + + user1 = new User(); + user1.setEmail("adminUser"); + user1.setPassword(passwordEncoder.encode("password")); + user1.setRoles(Collections.setOf(adminRole)); + user1.setEmail("admin@example.com"); + + user2 = new User(); + user2.setEmail("normalUser"); + user2.setPassword(passwordEncoder.encode("password")); + user2.setRoles(Collections.setOf(userRole)); + user2.setEmail("user@example.com"); + + userRepository.saveAll(Arrays.asList(user1, user2)); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); // Clean up after each test + roleRepository.deleteAll(); + } + + @Test + @WithMockUser(authorities = "ROLE_ADMIN") + void getUsers_shouldReturnListOfUsers_whenUserIsAdmin() throws Exception { + List expectedUsers = Arrays.asList(user1, user2); + + mockMvc.perform(get("/admin/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].name", is(user1.getName()))) + .andExpect(jsonPath("$[0].email", is(user1.getEmail()))) + .andExpect(jsonPath("$[0].password", is(user1.getPassword()))) + .andExpect(jsonPath("$[1].name", is(user2.getName()))) + .andExpect(jsonPath("$[1].email", is(user2.getEmail()))) + .andExpect(jsonPath("$[1].password", is(user2.getPassword()))); + } + + @Test + @WithMockUser(roles = "USER") // Simulate an authenticated user with USER role + void getUsers_shouldReturnForbidden_whenUserIsNotAdmin() throws Exception { + mockMvc.perform(get("/admin/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void getUsers_shouldReturnUnauthorized_whenUserIsNotAuthenticated() throws Exception { + mockMvc.perform(get("/admin/users") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/podzilla/auth/controller/AuthenticationControllerTest.java b/src/test/java/com/podzilla/auth/controller/AuthenticationControllerTest.java new file mode 100644 index 0000000..cc35333 --- /dev/null +++ b/src/test/java/com/podzilla/auth/controller/AuthenticationControllerTest.java @@ -0,0 +1,241 @@ +package com.podzilla.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.podzilla.auth.dto.LoginRequest; +import com.podzilla.auth.dto.SignupRequest; +import com.podzilla.auth.model.ERole; +import com.podzilla.auth.model.Role; +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.RoleRepository; +import com.podzilla.auth.repository.UserRepository; +import com.podzilla.auth.service.TokenService; // Assuming you have a JwtService +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Optional; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +class AuthenticationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoleRepository roleRepository; // Inject RoleRepository + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TokenService tokenService; + + private final String testUserEmail = "testuser@example.com"; + private final String testUserPassword = "password123"; + + @BeforeEach + void setUp() { + roleRepository.deleteAll(); + userRepository.deleteAll(); // Clean slate before each test + + Role adminRole = new Role(); + adminRole.setErole(ERole.ROLE_ADMIN); + roleRepository.save(adminRole); + + Role userRole = new Role(); + userRole.setErole(ERole.ROLE_USER); + roleRepository.save(userRole); + + // Create a pre-existing user for login tests + User user = new User(); + user.setEmail(testUserEmail); + user.setPassword(passwordEncoder.encode(testUserPassword)); + user.setName("Test User"); // Assuming name is required or desired + user.getRoles().add(userRole); + userRepository.save(user); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); // Clean up after each test + roleRepository.deleteAll(); + } + + @Test + void registerUser_shouldCreateNewUser_whenEmailIsNotTaken() throws Exception { + SignupRequest signupRequest = new SignupRequest(); + signupRequest.setEmail("newuser@example.com"); + signupRequest.setPassword("newpassword"); + signupRequest.setName("New User"); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(signupRequest))) + .andExpect(status().isCreated()) + .andExpect(content().string("Account registered.")); + + // Verify user exists in the database + Optional registeredUser = userRepository.findByEmail("newuser@example.com"); + assertTrue(registeredUser.isPresent()); + assertEquals("New User", registeredUser.get().getName()); + assertTrue(passwordEncoder.matches("newpassword", registeredUser.get().getPassword())); + // Verify role assignment if applicable (assuming default role is USER) + assertTrue(registeredUser.get().getRoles().stream() + .anyMatch(role -> role.getErole() == ERole.ROLE_USER)); + } + + @Test + void registerUser_shouldReturnBadRequest_whenEmailIsTaken() throws Exception { + SignupRequest signupRequest = new SignupRequest(); + signupRequest.setEmail(testUserEmail); // Email already exists from setup + signupRequest.setPassword("anotherpassword"); + signupRequest.setName("Another User"); + + mockMvc.perform(post("/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(signupRequest))) + // Assuming AuthenticationService throws an exception leading to 4xx + // Adjust status code based on actual exception handling (e.g., 400 Bad Request or 409 Conflict) + .andExpect(status().isBadRequest()); // Or Conflict (409) depending on implementation + } + + + @Test + void login_shouldReturnOkAndSetCookies_whenCredentialsAreValid() throws Exception { + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(testUserEmail); + loginRequest.setPassword(testUserPassword); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(content().string("User " + testUserEmail + " logged in successfully")) + .andExpect(cookie().exists("accessToken")) // Check if cookies are set + .andExpect(cookie().exists("refreshToken")); + // Add more specific cookie checks if needed (e.g., HttpOnly, Secure, MaxAge) + // .andExpect(cookie().httpOnly("accessToken", true)) + // .andExpect(cookie().maxAge("accessToken", expectedMaxAge)); + } + + @Test + void login_shouldReturnUnauthorized_whenCredentialsAreInvalid() throws Exception { + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(testUserEmail); + loginRequest.setPassword("wrongpassword"); + + mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isUnauthorized()); // Standard Spring Security behavior + } + + @Test + void logoutUser_shouldClearCookiesAndReturnOk() throws Exception { + // First, log in to get cookies + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(testUserEmail); + loginRequest.setPassword(testUserPassword); + + MvcResult loginResult = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andReturn(); + + Cookie accessTokenCookie = loginResult.getResponse().getCookie("accessToken"); + Cookie refreshTokenCookie = loginResult.getResponse().getCookie("refreshToken"); + + assertNotNull(accessTokenCookie); + assertNotNull(refreshTokenCookie); + + // Perform logout using the obtained cookies + mockMvc.perform(post("/auth/logout") + .cookie(accessTokenCookie) // Send cookies back + .cookie(refreshTokenCookie)) + .andExpect(status().isOk()) + .andExpect(content().string("You've been signed out!")) + // Check that cookies are cleared (Max-Age=0) + .andExpect(cookie().value("accessToken", (String) null)) + .andExpect(cookie().value("refreshToken", (String) null)); + } + + @Test + void refreshToken_shouldReturnOkAndNewTokens_whenRefreshTokenIsValid() throws Exception { + // 1. Login to get initial tokens + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(testUserEmail); + loginRequest.setPassword(testUserPassword); + + MvcResult loginResult = mockMvc.perform(post("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andReturn(); + + Cookie initialAccessToken = loginResult.getResponse().getCookie("accessToken"); + Cookie initialRefreshToken = loginResult.getResponse().getCookie("refreshToken"); + assertNotNull(initialRefreshToken); + assertNotNull(initialAccessToken); + assertNotEquals(0, initialRefreshToken.getMaxAge()); // Ensure it's not already expired + + Thread.sleep(3000); + + // 2. Use the refresh token to get new tokens + MvcResult refreshResult = mockMvc.perform(post("/auth/refresh-token") + .cookie(initialRefreshToken)) // Send only the refresh token + .andExpect(status().isOk()) + .andExpect(content().string(containsString("refreshed tokens successfully"))) + .andExpect(cookie().exists("accessToken")) + .andExpect(cookie().exists("refreshToken")) + .andReturn(); + + // 3. Verify new tokens are different from old ones + Cookie newAccessToken = refreshResult.getResponse().getCookie("accessToken"); + Cookie newRefreshToken = refreshResult.getResponse().getCookie("refreshToken"); + + assertNotNull(newAccessToken); + assertNotNull(newRefreshToken); + assertNotEquals(initialAccessToken.getValue(), newAccessToken.getValue()); + // Depending on your implementation, the refresh token might also be rotated + // assertNotEquals(initialRefreshToken.getValue(), newRefreshToken.getValue()); + + // Optional: Verify the expiry/max-age of the new cookies + assertNotEquals(0, newAccessToken.getMaxAge()); + assertNotEquals(0, newRefreshToken.getMaxAge()); + } + + @Test + void refreshToken_shouldReturnUnauthorized_whenRefreshTokenIsMissingOrInvalid() throws Exception { + // Test without sending any cookie + mockMvc.perform(post("/auth/refresh-token")) + .andExpect(status().isForbidden()); + // Test with an invalid/expired cookie + Cookie invalidCookie = new Cookie("refreshToken", "invalid-or-expired-token-value"); + invalidCookie.setPath("/"); // Set path and other relevant attributes if needed + + mockMvc.perform(post("/auth/refresh-token") + .cookie(invalidCookie)) + .andExpect(status().isForbidden()); + } +} \ No newline at end of file diff --git a/src/test/java/com/podzilla/auth/service/AdminServiceTest.java b/src/test/java/com/podzilla/auth/service/AdminServiceTest.java new file mode 100644 index 0000000..f09023b --- /dev/null +++ b/src/test/java/com/podzilla/auth/service/AdminServiceTest.java @@ -0,0 +1,42 @@ +package com.podzilla.auth.service; + +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private AdminService adminService; + + @Test + void getUsers_shouldReturnListOfUsers() { + User user1 = User.builder().id(1L).email("user1@example.com").name("User One").build(); + User user2 = User.builder().id(2L).email("user2@example.com").name("User Two").build(); + List expectedUsers = Arrays.asList(user1, user2); + + when(userRepository.findAll()).thenReturn(expectedUsers); + + List actualUsers = adminService.getUsers(); + + assertEquals(expectedUsers.size(), actualUsers.size()); + assertEquals(expectedUsers, actualUsers); + + verify(userRepository).findAll(); + } +} \ No newline at end of file diff --git a/src/test/java/com/podzilla/auth/service/AuthenticationServiceTest.java b/src/test/java/com/podzilla/auth/service/AuthenticationServiceTest.java new file mode 100644 index 0000000..87ac508 --- /dev/null +++ b/src/test/java/com/podzilla/auth/service/AuthenticationServiceTest.java @@ -0,0 +1,291 @@ +package com.podzilla.auth.service; + +import com.podzilla.auth.dto.CustomGrantedAuthority; +import com.podzilla.auth.dto.LoginRequest; +import com.podzilla.auth.dto.SignupRequest; +import com.podzilla.auth.exception.ValidationException; +import com.podzilla.auth.model.ERole; +import com.podzilla.auth.model.Role; +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.RoleRepository; +import com.podzilla.auth.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; // Added import +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + + @Mock + private AuthenticationManager authenticationManager; + @Mock + private PasswordEncoder passwordEncoder; + @Mock + private UserRepository userRepository; + @Mock + private TokenService tokenService; + @Mock + private RoleRepository roleRepository; + @Mock + private HttpServletResponse httpServletResponse; + @Mock // Added mock for HttpServletRequest + private HttpServletRequest httpServletRequest; + + @InjectMocks + private AuthenticationService authenticationService; + + private SignupRequest signupRequest; + private LoginRequest loginRequest; + private User user; + private Role userRole; + + @BeforeEach + void setUp() { + signupRequest = new SignupRequest(); + signupRequest.setName("Test User"); + signupRequest.setEmail("test@example.com"); + signupRequest.setPassword("password123"); + + loginRequest = new LoginRequest(); + loginRequest.setEmail("test@example.com"); + loginRequest.setPassword("password123"); + + userRole = new Role(ERole.ROLE_USER); + user = User.builder() + .id(1L) + .name("Test User") + .email("test@example.com") + .password("encodedPassword") + .roles(Collections.singleton(userRole)) + .build(); + + SecurityContextHolder.getContext().setAuthentication(null); + } + + // --- registerAccount Tests --- + + @Test + void registerAccount_shouldSaveUser_whenEmailNotExistsAndPasswordNotEmpty() { + // Arrange + when(userRepository.existsByEmail(signupRequest.getEmail())).thenReturn(false); + when(passwordEncoder.encode(signupRequest.getPassword())).thenReturn("encodedPassword"); + when(roleRepository.findByErole(ERole.ROLE_USER)).thenReturn(Optional.of(userRole)); + when(userRepository.save(any(User.class))).thenReturn(user); // Return the saved user + + // Act + authenticationService.registerAccount(signupRequest); + + // Assert + verify(userRepository).existsByEmail(signupRequest.getEmail()); + verify(passwordEncoder).encode(signupRequest.getPassword()); + verify(roleRepository).findByErole(ERole.ROLE_USER); + + // Capture the user argument passed to save + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + User savedUser = userCaptor.getValue(); + + assertEquals(signupRequest.getName(), savedUser.getName()); + assertEquals(signupRequest.getEmail(), savedUser.getEmail()); + assertEquals("encodedPassword", savedUser.getPassword()); + assertTrue(savedUser.getRoles().contains(userRole)); + } + + @Test + void registerAccount_shouldThrowValidationException_whenEmailExists() { + // Arrange + when(userRepository.existsByEmail(signupRequest.getEmail())).thenReturn(true); + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + authenticationService.registerAccount(signupRequest); + }); + + assertEquals("Validation error: Email already in use.", + exception.getMessage()); + verify(userRepository).existsByEmail(signupRequest.getEmail()); + verify(passwordEncoder, never()).encode(anyString()); + verify(roleRepository, never()).findByErole(any()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void registerAccount_shouldThrowValidationException_whenPasswordIsEmpty() { + // Arrange + signupRequest.setPassword(""); // Empty password + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + authenticationService.registerAccount(signupRequest); + }); + + assertEquals("Validation error: Password cannot be null.", + exception.getMessage()); + verify(userRepository, never()).existsByEmail(anyString()); + verify(passwordEncoder, never()).encode(anyString()); + verify(roleRepository, never()).findByErole(any()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void registerAccount_shouldHandleRoleNotFoundGracefully() { + // Arrange - Simulate role not found in DB + when(userRepository.existsByEmail(signupRequest.getEmail())).thenReturn(false); + when(passwordEncoder.encode(signupRequest.getPassword())).thenReturn("encodedPassword"); + when(roleRepository.findByErole(ERole.ROLE_USER)).thenReturn(Optional.empty()); // Role not found + + // Act + ValidationException exception = assertThrows(ValidationException.class, () -> { + authenticationService.registerAccount(signupRequest); + }); + + assertEquals("Validation error: Role_USER not found.", + exception.getMessage()); + + // Assert + verify(userRepository).existsByEmail(signupRequest.getEmail()); + verify(passwordEncoder).encode(signupRequest.getPassword()); + verify(roleRepository).findByErole(ERole.ROLE_USER); + } + + + // --- login Tests --- + + @Test + void login_shouldReturnUsernameAndSetTokens_whenCredentialsAreValid() { + // Arrange + UserDetails userDetails = new org.springframework.security.core.userdetails.User( + loginRequest.getEmail(), + "encodedPassword", // Password doesn't matter much here as AuthenticationManager handles it + Collections.singletonList(new CustomGrantedAuthority("ROLE_USER")) + ); + Authentication successfulAuth = new UsernamePasswordAuthenticationToken( + userDetails, // Principal + loginRequest.getPassword(), // Credentials + userDetails.getAuthorities() // Authorities + ); + + // Mock AuthenticationManager behavior + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenReturn(successfulAuth); + + // Mocks for token generation (void methods, no 'when' needed unless checking args) + // doNothing().when(tokenService).generateAccessToken(anyString(), any(HttpServletResponse.class)); + // doNothing().when(tokenService).generateRefreshToken(anyString(), any(HttpServletResponse.class)); + + // Act + String loggedInUsername = authenticationService.login(loginRequest, httpServletResponse); + + // Assert + assertEquals(loginRequest.getEmail(), loggedInUsername); + + // Verify AuthenticationManager was called with unauthenticated token + ArgumentCaptor authCaptor = + ArgumentCaptor.forClass(UsernamePasswordAuthenticationToken.class); + verify(authenticationManager).authenticate(authCaptor.capture()); + UsernamePasswordAuthenticationToken capturedAuthRequest = authCaptor.getValue(); + assertEquals(loginRequest.getEmail(), capturedAuthRequest.getName()); + assertEquals(loginRequest.getPassword(), capturedAuthRequest.getCredentials()); + assertFalse(capturedAuthRequest.isAuthenticated()); // Ensure it was unauthenticated initially + + // Verify token generation methods were called + verify(tokenService).generateAccessToken(loginRequest.getEmail(), httpServletResponse); + verify(tokenService).generateRefreshToken(loginRequest.getEmail(), httpServletResponse); + } + + @Test + void login_shouldThrowException_whenCredentialsAreInvalid() { + // Arrange + // Mock AuthenticationManager to throw an exception for bad credentials + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("Invalid credentials")); + + // Act & Assert + assertThrows(BadCredentialsException.class, () -> { + authenticationService.login(loginRequest, httpServletResponse); + }); + + // Verify token generation methods were NOT called + verify(tokenService, never()).generateAccessToken(anyString(), any(HttpServletResponse.class)); + verify(tokenService, never()).generateRefreshToken(anyString(), any(HttpServletResponse.class)); + } + + // --- logoutUser Tests --- + @Test + void logoutUser_shouldCallTokenServiceToRemoveTokens() { + // Arrange (no specific arrangement needed as methods are void) + + // Act + authenticationService.logoutUser(httpServletResponse); + + // Assert + verify(tokenService).removeAccessTokenFromCookie(httpServletResponse); + verify(tokenService).removeRefreshTokenFromCookieAndExpire(httpServletResponse); + } + + // --- refreshToken Tests --- + @Test + void refreshToken_shouldReturnEmailAndGenerateAccessToken_whenTokenIsValid() { + // Arrange + String expectedEmail = "test@example.com"; + String validRefreshToken = "valid-refresh-token"; + + when(tokenService.getRefreshTokenFromCookie(httpServletRequest)).thenReturn(validRefreshToken); + when(tokenService.renewRefreshToken(validRefreshToken, httpServletResponse)).thenReturn(expectedEmail); + // No need to mock generateAccessToken as it's void, we just verify it + + // Act + String actualEmail = authenticationService.refreshToken(httpServletRequest, httpServletResponse); + + // Assert + assertEquals(expectedEmail, actualEmail); + verify(tokenService).getRefreshTokenFromCookie(httpServletRequest); + verify(tokenService).renewRefreshToken(validRefreshToken, httpServletResponse); + verify(tokenService).generateAccessToken(expectedEmail, httpServletResponse); + } + + @Test + void refreshToken_shouldThrowAccessDeniedException_whenTokenIsInvalid() { + // Arrange + String invalidRefreshToken = "invalid-refresh-token"; + + when(tokenService.getRefreshTokenFromCookie(httpServletRequest)).thenReturn(invalidRefreshToken); + // Mock renewRefreshToken to throw the exception caught in the service method + when(tokenService.renewRefreshToken(invalidRefreshToken, httpServletResponse)) + .thenThrow(new IllegalArgumentException("Token invalid")); + + // Act & Assert + AccessDeniedException exception = assertThrows(AccessDeniedException.class, () -> { + authenticationService.refreshToken(httpServletRequest, httpServletResponse); + }); + + assertEquals("Invalid refresh token.", + exception.getMessage()); + verify(tokenService).getRefreshTokenFromCookie(httpServletRequest); + verify(tokenService).renewRefreshToken(invalidRefreshToken, httpServletResponse); + // Verify generateAccessToken was NOT called in the failure case + verify(tokenService, never()).generateAccessToken(anyString(), any(HttpServletResponse.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/podzilla/auth/service/CustomUserDetailsServiceTest.java b/src/test/java/com/podzilla/auth/service/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..b660052 --- /dev/null +++ b/src/test/java/com/podzilla/auth/service/CustomUserDetailsServiceTest.java @@ -0,0 +1,150 @@ +package com.podzilla.auth.service; + +import com.podzilla.auth.dto.CustomUserDetails; +import com.podzilla.auth.exception.NotFoundException; +import com.podzilla.auth.exception.ValidationException; // Added import +import com.podzilla.auth.model.ERole; +import com.podzilla.auth.model.Role; +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CustomUserDetailsServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CustomUserDetailsService customUserDetailsService; + + private User user; + private String userEmail; + private String userPassword; + + @BeforeEach + void setUp() { + userEmail = "test@example.com"; + userPassword = "encodedPassword"; + Role userRole = new Role(ERole.ROLE_USER); + Role adminRole = new Role(ERole.ROLE_ADMIN); + + Set roles = new HashSet<>(); + roles.add(userRole); + roles.add(adminRole); + + user = User.builder() + .id(1L) + .name("Test User") + .email(userEmail) + .password(userPassword) + .roles(roles) + .enabled(true) + .build(); + } + + @Test + void loadUserByUsername_shouldReturnUserDetails_whenUserExistsAndHasRoles() { + // Arrange + when(userRepository.findByEmail(userEmail)).thenReturn(Optional.of(user)); + + // Act + UserDetails userDetails = customUserDetailsService.loadUserByUsername(userEmail); + + // Assert + assertNotNull(userDetails); + assertEquals(userEmail, userDetails.getUsername()); + assertEquals(userPassword, userDetails.getPassword()); + assertNotNull(userDetails.getAuthorities()); + assertEquals(2, userDetails.getAuthorities().size()); // ROLE_USER and ROLE_ADMIN + + // Check specific authorities + Set expectedAuthorities = Set.of(ERole.ROLE_USER.name(), ERole.ROLE_ADMIN.name()); + Set actualAuthorities = userDetails.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + assertEquals(expectedAuthorities, actualAuthorities); + + assertInstanceOf(CustomUserDetails.class, userDetails, "Should return an instance of CustomUserDetails"); + + verify(userRepository).findByEmail(userEmail); + } + + @Test + void loadUserByUsername_shouldThrowNotFoundException_whenUserDoesNotExist() { + // Arrange + String nonExistentEmail = "notfound@example.com"; + when(userRepository.findByEmail(nonExistentEmail)).thenReturn(Optional.empty()); + + // Act & Assert + NotFoundException exception = assertThrows(NotFoundException.class, () -> { + customUserDetailsService.loadUserByUsername(nonExistentEmail); + }); + + assertEquals("Not Found: " + nonExistentEmail + " not found.", + exception.getMessage()); + verify(userRepository).findByEmail(nonExistentEmail); + } + + @Test + void loadUserByUsername_shouldThrowValidationException_whenUserHasEmptyRoles() { + // Arrange + String emailWithNoRoles = "norole@example.com"; + User userWithNoRoles = User.builder() + .id(2L) + .name("No Role User") + .email(emailWithNoRoles) + .password("password123") + .roles(Collections.emptySet()) // Empty roles set + .build(); + when(userRepository.findByEmail(emailWithNoRoles)).thenReturn(Optional.of(userWithNoRoles)); + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + customUserDetailsService.loadUserByUsername(emailWithNoRoles); + }); + + assertEquals("Validation error: User has no roles assigned.", + exception.getMessage()); + verify(userRepository).findByEmail(emailWithNoRoles); + } + + @Test + void loadUserByUsername_shouldThrowValidationException_whenUserHasNullRoles() { + // Arrange + String emailWithNullRoles = "nullrole@example.com"; + User userWithNullRoles = User.builder() + .id(3L) + .name("Null Role User") + .email(emailWithNullRoles) + .password("password456") + .roles(null) // Null roles set + .build(); + when(userRepository.findByEmail(emailWithNullRoles)).thenReturn(Optional.of(userWithNullRoles)); + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + customUserDetailsService.loadUserByUsername(emailWithNullRoles); + }); + + assertEquals("Validation error: User has no roles assigned.", + exception.getMessage()); + verify(userRepository).findByEmail(emailWithNullRoles); + } +} \ No newline at end of file diff --git a/src/test/java/com/podzilla/auth/service/TokenServiceTest.java b/src/test/java/com/podzilla/auth/service/TokenServiceTest.java new file mode 100644 index 0000000..6879bfd --- /dev/null +++ b/src/test/java/com/podzilla/auth/service/TokenServiceTest.java @@ -0,0 +1,489 @@ +package com.podzilla.auth.service; + +import com.podzilla.auth.exception.ValidationException; +import com.podzilla.auth.model.RefreshToken; +import com.podzilla.auth.model.User; +import com.podzilla.auth.repository.RefreshTokenRepository; +import com.podzilla.auth.repository.UserRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.util.WebUtils; + + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TokenServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private HttpServletResponse response; + + @Mock + private HttpServletRequest request; + + // Use InjectMocks to automatically inject the mocked dependencies + @InjectMocks + private TokenService tokenService; + + // Test data + private final String testEmail = "test@example.com"; + private final String testSecret = "testSecretKeyForJwtTokenGenerationWhichIsVeryLongAndSecure"; // Use a valid Base64 encoded key if possible + private final Long testUserId = 115642L; + private final UUID testRefreshTokenId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + // Use ReflectionTestUtils to set the private @Value fields + ReflectionTestUtils.setField(tokenService, "secret", testSecret); + Long testJwtExpiresMinutes = 30L; + ReflectionTestUtils.setField(tokenService, "jwtExpiresMinutes", testJwtExpiresMinutes); + // Reset claims if needed between tests (though it's mostly set during validation) + ReflectionTestUtils.setField(tokenService, "claims", null); + } + + @Test + @DisplayName("Should generate access token and add cookie") + void generateAccessToken_ShouldAddCookie() { + // Arrange + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + + // Act + tokenService.generateAccessToken(testEmail, response); + + // Assert + verify(response).addCookie(cookieCaptor.capture()); + Cookie addedCookie = cookieCaptor.getValue(); + + assertNotNull(addedCookie); + assertEquals("accessToken", addedCookie.getName()); + assertTrue(addedCookie.isHttpOnly()); + assertTrue(addedCookie.getSecure()); + assertEquals("/", addedCookie.getPath()); // Check path + assertEquals(60 * 30, addedCookie.getMaxAge()); // Check expiration + + // Optionally, validate the JWT content (requires parsing logic similar to validateAccessToken) + assertNotNull(addedCookie.getValue()); + // You could add more detailed JWT validation if needed + } + + @Test + @DisplayName("Should generate new refresh token if none exists") + void generateRefreshToken_WhenNoneExists_ShouldCreateNewAndAddCookie() { + // Arrange + User user = User.builder().id(testUserId).email(testEmail).build(); + when(userRepository.findByEmail(testEmail)).thenReturn(Optional.of(user)); + when(refreshTokenRepository.findByUserIdAndExpiresAtAfter(eq(testUserId), any(Instant.class))) + .thenReturn(Optional.empty()); // No existing valid token + when(refreshTokenRepository.save(any(RefreshToken.class))) + .thenAnswer(invocation -> { + RefreshToken token = invocation.getArgument(0); + token.setId(testRefreshTokenId); + return token; + }); // Mock save to return the + // token itself + + ArgumentCaptor refreshTokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + + // Act + tokenService.generateRefreshToken(testEmail, response); + + // Assert + verify(refreshTokenRepository).save(refreshTokenCaptor.capture()); + RefreshToken savedToken = refreshTokenCaptor.getValue(); + assertNotNull(savedToken); + assertEquals(user, savedToken.getUser()); + assertNotNull(savedToken.getCreatedAt()); + assertNotNull(savedToken.getExpiresAt()); + assertTrue(savedToken.getExpiresAt().isAfter(Instant.now())); + + verify(response).addCookie(cookieCaptor.capture()); + Cookie addedCookie = cookieCaptor.getValue(); + assertNotNull(addedCookie); + assertEquals("refreshToken", addedCookie.getName()); + assertEquals(savedToken.getId().toString(), addedCookie.getValue()); // Verify token value in cookie + assertTrue(addedCookie.isHttpOnly()); + assertTrue(addedCookie.getSecure()); + assertEquals("/api/auth/refresh-token", addedCookie.getPath()); // Check specific path + assertTrue(addedCookie.getMaxAge() > 0); // Check expiration + } + + @Test + @DisplayName("Should use existing refresh token if valid one exists") + void generateRefreshToken_WhenValidExists_ShouldUseExistingAndAddCookie() { + // Arrange + User user = User.builder().id(testUserId).email(testEmail).build(); + RefreshToken existingToken = RefreshToken.builder() + .id(testRefreshTokenId) + .user(user) + .createdAt(Instant.now().minus(1, ChronoUnit.DAYS)) + .expiresAt(Instant.now().plus(51, ChronoUnit.DAYS)) // Still + // valid + .build(); + + when(userRepository.findByEmail(testEmail)).thenReturn(Optional.of(user)); + when(refreshTokenRepository.findByUserIdAndExpiresAtAfter(eq(testUserId), any(Instant.class))) + .thenReturn(Optional.of(existingToken)); + + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + + // Act + tokenService.generateRefreshToken(testEmail, response); + + // Assert + verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); // Should not save a new one + + verify(response).addCookie(cookieCaptor.capture()); + Cookie addedCookie = cookieCaptor.getValue(); + assertNotNull(addedCookie); + assertEquals("refreshToken", addedCookie.getName()); + assertEquals(existingToken.getId().toString(), addedCookie.getValue()); // Uses existing token ID + assertTrue(addedCookie.isHttpOnly()); + assertTrue(addedCookie.getSecure()); + assertEquals("/api/auth/refresh-token", addedCookie.getPath()); + } + + + @Test + @DisplayName("Should throw ValidationException if user not found during refresh token generation") + void generateRefreshToken_WhenUserNotFound_ShouldThrowValidationException() { + // Arrange + when(userRepository.findByEmail(testEmail)).thenReturn(Optional.empty()); + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + tokenService.generateRefreshToken(testEmail, response); + }); + assertEquals("Validation error: User not found", + exception.getMessage()); + verify(refreshTokenRepository, never()).save(any()); + verify(response, never()).addCookie(any()); + } + + + @Test + @DisplayName("Should renew refresh token successfully") + void renewRefreshToken_ValidToken_ShouldExpireOldCreateNewAddCookieAndReturnEmail() { + // Arrange + User user = User.builder().id(testUserId).email(testEmail).build(); + RefreshToken oldToken = RefreshToken.builder() + .id(testRefreshTokenId) + .user(user) + .createdAt(Instant.now().minus(10, ChronoUnit.DAYS)) + .expiresAt(Instant.now().plus(5, ChronoUnit.DAYS)) + .build(); + String oldTokenString = oldToken.getId().toString(); + + when(refreshTokenRepository.findByIdAndExpiresAtAfter(eq(testRefreshTokenId), any(Instant.class))) + .thenReturn(Optional.of(oldToken)); + when(refreshTokenRepository.save(any(RefreshToken.class))) + .thenAnswer(invocation -> { + RefreshToken token = invocation.getArgument(0); + token.setId(UUID.randomUUID()); + return token; + }); // Mock save to return the token itself + + ArgumentCaptor refreshTokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + + // Act + String resultEmail = tokenService.renewRefreshToken(oldTokenString, response); + + // Assert + assertEquals(testEmail, resultEmail); + + verify(refreshTokenRepository, times(2)).save(refreshTokenCaptor.capture()); + RefreshToken expiredToken = refreshTokenCaptor.getAllValues().get(0); + RefreshToken newToken = refreshTokenCaptor.getAllValues().get(1); + + // Verify old token was expired (or set to expire immediately) + assertTrue(expiredToken.getExpiresAt().isBefore(Instant.now().plusSeconds(1))); // Check if expiration is set to now or very close + + // Verify new token details + assertNotEquals(oldToken.getId(), newToken.getId()); + assertEquals(user, newToken.getUser()); + assertTrue(newToken.getExpiresAt().isAfter(Instant.now())); + + // Verify cookie for the new token + verify(response).addCookie(cookieCaptor.capture()); + Cookie addedCookie = cookieCaptor.getValue(); + assertEquals("refreshToken", addedCookie.getName()); + assertEquals(newToken.getId().toString(), addedCookie.getValue()); + assertTrue(addedCookie.isHttpOnly()); + assertTrue(addedCookie.getSecure()); + assertEquals("/api/auth/refresh-token", addedCookie.getPath()); + } + + @Test + @DisplayName("Should throw ValidationException when renewing invalid refresh token") + void renewRefreshToken_InvalidToken_ShouldThrowValidationException() { + // Arrange + String invalidTokenString = UUID.randomUUID().toString(); + when(refreshTokenRepository.findByIdAndExpiresAtAfter(eq(UUID.fromString(invalidTokenString)), any(Instant.class))) + .thenReturn(Optional.empty()); + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + tokenService.renewRefreshToken(invalidTokenString, response); + }); + assertEquals("Validation error: Invalid refresh token", + exception.getMessage()); + verify(refreshTokenRepository, never()).save(any()); + verify(response, never()).addCookie(any()); + } + + @Test + @DisplayName("Should return access token from cookie") + void getAccessTokenFromCookie_WhenCookieExists_ShouldReturnTokenValue() { + // Arrange + String tokenValue = "dummyAccessToken"; + Cookie accessTokenCookie = new Cookie("accessToken", tokenValue); + // Static mocking for WebUtils (alternative: inject a mock WebUtils if preferred) + // Use a try-with-resources block for mocking static methods if using mockito-inline + try (var mockedStatic = mockStatic(WebUtils.class)) { + mockedStatic.when(() -> WebUtils.getCookie(request, "accessToken")).thenReturn(accessTokenCookie); + + // Act + String retrievedToken = tokenService.getAccessTokenFromCookie(request); + + // Assert + assertEquals(tokenValue, retrievedToken); + } + } + + @Test + @DisplayName("Should return null if access token cookie does not exist") + void getAccessTokenFromCookie_WhenCookieMissing_ShouldReturnNull() { + // Arrange + try (var mockedStatic = mockStatic(WebUtils.class)) { + mockedStatic.when(() -> WebUtils.getCookie(request, "accessToken")).thenReturn(null); + // Act + String retrievedToken = tokenService.getAccessTokenFromCookie(request); + + // Assert + assertNull(retrievedToken); + } + } + + // Similar tests for getRefreshTokenFromCookie... + + @Test + @DisplayName("Should validate a valid access token") + void validateAccessToken_ValidToken_ShouldNotThrow() { + // Arrange: Generate a valid token first + // Note: This relies on the internal generation logic using the test secret. + byte[] keyBytes = Decoders.BASE64.decode(testSecret); + SecretKey key = Keys.hmacShaKeyFor(keyBytes); + String validToken = Jwts.builder() + .subject(testEmail) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 5)) // Expires in 5 mins + .signWith(key) + .compact(); + + // Act & Assert: Should not throw any exception + assertDoesNotThrow(() -> tokenService.validateAccessToken(validToken)); + + // Also assert that claims are set + Claims claims = (Claims) ReflectionTestUtils.getField(tokenService, "claims"); + assertNotNull(claims); + assertEquals(testEmail, claims.getSubject()); + } + + @Test + @DisplayName("Should throw ValidationException for invalid access token (expired)") + void validateAccessToken_ExpiredToken_ShouldThrowValidationException() { + // Arrange: Generate an expired token + SecretKey key = Keys.hmacShaKeyFor(testSecret.getBytes()); + String expiredToken = Jwts.builder() + .subject(testEmail) + .issuedAt(new Date(System.currentTimeMillis() - 1000 * 60 * 10)) // Issued 10 mins ago + .expiration(new Date(System.currentTimeMillis() - 1000 * 60 * 5)) // Expired 5 mins ago + .signWith(key) + .compact(); + + // Act & Assert + assertThrows(ValidationException.class, () -> { + tokenService.validateAccessToken(expiredToken); + }); + } + + @Test + @DisplayName("Should throw ValidationException for malformed access token") + void validateAccessToken_MalformedToken_ShouldThrowValidationException() { + // Arrange + String malformedToken = "this.is.not.a.valid.jwt"; + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + tokenService.validateAccessToken(malformedToken); + }); + // Check if the message indicates a JWT format issue + assertTrue(exception.getMessage().toLowerCase().contains("jwt")); + } + + @Test + @DisplayName("Should remove access token cookie") + void removeAccessTokenFromCookie_ShouldAddNullCookie() { + // Arrange + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + + // Act + tokenService.removeAccessTokenFromCookie(response); + + // Assert + verify(response).addCookie(cookieCaptor.capture()); + Cookie addedCookie = cookieCaptor.getValue(); + + assertEquals("accessToken", addedCookie.getName()); + assertNull(addedCookie.getValue()); // Value should be null to remove + assertEquals("/", addedCookie.getPath()); + // MaxAge might be 0 or not set depending on exact removal strategy, check path mainly + } + + + // --- Tests for removeRefreshTokenFromCookieAndExpire --- + // This requires setting the 'claims' field first, as extractEmail depends on it. + + private void setupClaimsForEmailExtraction() { + // Helper to simulate that validateAccessToken was called successfully before + SecretKey key = Keys.hmacShaKeyFor(testSecret.getBytes()); + Claims claims = Jwts.claims().subject(testEmail).build(); + ReflectionTestUtils.setField(tokenService, "claims", claims); + } + + @Test + @DisplayName("Should remove refresh token cookie and expire token in DB") + void removeRefreshTokenFromCookieAndExpire_ValidState_ShouldPerformActions() { + // Arrange + setupClaimsForEmailExtraction(); // Simulate prior successful access token validation + + User user = User.builder().id(testUserId).email(testEmail).build(); + RefreshToken refreshToken = RefreshToken.builder() + .id(testRefreshTokenId) + .user(user) + .createdAt(Instant.now().minus(7, ChronoUnit.DAYS)) + .expiresAt(Instant.now().plus(21, ChronoUnit.DAYS)) + .build(); + + when(userRepository.findByEmail(testEmail)).thenReturn(Optional.of(user)); + when(refreshTokenRepository.findByUserIdAndExpiresAtAfter(eq(testUserId), any(Instant.class))) + .thenReturn(Optional.of(refreshToken)); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); + ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); + + // Act + tokenService.removeRefreshTokenFromCookieAndExpire(response); + + // Assert + // 1. Verify token was expired + verify(refreshTokenRepository).save(tokenCaptor.capture()); + RefreshToken expiredToken = tokenCaptor.getValue(); + assertEquals(refreshToken.getId(), expiredToken.getId()); + assertTrue(expiredToken.getExpiresAt().isBefore(Instant.now().plusSeconds(1))); // Expired now + + // 2. Verify cookie was removed + verify(response).addCookie(cookieCaptor.capture()); + Cookie removedCookie = cookieCaptor.getValue(); + assertEquals("refreshToken", removedCookie.getName()); + assertNull(removedCookie.getValue()); + assertEquals("/api/auth/refresh-token", removedCookie.getPath()); + } + + @Test + @DisplayName("removeRefreshTokenFromCookieAndExpire should throw if user not found") + void removeRefreshTokenFromCookieAndExpire_UserNotFound_ShouldThrowValidationException() { + // Arrange + setupClaimsForEmailExtraction(); + when(userRepository.findByEmail(testEmail)).thenReturn(Optional.empty()); + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + tokenService.removeRefreshTokenFromCookieAndExpire(response); + }); + assertEquals("Validation error: User not found", + exception.getMessage()); + verify(refreshTokenRepository, never()).findByUserIdAndExpiresAtAfter(any(), any()); + verify(refreshTokenRepository, never()).save(any()); + verify(response, never()).addCookie(any()); + } + + @Test + @DisplayName("removeRefreshTokenFromCookieAndExpire should throw if refresh token not found") + void removeRefreshTokenFromCookieAndExpire_TokenNotFound_ShouldThrowValidationException() { + // Arrange + setupClaimsForEmailExtraction(); + User user = User.builder().id(testUserId).email(testEmail).build(); + + when(userRepository.findByEmail(testEmail)).thenReturn(Optional.of(user)); + when(refreshTokenRepository.findByUserIdAndExpiresAtAfter(eq(testUserId), any(Instant.class))) + .thenReturn(Optional.empty()); // Token not found + + // Act & Assert + ValidationException exception = assertThrows(ValidationException.class, () -> { + tokenService.removeRefreshTokenFromCookieAndExpire(response); + }); + assertEquals("Validation error: Refresh token not found", + exception.getMessage()); + verify(refreshTokenRepository, never()).save(any()); + verify(response, never()).addCookie(any()); + } + + + @Test + @DisplayName("Should extract email from claims") + void extractEmail_WhenClaimsSet_ShouldReturnSubject() { + // Arrange + setupClaimsForEmailExtraction(); // Sets claims with testEmail as subject + + // Act + String extractedEmail = tokenService.extractEmail(); + + // Assert + assertEquals(testEmail, extractedEmail); + } + + @Test + @DisplayName("extractEmail should throw NullPointerException if claims not set") + void extractEmail_WhenClaimsNull_ShouldThrowNullPointerException() { + // Arrange: claims field is null by default or after reset + + // Act & Assert + assertThrows(NullPointerException.class, () -> { + tokenService.extractEmail(); + }, "Expected NullPointerException because claims object is null"); + } +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 6ac6fdd..b169238 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -6,5 +6,7 @@ spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=create-drop logging.level.org.hibernate.SQL=debug -jwt.token.secret=${SECRET_KEY:12345678901234567890123456789012} -jwt.token.expires=30 \ No newline at end of file +jwt.token.secret=${SECRET_KEY:testSecretKeyForJwtTokenGenerationWhichIsVeryLongAndSecure} +jwt.token.expires=30 + +appconfig.cache.enabled=false \ No newline at end of file