Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
98 changes: 89 additions & 9 deletions src/main/java/zim/tave/memory/controller/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@
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;

@RestController
Expand All @@ -31,22 +38,33 @@ public class LoginController {
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;

@Operation(summary = "카카오 로그인 요청",
description = "카카오 사용자 인증 후 토큰 발급, 토큰 이용하여 백엔드 서버가 로그인 처리")
@Operation(summary = "카카오 로그인 (인가 코드 방식)",
description = """
카카오 OAuth 인가 코드를 받아 로그인 처리 및 JWT 토큰 발급

프론트엔드는 먼저 카카오 로그인 URL로 리다이렉트:
https://kauth.kakao.com/oauth/authorize?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&response_type=code

카카오에서 받은 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,13 +77,75 @@ public class LoginController {
- INTERNAL_SERVER_ERROR: 로그인 처리 중 알 수 없는 오류가 발생했습니다.
""", content = @Content)
})
@PostMapping("/login")
public ResponseEntity<ApiResponseDto<LoginResponseDto>> kakaoLogin(
@RequestBody LoginRequestDto request) {
LoginResponseDto response = loginService.login(request);
@GetMapping("/login/kakao")
public ResponseEntity<ApiResponseDto<LoginResponseDto>> kakaoLoginWithCode(
@RequestParam String code) {

// 1. 인가 코드로 카카오 액세스 토큰 획득
String kakaoAccessToken = kakaoOAuthService.getAccessToken(code);

// 2. 카카오 액세스 토큰으로 로그인 처리
LoginRequestDto loginRequest = new LoginRequestDto();
loginRequest.setAccessToken(kakaoAccessToken);

LoginResponseDto response = loginService.login(loginRequest);
return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.LOGIN_SUCCESS, response));
}

@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(
@RequestHeader("Authorization") String refreshTokenHeader) {

if (refreshTokenHeader == null || !refreshTokenHeader.startsWith("Bearer ")) {
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
}

String refreshToken = refreshTokenHeader.substring(7); // "Bearer " 제거

// Refresh Token 검증
if (!jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) {
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
}

// 새 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());

TokenRefreshResponse response = TokenRefreshResponse.builder()
.accessToken(newAccessToken)
.build();

return ResponseEntity.ok(ApiResponseDto.success(ResponseCode.TOKEN_REFRESH_SUCCESS, response));
}

@Operation(summary = "로그아웃", description = "사용자 로그아웃 처리")
@SecurityRequirement(name = "bearerAuth")
@ApiErrorCodeExamples({
Expand Down
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;
}
14 changes: 8 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,29 @@ 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;

@Schema(description = "JWT Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6...")
private String refreshToken;

public static LoginResponseDto from(User user, String jwt, boolean registered) {
public static LoginResponseDto from(User user, String accessToken, String refreshToken, boolean registered) {
return new LoginResponseDto(
user.getId(),
registered,
user.getKakaoId(),
user.getProfileImageUrl(),
jwt
accessToken,
refreshToken
);
}
}
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;
}
3 changes: 3 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 @@ -99,6 +100,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)이 누락되었습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ 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입니다."),
//설정
NO_FIELDS_TO_UPDATE(HttpStatus.BAD_REQUEST, "수정할 필드가 없습니다."),
//보관
Expand Down
61 changes: 50 additions & 11 deletions src/main/java/zim/tave/memory/jwt/JwtUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

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;
Expand All @@ -13,20 +11,35 @@

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();
}
Expand All @@ -41,6 +54,11 @@ 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 validateToken(String token) {
try {
Expand All @@ -51,6 +69,24 @@ public boolean validateToken(String 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;
}
}

private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
Expand All @@ -70,8 +106,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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading