Skip to content
Open
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
7 changes: 6 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ jobs:
ANIMAL_API_SERVICE_KEY: ${{ secrets.ANIMAL_API_SERVICE_KEY }}
UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }}
UPSTAGE_AI_API_KEY: ${{ secrets.UPSTAGE_AI_API_KEY }}
KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }}
KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
Expand All @@ -84,7 +86,8 @@ jobs:
S3_REGION,S3_BUCKET_NAME,
AWS_ACCESS_KEY,AWS_SECRET_ACCESS_KEY,
JWT_SECRET_KEY,ANIMAL_API_SERVICE_KEY,
UPSTAGE_API_KEY, UPSTAGE_AI_API_KEY
UPSTAGE_API_KEY, UPSTAGE_AI_API_KEY,
KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET
script: |
aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $(echo $IMAGE_URI | cut -d'/' -f1)

Expand All @@ -109,4 +112,6 @@ jobs:
-e ANIMAL_API_SERVICE_KEY=$ANIMAL_API_SERVICE_KEY \
-e UPSTAGE_API_KEY=$UPSTAGE_API_KEY \
-e UPSTAGE_AI_API_KEY=$UPSTAGE_AI_API_KEY \
-e KAKAO_CLIENT_ID=$KAKAO_CLIENT_ID \
-e KAKAO_CLIENT_SECRET=$KAKAO_CLIENT_SECRET \
$IMAGE_URI:$IMAGE_TAG
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum ErrorStatus implements BaseErrorCode {
FILE_DOWNLOAD_FAILED(HttpStatus.BAD_REQUEST, "FILE400", "이미지 다운로드에 실패했습니다."),
FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE400", "파일 크기가 10MB를 초과했습니다."),
INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE400", "지원하지 않는 파일 형식입니다. (JPG, PNG, WEBP만 지원)"),
UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "OAUTH400", "지원하지 않는 OAuth 제공자입니다."),

// 401 Unauthorized
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "AUTH401", "인증이 필요합니다."),
Expand Down Expand Up @@ -49,6 +50,7 @@ public enum ErrorStatus implements BaseErrorCode {
S3_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3500", "파일 삭제 중 오류가 발생했습니다."),
DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "DB500", "데이터베이스 처리 중 오류가 발생했습니다."),
EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "API500", "외부 API 호출 중 오류가 발생했습니다."),
OAUTH_TOKEN_EXCHANGE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH500", "OAuth 토큰 교환에 실패했습니다."),

// 5001,5002
// RAG 관련 오류 코드
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**",
"/api/auth/login", "/api/auth/reissue"
"/api/auth/*/callback", "/api/auth/reissue"
).permitAll()
.anyRequest().authenticated()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@

import com.ganzi.backend.global.code.dto.ApiResponse;
import com.ganzi.backend.global.oauth.api.doc.AuthControllerDoc;
import com.ganzi.backend.global.oauth.api.dto.request.OAuthCallbackRequest;
import com.ganzi.backend.global.oauth.api.dto.response.LoginResponse;
import com.ganzi.backend.global.oauth.api.dto.response.UserInfoResponse;
import com.ganzi.backend.global.oauth.application.AuthService;
import com.ganzi.backend.global.oauth.application.OAuthService;
import com.ganzi.backend.global.security.userdetails.CustomUserDetails;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -24,12 +29,16 @@ public class AuthController implements AuthControllerDoc {
private static final String BEARER_PREFIX = "Bearer ";
private static final int BEARER_PREFIX_LENGTH = 7;

private final OAuthService oAuthService;
private final AuthService authService;

@Override
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestHeader("id_token") String idToken) {
LoginResponse response = authService.login(idToken);
@PostMapping("/{provider}/callback")
public ResponseEntity<ApiResponse<LoginResponse>> oauthCallback(
@PathVariable String provider,
@Valid @RequestBody OAuthCallbackRequest request
) {
LoginResponse response = oAuthService.loginWithCode(provider, request.code());
return ResponseEntity.ok(ApiResponse.onSuccess(response));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
package com.ganzi.backend.global.oauth.api.doc;

import com.ganzi.backend.global.code.dto.ApiResponse;
import com.ganzi.backend.global.oauth.api.dto.request.OAuthCallbackRequest;
import com.ganzi.backend.global.oauth.api.dto.response.LoginResponse;
import com.ganzi.backend.global.oauth.api.dto.response.UserInfoResponse;
import com.ganzi.backend.global.security.userdetails.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;

@Tag(name = "로그인", description = "로그인 API")
public interface AuthControllerDoc {

@Operation(
summary = "카카오 소셜 로그인",
description = "카카오 ID Token으로 로그인하여 JWT 토큰을 발급받습니다"
summary = "OAuth 소셜 로그인 (Authorization Code 방식)",
description = "프론트엔드에서 받은 OAuth authorization code를 백엔드에서 토큰으로 교환하여 JWT를 발급합니다."
)
ResponseEntity<ApiResponse<LoginResponse>> login(
@Parameter(description = "카카오 ID Token", required = true)
@RequestHeader("id_token") String idToken
ResponseEntity<ApiResponse<LoginResponse>> oauthCallback(
@Parameter(description = "OAuth 제공자 (kakao, google, apple)", required = true, example = "kakao")
@PathVariable String provider,

@Valid @RequestBody OAuthCallbackRequest request
);

@Operation(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ganzi.backend.global.oauth.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "OAuth 콜백 요청")
public record OAuthCallbackRequest(
@NotBlank(message = "Authorization code는 필수입니다")
@Schema(description = "OAuth 제공자로부터 받은 authorization code", example = "abc123xyz456")
String code
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.ganzi.backend.global.oauth.api.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "OAuth 토큰 응답")
public record OAuthTokenResponse(
@JsonProperty("access_token")
@Schema(description = "OAuth 액세스 토큰")
String accessToken,

@JsonProperty("token_type")
@Schema(description = "토큰 타입", example = "Bearer")
String tokenType,

@JsonProperty("refresh_token")
@Schema(description = "OAuth 리프레시 토큰")
String refreshToken,

@JsonProperty("id_token")
@Schema(description = "OpenID Connect ID 토큰 (JWT)")
String idToken,

@JsonProperty("expires_in")
@Schema(description = "액세스 토큰 만료 시간 (초)")
Integer expiresIn,

@JsonProperty("scope")
@Schema(description = "허용된 권한 범위")
String scope
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,6 @@ public class AuthService {
private final JwtService jwtService;
private final UserRepository userRepository;

@Transactional
public LoginResponse login(String idToken) {
CustomUserDetails userDetails = idTokenService.loadUserByIdToken(idToken);
User user = userDetails.getUser();

String accessToken = jwtService.createAccessToken(user.getEmail(), user.getId());
String refreshToken = jwtService.createRefreshToken();

jwtService.updateRefreshToken(user.getEmail(), refreshToken);

return new LoginResponse(accessToken, refreshToken, user.getId(), user.getNickname());
}

public UserInfoResponse getUserInfo(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.ganzi.backend.global.oauth.application;

import com.ganzi.backend.global.code.status.ErrorStatus;
import com.ganzi.backend.global.exception.GeneralException;
import com.ganzi.backend.global.oauth.api.dto.response.LoginResponse;
import com.ganzi.backend.global.oauth.api.dto.response.OAuthTokenResponse;
import com.ganzi.backend.global.oauth.client.OAuthClient;
import com.ganzi.backend.global.security.jwt.JwtService;
import com.ganzi.backend.global.security.userdetails.CustomUserDetails;
import com.ganzi.backend.user.User;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@Transactional(readOnly = true)
public class OAuthService {

private final IdTokenService idTokenService;
private final JwtService jwtService;
private final Map<String, OAuthClient> oAuthClientMap;

public OAuthService(
List<OAuthClient> oAuthClients,
IdTokenService idTokenService,
JwtService jwtService
) {
this.oAuthClientMap = oAuthClients.stream()
.collect(Collectors.toMap(
OAuthClient::getProviderName,
Function.identity()
));
this.idTokenService = idTokenService;
this.jwtService = jwtService;
}

@Transactional
public LoginResponse loginWithCode(String provider, String code) {
OAuthClient oAuthClient = getOAuthClient(provider);

OAuthTokenResponse tokenResponse = oAuthClient.exchangeCodeForToken(code);

CustomUserDetails userDetails = idTokenService.loadUserByIdToken(tokenResponse.idToken());
User user = userDetails.getUser();

String accessToken = jwtService.createAccessToken(user.getEmail(), user.getId());
String refreshToken = jwtService.createRefreshToken();

jwtService.updateRefreshToken(user.getEmail(), refreshToken);

return new LoginResponse(accessToken, refreshToken, user.getId(), user.getNickname());
}

private OAuthClient getOAuthClient(String provider) {
OAuthClient client = oAuthClientMap.get(provider.toLowerCase());
if (client == null) {
throw new GeneralException(ErrorStatus.UNSUPPORTED_OAUTH_PROVIDER);
}
return client;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.ganzi.backend.global.oauth.client;

import com.ganzi.backend.global.code.status.ErrorStatus;
import com.ganzi.backend.global.exception.GeneralException;
import com.ganzi.backend.global.oauth.api.dto.response.OAuthTokenResponse;
import com.ganzi.backend.global.oauth.config.OAuthProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Component
@RequiredArgsConstructor
public class KakaoOAuthClient implements OAuthClient {

private static final String PROVIDER_NAME = "kakao";
private static final String GRANT_TYPE = "authorization_code";

private final OAuthProperties oAuthProperties;
private final RestTemplate restTemplate = new RestTemplate();

@Override
public OAuthTokenResponse exchangeCodeForToken(String code) {
OAuthProperties.ProviderConfig config = oAuthProperties.getProvider(PROVIDER_NAME);

try {
HttpHeaders headers = createHeaders();
MultiValueMap<String, String> body = createTokenRequestBody(code, config);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);

ResponseEntity<OAuthTokenResponse> response = restTemplate.exchange(
config.getTokenUri(),
HttpMethod.POST,
request,
OAuthTokenResponse.class
);

if (response.getBody() == null) {
throw new GeneralException(ErrorStatus.OAUTH_TOKEN_EXCHANGE_FAILED);
}

log.info("카카오 토큰 교환 성공");
return response.getBody();

} catch (RestClientException e) {
log.error("카카오 토큰 교환 실패: {}", e.getMessage(), e);
throw new GeneralException(ErrorStatus.OAUTH_TOKEN_EXCHANGE_FAILED);
}
}

@Override
public String getProviderName() {
return PROVIDER_NAME;
}

private HttpHeaders createHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
return headers;
}

private MultiValueMap<String, String> createTokenRequestBody(
String code,
OAuthProperties.ProviderConfig config
) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", GRANT_TYPE);
body.add("client_id", config.getClientId());
body.add("client_secret", config.getClientSecret());
body.add("code", code);
return body;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ganzi.backend.global.oauth.client;

import com.ganzi.backend.global.oauth.api.dto.response.OAuthTokenResponse;

public interface OAuthClient {

OAuthTokenResponse exchangeCodeForToken(String code);

String getProviderName();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ganzi.backend.global.oauth.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(OAuthProperties.class)
public class OAuthConfig {
}
Loading
Loading