Django、Authlib、Keycloakを使ったOIDCによるSSO
2つのDjangoアプリケーション間でOpenID Connect(OIDC)によるシングルサインオン(SSO)を行うサンプルを作成した。
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の公式ドキュメントで紹介されている内容でコーディングした。authenticate
とget_user
は、オーバーライドが必要なメソッドである。authenticate
は認証を行い、認証が成功したらUser
オブジェクトを返す。
token = client.authorize_access_token(request)
の部分で、IDトークンを取得、検証を実施している。preferred_usernameをもとに、Django側にユーザーが存在していなければユーザーを作成している。
以上が肝で、あとはurlpatternsの定義などを実施した。詳しくはこのコミットを参照。なお、別途調査するが、state、nonceなどの検証は、Authlibが自動的にやってくれているようだ。
参考
Discussion