최근 진행하고 있는 프로젝트에서 소셜 로그인 구현을 맡게 됐다.
소셜 로그인의 경우, 확장성이 많은 부분이기 때문에 맞는 방향인지에 대한 확신은 없으나.. 최대한 확장성을 고려하여 구현해보고자 노오력 해봤다.
구현 과정에서의 고민에 대한 일지와도 비슷해 빠르게 구현을 하고자 하는 경우에는 적절한 글이 아닐 수 있습니다 (__)
OAuth2.0
위의 공식문서에 따르면 OAuth 는 인가를 위한 industry-standard 프로토콜이라고 한다.
보다 간단하게 client developer 들에게 authorization flow 를 제공하기 위해서 사용한다.
OAuth2.0 의 필요성
그렇다면 왜 OAuth 를 사용하는 것일까?
서비스에 카카오 로그인을 붙여야 한다고 생각해보자. 어떻게 구현할 수 있을까?
가장 먼저 떠오르는 방법은 사용자에게 직접 카카오톡의 아이디와 비밀번호를 받아 웹 서버에서 직접 kakao 서버에 로그인을 시도하는 것이다.
그러나 사용자 입장에서, 웹 서버에 대한 신뢰가 있을까?
사용자의 입장에서는 해당 웹 서버의 db 가 탈취된다면 해당 서비스뿐만 아니라 카카오의 정보까지 탈취당하게 되는 격이다.
그래서 제 3자인 OAuth 의 프로토콜을 이용해 권한을 위임받도록 하는 것이다.
OAuth2.0 의 주체
OAuth 를 사용하기 위해서는 세 가지의 주체를 알아야 한다.
1. Resource Owner
- 우리의 서비스, 소셜 플랫폼을 모두 이용하는 사용자
2. Authorization Server
- 소셜 로그인을 제공하는 플랫폼 사 ex) KAKAO, GOOGLE
3. Client
- 플랫폼으로 로그인을 요청하는 서버이다.
OAuth2.0 을 이용하기 위해서는..
OAuth 의 프로토콜을 이용하기 위해서는 client, 즉 우리의 서버를 Authorization Server (KAKAO) 에게 알려줘야 한다.
이 절차가 이루어져야 kakao 입장에서는 우리가 등록된 client 임을 알고 토큰을 넘겨줄 수 있는 것이다.
또한, Redirect URI 를 지정해야 한다. 이는 로그인 성공 시에 리디렉션 되는 엔드포인트이다. 보안을 위해서 localhost 를 제외하고는 https 만 허용된다.
등록 이후에는 client id 와 secret 을 얻는다. 이는 액세스 토큰을 얻기 위해서 사용된다.
id 는 노출되어도 상관없지만, secret 은 Authorization Server 가 권한 있는 client 인지 분간하기 위해 필요하므로 절대 노출되어서는 안된다.
본격적으로 소셜 로그인을 구현해볼 것이다.
우선 내가 구현한 서비스의 플로우는 다음과 같다.
1. 클라이언트 측에서 로그인 버튼을 누른 사용자들을 Authorization Server 로 로그인 요청을 보낸다.
여기에는 client Id, Redirection URI, response type, scope 등이 포함된다.
2. Authorization server 에서 제공한 로그인 페이지에서 사용자(Resource Onwer)가 로그인을 성공하면 authorization Code 를 제공한다.
3. 클라이언트는 이를 서버측으로 보낸다.
아래부터 내가 구현한 부분이다.
4. Authorization Code 를 이용해 Auhorization Server 로부터 액세스 토큰을 받고, 이 토큰을 통해 사용자의 정보를 얻는다.
5. 사용자의 정보 중에서 id 를 가져온다.
>> id 를 가져오기로 결정한 이유는, 플랫폼에 해당하는 고유 값이기 때문에 사용자 식별이 가능할 것이라고 판단했기 때문이다.
6. 사용자의 정보에 해당하는 고유 id + 해당 플랫폼이 db 에 존재한다면 이미 가입된 회원이기에 액세스 토큰을 발행하고 존재하지 않는다면 404 에러를 던진다.
위와 같은 플로우이기 때문에 kakao developers 에 우리의 서비스가 Client 임을 등록하는 것은 클라이언트 측에서 진행했으나 OAuth 에 대한 이해를 돕기 위해 직접 등록을 해보았다.
위에서 REST API 키가 위에서 언급헸던 client secret 에 해당한다.
Redirect URI 도 등록할 수 있다.
이제 등록도 마쳤으니 본격적으로 코드를 짜보자!
첫번째로, 소셜 로그인 제공사를 의미하는 platform 이라는 이름의 enum 클래스를 생성했다.
public enum Platform {
GOOGLE(),
KAKAO();
}
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@Column(nullable = false, unique = true)
protected Long socialId;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private Platform platform;
// 생략 . .
}
이는 사용자가 어떤 플랫폼을 통해 회원가입 / 로그인을 하는지 저장하기 위해 User 엔티티의 필드에도 추가해줬다.
두번째로는, Authorization Server 와 통신하는 코드를 구현했다.
Authorization Server 로부터 사용자의 Authorization Code 로 Access Token 을 받고, 사용자 정보까지 받아야 하기 때문에 통신을 해야 한다.
우리가 직접 client 가 되어서 요청을 보내는 것이기 때문에 header 를 설정하고, request 를 매번 설정해줘야 하는 것이 매우 번거롭고 코드의 가독성도 떨어진다고 생각했다.
@Override
public OauthUserResponse getUser(String accessToken) {
HttpEntity<HttpHeaders> request = createRequest(accessToken);
Jin409 marked this conversation as resolved.
restTemplate = new RestTemplate();
try {
ResponseEntity<KakaoUserResponse> response = restTemplate.exchange(
KAKAO_USER_INFO_URI,
younghch marked this conversation as resolved.
GET,
request,
KakaoUserResponse.class
);
return response.getBody();
} catch (Exception e) {
// do something . . .
}
}
위는 실제로 직접 header 를 설정하고, request 를 지정해줬던 코드이다.
한눈에 봐도 알 수 있듯, 코드의 가독성이 떨어진다.
이를 해결하기 위한 방법을 고민하던 중, OpenFeign 을 알게 되었다.
OpenFeign 이란?
feign 은 Java to HTTP client binder 이다. 이는 RESTful 웹 서비스 호출에 사용된다.
직접 코드를 전부 작성할 필요 없이 인터페이스와 어노테이션을 이용하면 된다.
public interface NaverSearchClient {
@RequestLine("GET /v1/search/{type}.json?query={query}")
@Headers({
"X-Naver-Client-Id: YOUR_CLIENT_ID",
"X-Naver-Client-Secret: YOUR_CLIENT_SECRET"
})
SearchResponse search(@Param("type") String type, @Param("query") String query);
}
위는 네이버의 검색 API 를 호출하는 예시이다.
위와 같이 간단하게 어노테이션만으로 header 와 request parameter 를 설정할 수 있다.
@FeignClient(name = "kakaoApiClient", url = KAKAO_API_URI)
public interface KakaoApiClient {
@GetMapping(value = "/v2/user/me")
KakaoUserResponse getUser(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);
}
@FeignClient(name = "kakaoAuthApiClient", url = KAKAO_AUTH_API_URI)
public interface KakaoAuthApiClient {
@PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
KakaoTokenResponse getAccessToken(
@RequestParam("grant_type") String grantType,
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code
);
}
나의 경우에는 위와 같이 코드를 작성하여 kakao 서버와 통신할 수 있도록 했다.
getAccessToken : authorization code 를 이용해 (Request Param 에서는 code) access token 을 가져오는 메서드 getUser : getAccessToken 에서 얻은 토큰을 이용해 카카오로부터 사용자의 정보를 가져오는 메서드
client Id 에는 위에서 발급받은 REST API 키를 넣고, 설정해준 redirect URI 와 클라이언트로부터 전달받은 code 를 파라미터로 넘긴다. 이가 유효하지 않다면 kakao 서버가 에러를 뱉는다.
{
"access_token": "토큰",
"token_type": "bearer",
"refresh_token": "토큰",
"expires_in": 21599,
"scope": "account_email",
"refresh_token_expires_in": 5183999
}
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class KakaoTokenResponse implements OAuthTokenResponse {
private String accessToken;
}
액세스 토큰 요청시의 응답값은 위와 같다.
여기서 우리는 액세스 토큰만 필요하기 때문에 response dto 는 위와 같이 지정해줬다.
@JsonIgnoreProperties(ignoreUnknown = true) : 해당 객체에서 선언되지 않은 필드는 모두 무시하도록 한다.
선언된 access_token 을 제외하고는 모두 필요 없기 때문에 해당 어노테이션을 붙여준다.
@JsonNaming : 모든 응답 필드의 형태가 snake case 이기 때문에 클래스의 필드를 모두 snake 케이스로 변경할 수 있도록 한다.
{
"id": 123456,
"connected_at": "2023-08-22T09:22:53Z",
"kakao_account": {
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "email@email.com"
}
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KakaoUserResponse implements OAuthUserResponse {
@NotNull
private Long id;
@Override
public Long getSocialId() {
return this.id;
}
}
위에서 가져온 access token 으로 getUser 메서드를 호출하게 되면 위와 같은 응답값이 온다. 여기에서 우리는 id 만 필요했기 때문에 id 만 가져왔다.
AccessToken 과 비슷하게 id 하나만 필요했기 때문에 @JsonIgnoreProperties(ignoreUnknown = true) 를 붙여줬다.
이를 통해 kakao_account 와 같은 필드는 아예 무시하고 넘어갈 수 있는 것이다.
그러나, 여기서 고려해야 할 지점이 있다. 과연 플랫폼마다 같은 과정을 통해 사용자의 정보를 얻을 수 있을까?
플랫폼마다 요구되는 값이 다양해질 수 있다. 예를 들어, apple 의 경우에는 다른 플랫폼들과는 다르게 추가적으로 해싱을 요구한다. 그렇다면 매번 플랫폼마다 로그인 코드를 새롭게 짜야 할까?
만약 그렇게 된다면 아래와 같이 코드가 짜여질 것이다.
public AccessToken kakaoLogin(final String authCode){
KakaoOAuthUser user = kakaoUserFactory.getUser(authCode);
//.. do something
}
public AccessToken appleLogin(final String authCode){
AppleOAuthUser user = appleUserFactory.getUser(authCode);
//.. do something
}
public AccessToken googleLogin(final String authCode){
GoogleOAuthUser user = googleUserFactory.getUser(authCode);
//.. do something
}
플랫폼명만 변경될 뿐, 중복되는 코드가 너무 많기에 좋은 방법이 아니라고 생각했다.
그렇기에 내가 선택한 방법은, 소셜 로그인 플랫폼에서 공통적으로 요구하는 기능을 묶어 인터페이스로 정의하는 것이었다.
public interface OAuthAgreedUserFactory {
OAuthAgreedUser getOAuthAgreedUser(String authCode);
}
이렇게 인터페이스를 정의하게 된 이유는, kakao 의 경우에는 액세스 토큰을 받고 사용자의 정보를 받는 flow 를 가지지만 플랫폼별로 플로우가 달라질 수 있기 때문에 인터페이스에는 최대한 간결하고 공통적일 요소만 적도록 했다.
@Component
@RequiredArgsConstructor
public class KakaoOAuthAgreedUserFactory implements OAuthAgreedUserFactory {
// 중략 ..
@Override
public OAuthAgreedUser getOAuthAgreedUser(String authCode) {
String accessToken = getAccessToken(authCode);
return new OAuthAgreedUser(PLATFORM, kakaoApiClient.getUser(TOKEN_PREFIX + accessToken));
}
private String getAccessToken(String authCode) {
return kakaoAuthApiClient.getAccessToken(GRANT_TYPE, clientId, redirectUri, authCode).getAccessToken();
}
@FeignClient(name = "kakaoApiClient", url = KAKAO_API_URI)
public interface KakaoApiClient {
@GetMapping(value = "/v2/user/me")
KakaoUserResponse getUser(@RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken);
}
@FeignClient(name = "kakaoAuthApiClient", url = KAKAO_AUTH_API_URI)
public interface KakaoAuthApiClient {
@PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
KakaoTokenResponse getAccessToken(
@RequestParam("grant_type") String grantType,
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code
);
}
}
getOauthAgreedUser 에서는 auth code 를 이용해 액세스 토큰을 얻고, 이를 getUser 에 kakao 의 토큰의 prefix 인 Bearer 를 붙여 요청을 보내 얻고, 사용자의 id 만 뽑아 반환한다.
@Service
@RequiredArgsConstructor
public class OauthAgreedUserFactoryProvider {
private static final Map<String, OAuthAgreedUserFactory> oAuthAgreedUserFactoryMap = new HashMap<>();
private final KakaoOAuthAgreedUserFactory kakaoOAuthAgreedUserFactory;
@PostConstruct
void initializeOAuthClientMap() {
oAuthAgreedUserFactoryMap.put(Platform.KAKAO.name(), kakaoOAuthAgreedUserFactory);
}
public OAuthAgreedUserFactory getClient(final String platformName) {
if (!oAuthAgreedUserFactoryMap.containsKey(platformName)) {
throw new UnsupportedOperationException();
}
return oAuthAgreedUserFactoryMap.get(platformName);
}
}
요청에 따른 플랫폼별 user factory 를 반환하기 위해 provider 를 구현했다.
@PostMapping("/login")
public ResponseEntity<AccessToken> login(
@Valid @RequestBody final LoginVo loginVo) throws NoSuchAlgorithmException, InvalidKeySpecException {
return ResponseEntity.ok(accessService.login(loginVo.getPlatformName(), loginVo.getAuthCode()));
}
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class LoginVo {
@NotBlank(message = "플랫폼명은 필수 값입니다.")
private String platformName;
@NotBlank(message = "사용자 코드는 필수 값입니다.")
private String authCode;
}
public AccessToken login(final String platformName, final String authCode) throws NoSuchAlgorithmException, InvalidKeySpecException {
OAuthAgreedUserFactory oAuthAgreedUserFactory = oauthAgreedUserFactoryProvider.getClient(platformName);
OAuthAgreedUser oAuthAgreedUser = oAuthAgreedUserFactory.getOAuthAgreedUser(authCode);
User registeredUser = getUser(oAuthAgreedUser.getSocialId());
return new AccessToken(registeredUser.getId(), jwtExpiration);
}
controller 와 service 코드는 위와 같이 짜주었다.
토큰 발행이나 에러를 던지고 회원가입으로 리다이렉트하는 내용까지 포함하게 되면 내용이 너무 길어질 것 같아 생략하도록 하겠다.
이가 알맞은 방향인지에 대한 확신은 없으나, 확장성을 고려하며 수수께끼 풀듯 이리저리 고민해본 경험이 꽤나 재밌었다!
질문이나 지적사항은 댓글로 주세요 :)
'SpringBoot' 카테고리의 다른 글
[SpringBoot] 이벤트 기반 아키텍처를 알아보고 스프링부트의 이벤트를 구현해보자 (0) | 2024.03.01 |
---|---|
[SpringBoot] Spring REST Docs 로 API 명세를 문서화 하자 (1) | 2024.01.23 |
[SpringBoot] 동시성 문제를 해결하자 (Synchronized, MySQL, Redis) (2) | 2024.01.10 |
[SpringBoot] N+1 을 고려하여 페이징 쿼리 작성하기 (1) | 2024.01.01 |
[SpringBoot] @ConfigurationProperties 로 프로퍼티들을 바인딩하기 (0) | 2023.09.08 |