keycloakと連携した認証認可制御を行う場合、Open ID Connectの認可フローを実装することが多い。
keycloak側で好きなclaimとscopeの設定ができるため、ほとんどの場合はアクセストークンの情報だけで認可制御ができる。
しかし、より細かな認可を行うために、keycloakではより細かな認可制御を行う機能が提供されている。
(例えば時間や条件に応じてアクセス可否を変更したい等)
keyclaokの公式ドキュメントにはjavaを利用したpolicy enforcerの実装が出てくるが、webを漁っても他の実装がなかなか出てこなかったので、メモがわりに。。
作ったもの
keycloakの認可
keycloakの認可機能ではAttribute-based access control (ABAC)やRule-based access controlに対応した様々な認可機能を備えている。
下図はkeycloakにおける認可制御のアーキテクチャ。
若干わかりにくいが、「PAP」「PDP」はkeyclaokが持っている機能で、PEPはkeycloakと連携するクライアント側(keyclaok-adopterと呼ばれている)で実装すべき機能となっている。
PEPとかPAPが何を示しているのかは以下の記事がとても参考になった。
OIDCの設定
細かな認可機能設定する前段として、ウェブアプリとの認証連携部分の設定を行う。
今回はOIDCで認証認可を実現する。
keycloakの設定
keycloakでは管理するアプリケーションをclientという形で登録する。
まず最初にclientの登録から行う
clientの登録
keycloakでclientを登録する。
keycloakの認可機能を利用する場合のポイントは1点で、
Client authentication
とAuthorization
をOnにするだけ。
他の設定は必要に応じて編集するが、今回はアプリ連携のために以下のようにリダイレクト/ログアウトurlのみ設定する。
項目 | url | |
---|---|---|
Valid redirect URIs | http://localhost:3000/callback | リダイレクト用 |
Valid post logout redirect URIs | http://localhost:3000/ | ログアウト処理後にリダイレクトされるURI |
webアプリの作成
openid-clientを利用してkeycloakとoidc連携を行う。
ウェブアプリ側ではkeyclaokに接続するための設定、および、ログイン画面へのリダイレクトとcallbackを処理するためのエンドポイントを作成する。
はじめにkeycloakと通信するclientを作成する。
import { Issuer, generators } from 'openid-client'
...
const keyclaokClientId = 'demo01'
const keyclaokClientSecret = '...'
const keycloakBaseUrl = 'http://localhost:18080'
const keyclaokRealmUrl = keycloakBaseUrl + '/realms/master'
const configUrl = keyclaokRealmUrl + '/.well-known/openid-configuration'
const appBaseUrl = 'http://localhost:3000'
const appCallbackPath = '/callback'
const appLogoutPath = '/logout'
const appCallbackUrl = appBaseUrl + appCallbackPath
// OIDCで、は当該 OpenID プロバイダーの情報を取得することが可能。
// keycloakでは http://..../realms/{realm name}/.well-known/openid-configuration
const keycloakIssuer = await Issuer.discover(configUrl);
console.log('Discovered issuer %s %O', keycloakIssuer.issuer, keycloakIssuer.metadata);
const client = new keycloakIssuer.Client({
client_id: keyclaokClientId,
client_secret: keyclaokClientSecret,
redirect_uris: [appCallbackUrl], // webアプリ側でcallbackを受け取るURI
response_types: ['code'],
});
keyclaokへのリダイレクトURLを設定する。
// keycloakのログイン画面へリダイレクトURLを取得する
let authorizationUrl = client.authorizationUrl({
redirect_uri: appCallbackUrl,
// ↓はoidcで初期設定されるscope
scope: 'openid email profile'
});
app.get('/login', function (req, res) {
// keycloakへリダイレクト
res.redirect(authorizationUrl);
})
最後にcallback処理のエンドポイントを作成する。
ここでトークンが取得できるため、保護対象のリソースへアクセスが可能になる。
app.get("/callback", (req, res) => {
const params = client.callbackParams(req)
client.callback(appCallbackUrl, params, { code_verifier })
.then(function(token){
// アクセストークン・IDトークンが取得できるので、
// 必要に応じてブラウザに返したり、セッションに保持したりを考える
...
// トークンをセットして保護対象のリソースにアクセスする
res.redirect(appHomeUrl)
})
.catch(function(error) {
console.log(error)
// エラーの場合はログイン画面に戻す
res.redirect(appBaseUrl);
})
})
認可設定
ここからやっと認可設定に入っていく。
[Clients] -> [該当 Client] -> 「Authorization」タブへと進み、認可制御の設定を行う。
いくつか設定があるが、最低限の設定は以下の3つ
- リソースの作成
- ポリシーの作成
- パーミッションの設定
- リソースとポリシーの紐付け
まずはじめにポリシーの登録を行う。
画面を見ると「Default Resource」がはじめに登録されている
The default configuration consists of:
- A default protected resource representing all resources in your application.
- A policy that always grants access to the resources protected by this policy.
- A permission that governs access to all resources based on the default policy.
全てのリソースに対する許可ポリシーとのことなので、とりあえずデフォルトポリシーは無効化しておくのが良さそう。
リソース定義
保護するリソースの情報を定義する。
ここではリソース名(任意)とそれに紐づくURIを定義しておく。
- name
- リソースを識別可能なユニークな名前を定義する
- uris
- リソースのパスを設定(webアプリで利用)
- (Authorization scope)
- リソースに紐づくscopeを設定する。今回は使わない
- (User-Managed access enabled)
- ここをenableにすることで、リソースオーナからのリソース制御が可能になる。(UMAの認可フローを使う場合はここをenableにするが、今回はどちらでも)
ポリシーの定義
[Policies]タブで「Create Policy」をクリックするとポリシータイプの選択画面が出てくる。
個人的にここが充実してるのがとても良いところ。
(この辺りの細かいポリシー制御機能を、自前で作るとかなりコストが高いので)
何を選んでも良いが今回は「Role」を選択する
Role based policyでは「role hogeのユーザはアクセス可/不可」のようなポリシーを作成できる。
(以下の例では「role demo-role01のユーザはアクセス可能」というポリシーを設定している。)
パーミッションの定義
パーミッションを定義する。
パーミッション定義では、作成したリソースとポリシーを多対多で関連付けることができる。
ここでリソースと、リソースに適用するポリシーを登録することで、1つのリソースに対し複数のポリシーを組み合わせて定義することが可能。
この辺りはxacmlにおけるruleとconditionの関係に似ている。
(以下の例では「role demo-role01のユーザはresource01(/protected01)にアクセス可能」というルールを定義している)
Webアプリ側での認可制御
ウェブアプリ側ではいわゆるPE(policy enforcer)を実装する。
keyclaokの公式ドキュメントにはjavaでのpolicy enforcerの実装が載っているが、わざわざこのためにjavaを使うのは面倒のなのでnodejs
からkeycloak APIを直に叩いて認可を実装する。
公式のやり方としては、UMA(User-Managed Access)のフローに従いPermission Ticketの取得 -> RPTの取得
という流れで認可制御を行うが、ここでは簡易的にPermission Ticket取得の流れを省いて認可を行う。
UMAではリソースサーバがpermission ticketを発行し、client側は発行されたticketを元に認可リクエストを行う。
このフローにより、permission ticketとkeycloakに定義したリソースが紐づき、clientとしてはkeycloakの管理情報を知らなくても認可リクエストが可能。
今回は、keycloakに設定されているresources
とclient_id
をウェブアプリ側(Client)が知っている前提で、なんちゃってUMAを実装する。
RPT取得
はじめに、アクセストークンを元にRPTを取得する。
keyclaokに以下のリクエストを送ると、access_token
としてRPTが返却される。
curl -X POST \
http://${host}:${port}/realms/${realm}/protocol/openid-connect/token \
-H "Authorization: Bearer ${access_token}" \
--data "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \
--data "audience={resource_server_client_id}"
fetchを使って以下のように書く。
accessTokenの部分はoidcのフローデー取得したアクセストークンを利用する。
const obj = {
grant_type : "urn:ietf:params:oauth:grant-type:uma-ticket",
audience: keyclaokClientId
}
const url = keyclaokRealmUrl + '/protocol/openid-connect/token'
const rpt = await fetch(url,{
method: 'POST',
headers: {
'Authorization': `Bearer ${req.session.accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: Object.keys(obj).map((key)=>key+"="+obj[key]).join("&")
})
.then(response => response.json())
.then(result => {
console.log(result)
return result
}).catch((error) => {
console.log(error)
throw error
})
// rpt.access_tokenとしてRPTが返却される
返却されたRPTの中身は以下のようになっており、authorization
としてアクセス可能なリソース名が入ってくる。
これを参照することで自信がどのリソースにアクセス可能かを判断できる。
{
...
"authorization": {
"permissions": [
{
"rsid": "f3aea62e-eb20-4694-9403-543ab6484140",
"rsname": "protected01"
}
]
}
...
}
認可判定
webアプリ側では、このトークンを元にリソースへのアクセスを許可するか否かを判定できる。
またpermission=[対象のリソース名]
とし、response_modeをdecision
と指定することで、シンプルに入力可否だけを判定することもできる。
const obj = {
grant_type : "urn:ietf:params:oauth:grant-type:uma-ticket",
audience: keyclaokClientId,
permission: "protected01",
response_mode: "decision"
}
レスポンスはtrue/falseで帰ってくる。
{ result: true }
scopeの利用
上記の例ではリソースに対してのアクセス可否のみ判定できる。
現実的にはアクセス可否だけでなく、read/write等のその他諸々のactionを判定する必要があったり、そんなに単純ではない。
scopeを利用することで上記RPTの認可情報にもう少し細かな権限情報を付与できる。
例えば、以下のようにread
write
のscopeを作成し、
リソースに対してスコープを紐付ける
するとRPTには以下のようにscopeの情報も返却される。
これを使えばパス・スコープでそこそこ細かい粒度の認可も設定できそう。
"authorization": {
"permissions": [
{
"scopes": [
"read",
"write"
],
"rsid": "f3aea62e-eb20-4694-9403-543ab6484140",
"rsname": "protected01"
}
]
},
scopeについてもresponse_mode=decision
での真偽値判定ができ、permission= "{リソース名}#{スコープ}"
としてリクエストすることで認可判定が可能。
const obj = {
grant_type : "urn:ietf:params:oauth:grant-type:uma-ticket",
audience: keyclaokClientId,
permission: "protected01#update",
response_mode: "decision"
}
{ result: true }
ちなみに、エラーの場合は以下のように帰ってくる
{
error: 'invalid_scope',
error_description: 'One of the given scopes [update] is invalid'
}
RPTにはリソースに設定したURLsの情報が入ってこない。
リソースオーナー = webアプリ開発者であり、webアプリ側でリソース名を知っている前提であれば上記で認可判定が可能。
そうでない場合はUMAの正式なフローに乗っかる必要がありそう。
UMAのフローに則った実装はまたの機会に。
まとめ
keyclaokの認可機能を利用して簡単な認可機能を試してみた。
複数条件を指定したアクセス制御が簡単に実装できるので有用だと思う。
(保護リソースと認可情報の全てをkeycloakに登録しなければいけないのは手間かも)
次はUMAのフローに乗っかった実装をやってみます。