diff --git a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java index c5ad59b..473d09c 100644 --- a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java +++ b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java @@ -3,6 +3,7 @@ import com.moongeul.backend.api.member.dto.LoginResponseDTO; import com.moongeul.backend.api.member.dto.LoginRequestDTO; import com.moongeul.backend.api.member.dto.UserInfoDTO; +import com.moongeul.backend.api.member.jwt.dto.JwtTokenDTO; import com.moongeul.backend.api.member.service.MemberService; import com.moongeul.backend.common.response.ApiResponse; import com.moongeul.backend.common.response.SuccessStatus; @@ -71,5 +72,20 @@ public ResponseEntity> getUserInfo(@AuthenticationPrinc UserInfoDTO response = memberService.getUserInfo(userDetails.getUsername()); return ApiResponse.success(SuccessStatus.GET_USERINFO_SUCCESS, response); } + + @Operation( + summary = "토큰 재발급 API", + description = "엑세스 토큰 만료 시, 유효한 리프레시 토큰을 통해 엑세스 토큰을 재발급 받습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "토큰이 만료되었거나 유효하지 않은 토큰입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 사용자를 찾을 수 없습니다.") + }) + @GetMapping("/reissue-token") + public ResponseEntity> reissueAccessToken(@RequestHeader(value = "Authorization-Refresh") String refreshToken){ + JwtTokenDTO response = memberService.reissueToken(refreshToken); + return ApiResponse.success(SuccessStatus.REISSUE_TOKEN_SUCCESS, response); + } } diff --git a/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java b/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java index 360c430..31d517d 100644 --- a/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java +++ b/src/main/java/com/moongeul/backend/api/member/repository/MemberRepository.java @@ -10,4 +10,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findBySocialId(String socialId); + + Optional findByRefreshToken(String refreshToken); } diff --git a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java index b60582e..63cfd9a 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java @@ -7,6 +7,7 @@ import com.moongeul.backend.api.member.repository.MemberRepository; import com.moongeul.backend.common.config.jwt.JwtTokenProvider; import com.moongeul.backend.common.exception.NotFoundException; +import com.moongeul.backend.common.exception.UnauthorizedException; import com.moongeul.backend.common.response.ErrorStatus; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -117,4 +118,27 @@ private Member getMemberByEmail(String email) { return memberRepository.findByEmail(email) .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); } + + @Transactional + public JwtTokenDTO reissueToken(String refreshToken){ + + // 1. Refresh Token 유효성 검증 + if(!jwtTokenProvider.validateToken(refreshToken)){ + throw new UnauthorizedException(ErrorStatus.TOKEN_UNAUTHORIZED.getMessage()); + } + + // 2. DB에서 해당 Refresh Token을 가진 회원 찾기 + Member member = memberRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + // 3. 토큰 재발급 (Access/Refresh) + JwtTokenDTO jwtToken = jwtTokenProvider.generateToken(member); + + // 4. DB에 새로 발급한 Refresh Token 저장 + member.updateRefreshToken(jwtToken.getRefreshToken()); + memberRepository.save(member); + + // 5. 재발급 토큰 반환 + return jwtToken; + } } diff --git a/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java b/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java index db67875..8a6d902 100644 --- a/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java +++ b/src/main/java/com/moongeul/backend/common/config/jwt/JwtTokenProvider.java @@ -77,21 +77,25 @@ public JwtTokenDTO generateToken(Member member) { } // 토큰 정보를 검증하는 메서드 - // 3. 신분증 검사 : 제출하는 액세스 토큰이 진짜인지 확인 + // 3. 신분증 검사 : 제출하는 토큰이 진짜인지 확인 public boolean validateToken(String token) { try { Jwts.parser() - .verifyWith(key) + .verifyWith(key) // 서명(signature) 검증 : Header + Payload를 key로 다시 서명하여 생성한 값과 토큰의 Signature를 비교 .build() - .parseSignedClaims(token); + .parseSignedClaims(token); // 만료 시간 확인 : Payload의 exp (expiration) claim과 현재 시간 비교 return true; } catch (SecurityException | MalformedJwtException e) { + // 서명이 잘못되었거나 JWT 형식이 올바르지 않음 log.info("Invalid JWT Token", e); } catch (ExpiredJwtException e) { + // 토큰이 만료됨 (exp < 현재시간) log.info("Expired JWT Token", e); } catch (UnsupportedJwtException e) { + // 지원하지 않는 서명 알고리즘 또는 JWT 타입 log.info("Unsupported JWT Token", e); } catch (IllegalArgumentException e) { + // 토큰이 null이거나 빈 문자열 log.info("JWT claims string is empty.", e); } return false; diff --git a/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java b/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java index e85412b..4c4f015 100644 --- a/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java +++ b/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java @@ -31,8 +31,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 무상태 설정 .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable())) //h2-console 화면 깨짐 방지(iframe 렌더링 오류) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/api/v2/member/google/login", "/api/v2/member/kakao/login", "/h2-console/**").permitAll() + .requestMatchers("/", "/h2-console/**").permitAll() .requestMatchers("/v3/api-docs/**", "/api-doc/**", "/swagger-ui/**").permitAll() + .requestMatchers("/api/v2/member/google/login", "/api/v2/member/kakao/login", "api/v2/member/reissue-token").permitAll() .anyRequest().authenticated() ); diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index 2779c0f..eb32a4b 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -23,6 +23,7 @@ public enum ErrorStatus { * 401 UNAUTHORIZED */ AUTH_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "유효하지 않은 인가코드 입니다."), + TOKEN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었거나 유효하지 않은 토큰입니다."), /** * 404 NOT_FOUND diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 83b47d1..60885d9 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -14,7 +14,8 @@ public enum SuccessStatus { */ SEND_HEALTH_CHECK_SUCCESS(HttpStatus.OK,"서버 상태 체크 성공"), SEND_LOGIN_SUCCESS(HttpStatus.OK, "로그인 성공"), - GET_USERINFO_SUCCESS(HttpStatus.OK, "사용자 정보 조회 성공") + GET_USERINFO_SUCCESS(HttpStatus.OK, "사용자 정보 조회 성공"), + REISSUE_TOKEN_SUCCESS(HttpStatus.OK, "토큰 재발급 성공") /** * 201