개발 노트/개발 삽질

Authlib를 이용한 간단한 Server 구현

j4ko 2023. 6. 27. 00:42
728x90
반응형

 

개요 

회사에서 시스템끼리 데이터 통신을 어떻게 할까를 논의하던 도중 Oauth가 언급되었다. 생각해 보니 Oauth는 이용만 했었지 막상 Oauth 서버가 어떻게 돌아가는지 코드를 통해 확인해본적은 없었다.


Oauth에 대해 검색해보니 Oauth에 대한 개념과 이론에 대한 정보가 많았는데 막상 또 코드로써 구현해보려니 처음부터 무엇을 해야될지 감이 안 잡히는 상황이었다. 그래서 이를 구현하기 위해 만들어진 라이브러리가 없나 검색해봤는데 Authlib이라는 라이브러리를 알게 되었다.


이 라이브러리가 내부적으로 어떻게 구현되었는지를 탐색해보는 과정에서 삽질을 조금 하게되었는데 이 포스팅에 그 내용을 담아보려고 한다.

 

1. Setting

Python으로 Oauth Server를 구현하기 위해 아래 라이브러리가 필요했다.

$ pip install Authlib

위 라이브러리에는 Flask, Django 등을 이용해 Oauth Server를 구현하기 위해 필요한 각종 모듈이 들어있으며 Github Repository를 보니 Star를 3796개나 받았다. 이 라이브러리 내부를 탐방했을때 rfc 규격에 맞춘 interface를 제공해주고 있었고 이 interface를 통해 구현을 한다면 flask나 django라는 프레임워크에 종속받지 않고 개발이 가능해보였다.

 

그러나 실제로 가져다 사용해본 입장에서는 빠르게 demo를 돌리는 것이 우선이었기에 authlib 내부적으로 flask 환경에서 구현하도록 지원하는 모듈을 이용했다.

 

2. Schema

Oauth Server에 Request를 전송하는 주체는 User와 Client로 분류된다. 그렇기 때문에 어떤 Request가 들어오면 Request 데이터를 파악해서 Oauth Server에 저장해야 한다. 즉 Oauth Server도 자체적으로 DataBase를 가지고 있으며 데이터를 관리하는 테이블이 존재하는 셈이다.

 

현실적으로는 Oauth Server를 어떤식으로 구현 할 것인지에 맞춘 요구사항에 따라 테이블을 재조정할 필요는 있겠지만  필자는 OauthServer를 구성하는 Demo 정도만 만들어보는 것이 목적이기에  Authlib에서 제공하는 기본적인 테이블 정보를 가져다 사용했다.

 

테이블의 종류는 다음과 같다.

  • OAuth2ClientMinin
  • OAuth2AuthorizationCodeMixin
  • OAuth2TokenMixin

구현과정에서 위 용어가 다소 헷갈리는 감이 있어 나름대로의 이해를 돕기 위해 테이블의 명칭을 다음과 같이 변경하였다.

  • OAuth2ClientMinin - > ClientModel
  • OAuth2AuthorizationCodeMixin → ClientCodeModel
  • OAuth2TokenMixin → ClientTokenModel

추가로 Client가 어떤 사용자에 의해 생성된 것인지 파악하기 위한 테이블이 필요하다. authlib에서는 이를 딱히 강제하지는 않았다. 그래서 어떤 Client를 생성한 사용자를 식별하는 모델을 다음과 같이 명명했다.

  • User - > Member Model

참고로 Authlib에서 제공하는 이 Mixin 종류들은 SQLAlchemy의 Model 생성방식이므로 Flask를 통해 SQLAlchemy를 사용할 수 있게 조성해놔야 한다.

 

2.1 Member Model

이 모델은 client를 생성한 User를 뜻한다. Oauth의 개념을 보면 Client라는 용어가 등장하는데 이 Client는 외부의 사용자가 아닌 내부의 개발 대상인 특정 서버를 뜻한다. 그러니 이 User라는 테이블은 Client를 등록한 사용자를 식별하거나 OauthServer를 관리하기 위한 사용자 정보로 이해했다.

 

Authlib에서는 User 테이블에 특정 칼럼을 강요하지 않는다. 그래서 다순한 예제로서 기본적인 사용자 정보를 담았다.

class MemberModel(db.Model):
    __tablename__ = "member"

    sequence = Column(Integer, primary_key=True)
    member_id = Column(VARCHAR(255), nullable=True)
    password = Column(VARCHAR(255), nullable=True)

    def get_member_id(self):
        return self.member_id

주의할 점은 User가 아닌 Member라 지칭되었다는 점이다. 그렇다 사실 authlib 내부 모듈의 규약에 따라 필요한 것만 선언되어 있으면 상관없다. 위 코드에서는 get_member_id()가 그러한 것인데 추후 설명하겠다.

 

2.2 Client Model

이 모델은 Oauth 개념에서 요구하는 그 Client를 뜻하는 것이 맞다. Authlib에서는 이 모델에 대해 다음과 같은 Column에 대해 선언되어있어야헀다.

# 참고: from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin

class ClientModel(db.Model):
    __tablename__ = 'client'

    sequence = Column(Integer, primary_key=True)
    member_sequence = Column(Integer, nullable=False) # 커스텀 된 컬럼
    client_id = Column(String(48), index=True)
    client_secret = Column(String(120))
    client_id_issued_at = Column(Integer, nullable=False, default=0)
    client_secret_expires_at = Column(Integer, nullable=False, default=0)
    client_metadata = Column('client_metadata', Text)
		...

위 ClientModel은 authlib에서 제공하는 OAuth2ClientMixin Class에 정의된 Column을 필자가 정의한 ClientModel에 다시 선언해놓은 것이다. 이처럼 직접 클래스를 명시해서 ClientModel를 생성하는 경우 필요한 몇 가지 Method가 존재하는데 다음과 같다.

def check_redirect_uri(self, redirect_uri) -> bool: ...
def check_response_type(self, response_type) -> bool: ...
def check_client_secret(self, client_secret) -> bool: ...
def check_endpoint_auth_method(self, method, endpoint) -> bool: ...
def check_grant_type(self, grant_type) -> bool : ...

코드의 구현체까지 본 포스팅에 기록하면 좋겠지만 authlib의 Oauth2ClientMixin Class를 참고하여 상황에 맞게 구현하는 것이 더 좋다. 이 Model의 경우 client_metadata라는 특별한 column이 존재하는데 이는 단어 그대로 client의 metadata를 json 형식으로 데이터베이스에 저장해 두는 필드이다. 이 필드는 json 형식에 어떤 속성이 필요한지 파악하는 것이 중요한데 필자가 파악한 경우에는 아래 데이터만 있으면 충분했다.

{
	"client_name": "test_client",
	"client_uri": "<http://localhost:9999>",
	"redirect_uris": ["<http://localhost:9998>"],
	"response_types": "code",
	"grant_types": ["authorization_code"]
}

위 데이터는 실제로 client의 OauthRequest가 유효한지 검증할 때 비교하는 대상이 되기 때문에 유의해야 한다.

2.3 Client Code Model

이 모델은 Client의 OauthRequest가 적절한지 검증하고 Token을 발급받기 위해 제출해야 되는 Code를 데이터베이스에 저장하기 위한 용도로 사용된다.

# 참고: from authlib.integrations.sqla_oauth2 import OAuth2AuthorizationCodeMixin
class ClientCodeModel(db.Model):
    __tablename__ = 'client_code'

    sequence = Column(Integer, primary_key=True)
    code = Column(String(120), unique=True, nullable=False)
    client_id = Column(String(48))
    redirect_uri = Column(Text, default='')
    response_type = Column(Text, default='')
    scope = Column(Text, default='')
    nonce = Column(Text)
    auth_time = Column(
        Integer, nullable=False,
        default=lambda: int(time.time())
    )

    code_challenge = Column(Text)
    code_challenge_method = Column(String(48))
		...

ClientCode를 생성하는 방식에 대해서는 특별히 파악해야 될 요소는 없었다. ClientCode를 특별한 규격에 맞춰 생성해야 되는 상황이 아니라면 그대로 사용해도 상관없을 듯하다.

2.4 Client Token Model

언급했지만 code를 발급받고 나면 token을 발급받아야 한다. 즉 token을 관리하는 테이블이 필요하는 뜻이며 이 테이블의 Column은 다음과 같이 정의했다.

# 참고: from authlib.integrations.sqla_oauth2 import OAuth2TokenMixin
class ClientTokenModel(db.Model):
    __tablename__ = 'client_token'

    sequence = Column(Integer, primary_key=True)
    client_id = Column(String(48))
    member_id = Column(String(48))
    token_type = Column(String(40))
    access_token = Column(String(255), unique=True, nullable=False)
    refresh_token = Column(String(255), index=True)
    scope = Column(Text, default='')
    issued_at = Column(
        Integer, nullable=False, default=lambda: int(time.time())
    )
    access_token_revoked_at = Column(Integer, nullable=False, default=0)
    refresh_token_revoked_at = Column(Integer, nullable=False, default=0)
    expires_in = Column(Integer, nullable=False, default=0)

ClientCode와 마찬가지로 token에 대해 특별한 요구사항을 추가할 것이 아니기 때문에 Oauth2TokenMixin class의 Column들을 그대로 가져왔다.

 

 

3. AuthorizationServer

Oauth 인증의 수행단계를 보면 Authorization Code와 Token을 발급받는 것을 알 수 있다. 그리고 이 Code와 Token를 생성하게 도와주는 객체가 AuthorizationServer이다.

from authlib.integrations.flask_oauth2.authorization_server import AuthorizationServer

이 객체를 instance화를 시킬 때 app과 query_client와 save_token 파라미터를 선택적으로 사용할 수 있다.

# /venv/lib/python3.8/site-packages/authlib/integrations/flask_oauth2/authorization_server.py
class AuthorizationServer(_AuthorizationServer):
    def __init__(self, app=None, query_client=None, save_token=None):
			...

필자의 경우 flask 환경으로 만들고 있기 때문에 flask app을 넘겨줘야 했다. 굳이 안 넘겨줘도 상관없지만 중요한 것은 query_client와 save_token에 넘겨줘야 하는 부분인데 예제는 다음과 같다.

AuthorizationServer(
    query_client=create_query_client_func(db.session, ClientModel),
    save_token=self.create_save_token(db.session, ClientTokenModel)
)

AuthorizationServer에 app을 넘기게 되면 app에 들어있는 config 속성을 찾아서 해당 config의 속성을 참조하게된다.  어떤 속성을 참조하는지는 AuthorizationServer의 init_app를 보면된다.

    def __init__(self, app=None, query_client=None, save_token=None):
		...
        if app is not None:
            self.init_app(app)

    def init_app(self, app, query_client=None, save_token=None):
        """Initialize later with Flask app instance."""
        if query_client is not None:
            self._query_client = query_client
        if save_token is not None:
            self._save_token = save_token

        self.register_token_generator('default', self.create_bearer_token_generator(app.config))
        self.scopes_supported = app.config.get('OAUTH2_SCOPES_SUPPORTED')
        self._error_uris = app.config.get('OAUTH2_ERROR_URIS')

config에서 읽는 값인 OAUTH2_ERROR_URIS는 이 문서를 참고하도록 하자. OAUTH2_SCOPES_SUPPORTED 는 찾지 못했다.

 

3.1 query_client

query_client에 넘겨야 하는 건 function이다. 이는 authlib 내부적으로 아래 위치의 코드를 사용한다.

from authlib.integrations.sqla_oauth2 import create_query_client_func

이 create_query_client_func은 다음과 같이 생겼다.

def create_query_client_func(session, client_model):
    """Create an ``query_client`` function that can be used in authorization
    server.

    :param session: SQLAlchemy session
    :param client_model: Client model class
    """
    def query_client(client_id):
        q = session.query(client_model)
        return q.filter_by(client_id=client_id).first()
    return query_client

client_id를 통해 client_model을 질의하는 것을 볼 수 있다. 이 client_model은 AuthorizationServer를 instance화 시킬 때 같이 넘겨줄 수 있다.

 

즉, Client와 연계된 SQLAlchemy Model을 사용하면 된다.

3.2 save_token

query_client와 비슷하게 사용한다. authlib 내부적으로 아래 위치의 코드를 사용한다.

from authlib.integrations.sqla_oauth2 import create_save_token_func
def create_save_token_func(session, token_model):
    """Create an ``save_token`` function that can be used in authorization
    server.

    :param session: SQLAlchemy session
    :param token_model: Token model class
    """
    def save_token(token, request):
        if request.user:
            user_id = request.user.get_user_id()
        else:
            user_id = None
        client = request.client
        item = token_model(
            client_id=client.client_id,
            user_id=user_id,
            **token
        )
        session.add(item)
        session.commit()
    return save_token

그런데 앞서 User를 MemberModel로 바꿔서 정의했기 때문에 위 함수에서 사용하는 get_user_id() 대신 get_member_id()로 변경해야 한다. 거의 비슷한 코드지만 아래처럼 수정하여 AuthorizationServer의 save_token에 넘겨줬다.

def create_save_token(self, session, token_model):
    def save_token(token, request):
        if request.user:
            member_id = request.user.get_member_id()
        else:
            member_id = None
        client = request.client
        item = token_model(
            client_id=client.client_id,
            member_id=member_id,
            **token
        )
        session.add(item)
        session.commit()

    return save_token

3.3 OauthRequest

AuthorizationServer는 Token 발급 요청이 오면 HttpRequest를 OauthRequest로 변환하는 과정을 거치게 된다. 이는 내부적으로 다음 위치에 선언되어있다.

# venv/lib/python3.8/site-packages/authlib/integrations/flask_oauth2/authorization_server.py

class AuthorizationServer(_AuthorizationServer):
    ...
    def create_oauth2_request(self, request):
        return create_oauth_request(request, OAuth2Request)

위 함수에서 선언된 request는 flask 상에서 request를 처리하기 위한 객체를 그대로 사용해도 무방했다. 위 코드에서 OAuth2Request라는 객체가 보이는데 이는 Oauth 인증에 필요한 데이터를 담아놓은 객체이다. create_oauth_request 함수의 동작을 보면 외부에서 들어온 request를 OAuth2Request 객체로 변환하는 과정이 포함되어있다.

def create_oauth_request(request, request_cls, use_json=False):
    if isinstance(request, request_cls):
        return request

    if not request:
        request = flask_req

    if request.method in ('POST', 'PUT'):
        if use_json:
            body = request.get_json()
        else:
            print(request.form)

            body = request.form.to_dict(flat=True)
    else:
        body = None

# query string in werkzeug Request.url is very weird# scope=profile%20email will be scope=profile email
    url = request.base_url
    if request.query_string:
        url = url + '?' + to_unicode(request.query_string)
    return request_cls(request.method, url, body, request.headers)

그렇다면 OAuth2Request로 변경하기 위해 request에는 어떤 데이터를 담아서 보내야할까? code 발급을 요청하는 경우에는 다음과 같다.

# AuthorizationCode 발급시에 요청해야하는 데이터
client_id:bccXf0CjTWcO8pGWxsU8GLx8
response_type:code
grant_type:authorization_code
redirect_uri:<http://localhost:5002>

그리고 token을 발급하는 경우엔 다음과 같이 발급된 code와 client_secret을 포함하여 요청해야한다.

client_id:bccXf0CjTWcO8pGWxsU8GLx8
client_secret:qjPborflbB5DpmZ44W5JDA1XBk2lJhVNGEchFbzqkF0XrpUH# 추가된 항목
code:mvZYYhXF6kSfT3lBAYcb7vnSjbx0ROQU4eRO0lmhyoGoUlDF# 추가된 항목
response_type:code
grant_type:authorization_code
redirect_uri:<http://localhost:5002>

 

4. AuthorizationCodeGrant

필자는 Oauth Server에서 가장 흔하게 사용되는 AuthorizationCode 방식을 채택해서 구현을 시도해 봤다. authlib 내부적으로는 아래 위치에 AuthorizationCode 방식에 해당하는 spec이 정의되어 있다.

from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant

위 객체는 상속받아서 implement 시켜줘야 하는 메서드들이 존재한다.

class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
    TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_post']

    def authenticate_user(self, authorization_code): ...

    def save_authorization_code(self, code, request): ...

    def query_authorization_code(self, code, client): ...

    def delete_authorization_code(self, authorization_code): ...

 

4.1 authenticate_user

정의된 spec에 따르면 authorization_code(ClientCodeModel) 통해 등록된 사용자를 식별하는 로직을 작성하는 것이 맞지만 애초에 Oauth2가 동작하는 원리를 보기 위해 구현하고 있었으므로 적당히 DB에 존재하는 사용자 하나를 넣어줬다.

# 참고: /venv/lib/python3.8/site-packages/authlib/oauth2/rfc6749/grants/authorization_code.pydef authenticate_user(self, authorization_code):
    return db.session.query(MemberModel).one()

 

4.2 save_authorization_code

Token을 발급하기 위한 용도로 사용하는 AuthorizationCode를 저장하는 함수이다. 이 Method도 필자가 선언한 SQLAlchemy의 Model로 대체하였다.

def save_authorization_code(self, code, request):
    """ 인증코드(authorization-code)를 DB에 저장 """
		auth_code = ClientCodeModel(
        code=code,
        client_id=request.client.client_id,
        redirect_uri=request.redirect_uri,
        scope=request.scope
    )

    db.session.add(auth_code)
    db.session.commit()
    return auth_code

 

4.3 query_authorization_code

DB에 존재하는 AuthorizationCode를 조회하는 함수이다.

def query_authorization_code(self, code, client):
    """ 인증코드 조회 """
		auth_code = ClientCodeModel.query.filter_by(
        code=code, client_id=client.client_id).first()

    return auth_code

 

4.4 delete_authorization_code

단순 AuthorizationCode를 삭제하는 로직이기 때문에 특별히 구현하지는 않았다.

 

맺음말

Oauth2 Server를 구현하면서 중점적으로 파악했던 내용들에 대해서 담았다. 재정리하면서 필요한 부분은 계속 업데이트 해나가야될 듯 싶다.

 

728x90
반응형