프로젝트에서 로그인 기능을 구현하는 파트를 맡게 되었다.
우리 프로젝트에서는 일반 로그인은 하지 않고, 다른 많은 사이트처럼 sns로그인만 지원하기로 했다.
그래서 나는 OAuth 2.0 프로토콜에서 Authorization Code Grant 방식으로 구현을 했다.
Spring Security에 대한 이해가 부족했어서, Spring Security없이 구현을 했다.
sns 로그인은 google, naver, kakao 등 여러 개가 추가 될 수 있기 때문에, 확장성을 고려하면서 구현을 해서, 여러 번에 리팩토링 과정이 있었다. 그 과정에 대해서 쓰려고 한다.
여기서, authorization code는 front-end에서 받아와서, 요청을 처리할 수 있도록 했다.
V1
구조
login 처리를 담당하는 AuthService
AuthService에서는 OauthHandler를 불러서 현재 들어온 oauthProvider(sns플랫폼)에서 userInfo(Resource 서버에서 제공하는 resource)를 가져올 수 있도록 했다.
OauthHandler내에서는 Requester를 주입 받을 수 있도록 했다. 현재 요청받은 oauthProvider를 처리할 수 있는 Requester를 반환해준다. 그리고 반환받은 Requester에서 Authorization code를 사용해, access token을 받아오고 access token을 사용해서, Resource server에서 userinfo를 받아와서 제공해준다.
모든 Requester는 OauthAPIRequester 인터페이스를 implements하도록 했다.
코드
AuthService
public class AuthService {
private final MemberRepository memberRepository;
private final OauthHandler oauthHandler;
private final JwtUtils jwtUtils;
@Transactional(readOnly = true)
public LoginResponseDto login(LoginRequestDto loginRequestDto) {
OauthProvider oauthProvider = OauthProvider.ignoreCase(loginRequestDto.getOauthProvider());
OauthUserInfo userInfoFromCode = oauthHandler.getUserInfoFromCode(oauthProvider, loginRequestDto.getCode());
String email = userInfoFromCode.getEmail();
Member member = memberRepository.findByEmail(email, oauthProvider)
.orElseThrow(() -> new NoSuchOAuthMemberException(email));
String token = jwtUtils.createToken(member.getId());
LoginResponseDto response = new LoginResponseDto(token);
return response;
}
}
OauthHandler
@Component
public class OauthHandler {
private final List<OauthAPIRequester> oauthAPIRequesters;
public OauthUserInfo getUserInfoFromCode(OauthProvider oauthProvider, String code) {
OauthAPIRequester requester = getRequester(oauthProvider);
return requester.getUserInfoByCode(code);
}
private OauthAPIRequester getRequester(OauthProvider oauthProvider) {
return oauthAPIRequesters.stream()
.filter(oauthAPIRequester -> oauthAPIRequester.supports(oauthProvider))
.findFirst()
.orElseThrow(UnsupportedOauthProviderException::new);
}
}
OauthAPIRequester
public interface OauthAPIRequester {
boolean supports(OauthProvider oauthProvider);
OauthUserInfo getUserInfoByCode(String code);
}
GoogleRequester
@Component
public class GoogleRequester implements OauthAPIRequester {
@Value("${oauth2.user.google.client-id}")
private String clientId;
@Value("${oauth2.user.google.client-secret}")
private String secretId;
@Value("${oauth2.user.google.redirect-uri}")
private String redirectUri;
@Value("${oauth2.provider.google.token-uri}")
private String tokenUri;
@Value("${oauth2.provider.google.user-info-uri}")
private String userInfoUri;
@Override
public boolean supports(OauthProvider oauthProvider) {
return oauthProvider.isSameAs(OauthProvider.GOOGLE);
}
@Override
public OauthUserInfo getUserInfoByCode(final String code) {
String token = getToken(code);
return getUserInfo(token);
}
private String getToken(final String code) {
Map<String, Object> responseBody = WebClient.create()
.post()
.uri(tokenUri)
.headers(header -> {
header.setBasicAuth(clientId, secretId);
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(tokenRequest(code))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.blockOptional()
.orElseThrow(UnableToGetTokenResponseFromGoogleException::new);
validateResponseBody(responseBody);
return responseBody.get("access_token").toString();
}
private MultiValueMap<String, String> tokenRequest(String code) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", redirectUri);
return formData;
}
private void validateResponseBody(Map<String, Object> responseBody) {
if (!responseBody.containsKey("access_token")) {
throw new ErrorResponseToGetAccessTokenException();
}
}
private GoogleUserInfo getUserInfo(final String token) {
Map<String, Object> responseBody = WebClient.create()
.get()
.uri(userInfoUri)
.headers(httpHeaders -> httpHeaders.setBearerAuth(token))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.blockOptional()
.orElseThrow(UnableToGetTokenResponseFromGoogleException::new);
return GoogleUserInfo.from(responseBody);
}
}
OauthProvider
public class OauthProvider {
GOOGLE, NAVER, KAKAO;
public boolean isSameAs(OauthProvider oauthProvider) {
return this.equals(oauthProvider);
}
public static OauthProvider ignoreCase(String oauthProvider) {
return OauthProvider.valueOf(oauthProvider.toUpperCase(Locale.ROOT));
}
}
V2
google, kakao, naver 모두 다 OAuth 2.0 protocol를 따르고 있다. 그래서 같은 format으로 요청을 보내면, access token과 userInfo를 받을 수 있다. 이 말은 OauthAPIRequster 인터페이스를 구현하는 class들에 중복이 생긴다는 것이다.
구현의 중복을 줄이기 위해, APIRequster class를 만들어 중복되는 부분들을 한 곳에 모아줘서 처리하도록 했다.
코드
ApiRequster
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ApiRequester {
public static Map<String, Object> getUserInfo(
String code,
String tokenUri,
String clientId,
String secretId,
String redirectUri,
String userInfoUri
) {
String token = getToken(code, tokenUri, clientId, secretId, redirectUri);
return getUserInfoByToken(token, userInfoUri);
}
private static String getToken(String code, String tokenUri, String clientId, String secretId, String redirectUri) {
Map<String, Object> responseBody = WebClient.create()
.post()
.uri(tokenUri)
.headers(header -> {
header.setBasicAuth(clientId, secretId);
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(tokenRequest(code, redirectUri))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.flux()
.toStream()
.findFirst()
.orElseThrow(UnableToGetTokenResponseException::new);
validateResponseBody(responseBody);
return responseBody.get("access_token").toString();
}
private static MultiValueMap<String, String> tokenRequest(String code, String redirectUri) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", redirectUri);
return formData;
}
private static void validateResponseBody(Map<String, Object> responseBody) {
if (!responseBody.containsKey("access_token")) {
throw new GetAccessTokenException();
}
}
private static Map<String, Object> getUserInfoByToken(String token, String userInfoUri) {
Map<String, Object> responseBody = WebClient.create()
.get()
.uri(userInfoUri)
.headers(httpHeaders -> httpHeaders.setBearerAuth(token))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.flux()
.toStream()
.findFirst()
.orElseThrow(UnableToGetTokenResponseException::new);
return responseBody;
}
}
GoogleRequster
public class GoogleRequester implements OauthAPIRequester {
@Value("${oauth2.user.google.client-id}")
private String clientId;
@Value("${oauth2.user.google.client-secret}")
private String secretId;
@Value("${oauth2.user.google.redirect-uri}")
private String redirectUri;
@Value("${oauth2.provider.google.token-uri}")
private String tokenUri;
@Value("${oauth2.provider.google.user-info-uri}")
private String userInfoUri;
@Override
public boolean supports(OauthProvider oauthProvider) {
return oauthProvider.isSameAs(OauthProvider.GOOGLE);
}
@Override
public OauthUserInfo getUserInfoByCode(String code) {
Map<String, Object> userInfo = ApiRequester.getUserInfo(code, tokenUri, clientId, secretId, redirectUri, userInfoUri);
return GoogleUserInfo.from(userInfo);
}
}
V3
v2에서도 문제점이 존재했다. 현재 @Value로 필드 주입을 받는 상태이다. 그래서 새로운 provider를 추가할 때, .yml파일에도 해당 정보들을 추가를 해줘야되고, 동일하게 xxxRequester class에서도 추가해줘야 되고, OauthProvider를 enum으로 관리하고 있기 때문에, OauthProvider에도 추가해줘야 된다는 문제가 있었다. 중복되는 곳은 줄였지만, 기능을 추가하면 변경점이 많이 존재한다는 문제가 있었다.
그래서 @ConfigurationProperties를 사용하여, yml에만 추가하면 값들을 얻어올 수 있도록 바꾸었다.
코드
OauthConfig
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(OauthProperties.class)
public class OauthConfig {
private final OauthProperties properties;
@Bean
public OauthHandler oauthHandler() {
Map<String, OauthProvider> providers = properties.getOauthProviders();
return new OauthHandler(providers, new ApiRequester());
}
}
OauthProperties
package com.handong.rebon.auth.domain;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import lombok.Getter;
import lombok.Setter;
@Getter
@ConfigurationProperties(prefix = "oauth2")
public class OauthProperties {
private final Map<String, User> user = new HashMap<>();
private final Map<String, Provider> provider = new HashMap<>();
@Getter
@Setter
public static class User {
private String clientId;
private String clientSecret;
private String redirectUri;
}
@Getter
@Setter
public static class Provider {
private String tokenUri;
private String userInfoUri;
}
public Map<String, OauthProvider> getOauthProviders() {
Map<String, OauthProvider> oauthProviders = new HashMap<>();
user.forEach((key, value) -> oauthProviders.put(key, new OauthProvider(value, provider.get(key))));
return oauthProviders;
}
}
V4
v3까지는 구글 로그인만 구현한 상태였다. 문제는 naver와 kakao login을 추가하는 과정에서 일어났다. accessToken으로 userInfo를 가져오는 곳과 accessToken을 가져오는 곳에서 문제가 생겼다.
먼저, accessToken을 가져올 때, kakao에서는 google과 naver와 다르게, clientId와 client secret을 header에 담는 것이 아닌, body에 담아서 보내야 됐다. 그래서 v3에서는 body에 담아서 보내는 방식으로 구현을 했는데, kakao에서는 header에 담아서 보내는 방식만 지원이 됐다. naver와 google도 header에 담는 방식이 지원이 되어, 그 부분만 바꿔주면 되서, 문제가 쉽게 해결됐다.
두번째는 UserInfo를 가져올 때, userInfo가 들어있는 depth들이 다 달랐다. 기존에 google에서는 responseBody로 받은 것에서 depth를 더 들어가는 것 없이, email을 얻어올 수 있었다. 하지만, naver에서는 responseBody안에, response객체 안에 email이 존재했고, kakao에서는 responseBody안에, kakao_account 객체 안에 email 정보가 있었다.
후에, 다른 sns 플랫폼을 추가할 경우, 그것도 다를 수 있을 것이다.
그래서 기존의 responseBody에서 email을 바로 가져오는 것이 아닌, OAuthAttributes라는 enum을 만들어, 각각의 provider에 맞게, email을 가져올 수 있도록 변경하였다.
코드
ApiRequester
public class ApiRequester {
public Map<String, Object> getUserInfo(String code, OauthProvider oauthProvider) {
String token = getToken(code, oauthProvider);
return getUserInfoByToken(token, oauthProvider.getUserInfoUrl());
}
private String getToken(String code, OauthProvider oauthProvider) {
Map<String, Object> responseBody = WebClient.create()
.post()
.uri(oauthProvider.getTokenUrl())
.headers(header -> {
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(tokenRequest(code, oauthProvider))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.flux()
.toStream()
.findFirst()
.orElseThrow(UnableToGetTokenResponseException::new);
validateResponseBody(responseBody);
return responseBody.get("access_token").toString();
}
private MultiValueMap<String, String> tokenRequest(String code, OauthProvider oauthProvider) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", oauthProvider.getRedirectUrl());
formData.add("client_id", oauthProvider.getClientId());
formData.add("client_secret", oauthProvider.getClientSecret());
return formData;
}
private void validateResponseBody(Map<String, Object> responseBody) {
if (!responseBody.containsKey("access_token")) {
throw new GetAccessTokenException();
}
}
private static Map<String, Object> getUserInfoByToken(String token, String userInfoUri) {
Map<String, Object> responseBody = WebClient.create()
.get()
.uri(userInfoUri)
.headers(httpHeaders -> httpHeaders.setBearerAuth(token))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.flux()
.toStream()
.findFirst()
.orElseThrow(UnableToGetTokenResponseException::new);
return responseBody;
}
}
OauthUserInfo
@Getter
@Builder
public class OauthUserInfo {
private String email;
}
OauthAttributes
public enum OauthAttributes {
NAVER("naver") {
@Override
public OauthUserInfo of(Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OauthUserInfo.builder()
.email((String) response.get("email"))
.build();
}
},
GOOGLE("google") {
@Override
public OauthUserInfo of(Map<String, Object> attributes) {
return OauthUserInfo.builder()
.email((String) attributes.get("email"))
.build();
}
},
KAKAO("kakao") {
@Override
public OauthUserInfo of(Map<String, Object> attributes) {
Map<String, Object> account = (Map<String, Object>) attributes.get("kakao_account");
return OauthUserInfo.builder()
.email((String) account.get("email"))
.build();
}
};
private final String providerName;
OauthAttributes(String name) {
this.providerName = name;
}
public static OauthUserInfo extract(String providerName, Map<String, Object> attributes) {
return Arrays.stream(values())
.filter(provider -> providerName.equals(provider.providerName))
.findFirst()
.orElseThrow(IllegalArgumentException::new)
.of(attributes);
}
public abstract OauthUserInfo of(Map<String, Object> attributes);
}
여기까지, 객체 지향 원칙인 OCP를 지키기 위해, OAuth Login을 구현한 과정에 대해 설명을 했다.
완성된 코드들 밑에 링크에서 확인할 수 있습니다.
https://github.com/RE-BON/ReBoN/tree/dev/backend/src/main/java/com/handong/rebon/auth
GitHub - RE-BON/ReBoN
Contribute to RE-BON/ReBoN development by creating an account on GitHub.
github.com
https://github.com/RE-BON/ReBoN/tree/dev/backend/src/main/java/com/handong/rebon/config
GitHub - RE-BON/ReBoN
Contribute to RE-BON/ReBoN development by creating an account on GitHub.
github.com
'📽project > 🎀ReBoN' 카테고리의 다른 글
코드리뷰 처음이니? (0) | 2022.03.28 |
---|---|
👐컨벤션 어디까지 해봤니❓ (0) | 2022.03.14 |
설계,,,,이렇게 하는 게 맞는 건가,,,?? (0) | 2022.03.10 |