Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
182 changes: 166 additions & 16 deletions src/main/java/zim/tave/memory/controller/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
카카오 인증 실패입니다. 다음 오류가 발생할 수 있습니다:
Expand All @@ -59,11 +82,92 @@ public class LoginController {
- INTERNAL_SERVER_ERROR: 로그인 처리 중 알 수 없는 오류가 발생했습니다.
""", content = @Content)
})
@PostMapping("/login")
public ResponseEntity<ApiResponseDto<LoginResponseDto>> kakaoLogin(
@RequestBody LoginRequestDto request) {
LoginResponseDto response = loginService.login(request);
return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.LOGIN_SUCCESS, response));
@GetMapping("/login/kakao")
public ResponseEntity<ApiResponseDto<LoginResponseDto>> 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<ApiResponseDto<TokenRefreshResponse>> 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 = "사용자 로그아웃 처리")
Expand Down Expand Up @@ -96,9 +200,55 @@ public ResponseEntity<ApiResponseDto<LoginResponseDto>> kakaoLogin(
content = @Content)
})
@PatchMapping("/logout")
public ResponseEntity<ApiResponseDto<Void>> logout(@AuthenticationPrincipal CustomUserDetails userDetails) {
public ResponseEntity<ApiResponseDto<Void>> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "카카오 사용자 인증 후 토큰 발급, 토큰 이용하여 백엔드 서버가 로그인 처리",
Expand Down
25 changes: 25 additions & 0 deletions src/main/java/zim/tave/memory/dto/response/KakaoTokenResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 6 additions & 6 deletions src/main/java/zim/tave/memory/dto/response/LoginResponseDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,27 @@ 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;

@Schema(description = "프로필 이미지 URL", example = "http://img1.kakaocdn.net/profile.jpeg")
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
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions src/main/java/zim/tave/memory/global/common/ResponseCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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, "보드 수정이 완료되었습니다."),
Expand Down Expand Up @@ -92,13 +93,23 @@ 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, "유효하지 않거나 만료된 카카오 액세스 토큰입니다."),
KAKAO_UNAUTHORIZED(401, "카카오 API 접근 권한이 없습니다."),
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)이 누락되었습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "수정할 필드가 없습니다."),
//보관
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ public ResponseEntity<ApiResponseDto<?>> 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;
Expand Down
Loading
Loading