Skip to content

Commit

Permalink
Feat/#425 카카오 소셜 로그인을 구현한다. (#434)
Browse files Browse the repository at this point in the history
* refactor: email 형식 제약조건 변경하기

* refactor: member의 nickname을 부여하는 방식 변경

* feat: 카카오 로그인 기능 추가

* refactor: OAuth 추상화하기

세부사항
- 소셜 로그인 과정 중에 accessToken을 가져오는 것과 사용자 정보를 조회하는 부분을 추상화
- authService는 각 infoProvider를 의존하는것이 아닌 OAuthInfoProvider를 의존하도록 수정

* refactor: QA를 위한 수정

* refactor: CI 실패 수정

* refactor: 에러 상세화를 위한 수정

* refactor: 카카오 로그인 확인을 위한 로그 추가

* refactor: 서버 엑세스 코드 확인 위한 로그 추가

* refactor: 서버 엑세스 코드 확인 위한 로그 추가

* refactor: QA를 종료후 기존의 코드 원복

세부사항
- 예외 핸들링 원복
- 불필요한 로그 제거

* refactor : 코드리뷰 반영

세부사항
- 명확한 클래스 명칭으로 수정
- requireNonNull을 이용할 시 nullPointException 처리
- 상수 분리

* refactor: CI 실패하는 오류 수정

* refactor: 서브모듈 최신화
  • Loading branch information
seokhwan-an authored Sep 21, 2023
1 parent 63c03aa commit d78c279
Show file tree
Hide file tree
Showing 25 changed files with 418 additions and 94 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import shook.shook.auth.application.dto.GoogleAccessTokenResponse;
import shook.shook.auth.application.dto.GoogleMemberInfoResponse;
import shook.shook.auth.application.dto.ReissueAccessTokenResponse;
import shook.shook.auth.application.dto.TokenPair;
import shook.shook.auth.repository.InMemoryTokenPairRepository;
Expand All @@ -16,20 +14,18 @@
public class AuthService {

private final MemberService memberService;
private final GoogleInfoProvider googleInfoProvider;
private final OAuthProviderFinder oauthProviderFinder;
private final TokenProvider tokenProvider;
private final InMemoryTokenPairRepository inMemoryTokenPairRepository;

public TokenPair login(final String authorizationCode) {
final GoogleAccessTokenResponse accessTokenResponse =
googleInfoProvider.getAccessToken(authorizationCode);
final GoogleMemberInfoResponse memberInfo = googleInfoProvider
.getMemberInfo(accessTokenResponse.getAccessToken());
public TokenPair oAuthLogin(final String oauthType, final String authorizationCode) {
final OAuthInfoProvider oAuthInfoProvider = oauthProviderFinder.getOAuthInfoProvider(oauthType);

final String userEmail = memberInfo.getEmail();
final String accessTokenResponse = oAuthInfoProvider.getAccessToken(authorizationCode);
final String memberInfo = oAuthInfoProvider.getMemberInfo(accessTokenResponse);

final Member member = memberService.findByEmail(userEmail)
.orElseGet(() -> memberService.register(userEmail));
final Member member = memberService.findByEmail(memberInfo)
.orElseGet(() -> memberService.register(memberInfo));

final Long memberId = member.getId();
final String nickname = member.getNickname();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@

@RequiredArgsConstructor
@Component
public class GoogleInfoProvider {
public class GoogleInfoProvider implements OAuthInfoProvider {

private static final String TOKEN_PREFIX = "Bearer ";
private static final String GRANT_TYPE = "authorization_code";
private static final String AUTHORIZATION_HEADER = "Authorization";

@Value("${oauth2.google.access-token-url}")
private String GOOGLE_ACCESS_TOKEN_URL;
Expand All @@ -41,10 +40,11 @@ public class GoogleInfoProvider {

private final RestTemplate restTemplate;

public GoogleMemberInfoResponse getMemberInfo(final String accessToken) {
@Override
public String getMemberInfo(final String accessToken) {
try {
final HttpHeaders headers = new HttpHeaders();
headers.set(AUTHORIZATION_HEADER, TOKEN_PREFIX + accessToken);
headers.set(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken);
final HttpEntity<Object> request = new HttpEntity<>(headers);

final ResponseEntity<GoogleMemberInfoResponse> response = restTemplate.exchange(
Expand All @@ -54,15 +54,16 @@ public GoogleMemberInfoResponse getMemberInfo(final String accessToken) {
GoogleMemberInfoResponse.class
);

return response.getBody();
return Objects.requireNonNull(response.getBody()).getEmail();
} catch (HttpClientErrorException e) {
throw new OAuthException.InvalidAccessTokenException();
} catch (HttpServerErrorException e) {
} catch (HttpServerErrorException | NullPointerException e) {
throw new OAuthException.GoogleServerException();
}
}

public GoogleAccessTokenResponse getAccessToken(final String authorizationCode) {
@Override
public String getAccessToken(final String authorizationCode) {
try {
final HashMap<String, String> params = new HashMap<>();
params.put("code", authorizationCode);
Expand All @@ -76,11 +77,11 @@ public GoogleAccessTokenResponse getAccessToken(final String authorizationCode)
params,
GoogleAccessTokenResponse.class);

return Objects.requireNonNull(response.getBody());
return Objects.requireNonNull(response.getBody()).getAccessToken();

} catch (HttpClientErrorException e) {
throw new OAuthException.InvalidAuthorizationCodeException();
} catch (HttpServerErrorException e) {
} catch (HttpServerErrorException | NullPointerException e) {
throw new OAuthException.GoogleServerException();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package shook.shook.auth.application;

import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
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.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
import shook.shook.auth.application.dto.KakaoAccessTokenResponse;
import shook.shook.auth.application.dto.KakaoMemberInfoResponse;
import shook.shook.auth.exception.OAuthException;

@RequiredArgsConstructor
@Component
public class KakaoInfoProvider implements OAuthInfoProvider {

private static final String APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded;charset=utf-8";
private static final String TOKEN_PREFIX = "Bearer ";
private static final String GRANT_TYPE = "authorization_code";


@Value("${oauth2.kakao.access-token-url}")
private String KAKAO_ACCESS_TOKEN_URL;

@Value("${oauth2.kakao.member-info-url}")
private String KAKAO_MEMBER_INFO_URL;

@Value("${oauth2.kakao.client-id}")
private String KAKAO_CLIENT_ID;

@Value("${oauth2.kakao.redirect-url}")
private String LOGIN_REDIRECT_URL;

private final RestTemplate restTemplate;

public String getMemberInfo(final String accessToken) {
try {
final HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + accessToken);
final HttpEntity<Object> request = new HttpEntity<>(headers);
final ResponseEntity<KakaoMemberInfoResponse> response = restTemplate.exchange(
KAKAO_MEMBER_INFO_URL,
HttpMethod.GET,
request,
KakaoMemberInfoResponse.class
);

return String.valueOf(Objects.requireNonNull(response.getBody()).getId());
} catch (HttpClientErrorException e) {
throw new OAuthException.InvalidAccessTokenException();
} catch (HttpServerErrorException | NullPointerException e) {
throw new OAuthException.KakaoServerException();
}
}

public String getAccessToken(final String authorizationCode) {
try {
final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", GRANT_TYPE);
params.add("client_id", KAKAO_CLIENT_ID);
params.add("redirect_uri", LOGIN_REDIRECT_URL);
params.add("code", authorizationCode);

HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED);

final HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);

final ResponseEntity<KakaoAccessTokenResponse> response = restTemplate.postForEntity(
KAKAO_ACCESS_TOKEN_URL,
request,
KakaoAccessTokenResponse.class);

return Objects.requireNonNull(response.getBody()).getAccessToken();
} catch (HttpClientErrorException e) {
throw new OAuthException.InvalidAuthorizationCodeException();
} catch (HttpServerErrorException | NullPointerException e) {
throw new OAuthException.KakaoServerException();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package shook.shook.auth.application;

public interface OAuthInfoProvider {

String getMemberInfo(final String accessToken);

String getAccessToken(final String authorizationCode);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package shook.shook.auth.application;

import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class OAuthProviderFinder {

private final Map<OAuthType, OAuthInfoProvider> oauthExecution = new HashMap<>();
private final KakaoInfoProvider kakaoInfoProvider;
private final GoogleInfoProvider googleInfoProvider;

@PostConstruct
public void init() {
oauthExecution.put(OAuthType.GOOGLE, googleInfoProvider);
oauthExecution.put(OAuthType.KAKAO, kakaoInfoProvider);
}

public OAuthInfoProvider getOAuthInfoProvider(final String oauthType) {
final OAuthType key = OAuthType.find(oauthType);
return oauthExecution.get(key);
}
}
16 changes: 16 additions & 0 deletions backend/src/main/java/shook/shook/auth/application/OAuthType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package shook.shook.auth.application;

import java.util.Arrays;
import shook.shook.auth.exception.OAuthException;

public enum OAuthType {

GOOGLE, KAKAO;

public static OAuthType find(final String oauthType) {
return Arrays.stream(OAuthType.values())
.filter(type -> type.name().equals(oauthType.toUpperCase()))
.findFirst()
.orElseThrow(OAuthException.OauthTypeNotFoundException::new);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class TokenPairScheduler {
private final TokenProvider tokenProvider;
private final InMemoryTokenPairRepository inMemoryTokenPairRepository;

@Scheduled(cron = "${schedules.cron}")
@Scheduled(cron = "${schedules.in-memory-token.cron}")
public void removeExpiredTokenPair() {
final Set<String> refreshTokens = inMemoryTokenPairRepository.getTokenPairs().keySet();
for (String refreshToken : refreshTokens) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package shook.shook.auth.application.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class KakaoAccessTokenResponse {

@JsonProperty("access_token")
private String accessToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package shook.shook.auth.application.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class KakaoMemberInfoResponse {

private Long id;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,18 @@ public GoogleServerException() {
super(ErrorCode.GOOGLE_SERVER_EXCEPTION);
}
}

public static class KakaoServerException extends OAuthException {

public KakaoServerException() {
super(ErrorCode.GOOGLE_SERVER_EXCEPTION);
}
}

public static class OauthTypeNotFoundException extends OAuthException {

public OauthTypeNotFoundException() {
super(ErrorCode.NOT_FOUND_OAUTH);
}
}
}
6 changes: 4 additions & 2 deletions backend/src/main/java/shook/shook/auth/ui/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import shook.shook.auth.application.AuthService;
Expand All @@ -19,12 +20,13 @@ public class AuthController implements AuthApi {
private final AuthService authService;
private final CookieProvider cookieProvider;

@GetMapping("/login/google")
@GetMapping("/login/{oauthType}")
public ResponseEntity<LoginResponse> googleLogin(
@RequestParam("code") final String authorizationCode,
@PathVariable("oauthType") final String oauthType,
final HttpServletResponse response
) {
final TokenPair tokenPair = authService.login(authorizationCode);
final TokenPair tokenPair = authService.oAuthLogin(oauthType, authorizationCode);
final Cookie cookie = cookieProvider.createRefreshTokenCookie(tokenPair.getRefreshToken());
response.addCookie(cookie);
final LoginResponse loginResponse = new LoginResponse(tokenPair.getAccessToken());
Expand Down
23 changes: 18 additions & 5 deletions backend/src/main/java/shook/shook/auth/ui/openapi/AuthApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import shook.shook.auth.ui.dto.LoginResponse;

Expand All @@ -21,14 +23,25 @@ public interface AuthApi {
responseCode = "200",
description = "구글 로그인 성공"
)
@Parameter(
name = "code",
description = "구글 로그인 후 발급받은 인증 코드",
required = true
@Parameters(
value = {
@Parameter(
name = "code",
description = "소셜 로그인 시 발급받은 인증 코드",
required = true
),
@Parameter(
name = "oauthType",
description = "소셜 로그인 타입",
required = true
)
}

)
@GetMapping("/login/google")
@GetMapping("/login/{oauthType}")
ResponseEntity<LoginResponse> googleLogin(
@RequestParam("code") final String authorizationCode,
@PathVariable("oauthType") final String oauthType,
final HttpServletResponse response
);
}
12 changes: 7 additions & 5 deletions backend/src/main/java/shook/shook/globalexception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ public enum ErrorCode {
INVALID_AUTHORIZATION_CODE(1003, "올바르지 않은 authorization code 입니다."),
INVALID_ACCESS_TOKEN(1004, "잘못된 구글 accessToken 입니다."),
GOOGLE_SERVER_EXCEPTION(1005, "구글 서버에서 오류가 발생했습니다."),
REFRESH_TOKEN_NOT_FOUND_EXCEPTION(1006, "accessToken 을 재발급하기 위해서는 refreshToken 이 필요합니다."),
ACCESS_TOKEN_NOT_FOUND(1007, "accessToken이 필요합니다."),
UNAUTHENTICATED_EXCEPTION(1008, "권한이 없는 요청입니다."),
INVALID_REFRESH_TOKEN(1009, "존재하지 않는 refreshToken 입니다."),
TOKEN_PAIR_NOT_MATCHING_EXCEPTION(1010, "올바르지 않은 TokenPair 입니다."),
KAKAO_SERVER_EXCEPTION(1006, "카카오 서버에서 오류가 발생했습니다."),
REFRESH_TOKEN_NOT_FOUND_EXCEPTION(1007, "accessToken 을 재발급하기 위해서는 refreshToken 이 필요합니다."),
ACCESS_TOKEN_NOT_FOUND(1008, "accessToken이 필요합니다."),
UNAUTHENTICATED_EXCEPTION(1009, "권한이 없는 요청입니다."),
NOT_FOUND_OAUTH(1010, "현재 지원하지 않는 OAuth 요청입니다."),
INVALID_REFRESH_TOKEN(1011, "존재하지 않는 refreshToken 입니다."),
TOKEN_PAIR_NOT_MATCHING_EXCEPTION(1012, "올바르지 않은 TokenPair 입니다."),

// 2000: 킬링파트 - 좋아요, 댓글

Expand Down
Loading

0 comments on commit d78c279

Please sign in to comment.