🦔

Django、Authlib、Keycloakを使ったOIDCによるSSO

2022/06/28に公開

2つのDjangoアプリケーション間でOpenID Connect(OIDC)によるシングルサインオン(SSO)を行うサンプルを作成した。

https://github.com/yazawa-takayuki/sso-example-django-keycloak

KeycloakをOP(OpenID Provider)、2つのDjangoアプリケーションをRP(Relying Party)とした。RP(クライアント)のコーディングには、GitHubのStar数が多かったAuthlibを使った。

OIDCについては、下記の書籍を2、3日読んで概要を理解した。

OpenID Connect入門: 概念からセキュリティまで体系的に押さえる
認証と認可 Keycloak入門 OAuth/OpenID Connectに準拠したAPI認可とシングルサインオンの実現


サンプルの動かし方

以下の手順とおりに進めると、下記3つのローカルポートを使用する。

  • 8080
  • 8001
  • 8002

以下の手順は、下記のソフトウェアーで動作確認した。

  • Python 3.10.5
  • pipenv, version 2022.6.7
  • Docker version 20.10.16, build aa7e414

Keycloakの起動

Guides / Getting started / Dockerの手順を参考に起動。

docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:18.0.1 start-dev

http://localhost:8080/adminにアカウントadmin/adminでログインし、

  • NameがtestのRealmを作成
  • Usernameがmyuserのユーザー作成
  • 下表の通りクライアントを2つ作成
Client ID Client Protocol Access Type Valid Redirect URIs Proof Key for Code Exchange Code Challenge Method
app1 openid-connect confidential http://localhost:8001/oidc/login/ S256
app2 openid-connect confidential http://localhost:8002/oidc/login/ S256

Access Typeをconfidentialとすることで、コンフィデンシャルクライアント扱いとなり、Credentialsタブからクライアントシークレットを取得できるようになる。Proof Key for Code Exchange Code Challenge MethodにS256を設定することで、PKCE(Proof Key for Code Exchange)を強制できる。Valid Redirect URIsはリダイレクトエンドポイントで、RPに実装する。

Djangoアプリケーションの起動

GitHubからクローンし、依存ライブラリーをインストールする。

git clone https://github.com/yazawa-takayuki/sso-example-django-keycloak.git
cd sso-example-django-keycloak
pipenv install

サンプルは下記の構成となっている。2つのDjangoプロジェクトが含まれており、project1にはapp1とoidc、project2にはapp2、合計3つのDjangoアプリケーションが含まれている。app1、app2には、@login_requiredで保護された簡単なviewが定義されている。oidcには、認証リクエストやIDトークン検証のためのviewや認証バックエンドが定義されている。

sso-example-django-keycloak
├── Pipfile
├── README.md
├── project1
│   ├── app1
│   ├── db.sqlite3
│   ├── manage.py
│   ├── oidc
│   └── project1
└── project2
    ├── app2
    ├── db.sqlite3
    ├── manage.py
    └── project2

2つのクライアントに対し、クライアントシークレットを設定する。具体的には、settings.pyのOIDC_CLIENT_SECRETに設定する。クライアントシークレットは、KeycloakのClients画面のCredentialsタブから取得できる。

vi project1/project1/settings.py
vi project2/project2/settings.py
OIDC_CLIENT_SECRET = 'ここにクライアントシークレットを設定'

1つ目のクライアント(app1)をポート8001で起動する。

pipenv shell
cd project1
python manage.py migrate
python manage.py runserver 8001

2つ目のクライアント(app2)をポート8002で起動する。認証リクエストやIDトークン検証のためのviewや認証バックエンドはproject1に含まれるため、project1へのPYTHONPATHを通す。

cd sso-example-django-keycloak
pipenv shell
export PYTHONPATH=`pwd`/project1
cd project2
python manage.py migrate
python manage.py runserver 8002

動作確認

http://localhost:8001/app1(app1)、またはhttp://localhost:8002/app2(app2)にアクセスすると、Keycloakのログイン画面が表示される。
どちらか一方にmyuserでログインすると、もう一方も認証済となり、@login_requiredで保護されていたviewが表示される。

ログアウト機能は作っていないので、ログアウトするには下記の様にする必要がある

  • Keycloak: http://localhost:8080/adminでSessionsのLogout allを押下する
  • Django: createsuperuserでスーパユーザーを作り、admin siteにログインし、ログアウトする

説明

Djangoアプリケーションapp1、app2には、基本的には簡単なviewを定義しただけ。詳しくはこのコミットを参照。下記のとおり、@login_requiredで保護されているため、アクセスにはログインが必要。

from django.http import HttpResponse
from django.contrib.auth.decorators import login_required


@login_required
def index(request):
    return HttpResponse('App1')

Djangoアプリケーションoidcが、サンプルの肝である。

views.pyには2つのviewを定義した。

from django.http import HttpResponse
from django.conf import settings
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.contrib import auth
from authlib.integrations.django_client import OAuth


OAUTH = OAuth()
OAUTH.register(
    name='rp',
    client_id=settings.OIDC_CLIENT_ID,
    client_secret=settings.OIDC_CLIENT_SECRET,
    access_token_url=settings.TOKEN_ENDPOINT,
    authorize_url=settings.AUTHORIZATION_ENDPOINT,
    jwks_uri=settings.JWKS_URI,
    client_kwargs={
        'scope': 'openid',
        'code_challenge_method': 'S256',
    },
)


def login_op(request):
    """
    API to login to OP
    """
    redirect_uri = request.build_absolute_uri(
        reverse_lazy('oidc:login_rp')
    )

    # keep next url because it gets lost when token response occurs
    if 'next' in request.GET:
        request.session['url_next_to_login'] = request.GET['next']

    # authentication request
    return OAUTH.rp.authorize_redirect(request, redirect_uri)


def login_rp(request):
    """
    Redirect endpoint which makes session with RP
    """
    if 'error' in request.GET:
        err_msg = request.GET['error_description']
        # error handling
        # ...

    user = auth.authenticate(request, client=OAUTH.rp)
    if user is not None and user.is_authenticated:
        auth.login(request, user)

        redirect_url = request.session.get('url_next_to_login', settings.LOGIN_REDIRECT_URL)
        if 'url_next_to_login' in request.session:
            del request.session['url_next_to_login']

        return redirect(redirect_url)

    # this is a test code. write error handling.
    return HttpResponse('error')

login_opは、OP(Keycloak)に認証リクエストを送るためのviewである。return OAUTH.rp.authorize_redirect(request, redirect_uri)の部分で、認証リクエストからリダイレクトエンドポイントへのリダイレクトまで行われる。リダイレクトエンドポイントは、定義したもう一方のview(login_rp)である。

login_rpは、リダイレクトエンドポイントである。user = auth.authenticate(request, client=OAUTH.rp)の部分で、後述のカスタムバックエンドにより、IDトークンを使った認証を行っている。認証されたら、auth.login(request, user)の部分でRP(app1, app2)とのログインセッションを確立する。

認証リクエストやIDトークンの検証は、AuthlibのOAUTHオブジェクトに移譲する。OAUTH.registerで、クライアントIDなど必要な情報を設定している。上のコードでは、必要な情報は下記のとおりsettings.pyに定数として保持している。各エンドポイントのURLは、KeycloakのRealm SettingsのGeneralタブのEndpointsにあるリンクから確認できる。

なお、'code_challenge_method': 'S256'がないとPKCEが実施されず、ログイン画面の代わりにerror=invalid_request&error_description=Missing parameter: code_challenge_methodをGETパラメーターとしてリダイレクトされる。

# get these values from keycloak
OIDC_CLIENT_ID = 'app1'
OIDC_CLIENT_SECRET = ''
TOKEN_ENDPOINT = 'http://localhost:8080/realms/test/protocol/openid-connect/token'
AUTHORIZATION_ENDPOINT = 'http://localhost:8080/realms/test/protocol/openid-connect/auth'
JWKS_URI = 'http://localhost:8080/realms/test/protocol/openid-connect/certs'

カスタムバックエンドは、backends.pyに下記のとおり定義した。

from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.models import User


class OidcBackend(BaseBackend):
    """
    custom backend for OIDC
    """
    def authenticate(self, request, client=None):
        """
        overriding method.
        params
            client: OAuth client of Authlib
        """
        if client is None:
            return None

        # token request
        token = client.authorize_access_token(request)

        # makes django user according to ID token
        if token and 'userinfo' in token:
            assert 'preferred_username' in token['userinfo']
            username = token['userinfo']['preferred_username']
            try:
                user = User.objects.get(username=username)
            except User.DoesNotExist:
                user = User(username=username)
                user.is_staff = False
                user.is_superuser = False
                user.save()
            return user

        # some error handling
        # ...
        return None

    def get_user(self, user_id):
        """
        overriding method.
        """
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

基本的に、Djangoの公式ドキュメントで紹介されている内容でコーディングした。authenticateget_userは、オーバーライドが必要なメソッドである。authenticateは認証を行い、認証が成功したらUserオブジェクトを返す。

token = client.authorize_access_token(request)の部分で、IDトークンを取得、検証を実施している。preferred_usernameをもとに、Django側にユーザーが存在していなければユーザーを作成している。

以上が肝で、あとはurlpatternsの定義などを実施した。詳しくはこのコミットを参照。なお、別途調査するが、state、nonceなどの検証は、Authlibが自動的にやってくれているようだ。

参考

https://www.keycloak.org/getting-started/getting-started-docker
https://docs.authlib.org/en/latest/client/frameworks.html
https://docs.authlib.org/en/latest/client/oauth2.html
https://docs.authlib.org/en/latest/client/django.html
https://docs.djangoproject.com/ja/4.0/topics/auth/customizing/

Discussion