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 extends GrantedAuthority> 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