Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
20 changes: 16 additions & 4 deletions src/main/java/com/mr/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ public class AuthController {

@SecurityRequirements
@Operation(
summary = "소셜 로그인 / 회원가입 API",
description = "카카오 및 구글 OAuth Access Token을 받아 로그인을 진행하고, 서비스 전용 JWT 토큰을 발급합니다."
summary = "소셜 로그인 / 회원가입 API (Authorization Code 또는 AccessToken)",
description = "카카오 및 구글 OAuth 인가 코드(code) 또는 Access Token을 전달받아 서비스 전용 JWT 토큰을 발급합니다."
)
@PostMapping("/login/{socialType}")
public ApiResponse<AuthResponseDTO.LoginResponse> socialLogin(
Expand All @@ -40,9 +40,15 @@ public ApiResponse<AuthResponseDTO.LoginResponse> socialLogin(
@RequestBody @Valid AuthRequestDTO.SocialLoginRequest request,
@RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo
) {
AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo);
AuthResponseDTO.LoginResponse response = authService.socialLogin(
socialType,
request.getEffectiveCodeOrToken(),
request.redirectUri(),
deviceInfo
);
return ApiResponse.onSuccess(response);
}

@SecurityRequirements
@Operation(summary = "토큰 재발급 API", description = "만료된 Access Token을 Refresh Token을 이용해 재발급합니다.")
@PostMapping("/reissue")
Expand All @@ -65,7 +71,13 @@ public ApiResponse<AuthResponseDTO.TokenInfo> linkSocialAccount(
@RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo
) {
Long userId = SecurityUtil.getCurrentUserId();
AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount(userId, socialType, request.accessToken(), deviceInfo);
AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount(
userId,
socialType,
request.getEffectiveCodeOrToken(),
request.redirectUri(),
deviceInfo
);
return ApiResponse.onSuccess(tokenInfo);
}

Expand Down
23 changes: 20 additions & 3 deletions src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
package com.mr.domain.auth.dto.req;

import com.mr.domain.auth.exception.AuthErrorStatus;
import com.mr.global.apipayload.exception.GeneralException;
import jakarta.validation.constraints.NotBlank;

public class AuthRequestDTO {

public record SocialLoginRequest(
@NotBlank(message = "소셜 액세스 토큰은 필수 입력값입니다.")
String accessToken
) {}
String code,
String authorizationCode,
String accessToken,
String redirectUri
) {
public String getEffectiveCodeOrToken() {
if (code != null && !code.isBlank()) {
return code.trim();
}
if (authorizationCode != null && !authorizationCode.isBlank()) {
return authorizationCode.trim();
}
if (accessToken != null && !accessToken.isBlank()) {
return accessToken.trim();
}
throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST);
}
}
Comment thread
kimyw1018 marked this conversation as resolved.
Resolved

public record TokenRefreshRequest(
@NotBlank(message = "Refresh Token은 필수 입력값입니다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.mr.domain.auth.dto.res;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleUserResponse(
String id,
String picture,
String email
String email,
String name,
String picture
) {}
12 changes: 6 additions & 6 deletions src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public record KakaoUserResponse(
) {
public record KakaoAccount(
Profile profile
) {}

public record Profile(
@JsonProperty("profile_image_url")
String profileImageUrl
) {}
) {
public record Profile(
@JsonProperty("profile_image_url")
String profileImageUrl
) {}
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/mr/domain/auth/dto/res/OAuthTokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.mr.domain.auth.dto.res;

import com.fasterxml.jackson.annotation.JsonProperty;

public record OAuthTokenResponse(
@JsonProperty("access_token")
String accessToken,
@JsonProperty("token_type")
String tokenType,
@JsonProperty("refresh_token")
String refreshToken,
@JsonProperty("expires_in")
Long expiresIn,
@JsonProperty("scope")
String scope
) {}
22 changes: 17 additions & 5 deletions src/main/java/com/mr/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
import com.mr.domain.auth.exception.AuthErrorStatus;
import com.mr.domain.auth.repository.SocialAuthRepository;
import com.mr.domain.user.entity.User;
import com.mr.domain.user.repository.UserRepository;
import com.mr.domain.user.exception.UserErrorStatus;
import com.mr.domain.user.repository.UserRepository;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -36,8 +36,10 @@ public class AuthService {
@Value("${app.profile.default-image-url}")
private String defaultProfileImageUrl;

public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) {
OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken);
public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String codeOrToken, String redirectUri, String deviceInfo) {
OAuthUserInfo userInfo = (redirectUri != null && !redirectUri.isBlank())
? oAuthClientService.getUserInfo(socialType, codeOrToken, redirectUri)
: oAuthClientService.getUserInfo(socialType, codeOrToken);

try {
return executeSocialLogin(socialType, userInfo, deviceInfo);
Expand All @@ -46,6 +48,10 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a
}
}

public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String codeOrToken, String deviceInfo) {
return socialLogin(socialType, codeOrToken, null, deviceInfo);
}

private AuthResponseDTO.LoginResponse executeSocialLogin(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) {
return transactionTemplate.execute(status -> {
Optional<SocialAuth> optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId());
Expand Down Expand Up @@ -127,8 +133,10 @@ private AuthResponseDTO.LoginResponse executeSocialLoginForExistingUser(SocialTy
});
}

public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, String accessToken, String deviceInfo) {
OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken);
public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, String codeOrToken, String redirectUri, String deviceInfo) {
OAuthUserInfo userInfo = (redirectUri != null && !redirectUri.isBlank())
? oAuthClientService.getUserInfo(socialType, codeOrToken, redirectUri)
: oAuthClientService.getUserInfo(socialType, codeOrToken);

return transactionTemplate.execute(status -> {
User user = userRepository.findById(userId)
Expand Down Expand Up @@ -170,6 +178,10 @@ public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socia
});
}

public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, String codeOrToken, String deviceInfo) {
return linkSocialAccount(userId, socialType, codeOrToken, null, deviceInfo);
}

private User registerNewUser(OAuthUserInfo userInfo) {
String profileImgUrl = userInfo.profileImgUrl();
if (profileImgUrl == null || profileImgUrl.isBlank()) {
Expand Down
110 changes: 103 additions & 7 deletions src/main/java/com/mr/domain/auth/service/OAuthClientService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
import com.mr.domain.auth.dto.OAuthUserInfo;
import com.mr.domain.auth.dto.res.GoogleUserResponse;
import com.mr.domain.auth.dto.res.KakaoUserResponse;
import com.mr.domain.auth.dto.res.OAuthTokenResponse;
import com.mr.domain.auth.entity.enums.SocialType;
import com.mr.domain.auth.exception.AuthErrorStatus;
import com.mr.domain.auth.exception.OAuthExceptionMapper;
import com.mr.global.apipayload.exception.GeneralException;
import com.mr.global.config.OAuthProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;

@Slf4j
Expand All @@ -20,23 +24,77 @@ public class OAuthClientService {

private final RestClient restClient;
private final OAuthExceptionMapper exceptionMapper;
private final OAuthProperties oAuthProperties;

public OAuthClientService(
@Qualifier("oauthRestClient") RestClient restClient,
OAuthExceptionMapper exceptionMapper) {
OAuthExceptionMapper exceptionMapper,
OAuthProperties oAuthProperties) {
this.restClient = restClient;
this.exceptionMapper = exceptionMapper;
this.oAuthProperties = oAuthProperties;
}

public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) {
public OAuthUserInfo getUserInfo(SocialType socialType, String codeOrToken, String customRedirectUri) {
if (customRedirectUri == null || customRedirectUri.isBlank()) {
return getUserInfo(socialType, codeOrToken);
}
return switch (socialType) {
case KAKAO -> getKakaoUserInfo(codeOrToken, customRedirectUri);
case GOOGLE -> getGoogleUserInfo(codeOrToken, customRedirectUri);
};
}

public OAuthUserInfo getUserInfo(SocialType socialType, String codeOrToken) {
return switch (socialType) {
case KAKAO -> getKakaoUserInfo(accessToken);
case GOOGLE -> getGoogleUserInfo(accessToken);
case KAKAO -> getKakaoUserInfo(codeOrToken, null);
case GOOGLE -> getGoogleUserInfo(codeOrToken, null);
};
}

private OAuthUserInfo getKakaoUserInfo(String accessToken) {
private String exchangeKakaoCode(String code, String customRedirectUri) {
OAuthProperties.ProviderProperties kakaoProps = oAuthProperties != null ? oAuthProperties.kakao() : null;
String clientId = kakaoProps != null ? kakaoProps.clientId() : null;
if (clientId == null || clientId.isBlank()) {
Comment thread
kimyw1018 marked this conversation as resolved.
Resolved
return code;
}

String redirectUri = (customRedirectUri != null && !customRedirectUri.isBlank())
Comment thread
kimyw1018 marked this conversation as resolved.
Resolved
? customRedirectUri
: (kakaoProps != null ? kakaoProps.redirectUri() : "");

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", redirectUri);
body.add("code", code);

if (kakaoProps.clientSecret() != null && !kakaoProps.clientSecret().isBlank()) {
body.add("client_secret", kakaoProps.clientSecret());
}

try {
OAuthTokenResponse response = restClient.post()
.uri("https://kauth.kakao.com/oauth/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(body)
.retrieve()
.body(OAuthTokenResponse.class);

if (response == null || response.accessToken() == null || response.accessToken().isBlank()) {
throw new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR);
}
return response.accessToken();
} catch (Exception e) {
log.warn("Kakao Authorization Code exchange failed or input was an AccessToken. Trying as AccessToken.");
return code;
}
}
Comment thread
kimyw1018 marked this conversation as resolved.
Resolved
Comment thread
kimyw1018 marked this conversation as resolved.
Resolved

private OAuthUserInfo getKakaoUserInfo(String codeOrToken, String customRedirectUri) {
try {
String accessToken = exchangeKakaoCode(codeOrToken, customRedirectUri);

KakaoUserResponse response = restClient.get()
.uri("https://kapi.kakao.com/v2/user/me")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
Expand Down Expand Up @@ -68,8 +126,46 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) {
}
}

private OAuthUserInfo getGoogleUserInfo(String accessToken) {
private String exchangeGoogleCode(String code, String customRedirectUri) {
OAuthProperties.ProviderProperties googleProps = oAuthProperties != null ? oAuthProperties.google() : null;
String clientId = googleProps != null ? googleProps.clientId() : null;
if (clientId == null || clientId.isBlank()) {
return code;
}

String redirectUri = (customRedirectUri != null && !customRedirectUri.isBlank())
? customRedirectUri
: (googleProps != null ? googleProps.redirectUri() : "");

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("client_secret", googleProps != null ? googleProps.clientSecret() : "");
body.add("redirect_uri", redirectUri);
body.add("code", code);

try {
OAuthTokenResponse response = restClient.post()
.uri("https://oauth2.googleapis.com/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(body)
.retrieve()
.body(OAuthTokenResponse.class);

if (response == null || response.accessToken() == null || response.accessToken().isBlank()) {
throw new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR);
}
return response.accessToken();
} catch (Exception e) {
log.warn("Google Authorization Code exchange failed or input was an AccessToken. Trying as AccessToken.");
return code;
}
}

private OAuthUserInfo getGoogleUserInfo(String codeOrToken, String customRedirectUri) {
try {
String accessToken = exchangeGoogleCode(codeOrToken, customRedirectUri);

GoogleUserResponse response = restClient.get()
.uri("https://www.googleapis.com/oauth2/v2/userinfo")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
Expand All @@ -89,4 +185,4 @@ private OAuthUserInfo getGoogleUserInfo(String accessToken) {
throw exceptionMapper.map(e, "Google");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import org.springframework.context.event.EventListener;
import java.time.LocalDateTime;

@Slf4j
Expand All @@ -36,7 +34,7 @@ public class NotificationEventListener {
backoff = @Backoff(delay = 1000)
)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 메인 트랜잭션이 커밋된(AFTER_COMMIT) 이후에만 알림을 저장하도록 변경
@EventListener
@Transactional
public void handleNotificationEvent(NotificationEvent event) {

Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/mr/global/config/OAuthProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.mr.global.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "oauth")
public record OAuthProperties(
ProviderProperties kakao,
ProviderProperties google
) {
public record ProviderProperties(
String clientId,
String clientSecret,
String redirectUri
) {}
}
Loading
Loading