Clojure
AdventCalendar
ClojureDay 16

buddyでユーザ認証

buddyとは

buddyはClojureのユーザ認証ライブラリです。主にAndrey Antukh氏により開発が進められているOSSであり、2条項BSDライセンスで配布されています。ClojureでWebアプリ開発を行うときに利用できます。

同種の認証ライブラリとしてはfriendが有名ですが、buddyのほうがシンプルにまとまっている印象を受けます。

以前、テンクーチームの一員として参加したClojure Cup(Clojure/ClojureScriptの48時間プログラミングコンテスト)で制作したニュースアプリ news.async でも使われています。

インストール

Leiningenを使っているなら、project.cljのdependenciesに以下を加えましょう。

project.clj
[buddy "1.3.0"]

上記は、buddyを構成するbuddy-core, buddy-sign, buddy-hashers, buddy-authを全て依存解決してくれます。

project.clj
[buddy/buddy-core "1.3.0"]
[buddy/buddy-auth "2.0.0"]

のように、コンポーネントごとに解決することも可能です。

なお、buddyはJDK7/8のみの対応なのでご注意ください。

使い方

buddyにはローレベルなハッシュアルゴリズムなども含まれていますが、本稿では使用機会が多いであろうRing/Compojureでbuddyを利用する方法を紹介します。その他詳細な使用方法は公式のドキュメントを参照ください。

アクセスルール

どのリクエストに対して認証を行うか、というルールを記述します。
下記のようなマップを用意すると、/admin/で始まるルートに対して認証をかけます。

{:pattern #"^/admin/.*"
 :handler admin-access}

RingベースのWebアプリの場合、wrap-access-rulesを使ってルールを適用します。

(ns foo.handler
  (:require [buddy.auth :refer [authenticated?]]
            [buddy.auth.accessrules :refer [wrap-access-rules]]))

(def app
  (let [rules [{:pattern #"^/admin/.*" :handler authenticated?}]]
    (-> app-routes
        (wrap-access-rules {:rules rules :policy :allow}))))

認証と承認

buddyではAuthentication(認証)とAuthorization(承認)が明確に分けられています。
認証プロセスのバックエンドとしてはHttp Basic, Session, Token, SignedTokenが用意されています。

認証プロセスを適用するにはwrap-authenticationwrap-authorizationを使用します。

(ns foo.handler
  (:require [buddy.auth.backends.session :refer [session-backend]]
            [buddy.auth.middleware :refer [wrap-authentication wrap-authorization]]))

(def app
  (let [backend (session-backend {:unauthorized-handler unauthorized-handler})]
    (-> app-routes
        (wrap-authentication backend)
        (wrap-authorization backend))))

実例

実際にCompojureを使ってユーザ認証を行うだけのWebアプリを作ってみましょう。

デモ

作ったアプリをHerokuにデプロイしておきました。また、ソースコードはGithubに公開してあります。

buddy-example-s.png

"To Secure Area" ボタンを押すと、ログインしていなければログインページに飛びます。適当なユーザ名・パスワードを入力して "Log in" ボタンを押すと、/adminが見られるようになります。"Logged in as [ユーザ名]" という表示がされているはずです。"Log out" を押すとログアウトします。

レシピ

それでは上記デモのWebアプリを順番に作っていきましょう。Herokuで動かすために必要な部分は省略するので、Github上のソースとは異なる部分があります。

まず、Compojureのテンプレートを使って、Leiningenプロジェクトを作成します。

$ lein new compojure cljac14-buddy

project.cljを開いて、buddyとページ作成に使用するHiccupを依存関係に追加します。

project.clj
:dependencies [[org.clojure/clojure "1.8.0"]
               [compojure "1.5.1"]
               [ring/ring-defaults "0.2.1"]
               [buddy "1.3.0"]   ; <= 追加
               [hiccup "1.0.5"]] ; <= 追加

準備ができたので、handler.cljに処理を書いていきます。まず必要なライブラリを読み込みます。

handler.clj
(ns cljac14_buddy.core.handler
  (:require [compojure.core :refer :all]
            [compojure.route :as route]
            [ring.middleware.defaults :refer [wrap-defaults site-defaults]]
            [ring.middleware.anti-forgery :refer [*anti-forgery-token*]] ; <=
            [ring.util.response :refer [redirect]]                       ; <=
            [buddy.auth :refer [authenticated?]]                         ; <=
            [buddy.auth.backends.session :refer [session-backend]]       ; <= 追加
            [buddy.auth.middleware :refer [wrap-authentication           ; <=
                                           wrap-authorization]]          ; <=
            [buddy.auth.accessrules :refer [wrap-access-rules]]          ; <=
            [hiccup.core :refer [html]]))                                ; <=

次にルーティングを定義します。/adminにアクセスしたとき、認証されていなければログインページにリダイレクトするようにします。その他にログアウトのためのルートも準備しておきましょう。

handler.clj
(defroutes app-routes
  (GET "/" [] "Hello, buddy!")
  (GET "/admin" req (admin req))       ; <=
  (GET "/login" req (login-get req))   ; <= 追加
  (POST "/login" req (login-post req)) ; <=
  (GET "/logout" [] (logout))          ; <=
  (route/not-found "Not Found"))

appにはアクセスルールとして/adminを指定します。また、認証プロセスのバックエンドとしてSessionを利用します。すでに述べたようにwrap-access-rules, wrap-authentication, wrap-authorizationを用いて適用していきます。

handler.clj
(def app
  (let [rules [{:pattern #"^/admin$" :handler authenticated?}]
        backend (session-backend {:unauthorized-handler unauthorized-handler})]
    (-> app-routes
        (wrap-access-rules {:rules rules :policy :allow})
        (wrap-authentication backend)
        (wrap-authorization backend)
        (wrap-defaults site-defaults))))

/adminにアクセスしたとき、承認済みでなければunauthorized-handlerが呼ばれます。unauthorized-handlerでは、認証されていなければログインページにリダイレクトするようにします。認証後に元のページに戻ってこられるよう、URLにnextパラメータを付加しておきます。

handler.clj
(defn unauthorized-handler
  [req meta]
  (if (authenticated? req)
    (redirect "/")
    (redirect (format "/login?next=%s" (:uri req)))))

各リクエストに対するレスポンスを記述していきます。

adminページではログインユーザ名が表示されるようにしておきましょう。

handler.clj
(defn admin
  [req]
  (str "Logged in as " (name (get-in req [:session :identity]))))

ログインページにGETリクエストがきたときはログインフォームを表示します。

handler.clj
(defn login-get
  [req]
  (html
   [:form {:method "post"}
    [:input {:type "text" :name "username"}]
    [:input {:type "password" :name "password"}]
    [:input {:type "hidden" :name "__anti-forgery-token" :value *anti-forgery-token*}]
    [:input {:type "submit"}]]))

ここではHiccupを用いてレンダリングしています。__anti-forgery-tokenはCSRFを防ぐRing-Anti-Forgeryのトークンです。submitボタンが押されると/loginにPOSTリクエストが送信されます。

Login page

バックエンドにセッションを利用する場合、request :session :identityにトークンを保存します。
今回は単純にユーザ名を保存しておきます。

handler.clj
(defn login-post
  [req]
  (let [username (get-in req [:form-params "username"])]
    (-> (redirect (get-in req [:query-params "next"] "/"))
        (assoc-in [:session :identity] (keyword username)))))

これだけで認証は終了です。
元のページ (admin) にリダイレクトされ、自分の名前が表示されていれば成功です。

Logged in as ...

せっかくなので、ログアウトもできるようにします。
ログアウトは簡単で、request :sessionを空にしてしまうだけです。

handler.clj
(defn logout
  []
  (-> (redirect "/")
      (assoc :session {})))

おわりに

本稿では、buddyを用いてRing/CompojureベースのWebアプリにユーザ認証を導入する方法を紹介しました。今回は説明のため非常に簡単なWebアプリを作りましたが、実際のWebアプリで利用するにはパスワードをハッシュ化してデータベースに保存したり、セッションの永続化を行ったりといったことが必要になります。

実を言うと直前まで別ネタで書こうとしていたため、少し説明が不十分かもしれませんが、お役に立てば幸いです。