용로그
article thumbnail

서론


서비스를 개발하는 중 다른 서비스(구글, 네이버, 카카오, 깃허브..)의 소셜 로그인이 필요하거나 또는 일부 API를 필요로 하는 상황이 많았습니다. 특히 외부 서비스에서 제공하는 데이터들을 크롤링 할 수도 있지만, 법적인 문제가 발생할 수도 있고요.

 

하지만 꽤나 많이 사용되는 부분임에도 불구하고, 많은 게시글 중 스프링 부트에서 소셜 로그인 또는 API 사용법을 명확하게 기술해놓은 글이 없었습니다.

 

그래서 이번에 아무것도 없는 제로(0)의 상태에서 Github API를 사용해서 데이터를 가져와보도록 하겠습니다. 이 글을 토대로 개념을 이해한다면 깃허브가 아닌 서비스들에서도 충분한 정보를 제공받을 수 있습니다.

 

OAuth 내부 동작 순서 및 개발 흐름


OAuth 동작 원리는 약간 복잡합니다. Resource owner, Authorization & Resource Server, Client... 비슷하면서도 많은 용어가 등장하니까요. 저는 처음에 아무것도 모르는 상태에서 OAuth의 동작원리만 보니까 파악하기 힘들더라구요.

 

그래서 우리가 소셜 로그인을 클릭하면 내부에서 어떻게 동작하는지, 인증 토큰을 실제로 어떻게 활용하는지 헷갈릴 수 있는 부분에 대해서 조금은 더 개발적인 관점에서 보려고 합니다.

 

개발 흐름

 

대부분의 개발 흐름도는 위와 같습니다. 추상적이라고 느낄 수 있는데 순서에 따른 자세한 태스크는 다음과 같습니다.

  1. 사용자가 깃허브 로그인 버튼을 클릭한다. : 말 그대로 사용자가 우리 서비스의 소셜 로그인 버튼을 클릭 또는 터치하는 행위를 말합니다.
  2. 깃허브 서버에서 인증코드를 발급한다. : 이 때 인증 코드란 클라이언트가 액세스 토큰을 획득하기 위해 사용하는 임시 코드라고 생각하시면 편합니다.
  3. 해당 인증 코드를 백엔드가 GET한다. : 서버 입장에서는 사용자의 인증 코드를 가지고 있어야 깃허브 서버에 액세스 토큰을 요청할 수 있습니다. 따라서 서버가 인증 코드를 가져오는 순서가 필요합니다.
  4. 깃허브 서버에서 인증에 관련된 토큰의 데이터들을 제공받는다. : 깃허브 서버에 인증 코드를 주면 액세스 토큰만 툭 던져주는 것이 아니라, 그에 관련된 많은 정보들을 담아서 반환합니다.(리프레시 토큰, 토큰 만료 시간..)
  5. 액세스 토큰을 추출해 깃허브의 공식 API들을 사용한다. : 4번에서 제공받는 토큰 정보들을 활용해 공식 API들을 사용할 수 있습니다.

각 단계에 대한 실제 코드와 자세한 설명은 아래에 이어서 하겠습니다.

OAuth App 만들기


우선 타 서비스의 정보를 제공받으려면 우리가 만드는 서비스의 OAuth App을 등록해야 합니다. 해당 OAuth App에 서비스의 이름, 도메인 url, 인증 코드를 제공받을 url(redirect URL 또는 call back URL) 등을 설정할 수 있습니다.

 

  • Settings - Developer Settings - OAuth Apps - New OAuth App

OAuth App 설정

 

서비스 이름 칸에는 자신이 만들고 있는 서비스의 이름을 적어주면 됩니다. 사용자가 깃허브로 로그인 할 때 등록된 서비스 이름으로 표시됩니다.

 

서비스 도메인은 말 그대로 서비스의 도메인을 등록해주면 되는데, 이 때 개발 단계에서 OAuth가 잘 동작하는지 테스트를 해야하기 때문에 localhost로 등록합니다.

 

  • Homepage URL : https://localhost:8080
Callback URL은 Redirect URL이라고도 부르는데요. 다른 단어지만 모두 상호적으로 교환되어서 사용되니(서로 같은 의미로 사용되는 단어) 혼동하지 않게 주의해주세요.

 

Callback URL에는 깃허브 서버가 어느 URL로 인증 코드를 반환할지 설정합니다. 우리는 해당 경로로 GET 요청을 보내 인증 코드를 받아올 수 있습니다.

 

  • Authorization callback URL : http://localhost:8080/login/oauth2/code/github

이렇게 설정했다면 애플리케이션을 등록합니다. 그러면 아래와 같은 화면이 나올텐데, Client ID와 Client Secret은 우리 서비스에서 깃허브 서버로 어떠한 요청을 보낼 때 사용해야하는 정보들이기 때문에 저장해둡니다.

 

해당 포스팅을 위해 만든 OAuth App이기 때문에 공개합니다.

 

환경 변수 등록


위에서 발급받은 Client ID와 Client Secret은 민감한 정보이기 때문에 절대 외부에 공개되어서는 안됩니다. 만약 공개된다면 해당 값들을 가지고 누군가가 우리 서비스 행새를 할 수 있겠죠? 그러니 JVM에 환경변수로 등록해서 사용합시다. 

 

application.yml

# oauth2
---
spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}

 

 

꼭 MainApplication으로 선택에 되었는지 확인하고 Edit Configurations를 클릭합니다. 만약 Environment variables가 안보인다면 우측 상단에 modify options를 클릭해 Environment variables를 선택해줍니다.

 

 

  • Environment variables 오른쪽에 클립보드 모양을 눌러 Client ID와 Client Secre을 등록합니다.

 

 

이렇게까지 설정했다면 아래와 같은 경로로 설정이 완료됩니다.

  • client-id : spring.security.oauth2.client.registration.github.client-id
  • client-secret : spring.security.oauth2.client.registration.github.client-secret

 

개발 흐름

 

이제 위에서 알아봤던 흐름도를 기준으로 개발을 진행해보면 되겠습니다. 1번은 그냥 사용자 관점에서 이해를 돕기위해 넣은 부분이니 2번부터 진행하겠습니다. 또한 이해하고 개발하는 것이 가장 중요하니 꼭 느낌이라도 파악해주세요!

 

깃허브 서버에서 인증 토큰을 발급한다.


깃허브 공식 문서를 보면 인증 코드를 받기 위한 url은 아래와 같다고 합니다. 만약 깃허브가 아니어도 authorize code를 발급하기 위한 url은 다 문서화가 되어 있을거에요.

 

위 url에 {client-id}만 자신의 client-id로 바인딩해서 요청 해보면 자신이 설정한 링크로 리다이렉트 되는걸 확인할 수 있습니다. 다만 설정한 리다이렉트 url뒤에 code라는 쿼리스트링이 들어가있죠?

 

 

이젠 해당 해당 code를 백엔드에서 GET하는 메서드를 만들어보겠습니다.

 

해당 인증 코드를 백엔드가 GET 한다.


Mapping 엔드 포인트 path는 자신이 설정한 리다이렉트 url로 설정합니다. 그리고 깃허브가 반환한 url에 인증 코드는 code라는 쿼리 스트링에 들어가있기 때문에 @RequestParam으로 가져옵니다.

 

@RestController
@RequiredArgsConstructor
public class AuthController {

    @Value("${spring.security.oauth2.client.registration.github.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.client.registration.github.client-secret}")
    private String clientSecret;

    private final GithubOAuthService githubOAuthService;

    @GetMapping("/login/oauth2/code/github")
    public String getCrewGithubInfo(@RequestParam final String code) {
        return code;
    }
 
}

 

  • localhost:8080/login/oauth2/code/github?code={authorize code}

 

위 url에 접근하면 백엔드가 받은 코드를 출력해볼 수 있습니다. 다만 프론트가 없는 상태로 작업을 진행하기 때문에 인증 코드를 매번 받은 뒤 값을 바인딩 해주어야 하는게 여간 귀찮습니다. 그래도 추후 클라이언트랑 연동을 한다면 자동화가 되어서 해결될 부분이니 지금은 귀찮더라도 저런 과정을 거쳐야합니다.

깃허브 서버에서 토큰에 관한 정보를 제공받는다.


이젠 인증 코드를 사용해서 깃허브 서버에 토큰에 관한 정보를 받아야합니다. API를 요청하는 방법은 많지만, 이 글에서는 RestTemplate을 이용해 API 요청을 해보겠습니다. 그리고 깃허브가 제공하는 API 스펙은 다음과 같습니다.

 

 

POST https://github.com/login/oauth/access_token으로 요청하고 바디에는 client_id, client_secret, code, redirect_uri와 같은 정보들이 들어가야한다고 합니다. 바디에 값을 요청해야할 경우 RestTemplate에 해당 프로퍼티를 가진 RequestDTO를 만들어주면 됩니다.

 

@RestController
@RequiredArgsConstructor
public class AuthController {

    private static final String ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
    private static final RestTemplate restTemplate = new RestTemplate();

    @Value("${spring.security.oauth2.client.registration.github.client-id}")
    private String clientId;

    @Value("${spring.security.oauth2.client.registration.github.client-secret}")
    private String clientSecret;

    private final GithubOAuthService githubOAuthService;

    @GetMapping("/login/oauth2/code/github")
    public ResponseEntity<Void> getAccessToken(@RequestParam final String code) {
        final OAuthTokensResponse response = getTokensInfo(code);
        return ResponseEntity.ok().body(response);
    }
    
    public OAuthTokensResponse getTokensInfo(final String code) {
        return restTemplate.postForObject(
                ACCESS_TOKEN_URL,
                new OAuthAccessTokenRequest(clientId, clientSecret, code),
                OAuthTokensResponse.class
        );
    }
 
}

 

OAuthAccessTokenRequest

액세스 토큰을 발급하기 위한 RequestDto를 만들어줍니다. 이 때 API의 명세와 똑같이 맞추기 위해 JsonProperty를 사용합니다. 사용하지 않아도 변수명만 똑같다면 별 문제 없이 사용할 수는 있습니다.

 

@Getter
@NoArgsConstructor
public class OAuthAccessTokenRequest {

    @JsonProperty("client_id")
    private String clientId;

    @JsonProperty("client_secret")
    private String clientSecret;
    private String code;

    public OAuthAccessTokenRequest(final String clientId, final String clientSecret, final String code) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.code = code;
    }

}

 

OAuthTokenResponse

@Getter
@NoArgsConstructor
public class OAuthTokensResponse {

    @JsonProperty("access_token")
    private String accessToken;

    @JsonProperty("expires_in")
    private Long accessTokenExp;

    @JsonProperty("refresh_token")
    private String refreshToken;

    @JsonProperty("refresh_token_expires_in")
    private Long refreshTokenExp;

}

 

  • localhost:8080/login/oauth2/code/github?code={authorize code}

 

getAccessToken 메서드를 더 작성하고 위 url로 접근하면 accessToken, accessTokenExp, refreshToken, refreshTokenExp json 데이터를 받을 수 있습니다.(Postman으로 요청해보세요!)

 

저는 Controller의 getMemberGithubInfo 메서드의 HTTP Method를 PUT으로 리팩터링할 것인데, 왜그럴까요? 백엔드 입장에서는 인증코드를 가져오는 행위인데, 이게 API는 왜 PUT으로 만들까요?

 

그 이유는 깃허브로 로그인을 한다면 해당 유저의 데이터를 저장하기 때문입니다. 사실 액세스 토큰만을 반환할 수는 있지만, 현재 개발하는 서비스는 깃허브 액세스 토큰을 우리 서비스의 메인 인증체계로 사용하지 않기 때문이기도 합니다.

 

그렇기 때문에 이 예시를 보고 그대로 적용해도 좋지만, 여러분들의 서비스에 맞도록 변경해서 작성해주면 더 좋은 코드가 되겠죠? PUT 메서드를 사용했습니다. (참고, POST는 create, PUT은 create 또는 update)

 

액세스 토큰을 활용한 공식 API 사용


보다시피 아래는 깃허브에서 제공하는 Pull Request 공식 API며, Header에 AccessToken이 필요한 것을 볼 수 있습니다. 위에서 받은 액세스 토큰을 활용해서 아래의 API를 요청해보겠습니다.

 

 

대략적인 흐름

이번에는 간단한 흐름만 다루고 자세한 내용은 각자 상황에 맞게 작성해주시면 됩니다. 

// AuthController
@GetMapping("/prs")
public ResponseEntity<List<OAuthMemberGithubPrResponse>> getCrewPrList(@RequestParam final String repo) {
    List<OAuthMemberGithubPrResponse> crewPrList = githubOAuthService.getCrewPrList(repo);
    return ResponseEntity.ok().body(memberPrList);
}

// GithubOAuthService
public List<OAuthMemberGithubPrResponse> getCrewPrList(final String repo) {
    ...
    if (accessTokenOptional.isPresent()) {
        final String accessToken = accessTokenOptional.get().getToken();
        return githubOAuthClient.getMemberPrInfoList(accessToken, repo);
    }
    if (refreshTokenOptional.isPresent()) {
        final String refreshToken = refreshTokenOptional.get().getToken();
        final String accessToken = githubOAuthClient.getNewAccessToken(refreshToken).getAccessToken();
        return githubOAuthClient.getMemberPrInfoList(accessToken, repo);
    }
    
    return githubOAuthClient.getMemberPrInfoList(accessToken, repo);
    throw GithubRefreshTokenNotFoundException.THROW;
}

// GithubClient
@Override
public List<OAuthMemberGithubPrResponse> getMemberPrInfoList(final String accessToken, final String repo) {
final Member loginMember = SecurityUtil.getLoginMember();
final HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
final HttpEntity<Void> request = new HttpEntity<>(headers);

return restTemplate.exchange(
        format(LIST_PULL_REQUEST_URL, loginMember.getGithubUsername(), repo) + "?state=all&per_page=100",
        GET,
        request,
        new ParameterizedTypeReference<List<OAuthMemberGithubPrResponse>>() {}).getBody();
}

 

 

사실 별거 없습니다. 액세스 토큰을 발급했을 때 처럼 RestTemplate을 사용해서 사용할 API에 맞는 값을 넣어서 요청하면 끝입니다. 다만 API를 호출해서 데이터를 제공받을 때 json에서도 부모 자식관계가 있는 경우가 많은데, 이를 매핑할 때 주의해야 합니다.

 

{
    nickname : "베베",
    parent {
    	child: "a"
    }
}

 

위와 같은 json에서 child를 파싱하고 싶다면 다음과 같은 Response를 구성해야 합니다.

 

@Getter
@Setter
@NoArgsConstructor
public class OAuthMemberGithubPrResponse {

    @JsonProperty("nickname")
    public String nickname;

    @JsonProperty("parent")
    public ParentDto parent;

    @Getter
    @Setter
    @NoArgsConstructor
    public static class ParentDto {

        @JsonProperty("child")
        private String child;
        
    }

}

 

결과


 

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05