작지만 꾸준한 반복

집고팀 OAuth 로그인 적용기 본문

카테고리 없음

집고팀 OAuth 로그인 적용기

iamjooon2 2023. 7. 16. 23:12

집사의고민 프로젝트를 진행하며, 카카오 로그인 기능을 맡게 되었습니다

이 과정에서 접한 OAuth 관련 개념과 용어들이 블로그마다 혼재되어있는 경우가 많아서, 많이 헷갈렸습니다🥲

이에 관련 개념을 제 언어로 정리하고, 구현하면서 가졌던 고민들을 정리해보려 합니다

 

먼저 공대생의 절친 위키백과에서 OAuth라는 단어의 뜻부터 한번 알아봅시다.

OAuth("Open Authorization")는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다

 

네. 그렇다고 하네요

 

제 언어로 정리해보면, 우리 서비스에서 다른 서비스의 정보를 사용할 수 있는 권한을 위임받는 것입니다.

즉, 우리는 집사의고민에서 카카오의 사용자 정보를 쓸 수 있는 권한을 위임받아 로그인을 적용할겁니다!

 

저희 팀이 구성한 로그인 플로우는 다음과 같습니다

나는 확신의 백엔드

 

공부하는 과정에서 OAuth 관련 포스트를 보면 플로우차트가 빠짐없이 나와서, 다른 방법으로 설명할 순 없을까 생각했는데요

막상 정리하려고보니, 플로우차트만한게 없네요😂

 

최대한 쉽게 풀어서 설명해보겠습니다!

 

Resource Owner

Resource Owner는 사용자입니다. (이하 사용자로 작성)

여기서 Resource는 외부 플랫폼의 자원인데요, 즉 외부 서비스에 자신의 정보를 가지고 있는 사용자를 말해요!

 

FrontEnd와 Backend는 우리 서비스입니다. 이 부분은 설명을 생략할게요

다른 포스트에서 Client라는 표현을 쓰기도 하는데, Resource 서버 입장에서 우리 서비스도 결국 고객이라 client라는 표현을 사용합니다.

대부분 client라고 퉁쳐놓는 블로그와 달리, 우리 프로젝트의 로그인 적용기므로 frontend와 backend로 나누어보았습니다

 

Auth Server

Autorization Server는 인증서버입니다.

사용자로부터 받아온 아이디/비밀번호가 적합한지 검증하여 인가 코드를 발급하고,

인가코드를 받아 Resource server에 접근할 수 있는 토큰을 발급하는 역할을 해요

만일 적합하지 않다면, 401등의 에러를 반환하겠죠?

 

현재 카카오에 저장되어있는 사용자 정보를 가져오기때문에,

이 Resource Owner는 결국 카카오 인증서버입니다.

 

Resource Server

Resource Server는 사용자의 정보를 가지고 있는 서버입니다.

발급받은 토큰을 받아 검증한 다음, 사용자의 정보를 반환하는 역할을 해요.

 

여기도 결국 카카오인 셈입니다.

 

가장 헷갈렸던 부분이 5~9의 플로우인데요

AuthServer에서 인가코드를 발급하고, 받은 인가코드를 발급받아 왜 다시 토큰을 발급받게 했을지?

그냥 로그인 성공시 바로 토큰을 발급하게 해버리면 안될지?

 

하지만 조금만 더 생각해봅시다

토큰이 클라이언트로 바로 발급됐고, 탈취당했다고 가정해볼게요

토큰을 탈취한 사용자는 자원서버로부터 사용자 정보를 계속 뽑아서 사용하겠죠?

실제로 카카오 로그인의 경우 한번 발급받은 인가 코드로 최대 2번까지 토큰 발급 요청을 할 수 있었습니다 😉

 

다음은 이렇게 플로우를 구성하기까지 생긴 고민과, 그 선택 이유를 정리해보려 해요

많은 블로그의 OAuth 포스트에서 미세하게 플로우가 다른 점들이 있었습니다.

 

대체로 아래 두가지였는데요

 

1) 2~3 흐름에 해당하는 사용자 로그인 요청을 각각 프론트엔드에서 백엔드 서버로 보낼지, Auth Server 직접 보낼지

2) 12~13에 해당하는 redirectURI를 프론트가 받을지, 백엔드가 받을지

 

1번의 경우, 카카오 인증 바로 서버로 보내는 것을 선택했고

2번의 경우, 프론트엔드에서 redirectURI를 받는 것을 선택했습니다

 

두 선택의 이유 모두, 사용자가 직접 인증 서버와 협력하는 데에는, 우리 백엔드 서버를 거칠 필요가 없다고 생각했어요

백엔드 서버의 역할이 아닌, 사용자가 인증 서버로부터 실제 자원을 소유하고있는 사용자인지 검증해야 하는지라, 불필요하게 우리 서버와 통신할 필요는 없다고 생각했기 때문입니다.

 

이제 실제 코드와 함께 살펴보겠습니다

 

먼저 컨트롤러 부분입니다

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;
    private final JwtProvider jwtProvider;
    private final MemberQueryService memberQueryService;
    
    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestParam("code") String authCode) {
        String token = authService.createToken(authCode);
        String memberId = jwtProvider.getPayload(token);
        Member member = memberQueryService.findById(Long.valueOf(memberId));
        return ResponseEntity.ok(TokenResponse.of(token, member));
    }

    @GetMapping
    public ResponseEntity<AuthResponse> getMemberDetail(@Auth AuthCredentials authCredentials) {
        Member member = memberQueryService.findById(authCredentials.id());
        return ResponseEntity.ok(AuthResponse.from(member));
    }

}

프론트엔드로부터 받아온 인가 코드를 받아 authService를 통해 토큰을 생성합니다

그 이후 토큰에 있는 정보로부터 추가로 사용자 정보를 찾아 반환합니다 (프론트엔드 요청건)

 

다음은 Service 부분입니다

@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {

    private final OAuthClient oAuthClient;
    private final JwtProvider jwtProvider;
    private final MemberRepository memberRepository;

    public String createToken(String authCode) {
        String accessToken = oAuthClient.getAccessToken(authCode);
        OAuthMemberResponse oAuthMemberResponse = oAuthClient.getMember(accessToken);

        Member member = memberRepository.findByEmail(oAuthMemberResponse.getEmail())
                .orElseGet(() -> memberRepository.save(oAuthMemberResponse.toMember()));

        return jwtProvider.create(String.valueOf(member.getId()));
    }

}

받은 인가코드를 바탕으로, 자원서버로부터 토큰을 발급받습니다

이후 받은 토큰을 바탕으로, 사용자 정보를 반환받습니다

 

이후 받은 사용자 정보를 바탕으로 이미 존재하는 회원인지 이메일로 찾은 후

존재한다면 조회하여 회원을 찾고, 존재하지 않는다면 저장 후 회원 정보를 가져오는 방입니다

 

그 이후, 사용자 식별자를 바탕으로 토큰을 발급하여 프론트엔드로 반환합니다

 

다음은 OAuthClient입니다

@Component
@RequiredArgsConstructor
public class KakaoOAuthClient implements OAuthClient {

    private static final String ACCESS_TOKEN_URI = "https://kauth.kakao.com/oauth/token";
    private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me";
    private static final String GRANT_TYPE = "authorization_code";

    private final KakaoCredentials kakaoCredentials;
    private final RestTemplate restTemplate;

    @Override
    public String getAccessToken(String authCode) {
        HttpHeaders header = createRequestHeader();
        MultiValueMap<String, String> body = createRequestBodyWithAuthCode(authCode);
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, header);

        ResponseEntity<KakaoTokenResponse> kakaoTokenResponse = getKakaoToken(request);

        return requireNonNull(requireNonNull(kakaoTokenResponse.getBody())).accessToken();
    }

    private HttpHeaders createRequestHeader() {
        HttpHeaders header = new HttpHeaders();
        header.setContentType(APPLICATION_FORM_URLENCODED);
        return header;
    }

    private MultiValueMap<String, String> createRequestBodyWithAuthCode(String authCode) {
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", GRANT_TYPE);
        body.add("client_id", kakaoCredentials.getClientId());
        body.add("redirect_uri", kakaoCredentials.getRedirectUri());
        body.add("client_secret", kakaoCredentials.getClientSecret());
        body.add("code", authCode);
        return body;
    }

    private ResponseEntity<KakaoTokenResponse> getKakaoToken(HttpEntity<MultiValueMap<String, String>> request) {
            try {
                return restTemplate.exchange(
                    ACCESS_TOKEN_URI,
                    POST,
                    request,
                    KakaoTokenResponse.class
            );
        } catch (HttpClientErrorException e) {
            throw new AuthException.ResourceNotFound("카카오 토큰을 가져오는 데 실패했습니다.", e);
        }
    }

    @Override
    public OAuthMemberResponse getMember(String accessToken) {
        HttpEntity<HttpHeaders> request = createRequest(accessToken);

        ResponseEntity<KakaoMemberResponse> response = getKakaoMember(request);

        return response.getBody();
    }

    private HttpEntity<HttpHeaders> createRequest(String accessToken) {
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);
        return new HttpEntity<>(headers);
    }

    private ResponseEntity<KakaoMemberResponse> getKakaoMember(HttpEntity<HttpHeaders> request) {
            try {
                return restTemplate.exchange(
                    USER_INFO_URI,
                    GET,
                    request,
                    KakaoMemberResponse.class
            );
        } catch (HttpClientErrorException e) {
            throw new AuthException.ResourceNotFound("카카오 사용자 정보를 가져오는 데 실패했습니다.", e);
        }
    }

}

조금 기네요 🥲

카카오 외의 네이버와 구글과 같은 다른 인증서버가 추가될수도 있다는 생각에 인터페이스화 했습니다. 

전체적인 OAuth 흐름이 토큰을 가져오는 것 한번, 사용자 정보를 가져오는 것 한번

이렇게 두 번을 외부 서버에 요청을 보내야 하기에, getToken 메서드와 getMember 메서드를 만들어두었습니다.

 

두 메서드의 KakaoOAuthclient에서의 구현은 위와 같아요

 

외부 API 호출시 RestTemplate과 HttpClient라는 두개의 선택지 중 고민했는데요

HttpClient는 추가로 의존성을 설치해야 한다는 점과, 비동기 처리가 가능하다는 점이 있지만, 동기적으로 실행되야하는 플로우인지라 

이점을 별로 느끼지 못했습니다.

코드가 조금 더 깔끔해진다고는 하는데, 추가로 의존성을 설치해야 될 정도는 아닌 것 같더라구요

 

RestTemplate은 Bean으로 등록해두어, 자동으로 주입받을 수 있고

KakaoCredentials는 카카오 로그인 관련 정보들을 모아둔 놈입니다.

@Getter
@RequiredArgsConstructor
@ConfigurationProperties(prefix = "oauth.kakao")
public class KakaoCredentials {

    private final String clientId;
    private final String redirectUri;
    private final String clientSecret;

}
// application.yml
oauth:
  kakao:
    client-id: ${CLIENT_ID}
    redirect-uri: ${REDIRECT_URI}
    client-secret: ${CLIENT_SECRET}
@SpringBootApplication
@EnableConfigurationProperties({KakaoCredentials.class, JwtCredentials.class})
public class ZipgoApplication {

    public static void main(String[] args) {
        SpringApplication.run(ZipgoApplication.class, args);
    }

}

@EnableConfigurationProperties 어노테이션을 통해,

yml 파일에 작성해둔 친구를 코드레벨로 편리하게 가지고 올 수 있습니다!

 

 

이런식으로 어렵게만 보이던 OAuth 인증을 적용해 본 결과를 정리해보았습니다

사실 처음 보고 플로우에 지레 겁먹던 저를 위한 글인데요

혹여 보는 분들에게 도움이 되셧으면 좋겠습니다