인증, OAuth 2.0

6 분 소요

인증과 OAuth 2.0에 대한 글입니다. TIL에 작성했던 글들을 한 포스팅에 모아둔 글입니다.

인증

토큰 방식

JWT (Json Web Token)

JSON Web Token은 AAAAAAAA.BBBBBBBBB.CCCCCCC 세 부분으로 나누어진다.

여기서 AAAAAAAA부분은 JOSE(JSON Object Signing and Encryption) 헤더, JWT Claim set, Signature 이다.

이 토큰을 어떻게 만드는지를 알아본다. RFC7519 - 7.1 Createing a JWT 부분을 보면 다음과 같다.

7.1. Creating a JWT

JWT를 만들기 위해서는 다음과 같은 단계가 수행되어야 한다. 각 스텝의 input, output사이에는 아무련 의존성이 없다.(순서가 중요하지 않다.)

  1. 원하는 Claim을 포함하는 JWT Claim을 만든다. 공백이 허용되고, 인코딩 전에 명시적으로 정규화 할 필요 없다.
  2. JWT Claims Set의 UTF-8 형식으로 바꿔라
  3. 원하는 헤더를 포함하는 JOSE Header를 만든다. JWT는 JWS 나 JWE 스펙을 따라야 한다. 공백이 허용되고, 인코딩 전에 명시적으로 정규화 할 필요 없다.
  4. JWT가 JWS 냐, JWE에 기반하냐에 따라서 두가지 경우가 있다.
    • JWS 기반이면, JWS Payload로 JWS를 만든다. JWS 스펙에 맞게 생성해야한다.
    • JWE 기반이라면, JWE를 위해 plaintext로 메세지를 만들어서 JWE로 사용한다. JWE 스펙에 맞게 생성한다.
  5. 서명 또는 암호화 작업이 수행되면, 메시지를 JWS 또는 JWE로 하고, 해당 단계에서 작성된 새로운 JOSE 헤더에서 “cty”(콘텐츠 유형) 값을 사용하여 3단계로 돌아간다.
    • 그렇지 않으면 결과 JWT를 JWS 나 JWE로 리턴한다.

JWS 스펙에 나와있는 토큰의 예시를 살짝 바꿔본 결과는 다음과 같다.

Header

{
    "typ":"JWT",
    "alg":"HS256"
}

이를 Base64로 인코딩 하면 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 이 나온다. 이렇게 나온 부분이 AAAAAAAA 부분이다.

Payload

{
  "iss":"martin",
  "exp":1300819380,
  "http://example.com/is_root":true
}

이를 Base64로 인코딩 하면 eyJpc3MiOiJtYXJ0aW4iLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ 이 나온다.

그리고 마지막 signature에는 위의 Header.Payload를 우리가 지정한 Secret과 SHA-256으로 암호화 해서 base64로 인코딩 한다.

나는 martin이라는 secret을 사용해서 암호화 하니 mSETIt5sw3WXtHnJoczWfZJ0O6hrF6F7jT7QKW0yRXQ와 같은 결과가 나왔다. (jwt.io사이트를 이용했다.)

그러면 최종적으로 위에서 말한 AAAAAAAA.BBBBBBBBB.CCCCCCC 형식의 JWT Token이 만들어진다.(아래와 같음)

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
.
eyJpc3MiOiJtYXJ0aW4iLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ
.
mSETIt5sw3WXtHnJoczWfZJ0O6hrF6F7jT7QKW0yRXQ

토큰 방식 장점

  1. 간편하다. 별도의 세션 저장소가 필요 없다.
  2. Stateless

토큰 방식 단점

  1. 한번 발급된 토큰은 만료될 때까지 없앨 수 없다.
  2. Access Token의 유효기간을 짧게 하고 Refresh를 계속 할 수 있으나, 비효율 적이다.
  3. 암호화 하는 것이 아니기 때문에 중요한 정보들을 그대로 담을 수 없다.

세션 방식

기본적으로 HTTP는 stateless한 프로토콜이다. 그래서 사용자 로그인이라거나 상태의 유지가 필요한 작업을 하기 위해 세션과 쿠키를 활용한다.

세션 방식으로 로그인을 처리하는 흐름은 다음과 같다.

  1. 사용자가 로그인 요청
  2. 서버는 해당 사용자의 요청을 DB에서 확인하고, 새로운 Session ID를 생성하여 세션 저장소(보통은 Redis 서버를 쓰는 것 같다)에 저장한다.
  3. Response Header에 Session ID를 포함하여 응답한다.
  4. 사용자는 해당 Session ID를 쿠키에 저장한다.
  5. 사용자 인증이 필요할 때마다 해당 Session ID를 이용하여 인증한다.
  6. 서버는 세션 서버에서 해당 세션의 유효성을 검사하여 이후 처리를 한다.

장점

  1. 노출되더라도 쿠키 자체는 아무런 정보도 없다.

단점

  1. 쿠키가 탈취되어 SessionId가 노출되어 세션 아이디로 해당 사용자 인 척하는 공격이 발생할 수 있다. (Session Hijacking?)
  2. 별도의 세션 저장소가 필요하다. (보통 redis나..)
  3. 쿠키는 단일 도메인 및 서브 도메인에서만 작동하도록 설계되어 여러 도메인에서 관리하기 어렵다.

OAuth

OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 이 매커니즘은 여러 기업들에 의해 사용되는데, 이를테면 아마존, 구글, 페이스북, 마이크로소프트, 트위터가 있으며 사용자들이 타사 애플리케이션이나 웹사이트의 계정에 관한 정보를 공유할 수 있게 허용한다. (위키백과)

OAuth를 알아보면서 이해가 안되는 이유는 용어가 익숙하지 않아서 라고 생각한다. 그리고 우리가 개발자이기 때문에, 개발할 때는 소비자로서 행동하고 실제 우리가 다른 서비스를 사용할 때는 사용자로서 행동하기 때문에 용어가 헷갈릴 수 있다.

관련된 용어를 살펴보면,

  • 사용자(user)
    • 서비스 제공자와 소비자를 사용하는 계정을 가지고 있는 개인 - github, kakao, naver 계정으로 인증을 하는 사람
  • 소비자(consumer)
    • Open API를 이용하여 개발된 OAuth를 사용하여 서비스 제공자에게 접근하는 웹사이트 또는 애플리케이션 - 사용자에게 인증을 요청하는 애플리케이션
  • 서비스 제공자(service provider)
    • OAuth를 통해 접근을 지원하는 웹 애플리케이션(Open API를 제공하는 서비스) - github, kakao, naver 등..
  • 소비자 비밀번호(consumer secret)
    • 서비스 제공자에서 소비자가 자신임을 인증하기 위한 키 - kakao login api 사용할 때 app 생성 하면 발급해주는 키
  • 요청 토큰(request token)
    • 소비자가 사용자에게 접근권한을 인증받기 위해 필요한 정보가 담겨있으며 후에 접근 토큰으로 변환된다.
  • 접근 토큰(access token)
    • 인증 후에 사용자가 서비스 제공자가 아닌 소비자를 통해서 보호된 자원에 접근하기 위한 키를 포함한 값.

OAuth인증은 소비자와 서비스 제공자 사이에서 일어나는데 이 인증 과정은 다음과 같다.

  1. 소비자가 서비스제공자에게 요청토큰을 요청한다.
  2. 서비스제공자가 소비자에게 요청토큰을 발급해준다.
  3. 소비자가 사용자를 서비스제공자로 이동시킨다. 여기서 사용자 인증이 수행된다.
  4. 서비스제공자가 사용자를 소비자로 이동시킨다.
  5. 소비자가 접근토큰을 요청한다.
  6. 서비스제공자가 접근토큰을 발급한다.
  7. 발급된 접근토큰을 이용하여 소비자에서 사용자 정보에 접근한다.

OAuth 2.0

OAuth 2 RFC 6749에 나온 Role 부분을 살펴보면 아래와 같다.

  1. 자원 소유자 (Resource Owner)
    • An entity capable of granting access to a protected resource. When the resource owner is a person, it is referred to as an end-user.
    • 정보의 소유권을 가진 사용자
  2. 자원 서버 (Resource Server)
    • The server hosting the protected resources, capable of accepting and responding to protected resource requests using access tokens.
    • 자원 소유자(Resource Owner)의 정보를 가지고 있는 서버
    • 구글, 페이스북, 네이버, 카카오 등
  3. 인가 서버 (Authorization Server)
    • The server issuing access tokens to the client after successfully authenticating the resource owner and obtaining authorization.
    • 자원 소유자 (Resource Owner) 을 인증
    • Client 에게 Access token 을 발행
  4. 클라이언트 (Client)
    • An application making protected resource requests on behalf of the resource owner and with its authorization. The term “client” does not imply any particular implementation characteristics (e.g., whether the application executes on a server, a desktop, or other devices).
    • 사용자 동의하에 Resource Server 에 Resource owner의 정보를 요청 할 수 있다.

인가 서버는 자원 서버와 동일한 서버일 수도 있고, 나누어져 있을 수도 있다. 단일 인가 서버는 여러 자원 서버로부터 받은 access token을 발행 할 것이다.

로그인 과정

kakao login api를 사용하여 카카오아이디로 로그인을 사용하려고 한다. OAuth 2에 기반한 이 로그인 과정을 통해 OAuth의 흐름에 대해 알아보자.

OAuth의 전체 흐름은 아래와 같다.

일단 Client는 Resource Server에 자신의 애플리케이션(Heaven라고 해보자)을 등록하는 과정을 거친다.(그러면 Client Id, Client Secret, Redirect URL을 설정해야 한다.)

현재 상태는 아래 그림과 같다.

OAuth2 login

  1. 사용자(Resource Owner)는 Client(Heaven)의 Login 버튼을 누른다.
  2. 그러면 Client 는 다음과 같은 페이지를 띄워준다.

kakao login

위의 사진 예시에서 살펴보면, https://resource.server/?client_id=1&scope=B,C&redirect_uri=https://smjeon.dev/done의 경로로 요청을 보낼 것이다. 그러면 Resource Server의 B, C 권한을 요청하는 인증요청을 보낸다.

그러면 Resource Server는 현재 Resource Owner가 로그인이 되어 있는지 확인 후 되어있지 않다면, 로그인을 하라는 화면을 보여준다. 로그인을 성공했다면, Resource Server는 Client ID값과 같은 Client ID가 있는 지 확인하고, redirect URI가 동일한 지 확인 한다. 동일하다면, 처음에 요청한 B, C 권한을 Client(Heaven)에 허용할 것이냐 라는 메세지를 보여준다. 그 다음 허용한다고 하면, Resource Server에 해당 정보를 기억해놓는다.

그 이후 Resource Server는 Resource Owner의 브라우저를 Redirect URI로 code를 담아서 redirect 시킨다. 그러면 이 Redirect URI로 요청을 받은 Client(Heaven)은 Authorization Code를 받는다.

Client는 이 code를 가지고 Resource Server에 직접 접근한다.(https://resource.server/token?grant_type=authorization_code&code=3&redircet_uri=https://smjeon.dev/done&client_id=1&client_secret=2와 같은 경로로 요청한다.) Resource Server는 Client Id, Client Secret, Code가 모두 일치하면 Client에게 해당 유저에 대한 Access Token(4)을 발급해준다.

Client(Heaven)는 발급받은 Access Token을 저장 해 둔다. 그 이후에는 이 Access Token(4)을 가지고 Resource Server에 실제 사용자(Resource Owner)의 B, C 권한에 대한 접근을 할 수 있다.

실제로 구현한 것을 기준으로 살펴보면, 1, 2번 과정을 거치면 https://kauth.kakao.com/oauth/authorize?client_id=f2f338d4cd150b4802b3dec123673221&redirect_uri=http://localhost:8080/oauth&response_type=code 의 경로로 요청을 보낸다.

그리고 Resource Owner는 kakao 로그인을 완료하고, 요청한 권한에 대한 승인을 한다. 그 이후 Resource Owner의 브라우저는 설정 해두었던 redirect uri인 /oauth 경로로 redirect 된다. redirect 할 때, code를 담아서 redirect 한다.

우리는 구현을 다음과 같이 했는데, /oauth 경로로 요청이 들어왔을 때, code 값을 받아오고 Resource Server인 카카오 서버에 client id와 redirect uri와 기타 정보들을 담아서 요청을 보낸다.

@GetMapping("/oauth")
public ResponseEntity oauth(HttpSession httpSession, @RequestParam("code") String code) {
    LOGGER.info("code: {}", code);

    TokenInfo tokenInfo = kakaoApiService.getTokenInfo(code);
    LOGGER.info("tokenInfo: {}", tokenInfo);

    String accessToken = tokenInfo.getAccess_token();
    String refreshToken = tokenInfo.getRefresh_token();

    User user = userService.save(kakaoApiService.getUser(accessToken, refreshToken));
    LOGGER.info("user: {}", user);

    UserSession userSession = new UserSession(user.getId(), user.getName(), accessToken);
    httpSession.setAttribute(UserSession.USER_SESSION, userSession);

    HttpHeaders headers = new HttpHeaders();
    headers.add("Location", "/");
    return new ResponseEntity<String>(headers, HttpStatus.FOUND);
}

// kakaoApiService.java
public TokenInfo getTokenInfo(String code) {
    TokenInfo tokenInfo = WebClient.create(kakaoConfig.getAuth().get("host"))
            .post()
            .uri(uriBuilder -> uriBuilder
                    .path(kakaoConfig.getAuth().get("tokenPath"))
                    .queryParam("grant_type", "authorization_code")
                    .queryParam("client_id", kakaoConfig.getAuth().get("clientId"))
                    .queryParam("redirect_uri", kakaoConfig.getAuth().get("redirectUri"))
                    .queryParam("code", code).build())
            .retrieve()
            .bodyToMono(TokenInfo.class)
            .block();

    LOGGER.info("tokenInfo: {}", tokenInfo);

    return tokenInfo;
}

이 과정을 거친 후 Resource Server에서는 access token을 client인 나에게 발급 해준다. 응답 온 것을 살펴보면 다음과 같다. access_token=pJqhveNPaqFToYHMT2b6JxbBgmeitxoBSYjLbAopyNkAAAFvYOJNaA, token_type=bearer, refresh_token=BlCnewxrmPdpnBEI5c_WhgtssdXAaDJzjdlqEAopyNkAAAFvYOJNZw, expires_in=21599, scope=age_range birthday account_email gender profile, refresh_token_expires_in=5183999 Access Token과 Refresh Token과 우리 어플리케이션이 요청했던 권한을 응답으로 되돌려주는 것을 알 수 있다.

이 Access Token을 가지고 Client는 Resource Server로부터 처음에 요청했던 권한에 대한 정보들을 얻을 수 있다.

카카오 로그인 API의 경우는, 설정 > 고급 > Client Secret에서 생성한 client_secret 코드 active 상태일 경우에는 필수로 설정해야 한다.

추가적으로 refresh_token은 설정된 유효기간 만큼 유효하고, refresh token의 만료가 1달 이내로 남은 시점에서 사용자 토근 갱신 요청을 하면 갱신된 access token과 갱신된 refresh token이 함께 반환된다. 요청은 다음과 같이 한다.

curl -v -X POST https://kauth.kakao.com/oauth/token \
 -d 'grant_type=refresh_token' \
 -d 'client_id={app_key}' \
 -d 'refresh_token={refresh_token}'

이에 대한 응답은 다음과 같이 온다.

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
    "access_token":"wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww",
    "token_type":"bearer",
    "refresh_token":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",  //optional
    "expires_in":43199,
}

참고자료(OAuth 2.0)

댓글남기기