[우아한테크코스 4기] 220714 F12 개발일지

Jihoon Oh·2022년 7월 14일
0

우아한테크코스 4기

목록 보기
22/43
post-thumbnail

오늘 진행한 일

GitHub OAuth 로그인 기능 구현(QA 미완)

F12 서비스는 개발자의 프로필을 중심으로 하기 때문에 개발자들이 가장 많이 사용하는 플랫폼인 GitHub의 프로필을 사용하려고 한다. 그래서 인증 방식으로 GitHub OAuth + JWT 방식을 사용하기로 했다. GitHub OAuth 인증 방식의 플로우는 다음과 같다.

  1. 클라이언트에서 GitHub OAuth App으로 로그인 요청을 한다.
  2. GitHub에서는 미리 지정된 redirect url에 code 값을 담아 redirect 시킨다.
  3. 서버에서 redirect 요청을 받는다. 파라미터로 받은 code 값으로 GitHub API에 접근하여 access token을 요청한다.
  4. GitHub에서는 code로 인증하여 access token을 발행한다. 이 값은 서버로 간다.
  5. 서버는 받아온 access token으로 GitHub API에 유저 프로필을 요청한다.
  6. GitHub API는 access token을 검증하여 유저 프로필을 서버로 보낸다.
  7. 서버는 받아온 유저 프로필을 DB에 저장하고 애플리케이션의 access token(깃허브에서 보내준 access token과 다른 클라이언트-서버 인가 과정에서 사용될 토큰)을 생성한다.
  8. 생성된 access token을 클라이언트로 보내주고, 이후의 인가 과정에서 클라이언트는 해당 토큰을 사용한다.

때문에 서버 쪽에서는 redirect url로 요청을 받기 시작하는 3번부터 토큰 값을 담은 최종 응답을 하는 8번 과정까지를 구현해야 했다. 결국 3~8번 과정이 리퀘스트 하나에서 이뤄지기 때문에 컨트롤러에서 매핑할 API는 한 가지 뿐이었다.

@RestController
@RequestMapping("/api/v1")
public class AuthController {

    private final AuthService authService;

    public AuthController(final AuthService authService) {
        this.authService = authService;
    }

    @GetMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestParam final String code) {
        return ResponseEntity.ok().body(authService.login(code));
    }
}

하지만 내부 구현이 복잡했다. 왜냐면, 요청을 받고 나서 서버가 다시 GitHub API와 통신을 해야 하기 때문이었다. 처음에는 예전에 하던 대로 RestTemplate를 사용하려고 했는데, 티키가 RestTemplate를 대체하기 위해 WebClient가 개발되었다면서, 곧 deprecated 될 RestTemplate 대신 WebClient를 사용하자고 했다.

WebClient는 스프링 5.0 이후로 도입되었는데, 현재 스프링에서는 RestTemplate 대신 WebClient를 사용하기를 권장하고 있다. RestTemplate와의 차이점으로는 Non-Blocking 방식이라는 것과 JSON, XML형태의 데이터를 RestTemplate보다 더 쉽게 받을 수 있다는 점이 있다. 자세한 설명은 우아한테크코스 Tecoble을 참고하자. 이미 RestTemplate를 사용하고 있는 프로젝트라면 RestTemplate를 그대로 사용하겠지만, 신규 프로젝트이므로 WebClient를 사용하기로 했다.

어쨌든 여러 개의 로직이 들어가는 AuthServicelogin 메서드에서는 다음과 같은 과정이 들어간다.

  1. GitHub로 access token 요청하기
  2. access token 받아서 GitHub로 GitHub 프로필 요청하기
  3. GitHub 프로필로 토큰 생성해서 반환하기

여기서 GitHub와의 통신을 하는 기능과 토큰 생성하는 기능은 각각 컴포넌트로 만들어 책임을 분리하기로 결정했다. GitHubOauthClient라는 빈과 JwtProvider라는 빈을 만들었다.

@Component
public class GitHubOauthClient {

    private final String clientId;
    private final String secret;
    private final String accessTokenUrl;
    private final String profileUrl;

    public GitHubOauthClient(@Value("${github.client.id}") final String clientId,
                             @Value("${github.client.secret}") final String secret,
                             @Value("${github.url.accessToken}") final String accessTokenUrl,
                             @Value("${github.url.user}") final String profileUrl) {
        this.clientId = clientId;
        this.secret = secret;
        this.accessTokenUrl = accessTokenUrl;
        this.profileUrl = profileUrl;
    }

    public String getAccessToken(final String code) {
        final GitHubTokenRequest gitHubTokenRequest = new GitHubTokenRequest(clientId, secret, code);
        final GitHubTokenResponse gitHubTokenResponse = requestGitHubAccessToken(gitHubTokenRequest);
        return gitHubTokenResponse.getAccessToken();
    }

    private GitHubTokenResponse requestGitHubAccessToken(final GitHubTokenRequest gitHubTokenRequest) {
        final WebClient webClient = WebClient.builder()
                .baseUrl(accessTokenUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
        return webClient.post()
                .body(Mono.just(gitHubTokenRequest), GitHubTokenRequest.class)
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> {
                    throw new InvalidGitHubLoginException();
                })
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> {
                    throw new GitHubServerException();
                })
                .bodyToMono(GitHubTokenResponse.class)
                .blockOptional()
                .orElseThrow(InvalidGitHubLoginException::new);
    }

    public GitHubProfileResponse getProfile(final String accessToken) {
        final WebClient webClient = WebClient.builder()
                .baseUrl(profileUrl)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "token " + accessToken)
                .build();
        return webClient.get()
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> {
                    throw new InvalidGitHubLoginException();
                })
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> {
                    throw new GitHubServerException();
                })
                .bodyToMono(GitHubProfileResponse.class)
                .blockOptional()
                .orElseThrow(InvalidGitHubLoginException::new);
    }
}
@Component
public class JwtProvider {

    private final Key secretKey;
    private final long validityInMilliseconds;

    public JwtProvider(@Value("${security.jwt.secret-key}") final String secretKey,
                       @Value("${security.jwt.expire-length}") final long validityInMilliseconds) {
        this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
        this.validityInMilliseconds = validityInMilliseconds;
    }

    public String createToken(final Long id) {
        final Date now = new Date();
        final Date validity = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
                .setSubject(id.toString())
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }
}

clientId, secretKey와 같이 외부에 공개되어서는 안되는 정보는 application-yml 파일에 넣어두고, git ignore한 뒤 프로그램을 실행할 때 읽어오도록 해서 외부에 노출되지 않도록 설정했다. WebClient를 처음 써봐서 조금 어렵기는 했는데, 설명을 잘 찾아보면서 하니 문제 없이 진행할 수 있었다.

오늘 발생한 이슈

WebClient

익숙한 RestTemplate 대신 WebClient를 사용하느라 처음에는 애를 먹었다. 다행히 잘 정리된 글이 있어서 해당 글을 참고하면서 코드를 완성할 수 있었다.

테스트에서 외부 API 의존을 끊기

GitHub OAuth 같은 외부 API를 이용한 로그인 방식의 최대 단점이라고 한다면 테스트하기 어렵다는 점이다. 서버 내부의 로그인 로직이 외부 API의 응답값에 대해 의존하고 있기 때문에, 서버 내부 로직을 테스트하는데도 외부 API를 연결해야 한다는 점에서 그렇다. 이 부분에 대해서 아예 테스트를 안할까도 고민해봤었는데, 그러면 QA만으로 모든 테스트를 진행해야 하기 때문에 안정성이 떨어진다고 생각했다. 또한 인수 테스트는 필연적으로 웹 환경을 구성하여 테스트를 진행해야 하는데, 외부 API 의존을 끊어주지 않으면 GitHub API에 실제로 요청을 보내게 된다.

그렇다면 어떻게 외부 API 의존을 끊을 수 있을까? 이 방법은 학습로그에 정리해두었으니 궁금하면 참고해보자.

내일 목표

오늘 인증을 완료했으므로, 내일은 해당 기능을 바탕으로 인가가 필요한 리뷰 작성 기능에 인가 과정을 추가하는 것을 목표로 한다.

profile
Backend Developeer

0개의 댓글