diff --git a/secret b/secret index 5a729a542..12a15d8f4 160000 --- a/secret +++ b/secret @@ -1 +1 @@ -Subproject commit 5a729a5427f14b5397a962eb89d8e66a0fe586e9 +Subproject commit 12a15d8f408649f862388f37f9e2be2c04a0b066 diff --git a/src/main/java/codesquad/fineants/global/errors/errorcode/JwtErrorCode.java b/src/main/java/codesquad/fineants/global/errors/errorcode/JwtErrorCode.java index b6d5becd2..0addcc7c5 100644 --- a/src/main/java/codesquad/fineants/global/errors/errorcode/JwtErrorCode.java +++ b/src/main/java/codesquad/fineants/global/errors/errorcode/JwtErrorCode.java @@ -9,7 +9,7 @@ @RequiredArgsConstructor public enum JwtErrorCode implements ErrorCode { - INVALID_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 토큰입니다"), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다"), EMPTY_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 존재하지 않습니다"), REFRESH_TOKEN_EXPIRE_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다"), ACCESS_TOKEN_EXPIRE_TOKEN(HttpStatus.FORBIDDEN, "액세스 토큰이 만료되었습니다"); diff --git a/src/main/java/codesquad/fineants/global/security/ajax/handler/AjaxAuthenticationSuccessHandler.java b/src/main/java/codesquad/fineants/global/security/ajax/handler/AjaxAuthenticationSuccessHandler.java index 0d16e8126..4d5bbd775 100644 --- a/src/main/java/codesquad/fineants/global/security/ajax/handler/AjaxAuthenticationSuccessHandler.java +++ b/src/main/java/codesquad/fineants/global/security/ajax/handler/AjaxAuthenticationSuccessHandler.java @@ -1,6 +1,7 @@ package codesquad.fineants.global.security.ajax.handler; import java.io.IOException; +import java.util.Date; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -37,7 +38,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("utf-8"); - Token token = tokenService.generateToken(MemberAuthentication.from(member)); + Token token = tokenService.generateToken(MemberAuthentication.from(member), new Date()); ApiResponse body = ApiResponse.success(MemberSuccessCode.OK_LOGIN); CookieUtils.setCookie(response, tokenFactory.createAccessTokenCookie(token)); diff --git a/src/main/java/codesquad/fineants/global/security/oauth/config/OauthSecurityConfig.java b/src/main/java/codesquad/fineants/global/security/oauth/config/OauthSecurityConfig.java index b385182d7..71d226d49 100644 --- a/src/main/java/codesquad/fineants/global/security/oauth/config/OauthSecurityConfig.java +++ b/src/main/java/codesquad/fineants/global/security/oauth/config/OauthSecurityConfig.java @@ -12,7 +12,7 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.web.cors.CorsConfiguration; import codesquad.fineants.domain.member.repository.MemberRepository; @@ -92,7 +92,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.addFilterBefore(urlParamFilter(), OAuth2AuthorizationRequestRedirectFilter.class); - http.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtAuthFilter(), AuthorizationFilter.class); http .oauth2Login(configurer -> configurer @@ -112,7 +112,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public JwtAuthenticationFilter jwtAuthFilter() { - return new JwtAuthenticationFilter(tokenService, oauthMemberRedisService); + return new JwtAuthenticationFilter(tokenService, oauthMemberRedisService, tokenFactory); } @Bean diff --git a/src/main/java/codesquad/fineants/global/security/oauth/filter/JwtAuthenticationFilter.java b/src/main/java/codesquad/fineants/global/security/oauth/filter/JwtAuthenticationFilter.java index a173cc400..24a3fbd85 100644 --- a/src/main/java/codesquad/fineants/global/security/oauth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/codesquad/fineants/global/security/oauth/filter/JwtAuthenticationFilter.java @@ -1,15 +1,19 @@ package codesquad.fineants.global.security.oauth.filter; import java.io.IOException; +import java.time.LocalDateTime; import org.apache.logging.log4j.util.Strings; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.GenericFilterBean; import codesquad.fineants.domain.member.service.OauthMemberRedisService; +import codesquad.fineants.global.security.factory.TokenFactory; import codesquad.fineants.global.security.oauth.dto.MemberAuthentication; +import codesquad.fineants.global.security.oauth.dto.Token; import codesquad.fineants.global.security.oauth.service.TokenService; import codesquad.fineants.global.util.CookieUtils; import jakarta.servlet.FilterChain; @@ -17,6 +21,7 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,23 +30,48 @@ public class JwtAuthenticationFilter extends GenericFilterBean { private final TokenService tokenService; private final OauthMemberRedisService oauthMemberRedisService; + private final TokenFactory tokenFactory; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, - ServletException { - String token = CookieUtils.getAccessToken((HttpServletRequest)request); - - if (token != null && !oauthMemberRedisService.isAlreadyLogout(token)) { - if (tokenService.verifyToken(token)) { - MemberAuthentication memberAuthentication = tokenService.parseMemberAuthenticationToken(token); - Authentication auth = getAuthentication(memberAuthentication); - SecurityContextHolder.getContext().setAuthentication(auth); + ServletException, + AuthenticationException { + String accessToken = CookieUtils.getAccessToken((HttpServletRequest)request); + String refreshToken = CookieUtils.getRefreshToken((HttpServletRequest)request); + + // accessToken 만료 and refreshToken 유효 => accessToken 갱신 + // accessToken 만료 and refreshToken 만료 임박 => accessToken 갱신, refreshToken 갱신 + // accessToken 유효 and refreshToken 만료 임박 => accessToken 갱신, refreshToken 갱신 + // accessToken 만료 and refreshToken 만료 => 401 + // accessToken 유효 and refreshToken 만료 => nothing + // accessToken 유효 and refreshToken 유효 => nothing + Token token = null; + if (accessToken != null && !oauthMemberRedisService.isAlreadyLogout(accessToken)) { + if (tokenService.isExpiredToken(accessToken)) { + token = tokenService.refreshToken(refreshToken, LocalDateTime.now()); + } else if (tokenService.verifyToken(accessToken) && tokenService.isRefreshTokenNearExpiry(refreshToken)) { + token = tokenService.refreshToken(refreshToken, LocalDateTime.now()); + } else if (tokenService.verifyToken(accessToken)) { + token = Token.create(accessToken, refreshToken); } } + + if (token != null) { + setAuthentication(token.getAccessToken()); + CookieUtils.setCookie((HttpServletResponse)response, tokenFactory.createAccessTokenCookie(token)); + CookieUtils.setCookie((HttpServletResponse)response, tokenFactory.createRefreshTokenCookie(token)); + } + chain.doFilter(request, response); } + private void setAuthentication(String accessToken) { + MemberAuthentication memberAuthentication = tokenService.parseMemberAuthenticationToken(accessToken); + Authentication auth = getAuthentication(memberAuthentication); + SecurityContextHolder.getContext().setAuthentication(auth); + } + private Authentication getAuthentication(MemberAuthentication memberAuthentication) { return new UsernamePasswordAuthenticationToken( memberAuthentication, diff --git a/src/main/java/codesquad/fineants/global/security/oauth/handler/OAuth2SuccessHandler.java b/src/main/java/codesquad/fineants/global/security/oauth/handler/OAuth2SuccessHandler.java index 6da92ba10..e4910c8bc 100644 --- a/src/main/java/codesquad/fineants/global/security/oauth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/codesquad/fineants/global/security/oauth/handler/OAuth2SuccessHandler.java @@ -1,6 +1,7 @@ package codesquad.fineants.global.security.oauth.handler; import java.io.IOException; +import java.util.Date; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; @@ -41,7 +42,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo log.debug("oAuth2User : {}", oAuth2User); log.debug("userDto : {}", memberAuthentication); - Token token = tokenService.generateToken(memberAuthentication); + Token token = tokenService.generateToken(memberAuthentication, new Date()); log.debug("token : {}", token); String redirectUrl = (String)request.getSession().getAttribute("redirect_url"); @@ -50,6 +51,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo } String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) + .queryParam("success", "true") .build() .toUriString(); diff --git a/src/main/java/codesquad/fineants/global/security/oauth/service/TokenService.java b/src/main/java/codesquad/fineants/global/security/oauth/service/TokenService.java index c72b48280..2725c8336 100644 --- a/src/main/java/codesquad/fineants/global/security/oauth/service/TokenService.java +++ b/src/main/java/codesquad/fineants/global/security/oauth/service/TokenService.java @@ -1,6 +1,8 @@ package codesquad.fineants.global.security.oauth.service; import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Base64; @@ -9,13 +11,14 @@ import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.stereotype.Service; import codesquad.fineants.global.errors.errorcode.JwtErrorCode; -import codesquad.fineants.global.errors.exception.FineAntsException; import codesquad.fineants.global.security.oauth.dto.MemberAuthentication; import codesquad.fineants.global.security.oauth.dto.Token; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -24,6 +27,8 @@ @Service @Slf4j public class TokenService { + + private static final Duration EXPIRY_IMMINENT_TIME = Duration.ofHours(1); private final String secretKey; private final Long tokenPeriod; private final Long refreshPeriod; @@ -38,10 +43,8 @@ public TokenService( this.refreshPeriod = refreshPeriod; } - public Token generateToken(MemberAuthentication authentication) { + public Token generateToken(MemberAuthentication authentication, Date now) { Claims claims = generateClaims(authentication); - - Date now = new Date(); String accessToken = generateAccessToken(claims, now); String refreshToken = generateRefreshToken(claims, now); return Token.create(accessToken, refreshToken); @@ -110,8 +113,64 @@ public Token refreshToken(String refreshToken, LocalDateTime now) { MemberAuthentication memberAuthentication = parseMemberAuthenticationToken(refreshToken); Claims claims = generateClaims(memberAuthentication); String accessToken = generateAccessToken(claims, Timestamp.valueOf(now)); - return Token.create(accessToken, refreshToken); + String newRefreshToken = refreshToken; + if (isRefreshTokenNearExpiry(refreshToken)) { + newRefreshToken = generateRefreshToken(claims, Timestamp.valueOf(now)); + } + return Token.create(accessToken, newRefreshToken); + } + throw new BadCredentialsException(JwtErrorCode.INVALID_TOKEN.getMessage()); + } + + /** + * 리프레시 토큰의 만료 임박 시간 체크 + * @param token 리프레시 토큰 + * @return 만료 임박 시간 체크 결과 + */ + public boolean isRefreshTokenNearExpiry(String token) { + Date expiration; + try { + Jws claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + expiration = claims.getBody() + .getExpiration(); + } catch (Exception e) { + return false; + } + + // 현재 시간 + Instant now = Instant.now(); + + // 만료 시간 + Instant expirationInstant = expiration.toInstant(); + + // 만료 까지의 시간 간격 + Duration duration = Duration.between(now, expirationInstant); + + // 만료 까지의 시간 간격이 EXPIRY_IMMINENT_TIME 미만 인지 확인 + return duration.compareTo(EXPIRY_IMMINENT_TIME) < 0; + } + + /** + * 토큰 만료 여부 확인 + * @param token JWT 토큰 + * @return 토큰 만료 여부 + */ + public boolean isExpiredToken(String token) { + try { + Jws claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + return claims.getBody() + .getExpiration() + .before(new Date()); + } catch (ExpiredJwtException e) { + return true; + } catch (Exception e) { + return false; } - throw new FineAntsException(JwtErrorCode.INVALID_TOKEN); } } diff --git a/src/test/java/codesquad/fineants/domain/member/controller/AuthenticationIntegrationTest.java b/src/test/java/codesquad/fineants/domain/member/controller/AuthenticationIntegrationTest.java index b7170bdbf..870699d24 100644 --- a/src/test/java/codesquad/fineants/domain/member/controller/AuthenticationIntegrationTest.java +++ b/src/test/java/codesquad/fineants/domain/member/controller/AuthenticationIntegrationTest.java @@ -3,19 +3,31 @@ import static io.restassured.RestAssured.*; import static org.hamcrest.Matchers.*; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; import java.util.Map; +import java.util.stream.Stream; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; import codesquad.fineants.AbstractContainerBaseTest; +import codesquad.fineants.domain.member.domain.entity.Member; import codesquad.fineants.domain.member.repository.MemberRepository; +import codesquad.fineants.global.security.factory.TokenFactory; +import codesquad.fineants.global.security.oauth.dto.MemberAuthentication; +import codesquad.fineants.global.security.oauth.dto.Token; +import codesquad.fineants.global.security.oauth.service.TokenService; import codesquad.fineants.global.util.ObjectMapperUtil; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; @@ -26,6 +38,12 @@ public class AuthenticationIntegrationTest extends AbstractContainerBaseTest { @Autowired private MemberRepository memberRepository; + @Autowired + private TokenService tokenService; + + @Autowired + private TokenFactory tokenFactory; + @LocalServerPort private int port; @@ -114,14 +132,36 @@ void logout() { .statusCode(401); } - // TODO: 테스트 해결 - @Disabled - @DisplayName("사용자는 프로필 조회를 요청하다가 액세스 토큰이 만료되어 갱신된다") - @Test - void refreshAccessToken() { + /** + * 토큰 갱신 테스트 + *

+ * 토큰 갱신 성공 케이스 + * - accessToken 만료 and refreshToken 유효 => accessToken 갱신 + * - accessToken 만료 and refreshToken 만료 임박 => accessToken 갱신, refreshToken 갱신 + * - accessToken 유효 and refreshToken 만료 임박 => refreshToken 갱신 + * 토큰 갱신 실패 케이스 + * - accessToken 만료 and refreshToken 만료 => 401 + * - accessToken 유효 and refreshToken 만료 => 401 + * + * @param accessTokenCreateDate AccessToken 생성 시간 + * @param refreshTokenCreateDate RefreshToken 생성 시간 + */ + @DisplayName("사용자는 액세스 토큰이 만료된 상태에서 액세스 토큰을 갱신한다") + @MethodSource(value = {"validJwtTokenCreateDateSource"}) + @ParameterizedTest(name = "{index} ==> the tokenCreateDate is {0}, {1} ") + void refreshAccessToken(Date accessTokenCreateDate, Date refreshTokenCreateDate) { // given - memberRepository.save(createMember()); - Map cookies = processLogin(); + Member member = memberRepository.save(createMember()); + Token token = tokenService.generateToken(MemberAuthentication.from(member), accessTokenCreateDate); + ResponseCookie accessTokenCookie = tokenFactory.createAccessTokenCookie(token); + + token = tokenService.generateToken(MemberAuthentication.from(member), refreshTokenCreateDate); + ResponseCookie refreshTokenCookie = tokenFactory.createRefreshTokenCookie(token); + + Map cookies = Map.of( + accessTokenCookie.getName(), accessTokenCookie.getValue(), + refreshTokenCookie.getName(), refreshTokenCookie.getValue() + ); // when & then given().log().all() @@ -130,13 +170,72 @@ void refreshAccessToken() { .when() .get("/api/profile") .then() - .cookie("accessToken", notNullValue()) - .cookie("refreshToken", notNullValue()) + .cookies("accessToken", notNullValue()) + .cookies("refreshToken", notNullValue()) .log() .body() .statusCode(200); } + public static Stream validJwtTokenCreateDateSource() { + Date now1 = Date.from(LocalDateTime.now().minusDays(1).toInstant(ZoneOffset.ofHours(9))); + Date now2 = Date.from( + LocalDateTime.now().minusDays(13).minusHours(23).minusMinutes(5).toInstant(ZoneOffset.ofHours(9))); + Date now3 = Date.from(LocalDateTime.now().toInstant(ZoneOffset.ofHours(9))); + return Stream.of( + + Arguments.of(now1, now1), + Arguments.of(now2, now2), + Arguments.of(now3, now2) + ); + } + + /** + * 토큰 갱신 실패 테스트 + *

+ * + * 토큰 갱신 실패 케이스 + * - accessToken 만료 and refreshToken 만료 => 401 + * + * @param accessTokenCreateDate AccessToken 생성 시간 + * @param refreshTokenCreateDate RefreshToken 생성 시간 + */ + @DisplayName("사용자는 리프레시 토큰이 만료된 상태에서는 액세스 토큰을 갱신할 수 없다") + @MethodSource(value = {"invalidJwtTokenCreateDateSource"}) + @ParameterizedTest(name = "{index} ==> the tokenCreateDate is {0}, {1} ") + void refreshAccessToken_whenExpiredRefreshToken_then401(Date accessTokenCreateDate, Date refreshTokenCreateDate) { + // given + Member member = memberRepository.save(createMember()); + Token token = tokenService.generateToken(MemberAuthentication.from(member), accessTokenCreateDate); + ResponseCookie accessTokenCookie = tokenFactory.createAccessTokenCookie(token); + + token = tokenService.generateToken(MemberAuthentication.from(member), refreshTokenCreateDate); + ResponseCookie refreshTokenCookie = tokenFactory.createRefreshTokenCookie(token); + + Map cookies = Map.of( + accessTokenCookie.getName(), accessTokenCookie.getValue(), + refreshTokenCookie.getName(), refreshTokenCookie.getValue() + ); + + // when & then + given().log().all() + .cookies(cookies) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when() + .get("/api/profile") + .then() + .log() + .body() + .statusCode(401); + } + + public static Stream invalidJwtTokenCreateDateSource() { + Date now1 = Date.from(LocalDateTime.now().minusDays(15).toInstant(ZoneOffset.ofHours(9))); + return Stream.of( + Arguments.of(now1, now1) + ); + } + private Map processLogin() { Map body = Map.of( "email", "dragonbead95@naver.com", diff --git a/src/test/java/codesquad/fineants/global/security/oauth/service/TokenServiceTest.java b/src/test/java/codesquad/fineants/global/security/oauth/service/TokenServiceTest.java index b3111e39d..281472333 100644 --- a/src/test/java/codesquad/fineants/global/security/oauth/service/TokenServiceTest.java +++ b/src/test/java/codesquad/fineants/global/security/oauth/service/TokenServiceTest.java @@ -3,7 +3,10 @@ import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; import java.util.Set; import org.jetbrains.annotations.NotNull; @@ -34,7 +37,7 @@ void generateToken() { // given MemberAuthentication authentication = createMemberAuthentication(); // when - Token token = tokenService.generateToken(authentication); + Token token = tokenService.generateToken(authentication, new Date()); // then assertAll( @@ -50,7 +53,7 @@ void generateToken() { void verifyToken() { // given MemberAuthentication authentication = createMemberAuthentication(); - Token token = tokenService.generateToken(authentication); + Token token = tokenService.generateToken(authentication, new Date()); // when boolean actual1 = tokenService.verifyToken(token.getAccessToken()); boolean actual2 = tokenService.verifyToken(token.getRefreshToken()); @@ -61,12 +64,38 @@ void verifyToken() { ); } + @DisplayName("액세스 토큰이 만료되면 false가 나온다") + @Test + void verifyToken_whenAccessTokenIsExpired_thenFalse() { + // given + Instant instant = LocalDateTime.now().minusDays(1L).toInstant(ZoneOffset.ofHours(9)); + Date now = Date.from(instant); + Token token = tokenService.generateToken(createMemberAuthentication(), now); + // when + boolean actual = tokenService.verifyToken(token.getAccessToken()); + // then + assertThat(actual).isFalse(); + } + + @DisplayName("액세스 토큰이 만료되었는지 확인한다") + @Test + void isExpiredToken_whenAccessTokenIsExpired_thenFalse() { + // given + Instant instant = LocalDateTime.now().minusDays(1L).toInstant(ZoneOffset.ofHours(9)); + Date now = Date.from(instant); + Token token = tokenService.generateToken(createMemberAuthentication(), now); + // when + boolean actual = tokenService.isExpiredToken(token.getAccessToken()); + // then + assertThat(actual).isTrue(); + } + @DisplayName("토큰을 파싱한다") @Test void parseMemberAuthenticationToken() { // given MemberAuthentication authentication = createMemberAuthentication(); - Token token = tokenService.generateToken(authentication); + Token token = tokenService.generateToken(authentication, new Date()); // when MemberAuthentication memberAuthentication = tokenService.parseMemberAuthenticationToken(token.getAccessToken()); @@ -85,7 +114,7 @@ void parseMemberAuthenticationToken() { void refreshToken() { // given MemberAuthentication authentication = createMemberAuthentication(); - Token token = tokenService.generateToken(authentication); + Token token = tokenService.generateToken(authentication, new Date()); // when Token newToken = tokenService.refreshToken(token.getRefreshToken(), LocalDateTime.now()); // then