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);