소셜 로그인 도입기

JeongYong Park·2023년 8월 11일
1

이번 프로젝트 요구사항으로 Github 소셜 로그인이 주어졌습니다. 처음 구현해보는 내용이기도 해서 이번 기회에 정리해보려고 합니다!

OAuth?

OAuth는 Open Authorization의 약자로 인증과 권한 부여를 위한 개방형 표준 프로토콜입니다.
구글, 깃헙, 카카오, 네이버 같이 다양한 플랫폼의 사용자 데이터에 접근하기 위해 클라이언트(우리의 서비스)가 플랫폼 내의 자신의 데이터에 대한 접근 권한을 부여받을 수 있도록 해줍니다. 사용자의 접근 권한을 위임받을 수 있다는 것은 개인정보 관리의 책임을 Third-Party Application에게 위임하여에 위임하는 것을 의미합니다.
또한 부여받은 접근권한을 통해 Third-Party Application의 사용자 리소스에 접근할 수 있습니다.

왜 필요할까?

OAuth 이전에는 다른 애플리케이션에 로그인하거나 개인 정보를 제공할 때, 사용자 이름과 비밀번호 등을 직접 입력하여 제공해야 했습니다. 이런 방식은 문제점을 가지고 있습니다.

위 그림에서도 알 수 있듯이 애플리케이션에 직접 로그인 정보를 제공해야하기 때문에, 다른 플랫폼의 개인 정보를 노출될 수 있습니다. 이런 문제를 해결하고자 하기 위해 OAuth가 등장했습니다.

용어 정리

OAuth 도입을 설명하기 전 용어정리부터 하려고 합니다.

  • Client (클라이언트)
    • 리소스에 접근하는 애플리케이션 혹은 서비스에 해당합니다. 여기서는 Spring Boot 서버에 해당합니다.
  • Authorization Server (인증 서버)
    • 인증 / 인가를 수행하는 서버로 access token을 발급합니다.
  • Resource Server (리소스 서버)
    • 구글, 깃헙, 카카오, 네이버 등 사용자의 리소스를 가지고 있는 서버를 의미합니다.
  • Authorization Code (인증 코드)
    • 사용자가 로그인 성공 후 발급받는 코드로 access token 발급시 필요합니다.

OAuth 도입기

설명에 앞서 개발환경은 스프링 부트 2.7.14, Java 11 버전에서 진행했음을 알립니다.

OAuth의 흐름

제가 생각한 OAuth의 로그인 흐름은 다음과 같습니다.

  1. 소셜 로그인 버튼 클릭
    • 소셜 로그인 버튼을 클릭하면 Github 로그인 페이지로 리다이렉트 시켜줍니다.
  2. 소셜 로그인 시도
    • 사용자가 Github의 아이디와 비밀번호를 입력하면 이 정보를 Authorization server로 보내줍니다.
  3. Authorization code 반환
    • 로그인에 성공하면 Authorization code를 발급해줍니다.
    • 이를 이용해서 access token을 요청할 수 있습니다.
  4. Access token 요청 & 발급
    • Github Autorization Server에 access token을 요청합니다.
    • 정상적인 code라면 인증 서버로부터 access token이 발급됩니다.
  5. 사용자 정보 요청 & 반환
    • 이전 단계에서 발급 받은 access token을 가지고 사용자 정보를 요청합니다.
    • 정상적인 access token 이라면 사용자 정보가 반환됩니다.
  6. JWT 발급
    • 이전 단계까지 성공했다면 인증에 성공한 것이기 때문에 JWT를 발급해줍니다.

앱 등록

이제 Spring Boot 환경에 OAuth를 적용해보겠습니다. 먼저 Github에 앱 등록을 수행해야 합니다.

Github Settings -> Developer Settings -> OAuth Apps으로 이동해 프로젝트의 앱을 등록해줍니다.

그러면 Client IDClient Secrets를 얻을 수 있습니다. 그리고 Hompage URL과 Authorization callback URL을 설정해줍니다.

Autorization callback URL은 Github 인증 성공 시 callback 해줄 url을 의미합니다.

외부 Properties 설정

client_id, client_secret을 노출시키지 않기 위해 따로 .yml파일에서 관리하고 이를 클래스에서 읽도록 합니다.

@Getter
@ConfigurationProperties("oauth")
public class OauthProperties {

	private final String clientId;
	private final String secretId;
	private final String githubOauthUrl;
	private final String githubOpenApiUrl;

	@ConstructorBinding
	public OauthProperties(String clientId, String secretId, String githubOauthUrl, String githubOpenApiUrl) {
		this.clientId = clientId;
		this.secretId = secretId;
		this.githubOauthUrl = githubOauthUrl;
		this.githubOpenApiUrl = githubOpenApiUrl;
	}
}
  • githubOauthUrl
    • github의 Authorization server url을 의미합니다.
  • githubOpenApiUrl
    • github의 Resource server url을 의미합니다.

소셜 로그인 버튼 클릭

먼저 소셜 로그인 버튼을 클릭하면 리다이렉트 시켜주도록 엔드포인트를 설정해줍니다.

	@GetMapping("/login/oauth")
	public ResponseEntity<Void> oauthLogin() {
		return ResponseEntity.status(HttpStatus.FOUND)
			.header(HttpHeaders.LOCATION, authService.getOAuthLoginPageUrl())
			.build();
	}

리다이렉트를 시켜줄 것이기 때문에 응답코드를 302 FOUND로 설정하고 Location 헤더에 github 로그인 페이지 url을 설정해줍니다.

return new StringBuilder().append(githubClient.getGithubLoginBaseUrl())
			.append("/authorize")
			.append("?client_id=").append(githubClient.getClientId())
			.append("&scope=user:email").toString();

공식문서를 보면 client_id를 필수로 넣어줍니다.
또한 scope를 통해 권한 범위를 설정해줄 수 있기 때문에 이를 설정해줍니다.

https://authorization-server.com/oauth/authorize
?client_id=a17c21ed
&scope=user:email

OAuth 2.0 scope

OAuth 2.0에서의 Scope는 사용자 계정에 대한 애플리케이션의 액세스를 제한하도록 하는 OAuth2.0의 메커니즘입니다. scope는 여러 개가 될 수 있으며 대소문자를 구분하는 문자열을 공백으로 구분하여 표현합니다. 이때 문자열은 OAuth 2.0 인증 서버에 의해 정의됩니다.

우리 서버에서 요청을 보냈을 때 scope를 user:email로 설정했기 때문에 아래와 같이 email addresses를 요청하는 화면을 볼 수 있습니다.

Authorization code 반환

인증에 성공하면 code가 반환됩니다. 이를 받아 access token 요청을 시도합니다.

If the user accepts your request, GitHub Enterprise Server redirects back to your site with a temporary code in a code parameter as well as the state you provided in the previous step in a state parameter.

	@GetMapping("/login/oauth/github")
	public ResponseEntity<LoginSuccessResponse> oauthLogin(@RequestParam("code") String code) {
		return ResponseEntity.ok(authService.oauthLogin(code));
	}

Authorization code는 왜 필요할까?

전체 흐름에서 바로 access token을 요청하지 않는 이유는 뭘까요?
OAuth 2.0 문서에서는 Authorization code가 다른 권한 부여 유형에 비해 몇 가지 이점을 제공한다고 합니다.

사용자가 애플리케이션에 권한을 부여하면 URL에 임시 코드가 있는 애플리케이션으로 다시 redirection됩니다. 애플리케이션은 해당 코드를 access token으로 교환합니다.

또한 이는 access token이 사용자나 브라우저에 절대 노출되지 않음을 의미합니다. 왜냐하면 redirection url을 통해 authorization code 발급 과정이 생략되면, access token을 사용자에게 전달하기 위해 redirect url을 통해야 하기 때문입니다. 하지만 access token은 민감한 정보이기 때문에 브라우저에 바로 노출되지 않기 때문에 authorization code를 사용합니다.

따라서 토큰을 애플리케이션에 다시 전달하는 것이 다른 사람에게 토큰이 유출될 위험을 줄이는 가장 안전한 방법입니다.

https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/

Access token 요청 & 발급

이제 이전단계에서 받은 code를 통해 인증서버에 access token을 요청합니다.

	private String getAccessToken(final String code) {
		Map<String, Object> response = githubLoginClient
				.post()
				.uri(uriBuilder -> uriBuilder
						.path("/access_token")
						.queryParam("code", code)
						.queryParam("client_id", clientId)
						.queryParam("client_secret", secretId)
						.build())
				.retrieve()
				.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
				})
				.blockOptional()
				.orElseThrow(() -> new ApplicationException(ErrorCode.GITHUB_FAILED_LOGIN));

		validateExistsAccessToken(response);
		return response.get("access_token").toString();
	}

서버에서 인증 서버로 요청을 보내야 하기 때문에 WebClient를 이용합니다. 이를 위해 먼저 의존성을 추가해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-webflux'

그렇다고 OAuth만을 위해 webflux를 추가하는 것은 부담이 될 수 있습니다. RestTemplate을 통해 소셜 로그인을 개발하는 것이 더 좋다고 생각합니다.

공식문서를 보면 인증서버에서 access token을 발급받기 위해서는code, client_id, client_secret이 필요하다고 나와 있습니다.

이를 쿼리 파라미터와 함께 요청을 보내면 다음과 같은 정보가 날라옵니다.

이때 잘못된 요청으로 access token이 존재하지 않을 수 있기 때문에 response를 검증해줍니다.

공식문서를 보면 잘못된 요청에 대해서는 아래와 같은 형태의 응답을 내려주기 때문에 이를 예외메시지로 전달해줍니다.

	private void validateExistsAccessToken(Map<String, Object> response) {
		if (!response.containsKey("access_token")) {
			throw new OAuthAccessTokenException(response.get("error_description").toString(),
					ErrorCode.GITHUB_FAILED_LOGIN);
		}
	}

사용자 정보 요청 & 반환

이제 위에서 발급받은 access token을 통해 리소스서버에게 사용자 정보를 요청을 합니다.

	private GithubUser getGithubUser(final String token) {
		Map<String, Object> response = githubResourceClient
				.get()
				.uri("/user")
				.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
				.retrieve()
				.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
				})
				.blockOptional()
				.orElseThrow(() -> new ApplicationException(ErrorCode.GITHUB_FAILED_LOGIN));
		return new GithubUser(response);
	}

Authorization 헤더에 Bearer 타입으로 access token을 넘겨주면 아래와 같은 응답을 받을 수 있습니다.

JWT 발급

인증까지 성공했으니 이제 JWT를 발급해주도록 합니다.

	LoginSuccessResponse.TokenResponse token = jwtProvider.createToken(Map.of(
			"userId", String.valueOf(userId),
			"loginid", username
		));

결론

위 과정을 거쳐 Github 로그인을 제공할 수 있었습니다.

  • OAuth로그인을 개발하면서 공식문서와 친해지는 계기가 되었습니다.
  • OAuth의 전체적인 흐름을 이해할 수 있었습니다.

전체 코드는 아래에 있습니다.
https://github.com/issue-tracker-08/issue-tracker-max

참고자료

https://oauth.net/2/scope/

https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/

https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps

https://docs.github.com/ko/enterprise-server@3.7/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-authorization-request-errors

https://hudi.blog/oauth-2.0/

https://tech.kakao.com/2023/01/19/social-login/

profile
다음 단계를 고민하려고 노력하는 사람입니다

1개의 댓글

comment-user-thumbnail
2023년 8월 11일

글 잘 봤습니다.

답글 달기