diff --git a/src/main/java/com/cmc/mercury/global/config/SecurityConfig.java b/src/main/java/com/cmc/mercury/global/config/SecurityConfig.java index a3b8e41..b9a3ef4 100644 --- a/src/main/java/com/cmc/mercury/global/config/SecurityConfig.java +++ b/src/main/java/com/cmc/mercury/global/config/SecurityConfig.java @@ -66,6 +66,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/login/**", "/oauth2/**").permitAll() // 도서 검색, 사용자 api, health check .requestMatchers("/api/books/search", "/api/users/**", "/api/health").permitAll() + // access token 없어도 호출 가능해야 함 + .requestMatchers("/api/auth/refresh").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 diff --git a/src/main/java/com/cmc/mercury/global/controller/AuthController.java b/src/main/java/com/cmc/mercury/global/controller/AuthController.java new file mode 100644 index 0000000..4c5d617 --- /dev/null +++ b/src/main/java/com/cmc/mercury/global/controller/AuthController.java @@ -0,0 +1,63 @@ +package com.cmc.mercury.global.controller; + +import com.cmc.mercury.domain.user.entity.User; +import com.cmc.mercury.global.exception.CustomException; +import com.cmc.mercury.global.exception.ErrorCode; +import com.cmc.mercury.global.jwt.JwtProvider; +import com.cmc.mercury.global.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "AuthController", description = "토큰 발급 API") +@Slf4j +public class AuthController { + + private final JwtProvider jwtProvider; + + @Value("${jwt.refresh-token-validity}") + private long refreshTokenValidity; + + @PostMapping("/refresh") + @Operation(summary = "refresh token 재발급", description = "access token 만료 시 refresh token을 통해 재발급을 요청합니다.") + public SuccessResponse refreshAccessToken( + @CookieValue(value = "refresh_token", required = false) String refreshToken, HttpServletResponse response) { + + log.info("Refresh Token을 이용한 Access Token 갱신 요청"); + + // Refresh Token 갱신 + String newRefreshToken = jwtProvider.refreshToken(refreshToken); + + // 새로운 Access Token 생성 + User user = jwtProvider.getUserFromToken(refreshToken); + String newAccessToken = jwtProvider.createAccessToken(user.getId(), user.getEmail()); + + // 새로운 Access Token을 헤더에 추가 + response.setHeader("Authorization", "Bearer " + newAccessToken); + + // 새로운 Refresh Token을 쿠키에 설정 + Cookie refreshTokenCookie = new Cookie("refresh_token", newRefreshToken); + refreshTokenCookie.setHttpOnly(true); // JavaScript에서 접근 방지 + // refreshTokenCookie.setSecure(true); // HTTPS만 허용 + refreshTokenCookie.setPath("/"); // 모든 경로에서 접근 가능 + // refreshTokenCookie.setDomain("mercuryplanet.co.kr"); // 도메인 간 쿠키 공유 + refreshTokenCookie.setMaxAge((int) refreshTokenValidity / 1000); // ms를 초 단위로 변환 + response.addCookie(refreshTokenCookie); + + return SuccessResponse.ok(new HashMap<>()); + } +} diff --git a/src/main/java/com/cmc/mercury/global/exception/ErrorCode.java b/src/main/java/com/cmc/mercury/global/exception/ErrorCode.java index 639fe52..3f095d2 100644 --- a/src/main/java/com/cmc/mercury/global/exception/ErrorCode.java +++ b/src/main/java/com/cmc/mercury/global/exception/ErrorCode.java @@ -65,7 +65,8 @@ public enum ErrorCode { EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "만료된 access 토큰입니다."), EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "만료된 refresh 토큰입니다."), TOKEN_TYPE_MISMATCH(HttpStatus.UNAUTHORIZED, "Jwt401", "토큰 타입이 일치하지 않습니다."), - EMPTY_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "토큰이 없습니다."); + EMPTY_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "access 토큰이 없습니다."), + EMPTY_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "refresh 토큰이 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/cmc/mercury/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/cmc/mercury/global/jwt/JwtAuthenticationFilter.java index f5aa84d..d0847b6 100644 --- a/src/main/java/com/cmc/mercury/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/cmc/mercury/global/jwt/JwtAuthenticationFilter.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -47,7 +48,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { path.startsWith("/api-docs") || path.startsWith("/api/books/search") || path.startsWith("/api/users") || - path.startsWith("/api/health"); + path.startsWith("/api/health") || + path.startsWith("/api/auth/refresh"); } @Override @@ -60,7 +62,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // Access Token이 없음 if (!StringUtils.hasText(accessToken)) { - throw new CustomException(ErrorCode.EMPTY_TOKEN); + throw new CustomException(ErrorCode.EMPTY_ACCESS_TOKEN); } try { @@ -76,48 +78,27 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); + return; + } catch (CustomException e) { - // Access Token이 만료된 경우, Refresh Token 확인 + // Access Token이 만료된 경우, 바로 에러 반환 if (e.getErrorCode() == ErrorCode.EXPIRED_ACCESS_TOKEN) { - String refreshToken = extractRefreshToken(request); - - if (StringUtils.hasText(refreshToken)) { - // Refresh Token이 유효한지 검증 - jwtProvider.validateToken(refreshToken, "RefreshToken"); - // Refresh Token이 DB에 저장된 것과 일치하는지 검증 - jwtProvider.checkRefreshToken(refreshToken); - - // Refresh Token이 유효하면 새로운 Access Token과 Refresh Token 발급 - User user = jwtProvider.getUserFromToken(refreshToken); - String newAccessToken = jwtProvider.createAccessToken(user.getId(), user.getEmail()); - String newRefreshToken = jwtProvider.createRefreshToken(user.getId(), user.getEmail()); - - // 새로운 Access Token을 헤더에 추가 - response.setHeader("Authorization", "Bearer " + newAccessToken); - - // 새로운 Refresh Token을 쿠키에 설정 - Cookie refreshTokenCookie = new Cookie("refresh_token", newRefreshToken); - refreshTokenCookie.setHttpOnly(true); // JavaScript에서 접근 방지 - // refreshTokenCookie.setSecure(true); // HTTPS만 허용 - refreshTokenCookie.setPath("/"); // 모든 경로에서 접근 가능 - // refreshTokenCookie.setDomain("mercuryplanet.co.kr"); // 도메인 간 쿠키 공유 - refreshTokenCookie.setMaxAge((int) refreshTokenValidity / 1000); // ms를 초 단위로 변환 - response.addCookie(refreshTokenCookie); - - // Authentication 객체 생성해서 SecurityContext에 저장 - Authentication authentication = createAuthentication(user); - SecurityContextHolder.getContext().setAuthentication(authentication); - - filterChain.doFilter(request, response); - - return; - } + + response.setStatus(e.getErrorCode().getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(e.getErrorCode()); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + + return; } - // 그 외 토큰 관련 에러 - throw new CustomException(e.getErrorCode()); + // 그 외 토큰 관련 에러 그대로 던짐 + throw e; } } catch (CustomException e) { - SecurityContextHolder.clearContext(); + SecurityContextHolder.clearContext(); // 최종적으로 인증이 실패한 경우 초기화 response.setStatus(e.getErrorCode().getHttpStatus().value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); @@ -163,4 +144,4 @@ private Authentication createAuthentication(User user) { userDetails.getAuthorities() ); } -} +} \ No newline at end of file diff --git a/src/main/java/com/cmc/mercury/global/jwt/JwtProvider.java b/src/main/java/com/cmc/mercury/global/jwt/JwtProvider.java index 7284a37..bd1d291 100644 --- a/src/main/java/com/cmc/mercury/global/jwt/JwtProvider.java +++ b/src/main/java/com/cmc/mercury/global/jwt/JwtProvider.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import java.util.Date; @@ -121,16 +122,40 @@ public void checkRefreshToken(String token) { User user = getUserFromToken(token); String storedRefreshToken = user.getRefreshToken(); - if (storedRefreshToken == null) { - // DB에 Refresh Token이 없는 경우 (이미 무효화된 경우) - throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN); + if (storedRefreshToken == null) { + // 사용자가 로그아웃한 경우 + throw new CustomException(ErrorCode.EMPTY_REFRESH_TOKEN); } - if (!token.equals(user.getRefreshToken())) { +/* if (!token.equals(user.getRefreshToken())) { // DB의 Refresh Token과 일치하지 않으면 재사용 시도로 간주 user.updateRefreshToken(null); // DB의 Refresh Token 무효화 userRepository.save(user); throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN); + }*/ + if (!token.equals(storedRefreshToken)) { + throw new CustomException(ErrorCode.EXPIRED_REFRESH_TOKEN); } } + + public String refreshToken(String refreshToken) { + + // Refresh Token이 없음 + if (!StringUtils.hasText(refreshToken)) { + throw new CustomException(ErrorCode.EMPTY_REFRESH_TOKEN); + } + + // Refresh Token 검증 + validateToken(refreshToken, "RefreshToken"); + // DB에 저장된 Refresh Token과 비교하여 유효성 확인 + checkRefreshToken(refreshToken); + + // Refresh Token이 유효하면 새로운 Refresh Token 발급 + User user = getUserFromToken(refreshToken); + String newRefreshToken = createRefreshToken(user.getId(), user.getEmail()); + // 새 Refresh Token을 DB에 저장 + updateRefreshToken(user.getId(), newRefreshToken); + + return newRefreshToken; + } }