diff --git a/build.gradle b/build.gradle index 6030f9e..bb9dad7 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthController.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthController.java index 2a1e1c2..f223054 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthController.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthController.java @@ -5,6 +5,8 @@ import kr.ac.ks.cs_web_back.domain.auth.dto.request.AuthLoginRequest; import kr.ac.ks.cs_web_back.domain.auth.dto.response.AuthLoginResponse; import kr.ac.ks.cs_web_back.domain.auth.service.AuthService; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.global.annotation.IdentifiedUser; import kr.ac.ks.cs_web_back.global.response.CsResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -23,4 +25,13 @@ public CsResponse login( AuthLoginResponse token = authService.loginMember(request); return CsResponse.of(AuthSuccessCode.LOGIN_SUCCESS, token); } + + @PostMapping("/logout") + public CsResponse logout ( + @Valid @RequestHeader("Authorization") String authorization, + @IdentifiedUser Member member + ) { + authService.logout(authorization, member.getEmail()); + return CsResponse.of(AuthSuccessCode.LOGOUT_SUCCESS); + } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/SpringDocAuthController.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/SpringDocAuthController.java index d132ac5..fee2fbe 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/SpringDocAuthController.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/SpringDocAuthController.java @@ -1,15 +1,19 @@ package kr.ac.ks.cs_web_back.domain.auth.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.ks.cs_web_back.domain.auth.dto.request.AuthLoginRequest; import kr.ac.ks.cs_web_back.domain.auth.dto.response.AuthLoginResponse; +import kr.ac.ks.cs_web_back.domain.member.model.Member; +import kr.ac.ks.cs_web_back.global.annotation.IdentifiedUser; import kr.ac.ks.cs_web_back.global.response.CsResponse; import kr.ac.ks.cs_web_back.global.swagger.error.ApiErrorResponse; import kr.ac.ks.cs_web_back.global.swagger.error.ErrorCase; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.RequestHeader; @Tag(name = "인증 API", description = "인증 관련 API") public interface SpringDocAuthController { @@ -30,4 +34,17 @@ CsResponse login( @RequestBody AuthLoginRequest request ); + @Operation(summary = "로그아웃", description = "사용자의 JWT토큰을 받아 형식을 검사하고 성공시 토큰을 만료시킵니다.") + @ApiResponse(responseCode = "200", description = "로그아웃에 성공했습니다.") + @ApiErrorResponse(status = HttpStatus.UNAUTHORIZED, instance = "/auth/logout", errorCases = { + @ErrorCase(description = "유효하지 않은 토큰", code = 8001, exampleMessage = "유효하지 않은 토큰입니다."), + @ErrorCase(description = "인증 실패", code = 8002, exampleMessage = "인증에 실패했습니다.") + }) + @ApiErrorResponse(status = HttpStatus.INTERNAL_SERVER_ERROR, instance = "/auth/logout", errorCases = { + @ErrorCase(description = "서버 오류", code = 5000, exampleMessage = "서버에서 예기치 못한 오류가 발생했습니다.") + }) + CsResponse logout( + @RequestHeader("Authorization") String authorizationHeader, + @Parameter(hidden=true)@IdentifiedUser Member member + ); } diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java index 2536293..63dd3ac 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthExceptionCode.java @@ -10,9 +10,10 @@ public enum AuthExceptionCode implements ExceptionCode { BAD_REQUEST_NO_EMAIL(9010, "이메일이 입력되지 않았습니다."), BAD_REQUEST_NO_PASSWORD(9010, "비밀번호가 입력되지 않았습니다."), + + UNAUTHORIZED_PASSWORD(8011, "이메일 또는 비밀번호가 일치하지 않습니다."), UNAUTHORIZED_INVALID_TOKEN(8001, "유효하지 않은 토큰입니다."), UNAUTHORIZED_FAILED_VALIDATION(8002, "인증에 실패했습니다."), - UNAUTHORIZED_PASSWORD(8011, "비밀번호가 일치하지 않습니다."), ; private final int code; diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthSuccessCode.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthSuccessCode.java index 7ce75e5..5fe4b7f 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthSuccessCode.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/controller/code/AuthSuccessCode.java @@ -9,6 +9,7 @@ public enum AuthSuccessCode implements SuccessCode { LOGIN_SUCCESS(2001, "로그인에 성공했습니다."), + LOGOUT_SUCCESS(2002, "로그아웃에 성공했습니다."), ; private final int code; diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java index 64d8652..f5490e8 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthService.java @@ -5,14 +5,20 @@ import kr.ac.ks.cs_web_back.domain.auth.dto.response.AuthLoginResponse; import kr.ac.ks.cs_web_back.domain.member.model.Member; import kr.ac.ks.cs_web_back.domain.member.repository.MemberRepository; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; import kr.ac.ks.cs_web_back.global.exeption.domain.NotFoundException; import kr.ac.ks.cs_web_back.global.exeption.domain.UnauthorizedException; +import kr.ac.ks.cs_web_back.global.jwt.JwtTokenResolver; import kr.ac.ks.cs_web_back.global.jwt.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Date; +import java.util.concurrent.TimeUnit; + @Service @RequiredArgsConstructor @Transactional @@ -20,7 +26,11 @@ public class AuthService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + private final RedisTemplate redisTemplate; private final JwtUtil jwtUtil; + private final JwtTokenResolver jwtTokenResolver; + private final JwtBlackListService jwtBlackListService; + public AuthLoginResponse loginMember(AuthLoginRequest request) { Member member = memberRepository.findByEmail(request.email()) @@ -28,12 +38,36 @@ public AuthLoginResponse loginMember(AuthLoginRequest request) { if(!passwordEncoder.matches(request.password(), member.getPassword())) throw new UnauthorizedException(AuthExceptionCode.UNAUTHORIZED_PASSWORD); - // 24 hours + String accessToken = jwtUtil.generateAccessToken(member.getEmail()); String refreshToken = jwtUtil.generateRefreshToken(member.getEmail()); + + Date refreshTokenExpiration = jwtUtil.getExpirationDateFromRefreshToken(refreshToken); + long remainingTime = refreshTokenExpiration.getTime() - System.currentTimeMillis(); + + redisTemplate.opsForValue().set( + "RT:"+ member.getEmail(), + refreshToken, + remainingTime, + TimeUnit.MILLISECONDS + ); + return AuthLoginResponse.builder() .authorization(accessToken) .authorizationRefresh(refreshToken) .build(); } + public void logout(String authorization, String email) { + String resolvedAccessToken = jwtTokenResolver.resolveToken(authorization); + + if (jwtBlackListService.isBlackListed(resolvedAccessToken)) + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); + + if (redisTemplate.opsForValue().get("RT:"+email) != null) + redisTemplate.delete("RT:"+email); + + Date expirationTime = jwtUtil.getExpirationDateFromAccessToken(resolvedAccessToken); + long remainingTime = expirationTime.getTime() - System.currentTimeMillis(); + jwtBlackListService.blacklist(resolvedAccessToken, remainingTime); + } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/JwtBlackListService.java b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/JwtBlackListService.java new file mode 100644 index 0000000..ba64b81 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/domain/auth/service/JwtBlackListService.java @@ -0,0 +1,27 @@ +package kr.ac.ks.cs_web_back.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class JwtBlackListService { + + private final RedisTemplate redisTemplate; + + public void blacklist(String accessToken, long remainingTime) { + redisTemplate.opsForValue().set( + accessToken, + "logout", + remainingTime, + TimeUnit.MILLISECONDS + ); + } + + public boolean isBlackListed(String accessToken) { + return redisTemplate.opsForValue().get(accessToken) != null; + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java index 2b93c99..46337c3 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/config/SecurityConfig.java @@ -28,7 +28,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz -> authz .requestMatchers(PathRequest.toH2Console()).permitAll() // H2 콘솔 경로 허용 - .requestMatchers("/member/register", "/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/member/register", "/auth/login", "/auth/logout", "/swagger-ui/**", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java index e415c4a..a07d6da 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/exeption/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -88,4 +89,13 @@ public ResponseEntity handleException(final ConflictException .body(new ExceptionResponse(exception.getCode(), exception.getMessage())); } + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity handleException(final MissingRequestHeaderException e) { + System.out.printf("%s : %s\n", e.getClass(), e.getMessage()); + + final int errorCode = 400; + final String errorMessage = "필수 헤더가 누락되었습니다: " + e.getHeaderName(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ExceptionResponse(errorCode, errorMessage)); + } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java index 0e44c28..88e202d 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilter.java @@ -39,7 +39,7 @@ protected void doFilterInternal( if (token != null) { jwtUtil.validateToken(token); - String email = jwtUtil.getEmailFromToken(token); + String email = jwtUtil.getEmailFromAccessToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(email); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtConfig.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtConfig.java new file mode 100644 index 0000000..e21526b --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtConfig.java @@ -0,0 +1,26 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +@PropertySource("classpath:env.properties") +@RequiredArgsConstructor +public class JwtConfig { + + private final JwtProperties jwtProperties; + + @Bean(name = "accessTokenProvider") + public TokenProvider accessTokenProvider() { + return new TokenProvider(jwtProperties.accessSecret(), jwtProperties.accessTokenExpireTime()); + } + + @Bean(name = "refreshTokenProvider") + public TokenProvider refreshTokenProvider() { + return new TokenProvider(jwtProperties.refreshSecret(), jwtProperties.refreshTokenExpireTime()); + } +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtProperties.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtProperties.java new file mode 100644 index 0000000..8e7c0c6 --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtProperties.java @@ -0,0 +1,12 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String accessSecret, + String refreshSecret, + Long accessTokenExpireTime, + Long refreshTokenExpireTime +) { +} diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java index b715d3f..82dbd62 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolver.java @@ -17,4 +17,10 @@ public String resolveToken(HttpServletRequest request) { return null; } + public String resolveToken(String authorizationHeader) { + if(StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith(HEADER_PREFIX)) + return authorizationHeader.substring(HEADER_PREFIX.length()); + + return null; + } } diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java index 1c1d0df..4c216d6 100644 --- a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/JwtUtil.java @@ -14,8 +14,11 @@ @Component public class JwtUtil { - @Value("${jwt.secret}") - private String secret; + @Value("${jwt.accessSecret}") + private String aSecret; + + @Value("${jwt.refreshSecret}") + private String rSecret; @Value("${jwt.accessTokenExpireTime}") private Long accessTokenExpireTime; @@ -23,45 +26,64 @@ public class JwtUtil { @Value("${jwt.refreshTokenExpireTime}") private Long refreshTokenExpireTime; - private Key hmacKey; + private Key hmacAccessKey; + private Key hmacRefreshKey; @PostConstruct public void init() { - byte[] decodedKey = Base64.getDecoder().decode(secret); - this.hmacKey = new SecretKeySpec(decodedKey, SignatureAlgorithm.HS256.getJcaName()); - } - public String generateAccessToken(String email) { - return generateToken(email, accessTokenExpireTime); - } + byte[] accessDecodedKey = Base64.getDecoder().decode(aSecret); + this.hmacAccessKey = new SecretKeySpec(accessDecodedKey, SignatureAlgorithm.HS256.getJcaName()); - public String generateRefreshToken(String email) { - return generateToken(email, refreshTokenExpireTime); + byte[] refreshDecodedKey = Base64.getDecoder().decode(rSecret); + this.hmacRefreshKey = new SecretKeySpec(refreshDecodedKey, SignatureAlgorithm.HS256.getJcaName()); } - private String generateToken(String email, Long expireTime) { + private String generateToken(String email, Long expireTime, Key key) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expireTime); return Jwts.builder().setSubject(email) .setIssuedAt(now) .setExpiration(expiryDate) - .signWith(hmacKey) + .signWith(key) .compact(); } - public String getEmailFromToken(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(hmacKey) + public String generateAccessToken(String email) { + return generateToken(email, accessTokenExpireTime, hmacAccessKey); + } + + public String generateRefreshToken(String email) { + return generateToken(email, refreshTokenExpireTime, hmacRefreshKey); + } + + public String generateTestToken(String email, Long expireTime) { + return generateToken(email, expireTime, hmacAccessKey); + } + + private Claims getAllClaimsFromToken(String token, Key key) { + return Jwts.parserBuilder() + .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); + } + + public Date getExpirationDateFromAccessToken(String token) { + return getAllClaimsFromToken(token, hmacAccessKey).getExpiration(); + } + + public Date getExpirationDateFromRefreshToken(String token) { + return getAllClaimsFromToken(token, hmacRefreshKey).getExpiration(); + } - return claims.getSubject(); + public String getEmailFromAccessToken(String token) { + return getAllClaimsFromToken(token, hmacAccessKey).getSubject(); } public void validateToken(String token) { try { Jwts.parserBuilder() - .setSigningKey(hmacKey) + .setSigningKey(hmacAccessKey) .build() .parseClaimsJws(token); } catch (ExpiredJwtException e) { diff --git a/src/main/java/kr/ac/ks/cs_web_back/global/jwt/TokenProvider.java b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/TokenProvider.java new file mode 100644 index 0000000..79e48fb --- /dev/null +++ b/src/main/java/kr/ac/ks/cs_web_back/global/jwt/TokenProvider.java @@ -0,0 +1,66 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import io.jsonwebtoken.*; +import kr.ac.ks.cs_web_back.domain.auth.controller.code.AuthExceptionCode; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; + +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +public class TokenProvider { + + private final Key hmacKey; + private final Long expireTime; + + public TokenProvider(String secret, Long expireTime) { + byte[] decodedKey = Base64.getDecoder().decode(secret); + this.hmacKey = new SecretKeySpec(decodedKey, SignatureAlgorithm.HS256.getJcaName()); + this.expireTime = expireTime; + } + + public String generateToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expireTime); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(hmacKey) + .compact(); + } + + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(hmacKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + public Date getExpiration(String token) { + return parseClaims(token).getExpiration(); + } + + public void validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(hmacKey) + .build() + .parseClaimsJws(token); + } catch (ExpiredJwtException e) { + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN); + } + } +} diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java index f245399..7fc3511 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/controller/AuthControllerTest.java @@ -7,11 +7,13 @@ import kr.ac.ks.cs_web_back.domain.auth.dto.response.AuthLoginResponse; import kr.ac.ks.cs_web_back.domain.auth.fixture.AuthFixture; import kr.ac.ks.cs_web_back.domain.auth.service.AuthService; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; import kr.ac.ks.cs_web_back.global.exeption.domain.NotFoundException; import kr.ac.ks.cs_web_back.global.exeption.domain.UnauthorizedException; import kr.ac.ks.cs_web_back.global.jwt.JwtTokenResolver; import kr.ac.ks.cs_web_back.global.jwt.JwtUtil; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; @@ -22,6 +24,8 @@ import org.springframework.test.web.servlet.MockMvc; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -85,7 +89,7 @@ void loginWithoutPasswordReturns400BadRequest() throws Exception { } @Test - @DisplayName("로그인 실패: 요청 본문에 이메일이 없으면 400 Bad Request를 반홚나다.") + @DisplayName("로그인 실패: 요청 본문에 이메일이 없으면 400 Bad Request를 반환한다.") void loginWithoutEmailReturns400BadRequest() throws Exception { // given AuthLoginRequest request = new AuthLoginRequest("", "examplePassword1234!"); @@ -132,4 +136,64 @@ void loginWithNonExistentEmailReturns404NotFound() throws Exception { .andExpect(jsonPath("$.code").value(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION.getCode())) .andExpect(jsonPath("$.message").value(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION.getMessage())); } + + @Nested + @DisplayName("로그아웃 API 테스트") + class LogoutTest { + final String authorization = "Bearer valid-fake-access-token"; + @Test + @DisplayName("로그아웃 성공: 유효한 토큰으로 로그아웃 시 200 OK를 반환한다.") + void logoutSuccessReturns200Ok() throws Exception { + // given + // void 반환하니, 예외 없으면 성공으로 간주 + // when & then + mockMvc.perform(post("/auth/logout") + .header("Authorization", authorization)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(AuthSuccessCode.LOGOUT_SUCCESS.getCode())) + .andExpect(jsonPath("$.message").value(AuthSuccessCode.LOGOUT_SUCCESS.getMessage())); + } + + @Test + @DisplayName("로그아웃 실패: Authorization 헤더가 없으면 400 Bad Request를 반환한다.") + void logoutWithoutTokenReturns400BadRequest() throws Exception { + // given + // 헤더 없이 요청을 보냄 + // when & then + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("로그아웃 실패: 유효하지 않은 토큰이면 401 Unauthorized를 반환한다.") + void logoutWithInvalidTokenReturns401() throws Exception { + // 토큰이 물리적으로 유효하지 않은 모든 경우 (서명불일치, 형식 오류) + // given + // 가짜 Authservice가 InvalidTokenException을 던지도록 설정 + doThrow(new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN)) + .when(authService).logout(anyString()); + + // when & then + mockMvc.perform(post("/auth/logout") + .header("Authorization", authorization)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN.getCode())) + .andExpect(jsonPath("$.message").value(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN.getMessage())); + } + + @Test + @DisplayName("로그아웃 실패: 이미 로그아웃/만료된 토큰이면 401 Unauthorized를 반환한다.") + void logoutWithAlreadyLoggedOutTokenReturns401() throws Exception { + // given + doThrow(new InvalidTokenException(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION)) + .when(authService).logout(anyString()); + + // when & then + mockMvc.perform(post("/auth/logout") + .header("Authorization", authorization)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION.getCode())) + .andExpect(jsonPath("$.message").value(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION.getMessage())); + } + } } diff --git a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java index a83b10a..805b329 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/domain/auth/service/AuthServiceTest.java @@ -8,15 +8,18 @@ import kr.ac.ks.cs_web_back.domain.member.fixture.MemberFixture; import kr.ac.ks.cs_web_back.domain.member.model.Member; import kr.ac.ks.cs_web_back.domain.member.repository.MemberRepository; +import kr.ac.ks.cs_web_back.global.exeption.domain.InvalidTokenException; import kr.ac.ks.cs_web_back.global.exeption.domain.NotFoundException; import kr.ac.ks.cs_web_back.global.exeption.domain.UnauthorizedException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import kr.ac.ks.cs_web_back.global.jwt.JwtUtil; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; @@ -32,15 +35,25 @@ public class AuthServiceTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private RedisTemplate redisTemplate; + + private Member testMember; + @BeforeEach // 각각의 테스트가 실행되기 직전에 항상 실행되는 메소드(테스트용 회원을 만들어 DB에 저장) void setUp() { - Member testMember = MemberFixture.memberFixture(); + redisTemplate.getConnectionFactory().getConnection().flushAll(); + + Member memberFixture = MemberFixture.memberFixture(); String rawPassword = "examplePassword1234!"; String encodedPassword = passwordEncoder.encode(rawPassword); - memberRepository.save(Member.builder() - .email(testMember.getEmail()) + this.testMember = memberRepository.save(Member.builder() + .email(memberFixture.getEmail()) .password(encodedPassword) - .username(testMember.getUsername()) + .username(memberFixture.getUsername()) .build() ); } @@ -58,6 +71,10 @@ void shouldLoginSuccessfullyAndReturnToken() { assertThat(response).isNotNull(); assertThat(response.authorization()).isNotNull().isNotBlank(); assertThat(response.authorizationRefresh()).isNotNull().isNotBlank(); + + String savedRefreshToken = redisTemplate.opsForValue().get("RT:"+request.email()); + assertThat(savedRefreshToken).isNotNull(); + assertThat(savedRefreshToken).isEqualTo(response.authorizationRefresh()); } @Test @@ -89,4 +106,118 @@ void shouldThrowExceptionWhenLoginWithNonExistentEmail() { assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); }); } + + @Nested + @DisplayName("로그아웃 API 테스트") + class LogoutTests { + private final String REFRESH_TOKEN_PREFIX = "RT:"; + + @Test + @DisplayName("로그아웃 성공: 유효한 토큰으로 로그아웃 시 200 OK를 반환한다.") + void shouldLogoutSuccessfullyWithValidToken() { + // given + String accessToken = jwtUtil.generateAccessToken(testMember.getEmail()); + String refreshToken = jwtUtil.generateRefreshToken(testMember.getEmail()); + redisTemplate.opsForValue().set(REFRESH_TOKEN_PREFIX+testMember.getEmail(),refreshToken); + + String authorizationHeader = "Bearer " + accessToken; + + // when + authService.logout(authorizationHeader); + + // then + String refreshInRedis = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX+testMember.getEmail()); + assertThat(refreshInRedis).isNull(); + + String accessInRedis = redisTemplate.opsForValue().get(accessToken); + assertThat(accessInRedis).isEqualTo("logout"); + } + + @Test + @DisplayName("로그아웃 성공: Redis에 refreshToken이 없어도 로그아웃 시 200 OK를 반환한다.") + void shouldLogoutSuccessfullyWithoutRefreshToken() { + // given + String accessToken = jwtUtil.generateAccessToken(testMember.getEmail()); + String authorizationHeader = "Bearer " + accessToken; + + // when + Assertions.assertDoesNotThrow(() -> authService.logout(authorizationHeader)); + + String blacklisted = redisTemplate.opsForValue().get(accessToken); + assertThat(blacklisted).isEqualTo("logout"); + } + + @Test + @DisplayName("로그아웃 실패: 이미 블랙리스트에 등록된 토큰으로 요청시 InvalidTokenException 예외가 발생한다.") + void shouldThrowExceptionWhenLogoutWithBlacklistedToken() { + //given + String accessToken = jwtUtil.generateAccessToken(testMember.getEmail()); + String authorizationHeader = "Bearer " + accessToken; + + redisTemplate.opsForValue().set(accessToken, "logout", 60, TimeUnit.SECONDS); + + // when & then + assertThatThrownBy(() -> authService.logout(authorizationHeader)) + .isInstanceOf(InvalidTokenException.class) + .satisfies(e ->{ + InvalidTokenException exception = (InvalidTokenException) e; + assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); + }); + } + + @Test + @DisplayName("로그아웃 실패: 만료된 토큰으로 요청 시 InvalidTokenException이 발생한다.") + void logoutFailWithExpiredToken() throws InterruptedException { + // given + String expiredToken = jwtUtil.generateTestToken(testMember.getEmail(), 1L); + String authorizationHeader = "Bearer " + expiredToken; + + // 토큰이 확실히 만료되도록 잠시 대기 + Thread.sleep(5); + + // when & then + assertThatThrownBy(() -> authService.logout(authorizationHeader)) + .isInstanceOf(InvalidTokenException.class) + .satisfies(e -> { + InvalidTokenException exception = (InvalidTokenException) e; + assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); + }); + } + + @Test + @DisplayName("로그아웃 실패: 서명이 유효하지 않은 토큰으로 요청 시 InvalidTokenException이 발생한다.") + void logoutFailWithInvalidSignatureToken() { + // given + // 임의의 잘못된 토큰 문자열 + String invalidToken = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.invalid-signature"; + String authorizationHeader = "Bearer " + invalidToken; + + // when & then + // JwtUtil의 현재 구현에 따라 UNAUTHORIZED_INVALID_TOKEN(8001) 코드를 검증 + assertThatThrownBy(() -> authService.logout(authorizationHeader)) + .isInstanceOf(InvalidTokenException.class) + .satisfies(e -> { + InvalidTokenException exception = (InvalidTokenException) e; + assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.UNAUTHORIZED_INVALID_TOKEN); + }); + } + + @Test + @DisplayName("로그아웃 실패: 토큰은 유효하지만 DB에 사용자가 없는 경우 InvalidTokenException이 발생한다.") + void logoutFailWhenUserNotInDB() { + // given + String accessToken = jwtUtil.generateAccessToken(testMember.getEmail()); + String authorizationHeader = "Bearer " + accessToken; + + memberRepository.delete(testMember); + + // when & then + assertThatThrownBy(() -> authService.logout(authorizationHeader)) + .isInstanceOf(InvalidTokenException.class) + .satisfies(e -> { + InvalidTokenException exception = (InvalidTokenException) e; + assertThat(exception.getExceptionCode()).isEqualTo(AuthExceptionCode.UNAUTHORIZED_FAILED_VALIDATION); + }); + } + } } diff --git a/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java index 3e3e364..a39701d 100644 --- a/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java +++ b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtAuthenticationFilterTest.java @@ -38,7 +38,7 @@ @AutoConfigureMockMvc public class JwtAuthenticationFilterTest { - @Value("${jwt.secret}") + @Value("${jwt.accessSecret}") private String secret; @Autowired diff --git a/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolverTest.java b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolverTest.java new file mode 100644 index 0000000..8e8eaf2 --- /dev/null +++ b/src/test/java/kr/ac/ks/cs_web_back/global/jwt/JwtTokenResolverTest.java @@ -0,0 +1,49 @@ +package kr.ac.ks.cs_web_back.global.jwt; + +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Transactional +public class JwtTokenResolverTest { + + @Autowired + private JwtTokenResolver jwtTokenResolver; + + @Test + @DisplayName("resolveToken: Authorization 헤더가 null인 경우 null을 반환한다.") + void shouldReturnNullWhenAuthorizationNull() { + //given + String authorizationHeader = null; + + // when & then + String result = jwtTokenResolver.resolveToken(authorizationHeader); + Assertions.assertNull(result); + } + + @Test + @DisplayName("resolveToken: 헤더가 Bearer로 시작하지 않는 경우 null을 반환한다.") + void shouldReturnNullWhenHeaderNotStartBearer() { + //given + String invalidHeader = "Basics iamstupid"; + + // when & then + String result = jwtTokenResolver.resolveToken(invalidHeader); + Assertions.assertNull(result); + } + + @Test + @DisplayName("resolveToken: Authorization 헤더가 null인 경우 null을 반환한다.") + void shouldReturnNullWhenAuthorizationBlank() { + //given + String emptyHeader = ""; + + // when & then + String result = jwtTokenResolver.resolveToken(emptyHeader); + Assertions.assertNull(result); + } +}