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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class AuthController {
private final AuthCommandService authCommandService;
private final EmailCommandService emailCommandService;

@Operation(summary = "회원가입 API by 요시", description = "최초 회원가입 시 필요한 정보를 포함하여 회원가입 진행")
@Operation(summary = "회원가입 API by 요시", description = "최초 회원가입 시 필요한 정보를 포함하여 회원가입 진행, 소셜 로그인인 경우에만 socialId 포함하고 아닌 경우 제거하거나 null")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "회원가입 성공"),
@ApiResponse(
Expand All @@ -33,6 +33,13 @@ public class AuthController {
다음과 같은 이유로 실패할 수 있습니다:
- AUTH400_1: 이미 존재하는 이메일입니다.
"""
),
@ApiResponse(
responseCode = "404",
description = """
다음과 같은 이유로 실패할 수 있습니다:
- SOCIAL404_1: 소셜을 찾을 수 없습니다.
"""
)
})
@PostMapping("/sign-up")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.withtime.be.withtimebe.domain.auth.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.namul.api.payload.response.DefaultResponse;
import org.springframework.web.bind.annotation.*;
import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.service.command.OAuth2CommandService;

@RestController
@RequestMapping("/api/v1/oauth2")
@RequiredArgsConstructor
@Tag(name = "소셜 로그인 API")
public class OAuth2Controller {

private final OAuth2CommandService oAuth2CommandService;

@Operation(summary = "소셜 로그인 API by 요시", description = "/oauth2/authorization/{provider}로 서버에 요청을 보낸 뒤 리다이렉트된 URI의 코드를 사용하여 요청, 리다이렉트되는 URI 의 Endpoint는 해당 API와 동일합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200",
description = """
소셜 로그인 성공
- isFirst: true 시 최초 회원가입 필요, 이메일은 인증된 상태로 1시간 유효
- isFirst: false 시 최초 회원가입 필요 X, 로그인 처리
"""
),
@ApiResponse(
responseCode = "400",
description = """
다음과 같은 이유로 실패할 수 있습니다:
- AUTH400_1: 지원하지 않는 소셜 로그인입니다. provider가 잘못되었거나 지원하지 않는 provider입니다.
"""
),
@ApiResponse(
responseCode = "500",
description = """
다음과 같은 이유로 실패할 수 있습니다:
- AUTH500_1: 사용자 정보를 가져오는데 실패했습니다. 인가코드가 잘못되었거나 OAuth2 인증 서버나 리소스 서버에 보낸 요청이 실패했습니다.
"""
),

})
@GetMapping("/callback/{provider}")
public DefaultResponse<OAuth2ResponseDTO.Login> loginWithOAuth2(HttpServletRequest request, HttpServletResponse response,
@Parameter(description = "소셜 로그인 플랫폼(대소문자 상관 없음), [kakao, google, naver]", example = "kakao") @PathVariable String provider,
@RequestParam String code) {
return DefaultResponse.ok(oAuth2CommandService.login(request, response, provider, code));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.withtime.be.withtimebe.domain.auth.converter;

import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.factory.support.dto.GoogleOAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.factory.support.dto.KakaoOAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.factory.support.dto.NaverOAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType;

public class OAuth2Converter {
public static OAuth2ResponseDTO.Login toLogin(String email, boolean isFirst, Long socialId) {
return OAuth2ResponseDTO.Login.builder()
.email(email)
.socialId(socialId)
.isFirst(isFirst)
.build();
}

public static OAuth2ResponseDTO.GetUserInfo toGetUserInfo(KakaoOAuth2ResponseDTO.KakaoProfile kakaoProfile) {
return OAuth2ResponseDTO.GetUserInfo.builder()
.email(kakaoProfile.kakao_account().email())
.providerId(String.valueOf(kakaoProfile.id()))
.socialType(SocialType.KAKAO)
.build();
}

public static OAuth2ResponseDTO.GetUserInfo toGetUserInfo(NaverOAuth2ResponseDTO.UserInfo.UserInfoData naver) {
return OAuth2ResponseDTO.GetUserInfo.builder()
.email(naver.email())
.providerId(naver.id())
.socialType(SocialType.NAVER)
.build();
}

public static OAuth2ResponseDTO.GetUserInfo toGetUserInfo(GoogleOAuth2ResponseDTO.UserInfo google) {
return OAuth2ResponseDTO.GetUserInfo.builder()
.email(google.email())
.providerId(google.id())
.socialType(SocialType.GOOGLE)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public record SignUp(
String password,
Gender gender,
String phoneNumber,
LocalDate birth
LocalDate birth,
Long socialId
) {

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.withtime.be.withtimebe.domain.auth.dto.response;

import lombok.Builder;
import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType;

public record OAuth2ResponseDTO() {

@Builder
public record Login(
String email,
Long socialId,
boolean isFirst
) {

}

@Builder
public record GetUserInfo(
String email,
String providerId,
SocialType socialType
) {

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.withtime.be.withtimebe.domain.auth.factory;

import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO;

public interface OAuth2UserLoader {
OAuth2ResponseDTO.GetUserInfo loadUser(String code);
String getSocialType();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.withtime.be.withtimebe.domain.auth.factory;

import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class OAuth2UserLoaderFactory {

private final Map<String, OAuth2UserLoader> oAuth2UserLoaderMap = new ConcurrentHashMap<>();

public OAuth2UserLoaderFactory(List<OAuth2UserLoader> oAuth2UserLoaders) {
oAuth2UserLoaders.forEach(
oAuth2UserLoader -> {
String socialType = oAuth2UserLoader.getSocialType().toLowerCase();
if (oAuth2UserLoaderMap.get(socialType) != null) {
throw new IllegalStateException("OAuth2UserLoader social type 중복: " + socialType);
}
oAuth2UserLoaderMap.put(socialType, oAuth2UserLoader);
}
);
}

public OAuth2UserLoader getUserLoader(String provider) {
return oAuth2UserLoaderMap.get(provider.toLowerCase());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.withtime.be.withtimebe.domain.auth.factory.support;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.factory.OAuth2UserLoader;
import org.withtime.be.withtimebe.global.data.OAuth2ConfigData;
import org.withtime.be.withtimebe.global.error.code.OAuthErrorCode;
import org.withtime.be.withtimebe.global.error.exception.OAuthException;

import java.io.IOException;
import java.util.Optional;

@RequiredArgsConstructor
public abstract class AbstractOAuth2UserLoader implements OAuth2UserLoader {

private final OAuth2ConfigData oAuth2ConfigData;

@Override
public OAuth2ResponseDTO.GetUserInfo loadUser(String code) {
try {
String token = getAccessToken(code);
return getUserInfo(token);
}
catch (Exception e) {
throw new OAuthException(OAuthErrorCode.FAIL_TO_GET_USER_INFO);
}
}

protected abstract String getAccessToken(String code) throws IOException;

protected abstract OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException;

protected <T> T getToken(String code, Class<T> clz) throws IOException {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders httpHeaders = new HttpHeaders();

httpHeaders.add("Content-Type", "application/x-www-form-urlencoded");

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", getClientId());
params.add("redirect_uri", getRedirectUri());
params.add("code", code);
Optional.ofNullable(getClientSecret()).ifPresent(secret -> params.add("client_secret", secret));
HttpEntity<MultiValueMap> request = new HttpEntity<>(params, httpHeaders);

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

ObjectMapper objectMapper = new ObjectMapper();

return objectMapper.readValue(response.getBody(), clz);

}

protected <T> T getProfile(String tokenPrefix, String token, Class<T> clz) throws IOException {
// 토큰으로 정보 가져오기
RestTemplate restTemplate = new RestTemplate();
HttpHeaders httpHeaders = new HttpHeaders();

httpHeaders.add("Authorization", tokenPrefix + token);
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

HttpEntity<MultiValueMap> request1 = new HttpEntity<>(httpHeaders);

ResponseEntity<String> response = restTemplate.exchange(
getUserInfoUri(),
HttpMethod.GET,
request1,
String.class
);

ObjectMapper om = new ObjectMapper();

return om.readValue(response.getBody(), clz);
}

protected String getClientId() {
return this.oAuth2ConfigData.getRegistration().get(this.getSocialType()).getClientId();
}

protected String getClientSecret() {
return this.oAuth2ConfigData.getRegistration().get(this.getSocialType()).getClientSecret();
}

protected String getRedirectUri() {
return this.oAuth2ConfigData.getRegistration().get(this.getSocialType()).getRedirectUri();
}

protected String getTokenUri() {
return this.oAuth2ConfigData.getProvider().get(this.getSocialType()).getTokenUri();
}

protected String getUserInfoUri() {
return this.oAuth2ConfigData.getProvider().get(this.getSocialType()).getUserInfoUri();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.withtime.be.withtimebe.domain.auth.factory.support;

import org.springframework.stereotype.Component;
import org.withtime.be.withtimebe.domain.auth.converter.OAuth2Converter;
import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.factory.support.dto.GoogleOAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType;
import org.withtime.be.withtimebe.global.data.OAuth2ConfigData;

import java.io.IOException;

@Component
public class GoogleUserLoader extends AbstractOAuth2UserLoader {

private static final String AUTHORIZATION_TOKEN_PREFIX = "Bearer ";
private static final SocialType SOCIAL_TYPE = SocialType.GOOGLE;

public GoogleUserLoader(OAuth2ConfigData oAuth2ConfigData) {
super(oAuth2ConfigData);
}

@Override
protected String getAccessToken(String code) throws IOException {
GoogleOAuth2ResponseDTO.Token token = super.getToken(code, GoogleOAuth2ResponseDTO.Token.class);
return token.access_token();
}

@Override
protected OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException {
GoogleOAuth2ResponseDTO.UserInfo userInfo = super.getProfile(AUTHORIZATION_TOKEN_PREFIX, token, GoogleOAuth2ResponseDTO.UserInfo.class);
return OAuth2Converter.toGetUserInfo(userInfo);
}

@Override
public String getSocialType() {
return SOCIAL_TYPE.name().toLowerCase();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.withtime.be.withtimebe.domain.auth.factory.support;

import org.springframework.stereotype.Component;
import org.withtime.be.withtimebe.domain.auth.converter.OAuth2Converter;
import org.withtime.be.withtimebe.domain.auth.dto.response.OAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.auth.factory.support.dto.KakaoOAuth2ResponseDTO;
import org.withtime.be.withtimebe.domain.member.entity.enums.SocialType;
import org.withtime.be.withtimebe.global.data.OAuth2ConfigData;

import java.io.IOException;

@Component
public class KakaoUserLoader extends AbstractOAuth2UserLoader {

private static final String AUTHORIZATION_TOKEN_PREFIX = "Bearer ";
private static final SocialType SOCIAL_TYPE = SocialType.KAKAO;

public KakaoUserLoader(OAuth2ConfigData oAuth2ConfigData) {
super(oAuth2ConfigData);
}

@Override
protected String getAccessToken(String code) throws IOException {
KakaoOAuth2ResponseDTO.Token oAuth2TokenDTO = getToken(code, KakaoOAuth2ResponseDTO.Token.class);
return oAuth2TokenDTO.access_token();
}

@Override
protected OAuth2ResponseDTO.GetUserInfo getUserInfo(String token) throws IOException {
KakaoOAuth2ResponseDTO.KakaoProfile kakaoProfile = super.getProfile(AUTHORIZATION_TOKEN_PREFIX, token, KakaoOAuth2ResponseDTO.KakaoProfile.class);
return OAuth2Converter.toGetUserInfo(kakaoProfile);
}


@Override
public String getSocialType() {
return SOCIAL_TYPE.name().toLowerCase();
}

}
Loading