Caveman2 + keycloak で 認証サンプルを作ってみた

以下の記事を参考にしながらCaveman2 + Keycloak でOpenID connectをするサンプルを作ってみたいと思います

qiita.com

注意! 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 設定を追加します f:id:moremagic:20180917173204p:plain

Instralletion からクライアントシークレット等を取得します f:id:moremagic:20180917173300p:plain

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してみます f:id:moremagic:20180917174021p:plain

admin/admin f:id:moremagic:20180917174056p:plain

login 成功! logout押してみると・・・ f:id:moremagic:20180917174145p:plain

ログアウトできました f:id:moremagic:20180917174313p:plain

ここまでの成果物はここにおいてあります。

github.com

大変だった・・・