diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AdminController.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AdminController.java
index 15696e03..4c260dae 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AdminController.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AdminController.java
@@ -19,7 +19,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
-@Tag(name = "admin: 관리자", description = "백엔드를 테스트를 위한 API")
+@Tag(name = "Admin: 관리자", description = "백엔드를 테스트를 위한 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin")
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AuthController.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AuthController.java
index 4caa82c7..f8fec25c 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AuthController.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/controller/AuthController.java
@@ -2,8 +2,12 @@
import com.tico.pomoro_do.domain.user.dto.request.GoogleJoinDTO;
import com.tico.pomoro_do.domain.user.dto.response.JwtDTO;
+import com.tico.pomoro_do.domain.user.dto.response.TokenDTO;
import com.tico.pomoro_do.domain.user.service.AuthService;
+import com.tico.pomoro_do.domain.user.service.TokenService;
+import com.tico.pomoro_do.global.auth.jwt.JWTUtil;
import com.tico.pomoro_do.global.code.SuccessCode;
+import com.tico.pomoro_do.global.enums.TokenType;
import com.tico.pomoro_do.global.response.SuccessResponseDTO;
import com.tico.pomoro_do.global.code.ErrorCode;
import com.tico.pomoro_do.global.exception.CustomException;
@@ -16,6 +20,8 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -38,19 +44,21 @@ public class AuthController {
// 토큰 갱신
private final AuthService authService;
+ private final TokenService tokenService;
+ //jwt관리 및 검증 utill
+ private final JWTUtil jwtUtil;
/**
* 구글 로그인 API
*
- * @param googleIdTokenHeader Google-ID-Token 헤더에는 구글 ID 토큰이 포함됩니다.
+ * @param googleIdTokenHeader Google-ID-Token 헤더에 포함된 구글 ID 토큰
* @return 성공 시 JwtDTO를 포함하는 SuccessResponseDTO
* @throws CustomException 구글 ID 토큰 검증에 실패한 경우 예외를 던집니다.
*/
@Operation(
- summary = "로그인",
- description = "구글 소셜 로그인을 통해 로그인을 수행합니다.
"
- + "Google-ID-Token 헤더에는 구글 ID 토큰을 입력해야 합니다. 예시: Bearer 차돌이짱귀여워
"
- + "로그인 성공 시에는 JwtDTO를 포함하는 SuccessResponseDTO를 반환합니다.",
+ summary = "구글 로그인",
+ description = "구글 소셜 로그인을 통해 사용자를 인증하고 JWT 토큰을 발급합니다.
"
+ + "Google-ID-Token 헤더에 구글 ID 토큰을 입력해야 합니다. 예시: Bearer ",
parameters = @Parameter(
name = "Google-ID-Token",
description = "Google ID Token",
@@ -68,7 +76,9 @@ public class AuthController {
content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class)))
})
@PostMapping("/google/login")
- public ResponseEntity> googleLogin(@RequestHeader("Google-ID-Token") String googleIdTokenHeader) {
+ public ResponseEntity> googleLogin(
+ @RequestHeader("Google-ID-Token") String googleIdTokenHeader
+ ) {
try {
JwtDTO jwtResponse = authService.googleLogin(googleIdTokenHeader);
SuccessResponseDTO response = SuccessResponseDTO.builder()
@@ -78,7 +88,7 @@ public ResponseEntity> googleLogin(@RequestHeader("Go
.build();
return ResponseEntity.ok(response);
} catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
- log.error("Google ID Token verification failed: {}", e.getMessage(), e);
+ log.error("구글 ID 토큰 검증 실패: {}", e.getMessage(), e);
throw new CustomException(ErrorCode.GOOGLE_TOKEN_VERIFICATION_FAILED);
}
}
@@ -86,16 +96,15 @@ public ResponseEntity> googleLogin(@RequestHeader("Go
/**
* 구글 회원가입 API
*
- * @param googleIdTokenHeader Google-ID-Token 헤더에는 구글 ID 토큰이 포함됩니다.
+ * @param googleIdTokenHeader Google-ID-Token 헤더에 포함된 구글 ID 토큰
* @param requestUserInfo 회원가입 요청 정보가 포함된 DTO
* @return 성공 시 JwtDTO를 포함하는 SuccessResponseDTO
* @throws CustomException 구글 ID 토큰 검증에 실패한 경우 예외를 던집니다.
*/
@Operation(
- summary = "회원 가입",
- description = "구글 소셜 로그인을 통해 회원가입을 수행합니다.
"
- + "Google-ID-Token 헤더에는 구글 ID 토큰을 입력하고, 요청 본문에는 닉네임 등의 추가 정보를 포함해야 합니다.
"
- + "회원가입 성공 시에는 JwtDTO를 포함하는 SuccessResponseDTO를 반환합니다.",
+ summary = "구글 회원가입",
+ description = "구글 소셜 로그인을 통해 사용자를 회원가입하고 JWT 토큰을 발급합니다.
"
+ + "Google-ID-Token 헤더에 구글 ID 토큰을 입력하고, 요청 본문에는 추가 정보를 포함해야 합니다.",
parameters = @Parameter(
name = "Google-ID-Token",
description = "Google ID Token",
@@ -113,8 +122,10 @@ public ResponseEntity> googleLogin(@RequestHeader("Go
content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class)))
})
@PostMapping("/google/join")
- public ResponseEntity> googleJoin(@RequestHeader("Google-ID-Token") String googleIdTokenHeader,
- @Valid @RequestBody GoogleJoinDTO requestUserInfo) {
+ public ResponseEntity> googleJoin(
+ @RequestHeader("Google-ID-Token") String googleIdTokenHeader,
+ @Valid @RequestBody GoogleJoinDTO requestUserInfo
+ ) {
try {
JwtDTO jwtResponse = authService.googleJoin(googleIdTokenHeader, requestUserInfo);
SuccessResponseDTO response = SuccessResponseDTO.builder()
@@ -124,8 +135,116 @@ public ResponseEntity> googleJoin(@RequestHeader("Goo
.build();
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
- log.error("Google ID Token verification failed: {}", e.getMessage(), e);
+ log.error("구글 ID 토큰 검증 실패: {}", e.getMessage(), e);
throw new CustomException(ErrorCode.GOOGLE_TOKEN_VERIFICATION_FAILED);
}
}
+
+ /**
+ * 토큰 재발급 API
+ *
+ * @param request HTTP 요청 객체
+ * @param response HTTP 응답 객체
+ * @return 재발급된 토큰을 포함하는 SuccessResponseDTO
+ */
+ @Operation(
+ summary = "토큰 재발급",
+ description = "리프레시 토큰을 사용하여 새로운 액세스 토큰 및 리프레시 토큰을 재발급합니다."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"),
+ @ApiResponse(responseCode = "401", description = "리프레시 토큰이 유효하지 않음",
+ content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class))),
+ @ApiResponse(responseCode = "400", description = "리프레시 토큰 헤더가 없음",
+ content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class)))
+ })
+ @PostMapping("/token/reissue")
+ public ResponseEntity> reissueToken(
+ HttpServletRequest request,
+ HttpServletResponse response
+ ) {
+ // 엑세스 토큰으로 현재 Redis 정보 삭제
+ // AuthService의 reissueToken 메서드 호출하여 결과 받기
+ TokenDTO tokenDTO = authService.reissueToken(request, response);
+
+ SuccessResponseDTO successResponse = SuccessResponseDTO.builder()
+ .status(SuccessCode.ACCESS_TOKEN_REISSUED.getHttpStatus().value())
+ .message(SuccessCode.ACCESS_TOKEN_REISSUED.getMessage())
+ .data(tokenDTO)
+ .build();
+
+ // 재발행 성공 시, HTTP 상태 코드 200(OK)와 함께 결과 반환
+ return ResponseEntity.ok(successResponse);
+ }
+
+// /**
+// * 액세스 토큰 검증 API
+// * 이 엔드포인트는 JWT 인증이 필요하지 않습니다.
+// *
+// * @param tokenHeader X-Auth-Token 헤더로 전달된 액세스 토큰
+// * @return 토큰 검증 결과를 반환합니다.
+// */
+// @Operation(
+// summary = "액세스 토큰 검증",
+// description = "전달된 액세스 토큰이 유효한지 검증합니다."
+// )
+// @ApiResponses(value = {
+// @ApiResponse(responseCode = "200", description = "토큰 검증 성공"),
+// @ApiResponse(responseCode = "401", description = "토큰이 유효하지 않음",
+// content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class))),
+// @ApiResponse(responseCode = "400", description = "토큰 헤더가 없음",
+// content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class)))
+// })
+// @GetMapping("/token/validate")
+// public ResponseEntity> validateAccessToken(
+// @RequestHeader("X-Auth-Token") String tokenHeader
+// ) {
+// String token = authService.extractToken(tokenHeader, TokenType.JWT);
+// tokenService.validateToken(token, "access");
+// SuccessResponseDTO successResponse = SuccessResponseDTO.builder()
+// .status(SuccessCode.ACCESS_TOKEN_VALIDATED.getHttpStatus().value())
+// .message(SuccessCode.ACCESS_TOKEN_VALIDATED.getMessage())
+// .data(SuccessCode.ACCESS_TOKEN_VALIDATED.name()) // data가 없을 때는 null로 설정
+// .build();
+//
+// // 검증 성공 시, HTTP 상태 코드 200(OK)와 함께 결과 반환
+// return ResponseEntity.ok(successResponse);
+// }
+
+ /**
+ * 로그아웃 API
+ * 로그아웃하여 Refresh 토큰을 삭제합니다.
+ *
+ * @param request HTTP 요청 객체
+ * @param response HTTP 응답 객체
+ * @return 로그아웃 및 토큰 삭제 결과를 반환합니다.
+ */
+ @Operation(
+ summary = "로그아웃 및 토큰 만료",
+ description = "사용자가 로그아웃할 때, 쿠키의 Refresh 토큰을 만료시켜 삭제합니다."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "로그아웃 성공",
+ content = @Content(schema = @Schema(implementation = SuccessResponseDTO.class))),
+ @ApiResponse(responseCode = "400", description = "로그아웃 요청이 잘못됨",
+ content = @Content(schema = @Schema(implementation = ErrorResponseEntity.class)))
+ })
+ @DeleteMapping("/logout")
+ public ResponseEntity> removeToken(
+ HttpServletRequest request,
+ HttpServletResponse response
+ ) {
+ // 액세스 토큰으로 현재 Redis 정보 삭제
+ tokenService.removeRefreshToken(request, response);
+
+ SuccessResponseDTO successResponse = SuccessResponseDTO.builder()
+ .status(SuccessCode.LOGOUT_SUCCESS.getHttpStatus().value())
+ .message(SuccessCode.LOGOUT_SUCCESS.getMessage())
+ .data(SuccessCode.LOGOUT_SUCCESS.name()) // data가 없을 때는 null로 설정
+ .build();
+
+ // 토큰 삭제 성공 응답 처리
+ return ResponseEntity.ok(successResponse);
+ }
+
}
\ No newline at end of file
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/dto/response/TokenDTO.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/dto/response/TokenDTO.java
new file mode 100644
index 00000000..a78c64ef
--- /dev/null
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/dto/response/TokenDTO.java
@@ -0,0 +1,18 @@
+package com.tico.pomoro_do.domain.user.dto.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+
+@Getter
+@Schema(description = "JWT Response")
+public class TokenDTO {
+
+ @Schema(description = "Access Token", nullable = false)
+ private String accessToken;
+
+ @Builder
+ public TokenDTO(String accessToken){
+ this.accessToken = accessToken;
+ }
+}
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/entity/Account.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/entity/Refresh.java
similarity index 54%
rename from backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/entity/Account.java
rename to backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/entity/Refresh.java
index f7fc8c5f..491c4e38 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/entity/Account.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/entity/Refresh.java
@@ -7,24 +7,26 @@
import lombok.NoArgsConstructor;
@Entity
-@Table(name = "account")
@Getter
+@Table(name = "refresh")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class Account {
+public class Refresh {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
- @Column(name = "account_id")
+ @Column(name = "refresh_id")
private Long id;
private String username;
- private String password;
- private String role;
+ @Column(name = "refresh_token")
+ private String refreshToken;
+ private String expiration;
+ // 생성자
@Builder
- public Account(String username, String password, String role) {
+ public Refresh (String username, String refreshToken, String expiration){
this.username = username;
- this.password = password;
- this.role = role;
+ this.refreshToken = refreshToken;
+ this.expiration = expiration;
}
-}
+}
\ No newline at end of file
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/repository/RefreshRepository.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/repository/RefreshRepository.java
new file mode 100644
index 00000000..8c1523b1
--- /dev/null
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/repository/RefreshRepository.java
@@ -0,0 +1,19 @@
+package com.tico.pomoro_do.domain.user.repository;
+
+import com.tico.pomoro_do.domain.user.entity.Refresh;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.transaction.annotation.Transactional;
+
+public interface RefreshRepository extends JpaRepository {
+
+ // 해당 리프레쉬 토큰의 존재 여부를 판단하는 메소드
+ Boolean existsByRefreshToken(String refreshToken);
+
+ // 해당 리프레쉬 토큰을 삭제하는 메소드
+ @Transactional
+ void deleteByRefreshToken(String refreshToken);
+
+ // 사용자 이름을 기준으로 모든 리프레쉬 토큰을 삭제하는 메소드
+ @Transactional
+ void deleteByUsername(String username);
+}
\ No newline at end of file
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthService.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthService.java
index 58a83daa..b14eb429 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthService.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthService.java
@@ -4,9 +4,12 @@
import com.tico.pomoro_do.domain.user.dto.GoogleUserInfoDTO;
import com.tico.pomoro_do.domain.user.dto.request.GoogleJoinDTO;
import com.tico.pomoro_do.domain.user.dto.response.JwtDTO;
+import com.tico.pomoro_do.domain.user.dto.response.TokenDTO;
import com.tico.pomoro_do.domain.user.entity.User;
import com.tico.pomoro_do.global.enums.TokenType;
import com.tico.pomoro_do.global.enums.UserRole;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.GeneralSecurityException;
@@ -32,5 +35,6 @@ public interface AuthService {
JwtDTO createJwtTokens(String email, String role);
// Refresh 토큰으로 Access토큰 발급
+ TokenDTO reissueToken(HttpServletRequest request, HttpServletResponse response);
}
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthServiceImpl.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthServiceImpl.java
index c979e35c..52f711de 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthServiceImpl.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/AuthServiceImpl.java
@@ -6,8 +6,10 @@
import com.tico.pomoro_do.domain.user.dto.GoogleUserInfoDTO;
import com.tico.pomoro_do.domain.user.dto.request.GoogleJoinDTO;
import com.tico.pomoro_do.domain.user.dto.response.JwtDTO;
+import com.tico.pomoro_do.domain.user.dto.response.TokenDTO;
import com.tico.pomoro_do.domain.user.entity.SocialLogin;
import com.tico.pomoro_do.domain.user.entity.User;
+import com.tico.pomoro_do.domain.user.repository.RefreshRepository;
import com.tico.pomoro_do.domain.user.repository.SocialLoginRepository;
import com.tico.pomoro_do.domain.user.repository.UserRepository;
import com.tico.pomoro_do.global.auth.jwt.JWTUtil;
@@ -16,6 +18,10 @@
import com.tico.pomoro_do.global.enums.UserRole;
import com.tico.pomoro_do.global.code.ErrorCode;
import com.tico.pomoro_do.global.exception.CustomException;
+import com.tico.pomoro_do.global.util.CookieUtil;
+import io.jsonwebtoken.ExpiredJwtException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -34,6 +40,7 @@
@RequiredArgsConstructor // 파이널 필드만 가지고 생성사 주입 함수 만듬 (따로 작성할 필요 없다.)
@Slf4j
public class AuthServiceImpl implements AuthService {
+
@Value("${google.clientId}")
private String clientId;
@@ -46,6 +53,8 @@ public class AuthServiceImpl implements AuthService {
private final JWTUtil jwtUtil;
private final UserRepository userRepository;
private final SocialLoginRepository socialLoginRepository;
+ private final RefreshRepository refreshRepository;
+ private final TokenService tokenService;
/**
* 구글 ID 토큰으로 무결성 검증
@@ -74,6 +83,7 @@ public GoogleUserInfoDTO verifyGoogleIdToken(String idToken) throws GeneralSecur
.pictureUrl((String) payload.get("picture"))
.build();
} else {
+ log.error("구글 ID 토큰 검증 실패: 토큰이 유효하지 않음");
throw new CustomException(ErrorCode.GOOGLE_TOKEN_VERIFICATION_FAILED);
}
}
@@ -88,13 +98,14 @@ public GoogleUserInfoDTO verifyGoogleIdToken(String idToken) throws GeneralSecur
@Override
public JwtDTO createJwtTokens(String email, String role) {
//토큰 생성 (카테고리, 유저이름, 역할, 만료시간)
+ log.info("Access 토큰 및 Refresh 토큰 생성: 이메일 = {}, 역할 = {}", email, role);
String accessToken = jwtUtil.createJwt("access", email, role, accessExpiration); //10분
String refreshToken = jwtUtil.createJwt("refresh", email, role, refreshExpiration); //24시간
return new JwtDTO(accessToken, refreshToken);
}
/**
- * 토큰 관련 헤더에서 토큰 값을 추출합니다.
+ * 헤더에서 토큰 값을 추출
*
* @param header 토큰 헤더 (예: "Bearer ")
* @param tokenType 토큰의 타입 (Google ID 토큰 또는 JWT)
@@ -129,14 +140,17 @@ public String extractToken(String header, TokenType tokenType) {
@Override
@Transactional
public JwtDTO googleLogin(String idTokenHeader) throws GeneralSecurityException, IOException {
+ log.info("구글 로그인 처리 시작");
String idToken = extractToken(idTokenHeader, TokenType.GOOGLE);
GoogleUserInfoDTO userInfo = verifyGoogleIdToken(idToken);
if (!userRepository.existsByUsername(userInfo.getEmail())) {
+ log.error("사용자 등록되지 않음: 이메일 = {}", userInfo.getEmail());
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}
+ log.info("구글 로그인 성공: 이메일 = {}", userInfo.getEmail());
return createJwtTokens(userInfo.getEmail(), String.valueOf(UserRole.USER));
}
@@ -153,10 +167,13 @@ public JwtDTO googleLogin(String idTokenHeader) throws GeneralSecurityException,
@Override
@Transactional
public JwtDTO googleJoin(String idTokenHeader, GoogleJoinDTO requestUserInfo) throws GeneralSecurityException, IOException {
+ log.info("구글 회원가입 처리 시작");
+
String idToken = extractToken(idTokenHeader, TokenType.GOOGLE);
GoogleUserInfoDTO userInfo = verifyGoogleIdToken(idToken);
if (userRepository.existsByUsername(userInfo.getEmail())) {
+ log.error("이미 등록된 사용자: 이메일 = {}", userInfo.getEmail());
throw new CustomException(ErrorCode.USER_ALREADY_REGISTERED);
}
@@ -176,11 +193,12 @@ public JwtDTO googleJoin(String idTokenHeader, GoogleJoinDTO requestUserInfo) th
.build();
socialLoginRepository.save(socialLogin);
+ log.info("구글 회원가입 성공: 이메일 = {}", userInfo.getEmail());
return createJwtTokens(userInfo.getEmail(), String.valueOf(UserRole.USER));
}
/**
- * USER 생성하기
+ * 새 사용자 생성
*
* @param username 사용자 이메일
* @param nickname 사용자 닉네임
@@ -190,9 +208,9 @@ public JwtDTO googleJoin(String idTokenHeader, GoogleJoinDTO requestUserInfo) th
*/
@Override
@Transactional
- public User createUser(String username, String nickname, String profileImageUrl, UserRole role){
+ public User createUser(String username, String nickname, String profileImageUrl, UserRole role) {
+ log.info("새 사용자 생성: 이메일 = {}, 닉네임 = {}", username, nickname);
- // 사용자 정보 저장
User user = User.builder()
.username(username)
.nickname(nickname)
@@ -201,8 +219,54 @@ public User createUser(String username, String nickname, String profileImageUrl,
.build();
userRepository.save(user);
+ log.info("사용자 저장 성공: 이메일 = {}", username);
return user;
}
- /** Refresh 토큰으로 Access 토큰을 재발급 **/
+ /**
+ * Refresh 토큰을 사용하여 Access 토큰 재발급
+ *
+ * @param request HTTP 요청 객체
+ * @param response HTTP 응답 객체
+ * @return 새 Access 토큰을 포함하는 TokenDTO
+ */
+ @Transactional
+ @Override
+ public TokenDTO reissueToken(HttpServletRequest request, HttpServletResponse response) {
+ log.info("Refresh 토큰으로 Access 토큰 재발급");
+
+ // 요청 쿠키에서 리프레시 토큰을 가져옵니다.
+ String refresh = CookieUtil.getRefreshToken(request);
+
+ log.info("Refresh 토큰 검증 시작");
+ // 리프레시 토큰을 검증합니다.
+ tokenService.validateToken(refresh, "refresh");
+ log.info("Refresh 토큰 검증 완료");
+
+ // 리프레시 토큰에서 사용자 정보를 추출합니다.
+ String username = jwtUtil.getUsername(refresh);
+ String role = jwtUtil.getRole(refresh);
+
+ log.info("새로운 Access, Refresh 토큰 생성");
+
+ // 새로운 액세스 및 리프레시 토큰을 생성합니다.
+ String newAccess = jwtUtil.createJwt("access", username, role, accessExpiration); // 60분
+ String newRefresh = jwtUtil.createJwt("refresh", username, role, refreshExpiration);
+
+ // DB에서 기존 리프레시 토큰을 삭제하고, 새로운 리프레시 토큰을 저장합니다.
+ refreshRepository.deleteByRefreshToken(refresh);
+ tokenService.addRefreshEntity(username, newRefresh, refreshExpiration);
+
+ //response
+ //응답 설정: header
+ //access 토큰 헤더에 넣어서 응답 (key: value 형태) -> 예시) access: 인증토큰(string)
+// response.setHeader("access", newAccess);
+
+ // 새로운 리프레시 토큰을 쿠키로 응답에 추가합니다.
+ response.addCookie(CookieUtil.createCookie("refresh", newRefresh));
+
+ // 새로운 액세스 토큰을 DTO로 반환합니다.
+ log.info("Access 토큰 재발급 완료");
+ return new TokenDTO(newAccess);
+ }
}
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/TokenService.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/TokenService.java
new file mode 100644
index 00000000..1b6b5aab
--- /dev/null
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/TokenService.java
@@ -0,0 +1,16 @@
+package com.tico.pomoro_do.domain.user.service;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+public interface TokenService {
+
+ // 리프레쉬 토큰 저장
+ void addRefreshEntity(String username, String refresh, Long expiredMs);
+
+ // 토큰 검증
+ void validateToken(String token, String expectedCategory);
+
+ // 토큰 삭제
+ void removeRefreshToken(HttpServletRequest request, HttpServletResponse response);
+}
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/TokenServiceImpl.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/TokenServiceImpl.java
new file mode 100644
index 00000000..b4710615
--- /dev/null
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/domain/user/service/TokenServiceImpl.java
@@ -0,0 +1,139 @@
+package com.tico.pomoro_do.domain.user.service;
+
+import com.tico.pomoro_do.domain.user.entity.Refresh;
+import com.tico.pomoro_do.domain.user.repository.RefreshRepository;
+import com.tico.pomoro_do.global.auth.jwt.JWTUtil;
+import com.tico.pomoro_do.global.code.ErrorCode;
+import com.tico.pomoro_do.global.exception.CustomException;
+import com.tico.pomoro_do.global.util.CookieUtil;
+import io.jsonwebtoken.ExpiredJwtException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+@Service
+@Transactional(readOnly = true) // (성능 최적화 - 읽기 전용에만 사용)
+@RequiredArgsConstructor // 파이널 필드만 가지고 생성사 주입 함수 만듬 (따로 작성할 필요 없다.)
+@Slf4j
+public class TokenServiceImpl implements TokenService{
+
+ private final JWTUtil jwtUtil;
+ private final RefreshRepository refreshRepository;
+
+ /**
+ * 새로운 리프레시 토큰 저장
+ *
+ * @param username 사용자 이메일
+ * @param refresh 새로운 리프레시 토큰
+ * @param expiredMs 토큰 만료 시간 (밀리초 단위)
+ */
+ @Transactional
+ @Override
+ public void addRefreshEntity(String username, String refresh, Long expiredMs) {
+
+ Date date = new Date(System.currentTimeMillis() + expiredMs);
+
+ Refresh refreshEntity = Refresh.builder()
+ .username(username)
+ .refreshToken(refresh)
+ .expiration(date.toString())
+ .build();
+
+ refreshRepository.save(refreshEntity);
+ log.info("리프레시 토큰 저장 성공: 사용자 = {}, 토큰 = {}", username, refresh);
+
+ }
+
+
+ /**
+ * 주어진 토큰을 검증
+ *
+ * @param token 검증할 토큰
+ * @param expectedCategory 예상되는 토큰 카테고리 (예: "access" 또는 "refresh")
+ * @throws CustomException 검증 실패 시 발생하는 예외
+ */
+ @Override
+ public void validateToken(String token, String expectedCategory) {
+
+ if (token == null) {
+ log.error("토큰이 null입니다. 카테고리 = {}", expectedCategory);
+ throw new CustomException(
+ expectedCategory.equals("access")
+ ? ErrorCode.MISSING_ACCESS_TOKEN
+ : ErrorCode.MISSING_REFRESH_TOKEN
+ );
+ }
+
+ // 토큰 만료 확인
+ try {
+ jwtUtil.isExpired(token);
+ } catch (ExpiredJwtException e) {
+ log.error("토큰 만료됨: 카테고리 = {}", expectedCategory);
+ throw new CustomException(
+ expectedCategory.equals("access")
+ ? ErrorCode.ACCESS_TOKEN_EXPIRED
+ : ErrorCode.REFRESH_TOKEN_EXPIRED
+ );
+ }
+
+ // 토큰 카테고리 확인
+ String category = jwtUtil.getCategory(token);
+ if (!category.equals(expectedCategory)) {
+ log.error("토큰 카테고리 불일치: 예상 = {}, 실제 = {}", expectedCategory, category);
+ throw new CustomException(
+ expectedCategory.equals("access")
+ ? ErrorCode.INVALID_ACCESS_TOKEN
+ : ErrorCode.INVALID_REFRESH_TOKEN
+ );
+ }
+
+ // 리프레시 토큰 검증
+ if ("refresh".equals(expectedCategory)) {
+ boolean exists = refreshRepository.existsByRefreshToken(token);
+ if (!exists) {
+ log.error("리프레시 토큰이 DB에 존재하지 않음: 토큰 = {}", token);
+ throw new CustomException(ErrorCode.MISSING_REFRESH_TOKEN_IN_DB);
+ }
+ }
+ }
+
+
+ //만료기간이 지난 토큰은 스케줄러를 돌려서 삭제하라.
+ /**
+ * 로그아웃 시 리프레시 토큰 삭제
+ *
+ * @param request HTTP 요청 객체
+ * @param response HTTP 응답 객체
+ */
+ @Transactional
+ @Override
+ public void removeRefreshToken(HttpServletRequest request, HttpServletResponse response) {
+ // 쿠키에서 리프레시 토큰을 가져옵니다.
+ String refreshToken = CookieUtil.getRefreshToken(request);
+
+ // 리프레시 토큰이 없는 경우, 로그를 기록하고 예외를 발생시킵니다.
+ if (refreshToken == null || refreshToken.isEmpty()) {
+ log.warn("리프레시 토큰이 쿠키에서 발견되지 않음");
+ throw new CustomException(ErrorCode.MISSING_REFRESH_TOKEN);
+ }
+
+ // 리프레시 토큰을 검증합니다.
+ validateToken(refreshToken, "refresh");
+
+ log.info("로그아웃: 리프레시 토큰 삭제 시작");
+
+ // 리프레시 토큰 쿠키를 만료시킵니다.
+ CookieUtil.expireCookie(response, "refresh");
+
+ // DB에서 리프레시 토큰을 제거합니다.
+ refreshRepository.deleteByRefreshToken(refreshToken);
+
+ log.info("로그아웃: 리프레시 토큰 삭제 완료");
+ }
+
+}
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/JWTFilter.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/JWTFilter.java
index 9f6dc36f..9ce12df4 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/JWTFilter.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/JWTFilter.java
@@ -46,7 +46,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
filterChain.doFilter(request, response);
return;
}
- if (requestUri.matches("^\\/token/reissue(?:\\/.*)?$")) {
+ if (requestUri.matches("^\\/api/auth/token/reissue(?:\\/.*)?$")) {
filterChain.doFilter(request, response);
return;
@@ -88,7 +88,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
//Bearer 부분 제거 후 순수 토큰만 획득
String accessToken = authorization.split(" ")[1];
-// String accessToken = authorization.substring(7);
//토큰이 있다면
//토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
@@ -171,7 +170,6 @@ private void sendErrorResponse(HttpServletResponse response, ErrorCode errorCode
//response body
ErrorResponseEntity errorResponse = ErrorResponseEntity.builder()
.status(errorCode.getHttpStatus().value())
-// .name(errorCode.name())
.code(errorCode.getCode())
.message(errorCode.getMessage())
.build();
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/LoginFilter.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/LoginFilter.java
index 3c83412f..f438ab94 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/LoginFilter.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/auth/jwt/LoginFilter.java
@@ -1,9 +1,8 @@
package com.tico.pomoro_do.global.auth.jwt;
-import com.tico.pomoro_do.global.auth.jwt.JWTUtil;
+import com.tico.pomoro_do.domain.user.service.TokenService;
import com.tico.pomoro_do.global.util.CookieUtil;
import jakarta.servlet.FilterChain;
-import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -30,9 +29,14 @@ public class LoginFilter extends UsernamePasswordAuthenticationFilter {
//JWTUtil 주입
private final JWTUtil jwtUtil;
+ private final TokenService tokenService;
+
// JWT 만료 시간 Long 형
@Value("${jwt.access-expiration}")
- private long accessTokenExpireLength;
+ private long accessExpiration; // 1시간
+
+ @Value("${jwt.refresh-expiration}")
+ private long refreshExpiration; // 24시간
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
@@ -67,13 +71,15 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR
String role = auth.getAuthority();
//토큰 생성 (카테고리, 유저이름, 역할, 만료시간)
- String access = jwtUtil.createJwt("access", username, role, 600000L); //10분
- String refresh = jwtUtil.createJwt("refresh", username, role, 86400000L); //24시간
+ String access = jwtUtil.createJwt("access", username, role, accessExpiration); //60분
+ String refresh = jwtUtil.createJwt("refresh", username, role, refreshExpiration); //24시간
+
+ //Refresh 토큰 저장
+ tokenService.addRefreshEntity(username, refresh, refreshExpiration);
//응답 설정
- //access 토큰 헤더에 넣어서 응답 (key: value 형태) -> 예시) Authorization: Bearer 인증토큰(string)
- response.addHeader("Authorization", "Bearer " + access);
-// response.setHeader("access", access);
+ //access 토큰 헤더에 넣어서 응답 (key: value 형태) -> 예시) access: 인증토큰(string)
+ response.setHeader("access", access);
//refresh 토큰 쿠키에 넣어서 응답
response.addCookie(CookieUtil.createCookie("refresh", refresh));
response.setStatus(HttpStatus.OK.value());
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/ErrorCode.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/ErrorCode.java
index 26aeaf0d..490251c0 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/ErrorCode.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/ErrorCode.java
@@ -104,10 +104,9 @@ public enum ErrorCode {
INVALID_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "T-204", "리프레시 토큰이 유효하지 않습니다."),
MISSING_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "T-205", "액세스 토큰이 없습니다."),
MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "T-206", "리프레시 토큰이 없습니다."),
- MISSING_REFRESH_TOKEN_IN_DB(HttpStatus.BAD_REQUEST, "T-207", "DB에 리프레시 토큰이 없습니다."),
- REFRESH_TOKEN_MISMATCH(HttpStatus.BAD_REQUEST, "T-208", "데이터베이스의 리프레시 토큰과 일치하지 않습니다."),
+ MISSING_REFRESH_TOKEN_IN_DB(HttpStatus.BAD_REQUEST, "T-207", "DB에 해당 리프레시 토큰이 없습니다."),
+ REFRESH_TOKEN_MISMATCH(HttpStatus.BAD_REQUEST, "T-208", "서버의 리프레시 토큰과 일치하지 않습니다."),
INVALID_AUTHORIZATION_HEADER(HttpStatus.BAD_REQUEST, "T-209", "AUTHORIZATION 헤더의 토큰이 유효하지 않습니다."),
- UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "T-210", "인증되지 않은 접근입니다."),
// JWT 서명 오류
INVALID_JWT_SIGNATURE(HttpStatus.UNAUTHORIZED, "T-211", "JWT 서명 검증에 실패했습니다."),
// 잘못된 JWT 형식
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/SuccessCode.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/SuccessCode.java
index 5183a132..21ecc33c 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/SuccessCode.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/code/SuccessCode.java
@@ -9,14 +9,20 @@
public enum SuccessCode {
SUCCESS(HttpStatus.OK, "요청이 성공적으로 처리되었습니다."),
+
//사용자
GOOGLE_LOGIN_SUCCESS(HttpStatus.OK, "구글 로그인에 성공했습니다."),
- //토큰
GOOGLE_SIGNUP_SUCCESS(HttpStatus.CREATED, "구글 회원가입에 성공했습니다."),
+ LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃 및 토큰 삭제에 성공했습니다."),
+
+ //토큰
ACCESS_TOKEN_REISSUED(HttpStatus.OK, "액세스 토큰이 성공적으로 재발급되었습니다."),
+ ACCESS_TOKEN_VALIDATED(HttpStatus.OK, "액세스 토큰이 유효합니다."),
+
//관리자
ADMIN_LOGIN_SUCCESS(HttpStatus.OK, "관리자 로그인에 성공했습니다."),
- ADMIN_SIGNUP_SUCCESS(HttpStatus.CREATED, "관리자 회원가입에 성공했습니다.");
+ ADMIN_SIGNUP_SUCCESS(HttpStatus.CREATED, "관리자 회원가입에 성공했습니다."),
+ ;
private final HttpStatus httpStatus; // HttpStatus
private final String message; // 설명
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/config/SecurityConfig.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/config/SecurityConfig.java
index 6334fc91..6ec2b5c5 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/config/SecurityConfig.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/config/SecurityConfig.java
@@ -95,7 +95,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요
//AuthenticationManager()와 JWTUtil 인수 전달
// http
-// .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
+// .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository), UsernamePasswordAuthenticationFilter.class);
//addFilterAt은 UsernamePasswordAuthenticationFilter 대체
/* 서버 자체 로그인 삭제 - END */
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/response/SuccessResponseDTO.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/response/SuccessResponseDTO.java
index d174a704..bfd1c796 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/response/SuccessResponseDTO.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/response/SuccessResponseDTO.java
@@ -14,7 +14,7 @@ public class SuccessResponseDTO {
private final String code = "SUCCESS";
@Schema(description = "상태 메세지", nullable = false, example = "요청이 성공적으로 처리되었습니다.")
private final String message; // 메시지
- @Schema(description = "해당 API의 응답 데이터", nullable = false)
+ @Schema(description = "해당 API의 응답 데이터", nullable = true)
private final T data; // 응답 데이터
@Builder
diff --git a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/util/CookieUtil.java b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/util/CookieUtil.java
index 24439784..3a6f7363 100644
--- a/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/util/CookieUtil.java
+++ b/backend/pomoro-do/src/main/java/com/tico/pomoro_do/global/util/CookieUtil.java
@@ -4,6 +4,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.SerializationUtils;
import java.io.Serializable;
@@ -12,10 +13,14 @@
@Slf4j
public class CookieUtil {
+ @Value("${jwt.refresh-expiration}")
+ private static int refreshExpiration; // (24*60*60 = 24시간)
+
// 쿠키에서 가져온다.
public static String getRefreshToken(HttpServletRequest request) {
log.info("쿠키에서 refresh 토큰 찾기");
+ //get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
@@ -26,7 +31,7 @@ public static String getRefreshToken(HttpServletRequest request) {
}
}
}
- log.info("쿠키의 refresh 토큰: {}", refresh);
+ log.info("쿠키의 refresh 토큰: " + refresh);
return refresh;
}
@@ -34,16 +39,16 @@ public static String getRefreshToken(HttpServletRequest request) {
//쿠키 만들기
// public static Cookie createCookie(String key, String value, int expireLength) {
public static Cookie createCookie(String key, String value) {
-
log.info(key + " 쿠키 생성");
+
//value: jwt
Cookie cookie = new Cookie(key, value);
//쿠키의 생명주기 - 살아있을 시간 (24*60*60 = 24시간)
- cookie.setMaxAge(24*60*60);
+ cookie.setMaxAge(refreshExpiration);
//https 통신에서만 사용 가능
cookie.setSecure(true);
//쿠키 적용 범위 (전역)
- cookie.setPath("/");
+// cookie.setPath("/");
//자바스크립트가 해당 쿠키를 가져가지 못하게 막음
cookie.setHttpOnly(true);