Caveman2 + keycloak で 認証サンプルを作ってみた
以下の記事を参考にしながらCaveman2 + Keycloak でOpenID connectをするサンプルを作ってみたいと思います
注意! OpenID 初心者によるサンプル実装ですので ご自身で使用するときは しっかりした調査とテストをよろしくおねがいします
Caveman2 をインストール
$ ros install caveman2
プロジェクト作成
~/common-lisp
ディレクトリ内であれば (ql:quickload :****) でロード可能なので
そこを作業ディレクトリにします
$ mkdir ~/common-lisp && cd ~/common-lisp
プロジェクトのひな型を作り、Webサーバを起動します
$ ros -s caveman2 -e "(caveman2:make-project #P\"openid-connect\" :auther \"moremagic\")" writing openid-connect/openid-connect.asd writing openid-connect/openid-connect-test.asd writing openid-connect/app.lisp writing openid-connect/README.markdown writing openid-connect/.gitignore writing openid-connect/db/schema.sql writing openid-connect/src/web.lisp writing openid-connect/src/view.lisp writing openid-connect/src/main.lisp writing openid-connect/src/db.lisp writing openid-connect/src/config.lisp writing openid-connect/static/css/main.css writing openid-connect/templates/index.html writing openid-connect/templates/_errors/404.html writing openid-connect/templates/layouts/default.html writing openid-connect/tests/openid-connect.lisp $ ros -s openid-connect -e "(openid-connect:start)" run To load "openid-connect": Load 1 ASDF system: openid-connect ; Loading "openid-connect" Hunchentoot server is started. Listening on localhost:5000. Clozure Common Lisp Version 1.11.5/v1.11.5 (LinuxX8664) For more information about CCL, please see http://ccl.clozure.com. CCL is free software. It is distributed under the terms of the Apache Licence, Version 2.0. ?
http://localhost:5000 でWebアプリケーションが起動します。
停止したい場合は (openid-connect:stop)
もしくは (quit)
と打つことでWebアプリケーションが停止します
keycloak の起動
Docker で keycloak Imageを起動します
docker run -d -p 18080:8080 \ -e KEYCLOAK_USER=admin \ -e KEYCLOAK_PASSWORD=admin \ --name keycloak \ jboss/keycloak
http://localhost:18080/auth/admin/ にアクセス。admin / admin でログインできます
Keycloakの設定
client 設定を追加します
Instralletion からクライアントシークレット等を取得します
Client側に認証コードを実装します
git diff
~/common-lisp/openid-connect$ git diff diff --git a/openid-connect.asd b/openid-connect.asd index 35dff1e..ac20e92 100644 --- a/openid-connect.asd +++ b/openid-connect.asd @@ -17,7 +17,13 @@ ;; for DB "datafly" - "sxql") + "sxql" + + ;; for OAuth + "dexador" + "cl-base64" + "secure-random" + "jsown") :components ((:module "src" :components ((:file "main" :depends-on ("config" "view" "db")) diff --git a/src/web.lisp b/src/web.lisp index 45fe73d..a725828 100644 --- a/src/web.lisp +++ b/src/web.lisp @@ -6,7 +6,8 @@ :openid-connect.view :openid-connect.db :datafly - :sxql) + :sxql + :quri) (:export :*web*)) (in-package :openid-connect.web) @@ -33,3 +34,135 @@ (declare (ignore app)) (merge-pathnames #P"_errors/404.html" *template-directory*)) + +;; auth parameter +(defparameter +keycloak-client-id+ + "openid-connect") +(defparameter +keycloak-client-secret+ + "9bc0468f-ce9b-4c90-ac93-4837b57494ab") +(defparameter +keycloak-auth-url+ + "http://localhost:18080/auth/realms/master/protocol/openid-connect/auth") +(defparameter +keycloak-token-url+ + "http://localhost:18080/auth/realms/master/protocol/openid-connect/token") +(defparameter +keycloak-token-info-url+ + "http://localhost:18080/auth/realms/master/protocol/openid-connect/userinfo") +(defparameter +keycloak-logout-url+ + "http://localhost:18080/auth/realms/master/protocol/openid-connect/logout") +(defparameter +keycloak-redirect-uri+ + "http://localhost:5000/oauth2callback") + +;; +;; Utility functions + +(defun get-keycloak-auth-url (state-token) + "keycloakアカウントでの認証URLを生成" + (render-uri + (make-uri :defaults +keycloak-auth-url+ + :query `(("client_id" . ,+keycloak-client-id+) + ("redirect_uri" . ,+keycloak-redirect-uri+) + ("scope" . "openid profile email") + ("response_type" . "code") + ("approval_prompt" . "force") + ("access_type" . "offline") + ("state" . ,state-token))))) + +(defun request-keycloak-token (code) + "トークンを要請" + (format t "[DEBUG] call request-token ~A~%" code) + (dex:post +keycloak-token-url+ + :content `(("code" . ,code) + ("client_id" . ,+keycloak-client-id+) + ("client_secret" . ,+keycloak-client-secret+) + ("redirect_uri" . ,+keycloak-redirect-uri+) + ("grant_type" . "authorization_code")))) + +(defun request-keycloak-token-info (access_token) + "トークン情報を要請" + (format t "[DEBUG]===========================================~%") + (format t "[DEBUG][request-keycloak-token-info] '~A'~%" access_token) + (dex:post +keycloak-token-info-url+ + :headers `(("content-type" . "application/json") + ("Accept" . "application/json") + ("Authorization" . ,(concatenate `string "Bearer " access_token))) + :content `())) + +(defun loginp () + "ログインしているかどうかを確認" + (format t "[DEBUG] session in accsess_token '~A'~%" (gethash :access_token *session* nil)) + (not (null (gethash :access_token *session* nil)))) + +(defun logout (refresh_token) + "ログアウト処理" + (format t "[DEBUG]logout api call...") + (dex:post +keycloak-logout-url+ + :headers `(("Content-Type" . "application/x-www-form-urlencoded")) + :content `(("client_id" . ,+keycloak-client-id+) + ("client_secret" . ,+keycloak-client-secret+) + ("refresh_token" . ,refresh_token ) + ))) + +(defroute "/" () + (if (loginp) + (redirect "/home") + (render #P"index.html"))) + +(defroute "/home" () + (if (loginp) + (render #P"home.html") + (redirect "/"))) + +(defroute ("/auth-keycloak" :method :POST) () + (let ((state-token + (cl-base64:usb8-array-to-base64-string + (secure-random:bytes 32 secure-random:*generator*)))) + (setf (gethash :oauth-keycloak *session*) (acons :state state-token (list))) + (redirect (get-keycloak-auth-url state-token)))) + +(defroute ("/oauth2callback" :method :GET) (&key |error| |state| |code|) + ;; エラーが発生した場合はエラーを表示してそのままルートにリダイレクト + (unless (null |error|) + (format t "Error: ~A~%" |error|) + (redirect "/")) + (let ((session-oauth-keycloak (gethash :oauth-keycloak *session* nil))) + ;; セッションにステートトークンが存在するか確認 + (if (not (null (assoc :state session-oauth-keycloak))) + ;; セッションのステートトークンがレスポンスのステートトークンと一致するか確認 + (if (string= (cdr (assoc :state session-oauth-keycloak)) |state|) + ;; レスポンスに認証コードが存在するか確認 + (if (not (null |code|)) + ;; keycloakの認証サーバーにトークンを要請 + (let ((response (jsown:parse (request-keycloak-token |code|)))) + ;; ログイン成功。access_tokenを取り出してセッションの:access_tokenに格納 + (format t "[DEBUG] response '~A'~%" response) + (setf (gethash :access_token *session*) (jsown:val response "access_token")) + (setf (gethash :refresh_token *session*) (jsown:val response "refresh_token")) + (format t "[DEBUG] refresh_token '~A'~%" (gethash :refresh_token *session*)) + + ;; トークンが有効か確認(ライフタイムが残っているか?) + ;; さらにIDトークンの存在を確認 + (if (and (> (jsown:val response "expires_in") 0) + (not (null (jsown:val response "id_token")))) + ;; IDトークンをkeycloakに投げてユーザー情報を取得 + ;; この処理は不要かも + (let ((api-result + (jsown:parse (request-keycloak-token-info (gethash :access_token *session*))))) + + ;; ユーザ名を取り出してセッションの:preferred_usernameに格納 + ;; 本来ならここでJWTをばらして、ロール等の情報をセッションに登録をする + (let ((preferred_username (jsown:val api-result "preferred_username"))) + (format t "Signin: success keycloak OAuth '~A'~%" preferred_username) + (setf (gethash :preferred_username *session*) preferred_username) + (redirect "/home"))))))))) + ;; 認証に失敗した場合はHTTP 401認証エラーコードを投げる + (throw-code 401)) + +(defroute "/logout" () + (if (loginp) + (progn + (format t "[DEBUG] refresh_token '~A'~%" (gethash :refresh_token *session*)) + (logout (gethash :refresh_token *session*)) + (setf (gethash :access_token *session*) nil) + (setf (gethash :refresh_token *session*) nil) + (redirect "/")) + (render #P"index.html"))) + diff --git a/templates/index.html b/templates/index.html index 6a3c687..c45861e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,5 +3,9 @@ {% block content %} <div id="main"> Welcome to <a href="http://8arrow.org/caveman/">Caveman2</a>! + <form action="/auth-keycloak" method="post"> + <button>Login with Keycloak</button> + </form> </div> + {% endblock %}
実行
http://localhost:5000 にアクセス。loginしてみます
admin/admin
login 成功! logout押してみると・・・
ログアウトできました
ここまでの成果物はここにおいてあります。
大変だった・・・