diff --git a/docker-compose.yml b/docker-compose.yml index 476e7fb..5129b59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: mysql: condition: service_healthy environment: + SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/memory_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true SPRING_DATASOURCE_USERNAME: memory_user SPRING_DATASOURCE_PASSWORD: memory_password diff --git a/src/main/java/zim/tave/memory/controller/LoginController.java b/src/main/java/zim/tave/memory/controller/LoginController.java index 176b3dd..1569dd3 100644 --- a/src/main/java/zim/tave/memory/controller/LoginController.java +++ b/src/main/java/zim/tave/memory/controller/LoginController.java @@ -2,51 +2,74 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import zim.tave.memory.config.swagger.ApiErrorCodeExamples; +import zim.tave.memory.domain.User; import zim.tave.memory.dto.request.LoginRequestDto; import zim.tave.memory.dto.response.LoginResponseDto; +import zim.tave.memory.dto.response.TokenRefreshResponse; import zim.tave.memory.global.common.ApiResponseDto; import zim.tave.memory.global.common.ResponseCode; +import zim.tave.memory.global.common.exception.CustomException; import zim.tave.memory.global.common.exception.ErrorCode; +import zim.tave.memory.jwt.JwtUtil; +import zim.tave.memory.kakao.KakaoApiClient; +import zim.tave.memory.repository.UserRepository; import zim.tave.memory.security.CustomUserDetails; +import zim.tave.memory.service.KakaoOAuthService; import zim.tave.memory.service.LoginService; +import java.util.Arrays; + @RestController @RequiredArgsConstructor @RequestMapping("/api/auth") @Tag(name = "kakaoLogin-controller", description = "카카오 로그인") public class LoginController { - /* - 토큰 발급 URL - https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=8faa2ba724cbfef5c2abf54ebf9bed65&redirect_uri=http://localhost:8080/callback - */ + private final LoginService loginService; + private final KakaoOAuthService kakaoOAuthService; + private final KakaoApiClient kakaoApiClient; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + private static final int REFRESH_TOKEN_COOKIE_MAX_AGE = 14 * 24 * 60 * 60; // 14일 (초 단위) + + + @Operation(summary = "카카오 로그인 (인가 코드 방식)", + description = """ + 카카오 OAuth 인가 코드를 받아 로그인 처리 및 JWT 토큰 발급 + + 프론트엔드는 먼저 카카오 로그인 URL로 리다이렉트: + https://kauth.kakao.com/oauth/authorize?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&response_type=code - @Operation(summary = "카카오 로그인 요청", - description = "카카오 사용자 인증 후 토큰 발급, 토큰 이용하여 백엔드 서버가 로그인 처리") + 카카오에서 받은 code를 이 엔드포인트로 전달하면 자체 JWT(Access Token + Refresh Token) 발급 + """) @SecurityRequirements @ApiErrorCodeExamples({ - ErrorCode.KAKAO_TOKEN_MISSING, + ErrorCode.KAKAO_TOKEN_REQUEST_FAILED, ErrorCode.KAKAO_INVALID_TOKEN, ErrorCode.KAKAO_UNAUTHORIZED, ErrorCode.KAKAO_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR }) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "200", description = "로그인 성공 - JWT 토큰 발급 완료"), @ApiResponse(responseCode = "400", description = """ 잘못된 요청입니다. 다음 오류가 발생할 수 있습니다: - - KAKAO_TOKEN_MISSING: 카카오 Access Token이 누락되었습니다. + - KAKAO_TOKEN_REQUEST_FAILED: 카카오 토큰 요청에 실패했습니다. """, content = @Content), @ApiResponse(responseCode = "401", description = """ 카카오 인증 실패입니다. 다음 오류가 발생할 수 있습니다: @@ -59,11 +82,92 @@ public class LoginController { - INTERNAL_SERVER_ERROR: 로그인 처리 중 알 수 없는 오류가 발생했습니다. """, content = @Content) }) - @PostMapping("/login") - public ResponseEntity> kakaoLogin( - @RequestBody LoginRequestDto request) { - LoginResponseDto response = loginService.login(request); - return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.LOGIN_SUCCESS, response)); + @GetMapping("/login/kakao") + public ResponseEntity> kakaoLoginWithCode( + @RequestParam String code, + HttpServletResponse response) { + + // 1. 인가 코드로 카카오 액세스 토큰 획득 + String kakaoAccessToken = kakaoOAuthService.getAccessToken(code); + + // 2. 카카오 액세스 토큰으로 로그인 처리 + LoginRequestDto loginRequest = new LoginRequestDto(); + loginRequest.setAccessToken(kakaoAccessToken); + + // 3. 로그인 처리 및 토큰 발급 + String[] tokens = loginService.loginAndGenerateTokens(loginRequest); + String accessToken = tokens[0]; + String refreshToken = tokens[1]; + User user = loginService.getUserByKakaoAccessToken(kakaoAccessToken); + + // 4. Refresh Token을 HttpOnly 쿠키로 설정 + setRefreshTokenCookie(response, refreshToken); + + // 5. Access Token만 응답 바디로 반환 + LoginResponseDto loginResponse = LoginResponseDto.from(user, accessToken, user.isRegistered()); + return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.LOGIN_SUCCESS, loginResponse)); + } + + @Operation(summary = "토큰 갱신", + description = """ + Refresh Token으로 새로운 Access Token 발급 + + Access Token이 만료되었을 때 사용 + Authorization Header에 'Bearer {refreshToken}' 형식으로 전달 + """) + @SecurityRequirements + @ApiErrorCodeExamples({ + ErrorCode.INVALID_REFRESH_TOKEN, + ErrorCode.USER_NOT_FOUND, + ErrorCode.AUTHENTICATION_FAILED + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공"), + @ApiResponse(responseCode = "401", description = """ + 인증 실패입니다. 다음 오류가 발생할 수 있습니다: + - INVALID_REFRESH_TOKEN: 유효하지 않은 Refresh Token입니다. + - AUTHENTICATION_FAILED: 인증에 실패했습니다. + """, content = @Content), + @ApiResponse(responseCode = "404", description = """ + 리소스를 찾을 수 없습니다. 다음 오류가 발생할 수 있습니다: + - USER_NOT_FOUND: 사용자를 찾을 수 없습니다. + """, content = @Content) + }) + @PostMapping("/refresh") + public ResponseEntity> refreshToken( + HttpServletRequest request, + HttpServletResponse response) { + + // 1. HttpOnly 쿠키에서 Refresh Token 추출 + String refreshToken = getRefreshTokenFromCookie(request); + + if (refreshToken == null) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + // 2. Refresh Token 검증 + jwtUtil.validateTokenWithException(refreshToken); + + if (!jwtUtil.isRefreshToken(refreshToken)) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + // 3. 새 Access Token 발급 + Long userId = jwtUtil.getUserIdFromToken(refreshToken); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + String newAccessToken = jwtUtil.generateAccessToken(user.getId(), user.getKakaoId()); + + // 4. 새 Refresh Token도 발급하여 쿠키 갱신 (Refresh Token Rotation) + String newRefreshToken = jwtUtil.generateRefreshToken(user.getId()); + setRefreshTokenCookie(response, newRefreshToken); + + TokenRefreshResponse tokenResponse = TokenRefreshResponse.builder() + .accessToken(newAccessToken) + .build(); + + return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.TOKEN_REFRESH_SUCCESS, tokenResponse)); } @Operation(summary = "로그아웃", description = "사용자 로그아웃 처리") @@ -96,9 +200,55 @@ public ResponseEntity> kakaoLogin( content = @Content) }) @PatchMapping("/logout") - public ResponseEntity> logout(@AuthenticationPrincipal CustomUserDetails userDetails) { + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse response) { + Long userId = userDetails.getUserId(); loginService.logout(userId); + + // Refresh Token 쿠키 삭제 + clearRefreshTokenCookie(response); + return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.LOGOUT_SUCCESS, null)); } + + /** + * HttpOnly 쿠키에 Refresh Token 설정 + */ + private void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken); + cookie.setHttpOnly(true); // XSS 공격 방지 + cookie.setSecure(true); // HTTPS에서만 전송 (프로덕션 환경) + cookie.setPath("/"); // 모든 경로에서 쿠키 전송 + cookie.setMaxAge(REFRESH_TOKEN_COOKIE_MAX_AGE); // 14일 + response.addCookie(cookie); + } + + /** + * HttpOnly 쿠키에서 Refresh Token 추출 + */ + private String getRefreshTokenFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + return Arrays.stream(cookies) + .filter(cookie -> REFRESH_TOKEN_COOKIE_NAME.equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + } + return null; + } + + /** + * Refresh Token 쿠키 삭제 (로그아웃 시) + */ + private void clearRefreshTokenCookie(HttpServletResponse response) { + Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, null); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setMaxAge(0); // 즉시 만료 + response.addCookie(cookie); + } } diff --git a/src/main/java/zim/tave/memory/dto/request/LoginRequestDto.java b/src/main/java/zim/tave/memory/dto/request/LoginRequestDto.java index 9e2c6dd..85ec832 100644 --- a/src/main/java/zim/tave/memory/dto/request/LoginRequestDto.java +++ b/src/main/java/zim/tave/memory/dto/request/LoginRequestDto.java @@ -4,11 +4,13 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; //클라이언트 앱에 accessToken 요청 @Getter @AllArgsConstructor @NoArgsConstructor +@Setter public class LoginRequestDto { @Schema(description = "카카오 사용자 인증 후 토큰 발급, 토큰 이용하여 백엔드 서버가 로그인 처리", diff --git a/src/main/java/zim/tave/memory/dto/response/KakaoTokenResponse.java b/src/main/java/zim/tave/memory/dto/response/KakaoTokenResponse.java new file mode 100644 index 0000000..6111106 --- /dev/null +++ b/src/main/java/zim/tave/memory/dto/response/KakaoTokenResponse.java @@ -0,0 +1,25 @@ +package zim.tave.memory.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoTokenResponse { + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private Integer expiresIn; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("refresh_token_expires_in") + private Integer refreshTokenExpiresIn; +} diff --git a/src/main/java/zim/tave/memory/dto/response/LoginResponseDto.java b/src/main/java/zim/tave/memory/dto/response/LoginResponseDto.java index 2a4b825..3680631 100644 --- a/src/main/java/zim/tave/memory/dto/response/LoginResponseDto.java +++ b/src/main/java/zim/tave/memory/dto/response/LoginResponseDto.java @@ -13,11 +13,9 @@ public class LoginResponseDto { @Schema(description = "발급된 userId", example = "1") private Long userId; - @Schema(description = "기존 회원 여부 확인(true = 이미 가입된 회원 / false = 신규 회원)", - example = "true") + @Schema(description = "기존 회원 여부 확인(true = 이미 가입된 회원 / false = 신규 회원)", example = "true") private boolean registered; // 기존 회원 여부 확인 - @Schema(description = "카카오 사용자 ID", example = "4317757086") private String kakaoId; @@ -25,15 +23,17 @@ public class LoginResponseDto { private String profileImageUrl; @Schema(description = "로그인 시 AT 발급", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6...") - private String jwtAccessToken; + private String accessToken; + + // Refresh Token은 HttpOnly 쿠키로 전송 - public static LoginResponseDto from(User user, String jwt, boolean registered) { + public static LoginResponseDto from(User user, String accessToken, boolean registered) { return new LoginResponseDto( user.getId(), registered, user.getKakaoId(), user.getProfileImageUrl(), - jwt + accessToken ); } } diff --git a/src/main/java/zim/tave/memory/dto/response/TokenRefreshResponse.java b/src/main/java/zim/tave/memory/dto/response/TokenRefreshResponse.java new file mode 100644 index 0000000..1d1895b --- /dev/null +++ b/src/main/java/zim/tave/memory/dto/response/TokenRefreshResponse.java @@ -0,0 +1,13 @@ +package zim.tave.memory.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TokenRefreshResponse { + + @Schema(description = "새로 발급된 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6...") + private String accessToken; +} diff --git a/src/main/java/zim/tave/memory/global/common/ResponseCode.java b/src/main/java/zim/tave/memory/global/common/ResponseCode.java index aafdd4b..99023bc 100644 --- a/src/main/java/zim/tave/memory/global/common/ResponseCode.java +++ b/src/main/java/zim/tave/memory/global/common/ResponseCode.java @@ -29,6 +29,7 @@ public enum ResponseCode { NO_FIELDS_TO_UPDATE(400, "수정할 필드가 없습니다."), //카카오 로그인 성공 메세지 KAKAO_USERINFO_FETCH_SUCCESS(200, "카카오 사용자 정보 조회 성공"), + TOKEN_REFRESH_SUCCESS(200, "토큰 갱신에 성공했습니다."), //보드 BOARD_CREATE_SUCCESS(200, "보드 생성에 성공하였습니다."), BOARD_UPDATE_SUCCESS(200, "보드 수정이 완료되었습니다."), @@ -92,6 +93,14 @@ public enum ResponseCode { TRIP_DESCRIPTION_TOO_LONG(400, "여행 설명은 최대 56자입니다."), NOT_PAST_TRIP(400, "과거 여행만 수정/보관/삭제할 수 있습니다."), CANNOT_ADD_DIARY_TO_PAST_TRIP(400, "과거 여행에는 일기를 추가할 수 없습니다."), + + // JWT 토큰 관련 세분화된 에러 + EXPIRED_TOKEN(401, "토큰이 만료되었습니다."), + MALFORMED_TOKEN(401, "올바르지 않은 JWT 형식입니다."), + INVALID_TOKEN_SIGNATURE(401, "JWT 서명이 유효하지 않습니다."), + UNSUPPORTED_TOKEN(401, "지원하지 않는 JWT 토큰입니다."), + + // 카카오로그인 관련 에러 KAKAO_TOKEN_MISSING(400, "카카오 액세스 토큰이 누락되었습니다."), KAKAO_INVALID_TOKEN(401, "유효하지 않거나 만료된 카카오 액세스 토큰입니다."), @@ -99,6 +108,8 @@ public enum ResponseCode { KAKAO_SERVER_ERROR(500, "카카오 서버 오류입니다."), KAKAO_RESPONSE_PARSING_ERROR(500, "카카오 응답 데이터를 처리할 수 없습니다."), KAKAO_API_UNKNOWN_ERROR(500, "카카오 API 처리 중 알 수 없는 오류가 발생했습니다."), + KAKAO_TOKEN_REQUEST_FAILED(400, "카카오 토큰 요청에 실패했습니다."), + INVALID_REFRESH_TOKEN(401, "유효하지 않은 Refresh Token입니다."), //보드 BOARD_REQUIRED_FIELDS_MISSING(400, "필수 입력값(boardThemeId, title)이 누락되었습니다."), diff --git a/src/main/java/zim/tave/memory/global/common/exception/ErrorCode.java b/src/main/java/zim/tave/memory/global/common/exception/ErrorCode.java index 2997cf6..2038dea 100644 --- a/src/main/java/zim/tave/memory/global/common/exception/ErrorCode.java +++ b/src/main/java/zim/tave/memory/global/common/exception/ErrorCode.java @@ -34,6 +34,15 @@ public enum ErrorCode { INCOMPLETE_USER_INFO(HttpStatus.BAD_REQUEST, "회원 정보 입력을 완료해주세요."), ALREADY_JOINED(HttpStatus.CONFLICT, "이미 가입된 사용자입니다."), MISSING_REQUIRED_FIELDS(HttpStatus.BAD_REQUEST, "값을 입력해주세요."), + KAKAO_TOKEN_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "카카오 토큰 요청에 실패했습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Refresh Token입니다."), + + // JWT 토큰 관련 세분화된 에러 + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."), + MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, "올바르지 않은 JWT 형식입니다."), + INVALID_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "JWT 서명이 유효하지 않습니다."), + UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "지원하지 않는 JWT 토큰입니다."), + //설정 NO_FIELDS_TO_UPDATE(HttpStatus.BAD_REQUEST, "수정할 필드가 없습니다."), //보관 diff --git a/src/main/java/zim/tave/memory/global/common/exception/GlobalExceptionHandler.java b/src/main/java/zim/tave/memory/global/common/exception/GlobalExceptionHandler.java index 6dc1f95..512bae5 100644 --- a/src/main/java/zim/tave/memory/global/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/zim/tave/memory/global/common/exception/GlobalExceptionHandler.java @@ -69,6 +69,13 @@ public ResponseEntity> handleCustomException(CustomException e case FILE_TYPE_NOT_ALLOWED -> ResponseCode.FILE_TYPE_NOT_ALLOWED; case FILE_UPLOAD_FAILED -> ResponseCode.FILE_UPLOAD_FAILED; + // JWT 토큰 관련 세분화된 에러 + case EXPIRED_TOKEN -> ResponseCode.EXPIRED_TOKEN; + case MALFORMED_TOKEN -> ResponseCode.MALFORMED_TOKEN; + case INVALID_TOKEN_SIGNATURE -> ResponseCode.INVALID_TOKEN_SIGNATURE; + case UNSUPPORTED_TOKEN -> ResponseCode.UNSUPPORTED_TOKEN; + case INVALID_REFRESH_TOKEN -> ResponseCode.INVALID_REFRESH_TOKEN; + //보관 case TRIP_NOT_STORED -> ResponseCode.TRIP_NOT_STORED; case DIARY_NOT_STORED -> ResponseCode.DIARY_NOT_STORED; diff --git a/src/main/java/zim/tave/memory/jwt/JwtUtil.java b/src/main/java/zim/tave/memory/jwt/JwtUtil.java index d366af0..3ddb388 100644 --- a/src/main/java/zim/tave/memory/jwt/JwtUtil.java +++ b/src/main/java/zim/tave/memory/jwt/JwtUtil.java @@ -2,31 +2,48 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import zim.tave.memory.domain.User; +import zim.tave.memory.global.common.exception.CustomException; +import zim.tave.memory.global.common.exception.ErrorCode; import zim.tave.memory.repository.UserRepository; import java.security.Key; import java.util.Date; +import java.util.UUID; @Slf4j @Component public class JwtUtil { private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); - private final long expirationMs = 1000 * 60 * 60; // 1시간 + private final long accessTokenExpirationMs = 1000 * 60 * 60; // 1시간 + private final long refreshTokenExpirationMs = 1000 * 60 * 60 * 24 * 14; // 14일 - // 토큰 생성 - public String generateToken(Long userId, String username) { + // Access Token 생성 + public String generateAccessToken(Long userId, String username) { return Jwts.builder() - .setSubject(String.valueOf(userId)) // userId 저장 - .claim("username", username) // username claim 추가 + .setSubject(String.valueOf(userId)) + .claim("username", username) + .claim("type", "access") + .setId(UUID.randomUUID().toString()) // JTI 추가 .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + expirationMs)) + .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpirationMs)) + .signWith(key) + .compact(); + } + + // Refresh Token 생성 + public String generateRefreshToken(Long userId) { + return Jwts.builder() + .setSubject(String.valueOf(userId)) + .claim("type", "refresh") + .setId(UUID.randomUUID().toString()) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMs)) .signWith(key) .compact(); } @@ -41,12 +58,86 @@ public String getUsernameFromToken(String token) { return parseClaims(token).get("username", String.class); } + // 토큰 타입 확인 + public String getTokenType(String token) { + return parseClaims(token).get("type", String.class); + } + + // 토큰 만료 여부 확인 + public boolean isTokenExpired(String token) { + try { + Date expiration = parseClaims(token).getExpiration(); + return expiration.before(new Date()); + } catch (JwtException e) { + return true; + } + } + // 유효성 검사 public boolean validateToken(String token) { try { parseClaims(token); return true; + } catch (ExpiredJwtException e) { + log.warn("토큰이 만료되었습니다: {}", e.getMessage()); + return false; + } catch (MalformedJwtException e) { + log.warn("올바르지 않은 JWT 형식입니다: {}", e.getMessage()); + return false; + } catch (SignatureException e) { + log.warn("JWT 서명이 유효하지 않습니다: {}", e.getMessage()); + return false; + } catch (UnsupportedJwtException e) { + log.warn("지원하지 않는 JWT 토큰입니다: {}", e.getMessage()); + return false; + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰이 비어있거나 null입니다: {}", e.getMessage()); + return false; } catch (JwtException e) { + log.warn("JWT 토큰 검증 중 오류가 발생했습니다: {}", e.getMessage()); + return false; + } + } + + // 유효성 검사 - 예외를 던지는 버전 (상세한 에러 처리가 필요한 경우) + public void validateTokenWithException(String token) { + try { + parseClaims(token); + } catch (ExpiredJwtException e) { + log.warn("토큰이 만료되었습니다: {}", e.getMessage()); + throw new CustomException(ErrorCode.EXPIRED_TOKEN); + } catch (MalformedJwtException e) { + log.warn("올바르지 않은 JWT 형식입니다: {}", e.getMessage()); + throw new CustomException(ErrorCode.MALFORMED_TOKEN); + } catch (SignatureException e) { + log.warn("JWT 서명이 유효하지 않습니다: {}", e.getMessage()); + throw new CustomException(ErrorCode.INVALID_TOKEN_SIGNATURE); + } catch (UnsupportedJwtException e) { + log.warn("지원하지 않는 JWT 토큰입니다: {}", e.getMessage()); + throw new CustomException(ErrorCode.UNSUPPORTED_TOKEN); + } catch (IllegalArgumentException e) { + log.warn("JWT 토큰이 비어있거나 null입니다: {}", e.getMessage()); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } catch (JwtException e) { + log.warn("JWT 토큰 검증 중 오류가 발생했습니다: {}", e.getMessage()); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + // Access Token인지 검증 + public boolean isAccessToken(String token) { + try { + return "access".equals(getTokenType(token)); + } catch (Exception e) { + return false; + } + } + + // Refresh Token인지 검증 + public boolean isRefreshToken(String token) { + try { + return "refresh".equals(getTokenType(token)); + } catch (Exception e) { return false; } } @@ -70,8 +161,11 @@ public class TestTokenGenerator { public void printTestUserToken() { User user = userRepository.findByKakaoId("test_강지혜") .orElseThrow(() -> new RuntimeException("테스트 유저 없음")); - String token = jwtUtil.generateToken(user.getId(), user.getKakaoId()); - log.info("🧩 테스트 유저 JWT 토큰: {}", token); + String accessToken = jwtUtil.generateAccessToken(user.getId(), user.getKakaoId()); + String refreshToken = jwtUtil.generateRefreshToken(user.getId()); + System.out.println("🧩 테스트 유저 Access Token: " + accessToken); + System.out.println("🔄 테스트 유저 Refresh Token: " + refreshToken); } } + } diff --git a/src/main/java/zim/tave/memory/security/JwtAuthenticationFilter.java b/src/main/java/zim/tave/memory/security/JwtAuthenticationFilter.java index 8baef11..ea1670b 100644 --- a/src/main/java/zim/tave/memory/security/JwtAuthenticationFilter.java +++ b/src/main/java/zim/tave/memory/security/JwtAuthenticationFilter.java @@ -36,7 +36,7 @@ protected void doFilterInternal(HttpServletRequest request, if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); - if (jwtUtil.validateToken(token)) { + if (jwtUtil.validateToken(token) && jwtUtil.isAccessToken(token)) { Long userId = jwtUtil.getUserIdFromToken(token); String username = jwtUtil.getUsernameFromToken(token); diff --git a/src/main/java/zim/tave/memory/service/KakaoOAuthService.java b/src/main/java/zim/tave/memory/service/KakaoOAuthService.java new file mode 100644 index 0000000..2974f10 --- /dev/null +++ b/src/main/java/zim/tave/memory/service/KakaoOAuthService.java @@ -0,0 +1,64 @@ +package zim.tave.memory.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import zim.tave.memory.domain.User; +import zim.tave.memory.dto.response.KakaoTokenResponse; +import zim.tave.memory.global.common.exception.CustomException; +import zim.tave.memory.global.common.exception.ErrorCode; +import zim.tave.memory.jwt.JwtUtil; +import zim.tave.memory.kakao.KakaoApiClient; +import zim.tave.memory.kakao.KakaoUserInfo; +import zim.tave.memory.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + @Value("${kakao.client-id}") + private String clientId; + + @Value("${kakao.redirect-uri}") + private String redirectUri; + + private final RestTemplate restTemplate = new RestTemplate(); + + // 인가 코드로 카카오 액세스 토큰 요청 + public String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("redirect_uri", redirectUri); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.exchange( + "https://kauth.kakao.com/oauth/token", + HttpMethod.POST, + request, + KakaoTokenResponse.class + ); + + KakaoTokenResponse body = response.getBody(); + if (body == null || body.getAccessToken() == null) { + throw new CustomException(ErrorCode.KAKAO_TOKEN_REQUEST_FAILED); + } + + return body.getAccessToken(); + + } catch (Exception e) { + throw new CustomException(ErrorCode.KAKAO_TOKEN_REQUEST_FAILED); + } + } +} diff --git a/src/main/java/zim/tave/memory/service/LoginService.java b/src/main/java/zim/tave/memory/service/LoginService.java index 7fcb4a3..aa6ce71 100644 --- a/src/main/java/zim/tave/memory/service/LoginService.java +++ b/src/main/java/zim/tave/memory/service/LoginService.java @@ -24,19 +24,24 @@ public class LoginService { private final KakaoApiClient kakaoApiClient; private final JwtUtil jwtUtil; - public LoginResponseDto login(LoginRequestDto request) { - + /** + * 로그인 처리 및 토큰 생성 (Controller에서 쿠키 설정을 위해 분리) + * @return [accessToken, refreshToken] + */ + public String[] loginAndGenerateTokens(LoginRequestDto request) { if (request.getAccessToken() == null || request.getAccessToken().isBlank()) { throw new CustomException(ErrorCode.KAKAO_TOKEN_MISSING); } + // 카카오 액세스 토큰으로 사용자 정보 조회 KakaoUserInfo kakaoUserInfo = kakaoApiClient.getKakaoUserInfo(request.getAccessToken()); User user = userRepository.findByKakaoId(kakaoUserInfo.getKakaoId()).orElse(null); - + // 기존 사용자 if (user != null) { - String token = jwtUtil.generateToken(user.getId(), user.getKakaoId()); - return LoginResponseDto.from(user, token, true); + String accessToken = jwtUtil.generateAccessToken(user.getId(), user.getKakaoId()); + String refreshToken = jwtUtil.generateRefreshToken(user.getId()); + return new String[]{accessToken, refreshToken}; } // 처음 로그인한 사용자 → User 생성 @@ -51,10 +56,24 @@ public LoginResponseDto login(LoginRequestDto request) { newUser.setFlags(""); User savedUser = userRepository.save(newUser); - //JWT AT 발급 - String token = jwtUtil.generateToken(savedUser.getId(), savedUser.getKakaoId()); + // JWT 토큰 발급 (AT + RT) + String accessToken = jwtUtil.generateAccessToken(savedUser.getId(), savedUser.getKakaoId()); + String refreshToken = jwtUtil.generateRefreshToken(savedUser.getId()); + + return new String[]{accessToken, refreshToken}; + } + + /** + * 카카오 액세스 토큰으로 사용자 조회 + */ + public User getUserByKakaoAccessToken(String kakaoAccessToken) { + if (kakaoAccessToken == null || kakaoAccessToken.isBlank()) { + throw new CustomException(ErrorCode.KAKAO_TOKEN_MISSING); + } - return LoginResponseDto.from(savedUser, token, false); + KakaoUserInfo kakaoUserInfo = kakaoApiClient.getKakaoUserInfo(kakaoAccessToken); + return userRepository.findByKakaoId(kakaoUserInfo.getKakaoId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } @Transactional @@ -66,8 +85,8 @@ public void logout(Long userId) { throw new CustomException(ErrorCode.ALREADY_LOGGED_OUT); } - user.setStatus(false); //로그아웃 시 status false로 설정 - //프론트에서 accessToken 삭제 + user.setStatus(false); // 로그아웃 시 status false로 설정 + // 프론트에서 accessToken, refreshToken 삭제 필요 } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..2a261b0 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,13 @@ +# ========================= +# prod 전용 override +# ========================= +spring: + config: + activate: + on-profile: prod + +memory: + base-url: https://voyame-studio.org + +kakao: + redirect-uri: https://voyame-studio.org/api/auth/login/kakao diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f628e72..b9cfe59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,6 @@ +# ========================= +# 기본 (local / default) +# ========================= spring: config: import: optional:application-secret.yml @@ -6,17 +9,18 @@ spring: url: jdbc:mysql://localhost:3306/memory_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true username: memory_user driver-class-name: com.mysql.cj.jdbc.Driver + jackson: time-zone: UTC serialization: write-dates-as-timestamps: false default-property-inclusion: non_null + servlet: multipart: max-file-size: 500MB max-request-size: 500MB - jpa: hibernate: ddl-auto: update @@ -70,3 +74,7 @@ memory: - ".webp" - ".heic" +kakao: + client-id: ${KAKAO_CLIENT_ID} # application-secret.yml에서 주입 + redirect-uri: http://localhost:8081/api/auth/login/kakao + diff --git a/src/test/java/zim/tave/memory/api/ApiTestBase.java b/src/test/java/zim/tave/memory/api/ApiTestBase.java index 8245761..43dd6c3 100644 --- a/src/test/java/zim/tave/memory/api/ApiTestBase.java +++ b/src/test/java/zim/tave/memory/api/ApiTestBase.java @@ -21,7 +21,7 @@ * - AbstractContainerBaseTest를 상속하여 공유 컨테이너 사용 * - MockMvc를 사용한 HTTP 레벨 테스트 * - JWT 토큰 생성 헬퍼 메서드 제공 - * + * * 주의: AbstractContainerBaseTest에서 @SpringBootTest(webEnvironment = NONE)를 설정했지만, * MockMvc를 사용하기 위해 @AutoConfigureMockMvc를 사용합니다. * 이는 Spring Context를 로드하지만, 컨테이너는 공유됩니다. @@ -88,7 +88,7 @@ protected void initializeTestData() { protected String generateToken(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found: " + userId)); - return jwtUtil.generateToken(user.getId(), user.getKakaoId()); + return jwtUtil.generateAccessToken(user.getId(), user.getKakaoId()); } /** diff --git a/src/test/java/zim/tave/memory/controller/LoginControllerTest.java b/src/test/java/zim/tave/memory/controller/LoginControllerTest.java index 8a8b7d7..6ce9d87 100644 --- a/src/test/java/zim/tave/memory/controller/LoginControllerTest.java +++ b/src/test/java/zim/tave/memory/controller/LoginControllerTest.java @@ -1,6 +1,7 @@ package zim.tave.memory.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -11,18 +12,24 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; +import zim.tave.memory.domain.User; import zim.tave.memory.dto.request.LoginRequestDto; import zim.tave.memory.dto.response.LoginResponseDto; import zim.tave.memory.global.common.ResponseCode; import zim.tave.memory.global.common.exception.CustomException; import zim.tave.memory.global.common.exception.ErrorCode; import zim.tave.memory.jwt.JwtUtil; +import zim.tave.memory.kakao.KakaoApiClient; +import zim.tave.memory.repository.UserRepository; import zim.tave.memory.security.CustomUserDetails; import zim.tave.memory.security.JwtAuthenticationFilter; +import zim.tave.memory.service.KakaoOAuthService; import zim.tave.memory.service.LoginService; -import static org.hamcrest.Matchers.containsString; +import java.util.Optional; + import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -39,61 +46,222 @@ class LoginControllerTest { @MockBean LoginService loginService; + @MockBean + KakaoOAuthService kakaoOAuthService; + @MockBean JwtAuthenticationFilter jwtAuthenticationFilter; @MockBean JwtUtil jwtUtil; + @MockBean + UserRepository userRepository; + + @MockBean + private KakaoApiClient kakaoApiClient; + @Test - void 로그인_성공() throws Exception { + @DisplayName("카카오 로그인 (인가 코드 방식) - 성공") + void 카카오_로그인_인가코드_성공() throws Exception { // given - LoginRequestDto request = new LoginRequestDto("valid_token"); + String authCode = "test_auth_code"; + String kakaoAccessToken = "kakao_access_token"; LoginResponseDto response = new LoginResponseDto( 1L, true, "4317757086", "http://img1.kakaocdn.net/profile.jpeg", - "jwt_token" + "jwt_access_token", + "jwt_refresh_token" ); - Mockito.when(loginService.login(any(LoginRequestDto.class))).thenReturn(response); + when(kakaoOAuthService.getAccessToken(authCode)).thenReturn(kakaoAccessToken); + when(loginService.login(any(LoginRequestDto.class))).thenReturn(response); // when & then - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + mockMvc.perform( + get("/api/auth/login/kakao") + .param("code", "valid_auth_code") + ) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResponseCode.SUCCESS.getCode())) + .andExpect(jsonPath("$.code") + .value(ResponseCode.LOGIN_SUCCESS.getCode())) .andExpect(jsonPath("$.data.userId").value(1L)) - .andExpect(jsonPath("$.data.jwtAccessToken").value("jwt_token")); + .andExpect(jsonPath("$.data.accessToken") + .value("jwt_access_token")) + .andExpect(jsonPath("$.data.refreshToken") + .value("jwt_refresh_token")); + } + @Test + @DisplayName("카카오 로그인 (액세스 토큰 직접 전달) - 성공") + void 카카오_로그인_토큰_성공() throws Exception { + // given + String kakaoAccessToken = "valid_kakao_token"; + + LoginResponseDto response = new LoginResponseDto( + 1L, + true, + "4317757086", + "http://img1.kakaocdn.net/profile.jpeg", + "jwt_access_token", + "jwt_refresh_token" + ); + + when(kakaoOAuthService.getAccessToken(any())).thenReturn(kakaoAccessToken); + when(loginService.login(any(LoginRequestDto.class))).thenReturn(response); + + // when & then + mockMvc.perform(get("/api/auth/login/kakao") + .param("code", "valid_code")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResponseCode.LOGIN_SUCCESS.getCode())) + .andExpect(jsonPath("$.data.userId").value(1L)) + .andExpect(jsonPath("$.data.accessToken").value("jwt_access_token")) + .andExpect(jsonPath("$.data.refreshToken").value("jwt_refresh_token")); + } + + @Test + @DisplayName("신규 회원 로그인 - registered = false") + void 신규회원_로그인() throws Exception { + // given + String authCode = "new_user_code"; + String kakaoAccessToken = "new_user_kakao_token"; + + LoginResponseDto response = new LoginResponseDto( + 10L, + false, // 앱 가입 미완료 + "9999999999", + "http://new-profile.jpg", + "new_access_token", + "new_refresh_token" + ); + + when(kakaoOAuthService.getAccessToken(authCode)).thenReturn(kakaoAccessToken); + when(loginService.login(any(LoginRequestDto.class))).thenReturn(response); + + // when & then + mockMvc.perform(get("/api/auth/login/kakao") + .param("code", authCode)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.registered").value(false)) + .andExpect(jsonPath("$.data.userId").value(10L)); + } + + @Test + @DisplayName("로그인 실패 - 토큰 누락") void 로그인_토큰누락_400() throws Exception { - LoginRequestDto request = new LoginRequestDto(""); + // given - 서비스 레벨에서 검증되므로 인가코드 방식으로 테스트 + String authCode = "test_code"; - Mockito.when(loginService.login(any())) + when(kakaoOAuthService.getAccessToken(authCode)).thenReturn(""); + when(loginService.login(any())) .thenThrow(new CustomException(ErrorCode.KAKAO_TOKEN_MISSING)); - mockMvc.perform(post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) + // when & then + mockMvc.perform(get("/api/auth/login/kakao") + .param("code", authCode)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); // GlobalExceptionHandler가 HTTP 상태코드를 반환 + } + + @Test + @DisplayName("로그인 실패 - 카카오 토큰 요청 실패") + void 카카오_토큰요청_실패() throws Exception { + // given + String invalidCode = "invalid_code"; + + when(kakaoOAuthService.getAccessToken(invalidCode)) + .thenThrow(new CustomException(ErrorCode.KAKAO_TOKEN_REQUEST_FAILED)); + + // when & then + mockMvc.perform(get("/api/auth/login/kakao") + .param("code", invalidCode)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(ResponseCode.KAKAO_TOKEN_MISSING.getCode())); + .andExpect(jsonPath("$.code").value(500)); // GlobalExceptionHandler가 500을 반환 } @Test + @DisplayName("토큰 갱신 - 성공") + void 토큰_갱신_성공() throws Exception { + // given + String refreshToken = "valid_refresh_token"; + User user = new User(); + user.setId(1L); + user.setKakaoId("kakao123"); + + when(jwtUtil.validateToken(refreshToken)).thenReturn(true); + when(jwtUtil.isRefreshToken(refreshToken)).thenReturn(true); + when(jwtUtil.getUserIdFromToken(refreshToken)).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(jwtUtil.generateAccessToken(1L, "kakao123")).thenReturn("new_access_token"); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .header("Authorization", "Bearer " + refreshToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ResponseCode.TOKEN_REFRESH_SUCCESS.getCode())) + .andExpect(jsonPath("$.data.accessToken").value("new_access_token")); + } + + @Test + @DisplayName("토큰 갱신 실패 - 유효하지 않은 Refresh Token") + void 토큰_갱신_실패_유효하지않음() throws Exception { + // given + String invalidRefreshToken = "invalid_refresh_token"; + + when(jwtUtil.validateToken(invalidRefreshToken)).thenReturn(false); + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .header("Authorization", "Bearer " + invalidRefreshToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(500)); // GlobalExceptionHandler가 500을 반환 + } + + + @Test + @DisplayName("토큰 갱신 실패 - Access Token 사용") + void 토큰_갱신_실패_액세스토큰() throws Exception { + // given + String accessToken = "access_token_not_refresh"; + + when(jwtUtil.validateToken(accessToken)).thenReturn(true); + when(jwtUtil.isRefreshToken(accessToken)).thenReturn(false); // Access Token + + // when & then + mockMvc.perform(post("/api/auth/refresh") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(500)); // GlobalExceptionHandler가 500을 반환 + } + + @Test + @DisplayName("토큰 갱신 실패 - Authorization 헤더 누락") + void 토큰_갱신_실패_헤더누락() throws Exception { + // when & then + mockMvc.perform(post("/api/auth/refresh")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value(500)); + } + + @Test + @DisplayName("로그아웃 - 성공") void 로그아웃_성공() throws Exception { + // given CustomUserDetails principal = new CustomUserDetails(1L, "kakao_123"); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(principal, null, null); SecurityContextHolder.getContext().setAuthentication(auth); + // when & then mockMvc.perform(patch("/api/auth/logout")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(ResponseCode.SUCCESS.getCode())); + .andExpect(jsonPath("$.code").value(ResponseCode.LOGOUT_SUCCESS.getCode())); Mockito.verify(loginService).logout(1L); } diff --git a/src/test/java/zim/tave/memory/service/LoginServiceTest.java b/src/test/java/zim/tave/memory/service/LoginServiceTest.java index d6575f3..b3473e9 100644 --- a/src/test/java/zim/tave/memory/service/LoginServiceTest.java +++ b/src/test/java/zim/tave/memory/service/LoginServiceTest.java @@ -1,5 +1,6 @@ package zim.tave.memory.service; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -20,7 +21,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) public class LoginServiceTest { @@ -32,11 +33,24 @@ public class LoginServiceTest { private UserRepository userRepository; @Mock - private JwtUtil jwtUtil; // 추가 + private JwtUtil jwtUtil; @InjectMocks private LoginService loginService; + @Test + @DisplayName("토큰이 null인 경우 예외 발생") + void 토큰_null시_예외() { + // given + LoginRequestDto request = new LoginRequestDto(); + request.setAccessToken(null); + + // when & then + assertThatThrownBy(() -> loginService.login(request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.KAKAO_TOKEN_MISSING.getMessage()); + } + @Test void 토큰_누락시_예외() { LoginRequestDto request = new LoginRequestDto(""); @@ -47,33 +61,48 @@ public class LoginServiceTest { } @Test + @DisplayName("기존 회원 로그인 - Access Token과 Refresh Token 발급") void 기존회원_로그인() { - LoginRequestDto request = new LoginRequestDto("valid_token"); + // given + LoginRequestDto request = new LoginRequestDto("valid_kakao_token"); KakaoUserInfo kakaoInfo = new KakaoUserInfo("kakao123", "http://img.jpg"); User user = new User(); user.setId(1L); user.setKakaoId("kakao123"); + user.setRegistered(true); // 앱 가입 완료 상태 - when(kakaoApiClient.getKakaoUserInfo("valid_token")).thenReturn(kakaoInfo); + when(kakaoApiClient.getKakaoUserInfo("valid_kakao_token")).thenReturn(kakaoInfo); when(userRepository.findByKakaoId("kakao123")).thenReturn(Optional.of(user)); - when(jwtUtil.generateToken(any(), any())).thenReturn("jwt_token"); + when(jwtUtil.generateAccessToken(anyLong(), anyString())).thenReturn("access_token_123"); + when(jwtUtil.generateRefreshToken(anyLong())).thenReturn("refresh_token_456"); + // when LoginResponseDto response = loginService.login(request); + // then assertThat(response.isRegistered()).isTrue(); assertThat(response.getUserId()).isEqualTo(1L); + assertThat(response.getKakaoId()).isEqualTo("kakao123"); + assertThat(response.getAccessToken()).isEqualTo("access_token_123"); + assertThat(response.getRefreshToken()).isEqualTo("refresh_token_456"); + + verify(jwtUtil, times(1)).generateAccessToken(1L, "kakao123"); + verify(jwtUtil, times(1)).generateRefreshToken(1L); } @Test + @DisplayName("신규 회원 로그인 - 카카오 로그인만 완료, 앱 가입 미완료") void 신규회원_로그인() { - LoginRequestDto request = new LoginRequestDto("valid_token"); + // given + LoginRequestDto request = new LoginRequestDto("valid_kakao_token"); - KakaoUserInfo kakaoInfo = new KakaoUserInfo("kakao123", "http://img.jpg"); + KakaoUserInfo kakaoInfo = new KakaoUserInfo("kakao999", "http://profile.jpg"); - when(kakaoApiClient.getKakaoUserInfo("valid_token")).thenReturn(kakaoInfo); - when(userRepository.findByKakaoId("kakao123")).thenReturn(Optional.empty()); - when(jwtUtil.generateToken(any(), any())).thenReturn("jwt_token"); + when(kakaoApiClient.getKakaoUserInfo("valid_kakao_token")).thenReturn(kakaoInfo); + when(userRepository.findByKakaoId("kakao999")).thenReturn(Optional.empty()); + when(jwtUtil.generateAccessToken(anyLong(), anyString())).thenReturn("new_access_token"); + when(jwtUtil.generateRefreshToken(anyLong())).thenReturn("new_refresh_token"); when(userRepository.save(any(User.class))).thenAnswer(invocation -> { User u = invocation.getArgument(0); @@ -81,35 +110,78 @@ public class LoginServiceTest { return u; }); + // when LoginResponseDto response = loginService.login(request); - assertThat(response.isRegistered()).isFalse(); + // then + assertThat(response.isRegistered()).isFalse(); // 앱 가입 미완료 assertThat(response.getUserId()).isEqualTo(10L); + assertThat(response.getKakaoId()).isEqualTo("kakao999"); + assertThat(response.getAccessToken()).isEqualTo("new_access_token"); + assertThat(response.getRefreshToken()).isEqualTo("new_refresh_token"); + + verify(userRepository, times(1)).save(any(User.class)); + verify(jwtUtil, times(1)).generateAccessToken(10L, "kakao999"); + verify(jwtUtil, times(1)).generateRefreshToken(10L); } @Test void 로그아웃_성공() { + // given User user = new User(); user.setId(1L); user.setStatus(true); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + // when loginService.logout(1L); + // then assertThat(user.isStatus()).isFalse(); + verify(userRepository, times(1)).findById(1L); } @Test void 이미_로그아웃된_사용자() { + // given User user = new User(); user.setId(1L); user.setStatus(false); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + // when & then assertThatThrownBy(() -> loginService.logout(1L)) .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.ALREADY_LOGGED_OUT.getMessage()); } + + @Test + @DisplayName("존재하지 않는 사용자 로그아웃 - 예외 발생") + void 존재하지않는_사용자_로그아웃() { + // given + when(userRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> loginService.logout(999L)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("카카오 API 호출 실패 시 예외 전파") + void 카카오_API_실패() { + // given + LoginRequestDto request = new LoginRequestDto("invalid_kakao_token"); + + when(kakaoApiClient.getKakaoUserInfo("invalid_kakao_token")) + .thenThrow(new CustomException(ErrorCode.KAKAO_INVALID_TOKEN)); + + // when & then + assertThatThrownBy(() -> loginService.login(request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.KAKAO_INVALID_TOKEN.getMessage()); + } + }