이 포스트에서는 Django로 Myinfo oauth2 클아이언트를 만드는 프로젝트를 다룬다. 끝에서는 Python에서 jwcrypto와 Crypto를 이용해 PKI를 다루는 법을 알아본다.

[github]
[api document]
[quick start]


Index

  1. Myinfo란?
  2. 프로젝트 목표
  3. 프로젝트 구현
  4. 프로젝트 회고

Myinfo란?

싱가폴 Mydata 서비스

정부가 주도한 Mydata 서비스가 몇 개의 나라에 있다고 하는데, 싱가폴 정부의 Singpass는 그 중에서도 모범사례로 뽑힌다고 한다. Singpass는 싱가포르의 15세 이상의 인구 중 97% 가 쓰고 있는 아주 활발한 서비스이다.

Singpass에 있는 여러가지 서비스 중 Myinfo는 Person Data를 제공하는 서비스로, 한국의 카카오나 네이버 아이디 처럼 ouath2 로그인과 회원가입을 할 수 있다. [Picture 1] 에서 singpass에 대해서 조금 더 자세히 살펴볼 수 있다.

[Picture 1] Introduce Singpass

Myinfo oauth2

Myinfo는 [Picture 2] 와 같은 oauth2 구조를 가진다.

[Picture 2] Myinfo oauth2 구조

Resource Owner

  • Myinfo 사용자

Application

  • 내가 구현하는 connector로, Myinfo 사용자의 데이터를 사용하는 주체

Identify Providers / Service Authorization Platform

  • 인증서버
  • 사용자의 인증정보와 권한 정보를 소유한 서버
  • Singpass 로그인 페이지 제공

Resource Server

  • 사용자 데이터를 소유한 서버
  • 인증 서버에 로그인 성공 후 접근

Myinfo Resource API

권한 인증 요청

  • [authorise api]
  • GET /v3/authorise
  • Singpass 로그인 페이지를 불러온다.
  • 로그인 후 Singpass에서 사용자의 데이터를 불러오는 것에 대한 동의를 진행한다.
  • 사용자가 동의했을 경우 authcode를 return 한다.

Token 요청

  • [token api]
  • POST /v3/token
  • authocode를 사용하여 token을 요청한다.
  • PKI를 사용하여 인증을 진행한다.
  • 인증이 완료되면 token을 return 한다.

사용자 정보 요청

  • [person api]
  • GET /v3/person/{sub}
  • token 속의 access token을 사용하여 사용자 정보를 요청한다.
  • 사용자 정보를 return 한다.

프로젝트 목표

Singpass가 제공하는 [Java][node.js] 버전의 client connector 처럼 이번에 프로젝트로 python 버전의 connector를 만들었다. 아예 하나의 REST API 형태로 제공을 하기 위해 프레임워크로 Django를 선택했다.

프로젝트 목표

프로젝트를 진행하면서 이루고자 한 목표는 아래와 같다. 대부분의 토이 프로젝트가 제대로 정리가 되어있지 않거나 코드가 엉망으로 짜여질 때가 많아서 이번에는 처음부터 확실하게 목표를 설정했다.


Code Quality

  • DDD 아키텍처로 서버를 구현한다.
  • 최소 2회의 리팩토링을 진행한다.
  • python lint(flake8, pylint, mypy)를 사용하여 최대한 python style을 고수한다.
  • Pipenv를 사용해서 python 패키지를 관리한다.

Documentation

  • Github README를 작성한다.
  • API document를 작성한다.
  • Project에 대한 블로그 포스팅을 작성한다.

Dev Stack

stack info
Backend Language Python
Backend Framework Django
Code Architecture Domain Driven Desgin
Python Package Managment Pipenv
API Security PKI
Version Control Github
API Document GitBook

프로젝트 구현

Make Request

Myinfo oauth2 구조를 반영하여, connector 서버의 호출 flow를 [Picture 3] 같이 설계했다.

[Picture 3] connector 서버 호출 flow

Step 1: Get myinfo login url

Request

GET /users/me/external/myinfo-redirect-login

curl -i -H 'Accept: application/json' <http://localhost:3001/user/me/external/myinfo-redirect-login>
Response
{
    "message": "OK",
    "data": {
        "url": "https://test.api.myinfo.gov.sg/com/v3/authorise?client_id=STG2-MYINFO-SELF-TEST&attributes=name,dob,birthcountry,nationality,uinfin,sex,regadd&state=eb03c000-00a3-4708-ab30-926306bfc4a8&redirect_uri=http://localhost:3001/callback&purpose=python-myinfo-connector",
        "state": "eb03c000-00a3-4708-ab30-926306bfc4a8"
    }
}

Step 2: Browse myinfo login url

Request
curl <https://test.api.myinfo.gov.sg/com/v3/authorise?client_id=STG2-MYINFO-SELF-TEST&attributes=name,dob,birthcountry,nationality,uinfin,sex,regadd&state=eb03c000-00a3-4708-ab30-926306bfc4a8&redirect_uri=http://localhost:3001/callback&purpose=python-myinfo-connector>
Response
[Picture 4] Myinfo Login Page

Step 3: Login and check agree terms

Login

Check Login page in [Picture 5]

Agree Terms
[Picture 5] Myinfo Terms Agreement Page

Step 4: Callback API get called by Myinfo

Myinfo에서 Request를 하는 Step이다.

로그인을 하고 terms에 동의를 하면 Myinfo에서 connector client의 callback API를 호출authcode를 넘겨준다.

Request

GET /callback?{code}

curl <http://localhost:3001/callback?code=8932a98da8720a10e356bc76475d76c4c628aa7f&state=e2ad339a-337f-45ec-98fa-1672160cf463>
Response
[Picture 6] Callback Response Page

[Picture 6] Callback Response Page


Final Step: Get Person data

자동화된 Step이다.

Callback API의 응답인 callback 페이지는 자동으로 connector client의 person data API를 호출하도록 했다. 해당 API가 Myinfo에서 사용자 정보를 가져오는 마지막 단계이다.

Request

GET /users/me/external/myinfo

curl -i -H 'Accept: application/json' <http://localhost:3001/user/me/external/myinfo>
Response
{
    "message": "OK",
    "sodata": {
        "regadd": {
            "country": {
                "code": "SG",
                "desc": "SINGAPORE"
            },
            "unit": {
                "value": "10"
            },
            "street": {
                "value": "ANCHORVALE DRIVE"
            },
            "lastupdated": "2022-07-14",
            "block": {
                "value": "319"
            },
            "source": "1",
            "postal": {
                "value": "542319"
            },
            "classification": "C",
            "floor": {
                "value": "38"
            },
            "type": "SG",
            "building": {
                "value": ""
            }
        },
        "dob": "1988-10-06",
        "sex": "M",
        "name": "ANDY LAU",
        "birthcountry": "SG",
        "nationality": "SG",
        "uinfin": "S6005048A"
    }
}

PKI Digital Signature

Myinfo는 PKI Digital Signature를 필요로 한다. 해당 문서에서는 python에서 구현을 할 때 PKI를 사용하는 방법만을 다루기 때문에 PKI에 대한 더 자세한 설명은 링크로 첨부하겠다. [LeeLee- Digital Certificate]

python 패키지로는 *jwcrypto*와 Crypto를 사용했다.


PKI 시나리오

connector client private key

myinfo public key

  • myinfo token api 응답 verify 할 때
    • myinfo에서 myinfo의 private key로 응답을 암호화 했기 때문
  • myinfo person api 응답 verify 할 때
    • myinfo에서 myinfo의 private key로 응답을 암호화 했기 때문

public, privateKey 불러오기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from jwcrypto import jwk

PrivateKey = jwk.JWK
PublicKey = jwk.JWK
    

def _get_key(self, key: str) -> Union[PrivateKey, PublicKey]:
        encode_key = key.encode('utf-8')
        key_dict = jwk.JWK.from_pem(encode_key)

        return key_dict

connector client private key로 서명하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import base64
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5

Signature = str

def _sign_on_raw_header(
            self,
            base_string: str,
            private_key: str,
    ) -> Signature:

        digest = SHA256.new()
        digest.update(
            base_string.encode('utf-8'),
        )

        pk = RSA.importKey(private_key)
        signer = PKCS1_v1_5.new(pk)
        signature = str(base64.b64encode(signer.sign(digest)), 'utf-8')

        return signature

connector client private key로 응답 decrypt 하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import json
from jwcrypto import (
    jwe,
    jwk,
)

PrivateKey = jwk.JWK

def _decrypt(
            self,
            encrypted_payload: str,
            key: PrivateKey,
    ) -> DecryptedPersonData:

        params = self._get_decrypt_params(encrypted_payload)

        encryption = jwe.JWE()
        encryption.deserialize(params, key)

        data = encryption.plaintext
        data_str = json.loads(data)

        return data_str

myinfo public key로 응답 verify 하기

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import json
from jwcrypto import jwk
   

PublicKey = jwk.JWK
DecodedPersonData = dict

def _decode(
            self,
            encoded_payload: str,
            key: PublicKey,
    ) -> DecodedPersonData:

        token = jwt.JWT()
        token.deserialize(jwt=encoded_payload, key=key)

        data = token.claims
        data_dict = json.loads(data)

        person = DecodedPersonData(**data_dict)
        return person

프로젝트 회고

문서화

정해둔 목표를 잘 이행한 기분이 들어서 뿌듯했다. 또한 항상 미흡했던 문서화를 꼼꼼하게 해 둔 거 같아 만족스럽다. 하지만 문서화를 하는 과정에서 어떻게 내가 말하고자 하는 바를 더 깔끔하게 글로 옮길 수 있을까 고민을 많이 했고, 아직도 부족한 부분이 많이 보인다.

리팩토링

코드 또한 2번 리팩토링을 진행했지만 포스팅을 위해서 다시 코드를 보니 또 리팩토링 해야겠다는 생각이 든다. 프로젝트를 할 때 1,2번의 리팩토링을 하고 프로젝트가 끝나고 2~3달 지나서 리팩토링을 1 번 진행하면 좋을 거 같다.

Boilerplate

어떤 프로젝트를 하더라도 프레임워크 setting을 하는데 초기 시간을 많이 소요하는데, 앞으로 Django로 계속 프로젝트를 진행할 예정이라면 pre-setting이 어느 정도 되어있는 Django Boilerplate 를 만들어야겠다.