이번 프로젝트 요구사항으로 Github 소셜 로그인이 주어졌습니다. 처음 구현해보는 내용이기도 해서 이번 기회에 정리해보려고 합니다!
OAuth는 Open Authorization의 약자로 인증과 권한 부여를 위한 개방형 표준 프로토콜
입니다.
구글, 깃헙, 카카오, 네이버 같이 다양한 플랫폼의 사용자 데이터에 접근하기 위해 클라이언트(우리의 서비스)가 플랫폼 내의 자신의 데이터에 대한 접근 권한을 부여받을 수 있도록 해줍니다. 사용자의 접근 권한을 위임받을 수 있다는 것은 개인정보 관리의 책임을 Third-Party Application에게 위임하여에 위임하는 것을 의미합니다.
또한 부여받은 접근권한을 통해 Third-Party Application의 사용자 리소스에 접근할 수 있습니다.
OAuth 이전에는 다른 애플리케이션에 로그인하거나 개인 정보를 제공할 때, 사용자 이름과 비밀번호 등을 직접 입력하여 제공해야 했습니다. 이런 방식은 문제점을 가지고 있습니다.
위 그림에서도 알 수 있듯이 애플리케이션에 직접 로그인 정보를 제공해야하기 때문에, 다른 플랫폼의 개인 정보를 노출될 수 있습니다. 이런 문제를 해결하고자 하기 위해 OAuth가 등장했습니다.
OAuth 도입을 설명하기 전 용어정리부터 하려고 합니다.
설명에 앞서 개발환경은 스프링 부트 2.7.14, Java 11 버전에서 진행했음을 알립니다.
제가 생각한 OAuth의 로그인 흐름은 다음과 같습니다.
이제 Spring Boot 환경에 OAuth를 적용해보겠습니다. 먼저 Github에 앱 등록을 수행해야 합니다.
Github Settings -> Developer Settings -> OAuth Apps으로 이동해 프로젝트의 앱을 등록해줍니다.
그러면 Client ID
와 Client Secrets
를 얻을 수 있습니다. 그리고 Hompage URL과 Authorization callback URL을 설정해줍니다.
Autorization callback URL
은 Github 인증 성공 시 callback 해줄 url을 의미합니다.
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;
}
}
먼저 소셜 로그인 버튼을 클릭하면 리다이렉트 시켜주도록 엔드포인트를 설정해줍니다.
@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는 사용자 계정에 대한 애플리케이션의 액세스를 제한하도록 하는 OAuth2.0의 메커니즘입니다. scope는 여러 개가 될 수 있으며 대소문자를 구분하는 문자열을 공백으로 구분하여 표현합니다. 이때 문자열은 OAuth 2.0 인증 서버에 의해 정의됩니다.
우리 서버에서 요청을 보냈을 때 scope를 user:email로 설정했기 때문에 아래와 같이 email addresses를 요청하는 화면을 볼 수 있습니다.
인증에 성공하면 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));
}
전체 흐름에서 바로 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/
이제 이전단계에서 받은 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를 발급해주도록 합니다.
LoginSuccessResponse.TokenResponse token = jwtProvider.createToken(Map.of(
"userId", String.valueOf(userId),
"loginid", username
));
위 과정을 거쳐 Github 로그인을 제공할 수 있었습니다.
전체 코드는 아래에 있습니다.
https://github.com/issue-tracker-08/issue-tracker-max
https://www.oauth.com/oauth2-servers/server-side-apps/authorization-code/
글 잘 봤습니다.