イントロダクション
Microservices with Clojureより。前回は次世代DB Datomicを利用したマイクロサービスの為のデータベース構築について述べる。今回はClojureでのAuthとPedestalを使ったAPIを使ったマイクロサービスでのセキュリティについて述べる。
マイクロサービスは、分離してデプロイし、使用状況を監視する必要がある。現在の作業負荷と処理時間を監視することで、それらをスケールアップするタイミングやスケールダウンするタイミングを判断することもできる。マイクロサービスベースのアーキテクチャのもう1つの重要な側面は、セキュリティとなる。マイクロサービスのセキュリティを確保する方法の1つは、各サービスが独自の認証・認可モジュールを持つようにすることとなる。だがこの方法はすぐに問題になる。各マイクロサービスが孤立してデプロイされるため、ユーザーを認証するための共通の基準に合意することが信じられないほど難しくなるためである。また、この場合、ユーザーとそのロールの所有権は、サービス間で分散してしまう。この章では、このような問題を取り上げ、マイクロサービスベースのアプリケーションを安全に、監視し、拡張するためのソリューションについて述べる。
認証と認可の有効化
認証は、ユーザーが誰であるかを特定するプロセスであり、一方、認可は、認証されたユーザーが何にアクセスできるかを確認するプロセスとなる。認証を行う最も一般的な方法は、ユーザーにユーザー名とパスワードの入力を求め、それをバックエンドのデータベースで検証する方法となる。
パスワードは決してプレーンテキストでバックエンドデータベースに保存してはいけない。パスワードの一方向ハッシュを計算し、代わりにそれを保存することが推奨されている。パスワードをリセットするために、システムは単にランダムなパスワードを生成し、そのハッシュを保存し、ランダムなパスワードをプレーンテキストでユーザーと共有することができる。あるいは、パスワードをリセットするための一意のURLをユーザーに送信し、プリセットの質問と回答やワンタイムパスワード(OTP)などの方法によってユーザーの身元を確認することもできる。
アプリケーションに複数のセキュリティ境界がある場合、ユーザーを認証するだけでは十分ではない。例えば、あるアプリケーションでは、特定のユーザーのみがシステムを通じて通知を送信することを要求し、それ以外のユーザーは送信できないようにすることができる。このような場合、アプリケーションはそのリソースのセキュリティ境界を作成する必要がある。この境界は、多くの場合、リソースや通知などの機能へのアクセスを許可する前にアプリケーションによって検証される、1つ以上のアクセス許可を持つロールを使用して定義される。ロールとパーミッションは、アプリケーションがそのリソースに対して複数のセキュリティ境界を作成することを可能にする、認可の重要な要素となる。
トークンと JWT の導入
モノリシックな環境では、認証と認可は、次の図に示すように、必要な認証と認可情報の受信要求を検証するモジュールを使用して、同じアプリケーション内で処理される。このモジュールでは、認可されたユーザーがロールとパーミッションを定義して、システム内の他のユーザーに割り当て、保護されたリソースへのアクセスを許可することもできる。
モノリシックアプリケーションは、モノリシックアプリケーションの各インスタンスが受信したリクエストを検証し、ユーザーのための有効なセッションを決定できる、セッションストアを維持することもできる。多くの場合、そのようなセッション情報は、ユーザーが正常に認証されると、トークンとしてクライアントに送信されるクッキーに格納される。このクッキーは、クライアントによる各リクエストに添付され、サーバーによって有効なセッションと関連するロールが検証され、前述の図に示すように、要求されたリソースへのアクセスを許可するか拒否するかを決定することができる。
マイクロサービスベースのアプリケーションでは、各マイクロサービスは分離してデプロイされるため、個別のユーザーデータベースやセッションデータベースを維持する責任を持っていない。さらに、マイクロサービス間でユーザーを認証・認可するための標準的な方法がなければならない。認証と承認の責任は、ユーザーデータベースを所有できる別のAuthサービスとして分離し、ユーザーを認証および承認することが一般的となる。これは、Authサービスを通じて一度ユーザーを認証し、その後、他のマイクロサービスを通じてリソースや関連サービスにアクセスするための認可を行う際にも役立つ。
各マイクロサービスは独自の技術スタックを使用し、Authサービスに関する事前知識を持っていない可能性があるため、マイクロサービス間で認証されたユーザを検証するための共通の標準が必要となる。JSON Web Tokens (JWT) はそのような標準の一つで、ヘッダー、ペイロード、署名で構成され、認証に成功するとユーザーにトークンとして発行することができる。ユーザーはこのトークンをリクエストごとにマイクロサービスに送信し、マイクロサービスはそのトークンを検証してリクエストされたリソースへのアクセスを許可することができる。
JWTは、コンテンツを暗号化するか、デジタル署名やメッセージ認証コードを用いて保護するか、どちらかを選択することができる。JSON Web Signature (JWS) は、デジタル署名またはメッセージ認証コード (MACs) で保護されたコンテンツを表し、JSON Web Encryption (JWE) は、JSONベースのデータ構造を使って暗号化されたコンテンツを表す。トークンが暗号化されている場合、トークンを暗号化するために使用された鍵でのみ読み取ることができる。JWEトークンを読み取るには、サービスがトークンの暗号化に使用されたキーを所有している必要がある。マイクロサービス間でキーを共有する代わりに、トークンを直接 Auth サービスに送信してトークンを復号化し、サービスの代わりにリクエストを承認することが一般的となる。この場合、各サービスが認証のために最初にAuthサービスを取得しようとするため、パフォーマンスのボトルネックやシングルポイントオブフェイルが発生する可能性がある。これは、各マイクロサービスレベルで、トークンの有効期限に基づいて設定可能な時間、事前検証済みのトークンをキャッシュすることで防ぐことができる。
有効期限は、JWTを扱う際の重要な基準となる。有効期限が非常に長いJWTは、アプリケーションがユーザーをログアウトしたり、トークンを無効にしたりする方法がないため、避ける必要がある。発行されたトークンは、有効期限が切れない限り、有効なままとなる。ユーザは有効なトークンを所有している限り、発行されたトークンを使ってサービスにアクセスすることができる。ログアウトの問題を防ぐために、マイクロサービスでは、ユーザーの役割や権限と同期したユーザー認証の詳細のキャッシュを保持するAuthサービスで、常にトークンを検証するようにするのも1つの方法となる。Auth サービスはトークンを受け取るたびに、このキャッシュに対して検証を行い、ユーザーの役割やその他のプロパティに変更があった場合は、トークンを無効にして、ユーザーに新しいトークンを要求するように強制することができる。
Helping Hands 用の Auth サービスを作成する
Helping Hands の Auth サービスは、Helping Hands の他のマイクロサービスと同じ pedestal プロジェクトテンプレートを使って構築することができる。この例では、JWEを使用してユーザー用のJWTトークンを作成する。まず始めに、次の例に示すようなディレクトリ構造で新しいプロジェクトを作成します。このプロジェクトには、JWT に関連する実装を含む新しい名前空間 helping-hands.auth.jwt が含まれており、残りの名前空間は前の章で説明したように使用される。
├── Capstanfile
├── config
│ ├── conf.edn
│ └── logback.xml ├── Dockerfile
├── project.clj ├── README.md
├── resources
├── src
│ ├── clj
│ │ └── helping_hands
│ │ └── auth
│ │ ├── config.clj
│ │ ├── core.clj
│ │ ├── jwt.clj
│ │ ├── persistence.clj
│ │ ├── server.clj
│ │ ├── service.clj
│ │ └── state.clj
│ └── jvm
└── test
├── clj
│ └── helping_hands
│ └── auth
│ ├── core_test.clj
│ └── service_test.clj
└── jvm
12 directories, 14 files
トークンのための Nimbus JOSE JWT ライブラリの使用
Auth サービスプロジェクトでは、さらに Nimbus-JOSE-JWT ライブラリ を使用して JSON Web Token を作成・検証し、 permissions を使用してロールや権限のセットに対してユーザーを認可する。以下の project.clj ファイルに示すように、Nimbus-JOSE-JWT と permissions ライブラリの依存関係を追加している。
(defproject helping-hands-auth "0.0.1-SNAPSHOT"
:description "Helping Hands Auth Service"
:url
"https://www.packtpub.com/application-development/microservices-clojure"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.8.0"]
[io.pedestal/pedestal.service "0.5.3"]
[io.pedestal/pedestal.jetty "0.5.3"]
[com.datomic/datomic-free "0.9.5561.62"] ;; Omniconf
[com.grammarly/omniconf "0.2.7"]
;; Mount
[mount "0.1.11"]
;; nimbus-jose for JWT [com.nimbusds/nimbus-jose-jwt "5.4"]
;; used for roles and permissions [agynamix/permissions "0.2.2-SNAPSHOT"] ;; logger
[org.clojure/tools.logging "0.4.0"] [ch.qos.logback/logback-classic "1.1.8"
:exclusions [org.slf4j/slf4j-api]]
[org.slf4j/jul-to-slf4j "1.7.22"]
[org.slf4j/jcl-over-slf4j "1.7.22"]
[org.slf4j/log4j-over-slf4j "1.7.22"]]
:min-lein-version "2.0.0"
:source-paths ["src/clj"]
:java-source-paths ["src/jvm"]
:test-paths ["test/clj" "test/jvm"]
:resource-paths ["config", "resources"]
:plugins [[:lein-codox "0.10.3"]
;; Code Coverage
[:lein-cloverage "1.0.9"]
;; Unit test docs
[test2junit "1.2.2"]]
:codox {:namespaces :all}
:test2junit-output-dir "target/test-reports"
:profiles {:provided {:dependencies [[org.clojure/tools.reader "0.10.0"]
[org.clojure/tools.nrepl "0.2.12"]]}
:dev {:aliases
{"run-dev" ["trampoline" "run" "-m"
"helping-hands.auth.server/run-dev"]}
:dependencies
[[io.pedestal/pedestal.service-tools "0.5.3"]]
:resource-paths ["config", "resources"]
:jvm-opts ["-Dconf=config/conf.edn"]}
:uberjar {:aot [helping-hands.auth.server]}
:doc {:dependencies [[codox-theme-rdash "0.1.1"]]
:codox {:metadata {:doc/format :markdown}
:themes [:rdash]}}
:debug {:jvm-opts
["-server" (str "-agentlib:jdwp=transport=dt_socket,"
"server=y,address=8000,suspend=n")]}}
:main ^{:skip-aot true} helping-hands.auth.server)
JSON Web暗号化のための秘密鍵の作成
まず、クレームを暗号化したJWTの実装を行うために、暗号化用の秘密鍵を生成するためのget-secret関数を作成する。また、以下のコードのように、get-secret関数で生成した秘密鍵を使ってJSON Web Keyを作成するためのget-secret-jwk関数を追加する。
(ns helping-hands.auth.jwt
"JWT Implementation for Auth Service"
(:require [cheshire.core :as jp])
(:import [com.nimbusds.jose EncryptionMethod
JWEAlgorithm JWSAlgorithm
JWEDecrypter JWEEncrypter
JWEHeader$Builder JWEObject Payload]
[com.nimbusds.jose.crypto
AESDecrypter AESEncrypter]
[com.nimbusds.jose.jwk KeyOperation KeyUse
OctetSequenceKey OctetSequenceKey$Builder]
[com.nimbusds.jwt JWTClaimsSet JWTClaimsSet$Builder]
[com.nimbusds.jwt.proc DefaultJWTClaimsVerifier]
[com.nimbusds.jose.util Base64URL]
[java.util Date]
[javax.crypto KeyGenerator]
[javax.crypto.spec SecretKeySpec]))
(def ^:cons khash-256 "SHA-256")
(defonce ^:private kgen-aes-128
(let [keygen (KeyGenerator/getInstance "AES")
_ (.init keygen 128)]
keygen))
(defonce ^:private alg-a128kw
(JWEAlgorithm/A128KW))
(defonce ^:private enc-a128cbc_hs256
(EncryptionMethod/A128CBC_HS256))
(defn get-secret
"Gets the secret key"
([] (get-secret kgen-aes-128))
([kgen]
;; must be created iff the key hasn't
;; been creaed earlier. Create once and
;; persist in an external database
(.generateKey kgen)))
(defn get-secret-jwk
"Generates a new JSON Web Key (JWK)"
[{:keys [khash kgen alg] :as enc-impl} secret]
;; must be created iff the key hasn't
;; been creaed earlier. Create once and
;; persist in an external database
(.. (OctetSequenceKey$Builder. secret)
(keyIDFromThumbprint (or khash khash-256))
(algorithm (or alg alg-a128kw))
(keyUse (KeyUse/ENCRYPTION))
(build)))
先に示した実装では、AES 128ビットアルゴリズムを使用して鍵を生成している。get-secret 関数で生成される秘密鍵は、アプリケーションの存続期間中、一度だけ生成する必要がある。したがって、Auth サービスが複数のインスタンスに拡張されたときに、インスタンス間で共有できる外部データベースに保存することとする。
Nimbus-JOSE-JWTは256ビットアルゴリズムもサポートしている。256ビットアルゴリズムを動作させるためには、JREは明示的にJava Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Filesを必要とする。
get-secret-jwk関数は、以下のREPLセッションに示すように、入力パラメータの1つとして秘密鍵を受け取り、JWKを生成する。JWKは、JWK RFC-7517 で定義されている鍵種別 (kty), 公開鍵用途 (use), 鍵ID (kid), 鍵価値 (k), アルゴリズム (alg) パラメータから構成されている。
JWKは秘密鍵をJSON形式で表現しているだけなので、以下の実装のように、ユーティリティ関数enckey->secretを使ってJWKから秘密鍵を取り出すことができる。
(defn enckey->secret
"Converts JSON Web Key (JWK) to the secret key"
[{:keys [k kid alg] :as enc-key}]
(.. (OctetSequenceKey$Builder. k)
(keyID kid)
(algorithm (or alg alg-a128kw))
(keyUse (KeyUse/ENCRYPTION))
(build)
(toSecretKey "AES")))
enckey->secret関数は、キーID(kid)とキーバリュー(k)を入力として、ソースJSON Web Keyの作成に使用したものと同じ秘密鍵を作成する。algパラメータはオプションで、指定しない場合はデフォルトのAES-128アルゴリズムにフォールバックする。次のREPLセッションでは、先に生成されたJWKから秘密鍵を作成し、常に同じJWKを生成することを検証している。
;; JSON Web Key (JWK) generated earlier
helping-hands.auth.server> (.toJSONObject jwk)
{"kty" "oct", "use" "enc", "kid" "F5UNJYT4A-
GpngZwRMYfs8ZuCKsmRGt08Xo_dMQrY5w", "k" "CvTaCBfdEkAlXfuOnW7pnw", "alg"
"A128KW"}
;; extract the secret key
helping-hands.auth.server> (def secret-extracted (jwt/enckey->secret {:k
(.getKeyValue jwk) :kid (.getKeyID jwk)}))
#'helping-hands.auth.server/secret-extracted
;; generate JSON Web Key that is exactly same as source
helping-hands.auth.server> (.toJSONObject (jwt/get-secret-jwk {} secret-
extracted))
{"kty" "oct", "use" "enc", "kid" "F5UNJYT4A-
GpngZwRMYfs8ZuCKsmRGt08Xo_dMQrY5w", "k" "CvTaCBfdEkAlXfuOnW7pnw", "alg"
"A128KW"}
helping-hands.auth.server> (.toJSONObject jwk)
{"kty" "oct", "use" "enc", "kid" "F5UNJYT4A-
GpngZwRMYfs8ZuCKsmRGt08Xo_dMQrY5w", "k" "CvTaCBfdEkAlXfuOnW7pnw", "alg"
"A128KW"}
トークンの作成
次のステップは、JWTを作成し、読み取るための関数を定義することとなる。Helping Hands アプリケーションで使用する JWT は JWE を使用してクレームを暗号化するため、ペイロード内にユーザー ID とロール情報の両方を追加して、後で有効なトークンから取得してユーザーを認証できるようにしてもかまわない。
次の例に示す create-token 関数と read-token 関数は、それぞれ JSON Web Token を作成する方法と既存のものを読み込む方法を提供する。create-token関数は、ユーティリティ関数であるcreate-payloadを使用して、JWTのクレームセットとペイロードを作成する。今回の例で使用するクレームセットは、トークンが作成されたエポックタイムを定義する issueTime、トークンが期限切れとみなされる時間を設定する expirationTime、認証されたユーザー名とトークン発行時にユーザーに割り当てられたロールを格納する user and roles custom claims となる。利用可能なクレームセットのオプションの詳細については、JWT RFC-7519 を参照のこと。
(defn- create-payload
"Creates a payload as JWT Claims"
[{:keys [user roles] :as params}]
(let [ts (System/currentTimeMillis)
claims (.. (JWTClaimsSet$Builder.)
(issuer "Packt")
(subject "HelpingHands")
(audience "https://www.packtpub.com")
(issueTime (Date. ts))
(expirationTime (Date. (+ ts 120000)))
(claim "user" user)
(claim "roles" roles)
(build))]
(.toJSONObject claims)))
(defn create-token
"Creates a new token with the given payload"
[{:keys [user roles alg enc] :as params} secret]
(let [enckey (get-secret-jwk params secret)
payload (create-payload {:user user :roles roles})
passphrase (JWEObject.
(.. (JWEHeader$Builder.
(or alg alg-a128kw)
(or enc enc-a128cbc_hs256))
(build))
(Payload. payload))
encrypter (AESEncrypter. enckey)
_ (.encrypt passphrase encrypter)]
(.serialize passphrase)))
(defn read-token
"Decrypts the given token with the said algorithm
Throws BadJWTException is token is invalid or expired"
[token secret]
(let [passphrase (JWEObject/parse token)
decrypter (AESDecrypter. secret)
_ (.decrypt passphrase decrypter)
payload (.. passphrase getPayload toString)
claims (JWTClaimsSet/parse payload)
;; throws exception if the token is invalid
_ (.verify (DefaultJWTClaimsVerifier.) claims)]
(jp/parse-string payload)))
次の REPL セッションは、トークンの作成と読み取りを行い、その後、トークンが期限切れになるのを待つ手順を示している。ライブラリが投げる例外をキャプチャして、トークンの有効期限が切れたことを示すことができることに注意が必要となる。
;; generate a new token with the user and roles
helping-hands.auth.server> (def token (jwt/create-token {:user "hhuser"
:roles #{"hh/notify"}} secret))
#'helping-hands.auth.server/token
;; dump the compact serialization string
helping-hands.auth.server> token
"eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.FiAelEg_R8We8xEF2xRxcC
908BCoH1nRYvY3nV_jkqYO8JPp-QukBw.86-
JKq6cYFH2rtFBOXiA6A.Pxz3ZzBGKX2Cd_sjtYdEwKDltzKQiolWSvrjPbLLGL8NlShcWWEIqkd
7NL2WcXHukDa6zS4ANIWnee2hNWUraItqZFEY6N_RhXZVVXQvZJsqzeiueBxvxc1fj1LFUKsyR6
3oOwLd5ZIIT99ItrqaYPM88enMsjchsXYBJ_Tcb-
WR6R_KirmDBxCVjqFcg7OdWjjcKTP4FcUNIQU9G8fSnQ.pfLyW8ggXV8vQnidytJmMw"
;; read the token back
helping-hands.auth.server> (pprint (jwt/read-token token secret))
{"sub" "HelpingHands",
"aud" "https://www.packtpub.com",
"roles" ["hh/notify"],
"iss" "Packt",
"exp" 1515959756,
"iat" 1515959636,
"user" "hhuser"}
nil
;; wait for 2 mins (expiry time as per implementation)
;; token is now expired
helping-hands.auth.server> (pprint (jwt/read-token token secret))
BadJWTException Expired JWT
com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier.
(DefaultJWTClaimsVerifier.java:62)
ユーザーとロールを認可できるようにする
理想的には、Auth サービスは永続的なストアによってバックアップされ、 ユーザ、ロール、アプリケーションの秘密鍵を保持する必要がある。この例を簡単にするために、以下のように help-hands.auth.persistence ネームスペースにサンプルのインメモリデータベースを作成する。
(ns helping-hands.auth.persistence
"Persistence Implementation for Auth Service"
(:require [agynamix.roles :as r]
[cheshire.core :as jp])
(:import [java.security MessageDigest]))
(defn get-hash
"Creates a MD5 hash of the password"
[creds]
(.. (MessageDigest/getInstance "MD5")
(digest (.getBytes creds "UTF-8"))))
(def userdb
;; Used ony for demonstration
;; TODO Persist in an external database
(atom
{:secret nil
:roles {"hh/superadmin" "*"
"hh/admin" "hh:*"
"hh/notify" #{"hh:notify" "notify/alert"}
"notify/alert" #{"notify:email" "notify:sms"}}
:users {"hhuser" {:pwd (get-hash "hhuser")
:roles #{"hh/notify"}}
"hhadmin" {:pwd (get-hash "hhadmin")
:roles #{"hh/admin"}}
"superadmin" {:pwd (get-hash "superadmin")
:roles #{"hh/superadmin"}}}}))
(defn has-access?
"Checks for relevant permission"
[uid perms]
(r/has-permission?
(-> @userdb :users (get uid))
:roles :permissions perms))
(defn init-db
"Initializes the roles for permission framework"
[]
(r/init-roles (:roles @userdb))
userdb)
userdbには、nilに初期化された:secret keyと、ユーザとロールの情報をそれぞれ格納した:usersと:rolesからなるサンプルのインメモリデータベースが格納されている。ロールの定義はpermissionライブラリのガイドラインに従い、ライブラリの使用方法に従ってロールとパーミッションを定義する。ロールの名前にはスラッシュ「/」が、パーミッションの名前にはコロン「:」が、直前のロール定義で定義されたとおりに付く。ロール定義は再帰的であり、1つのロールがロールとパーミッションの両方をカプセル化することができる。
init-db関数は、データベースとロール定義を初期化するために使用される。has-access?はユーティリティ関数で、ユーザが与えられた権限のセットを含んでいるかどうかを検証するために使用することができる。次のREPLセッションでは、has-access?関数の使用方法を例示して説明している。
;; require the persistence namespace
helping-hands.auth.server> (require '[helping-hands.auth.persistence :as
p])
nil
;; since there is no secret key define,
;; initialize the database with a secret-key
;; if it does not exist
helping-hands.auth.server> (let [db (p/init-db)]
;; if key does not exist, initialize one
;; and update the database with :secret key
(if-not (:secret @db)
(swap! db #(assoc % :secret (jwt/get-
secret))) @db))
{:secret #object[javax.crypto.spec.SecretKeySpec 0xebc150b "javax.crypto.spec.SecretKeySpec@17ce8"], :roles {"hh/superadmin" "*", "hh/admin" "hh:*", "hh/notify" #{"notify/alert" "hh:notify"}, "notify/alert" #{"notify:email" "notify:sms"}}, :users {"hhuser" {:pwd #object["[B" 0x1b46ced7 "[B@1b46ced7"], :roles #{"hh/notify"}}, "hhadmin" {:pwd #object["[B" 0x7b9083e6 "[B@7b9083e6"], :roles #{"hh/admin"}}, "superadmin" {:pwd #object["[B" 0x64083ac1 "[B@64083ac1"], :roles #{"hh/superadmin"}}}}
;; validate that `hhuser` has the ``hh:notify`` permission helping-hands.auth.server> (p/has-access? "hhuser" #{"hh:notify"}) true
;; validate permissions that are not defined
helping-hands.auth.server> (p/has-access? "hhuser" #{"hh:admin"}) false
;; validate permissions that are obtained by other role references helping-hands.auth.server> (p/has-access? "hhuser" #{"hh:notify" "notify:email"})
true
前述の例では、REPLで明示的にデータベースを初期化し、秘密鍵を設定している。明示的にデータベースを初期化する代わりに、スタートアップ自体で行うことができる。
を使用して、起動時に行うことができる。mountでデータベースの状態を秘密鍵で初期化し、他の名前空間でも利用できるようにするには、以下のようにhelping-hands.auth.state名前空間でデータベースの状態を定義する。
(ns helping-hands.auth.state
"Initializes State for Auth Service"
(:require [mount.core :refer [defstate] :as mount]
[helping-hands.auth.jwt :as jwt]
[helping-hands.auth.persistence :as p]))
(defstate auth-db
:start (let [db (p/init-db)]
;; if key does not exist, initialize one
;; and update the database with :secret key
(if-not (:secret @db)
(swap! db #(assoc % :secret (jwt/get-secret))) @db))
:stop nil)
次に、以下の例に示すように、helping-hands.auth.server 名前空間のサーバー起動関数に mount/start および mount/stop 関数を追加して、起動および停止イベントを有効にする。
(ns helping-hands.auth.server
(:gen-class) ; for -main method in uberjar
(:require [io.pedestal.http :as server]
[io.pedestal.http.route :as route]
[mount.core :as mount]
[helping-hands.auth.config :as cfg]
[helping-hands.auth.service :as service]))
;; This is an adapted service map, that can be started and stopped
;; From the REPL you can call server/start and server/stop on this service
(defonce runnable-service (server/create-server service/service))
(defn run-dev
"The entry-point for 'lein run-dev'"
[& args]
(println "\nCreating your [DEV] server...")
;; initialize configuration
(cfg/init-config {:cli-args args :quit-on-error true})
;; initialize state
(mount/start)
;; Add shutdown-hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. mount/stop))
(-> service/service ;; start with production configuration
...
;; Wire up interceptor chains
server/default-interceptors
server/dev-interceptors
server/create-server
server/start))
(defn -main
"The entry-point for 'lein run'"
[& args]
(println "\nCreating your server...")
;; initialize configuration
(cfg/init-config {:cli-args args :quit-on-error true}) ;; initialize state
(mount/start)
;; Add shutdown-hook
(.addShutdownHook
(Runtime/getRuntime)
(Thread. mount/stop))
(server/start runnable-service))
Pedestalを使ったAuth APIの作成
次に、Authサービスがユーザーを認証・認可するためのAPIを定義する。以下のように、/tokensと/tokens/validateのルートをhelping-hands.auth.service名前空間に追加する。
(ns helping-hands.auth.service
(:require [helping-hands.auth.core :as core]
[cheshire.core :as jp]
[io.pedestal.http :as http]
[io.pedestal.http.route :as route]
[io.pedestal.http.body-params :as body-params]
[io.pedestal.interceptor.chain :as chain]
[ring.util.response :as ring-resp]))
;; Defines "/" and "/about" routes with their associated :get handlers.
;; The interceptors defined after the verb map (e.g., {:get home-page}
;; apply to / and its children (/about).
(def common-interceptors [(body-params/body-params) http/html-body])
;; Tabular routes
(def routes #{["/tokens"
:get (conj common-interceptors
`core/validate `core/get-token)
:route-name :token-get]
["/tokens/validate"
:post (conj common-interceptors
`core/validate `core/validate-token)
:route-name :token-validate]})
;; See http/default-interceptors for additional options you can configure
(def service {:env :prod
::http/routes routes
::http/resource-path "/public"
::http/type :jetty
::http/port 8080
;; Options to pass to the container (Jetty)
::http/container-options {:h2c? true
:h2? false
:ssl? false}})
GET /tokens ルートは、リクエストを処理するために uid と pwd パラメータ、または有効な認可ヘッダを探す。uidとpwdパラメータが指定され、それらが有効である場合、JWTトークンが認可ヘッダの一部として発行される。リクエストの認可ヘッダーの一部として既存のJWTが指定された場合、Authサービスはユーザー名とそれに関連するロールを返す。
POST /tokens/validate ルートは、フォーム・パラメータである perms と、与えられた権限に対してユーザーを認証するためのJWT を含む有効な認証ヘッダを期待する。このエンドポイントは、Helping Hands アプリケーションの他のマイクロサービスによって使用され、マイクロサービスが管理するリソースへのアクセスを提供するために必要なパーミッションに対して、ユーザーを認証する。パーミッションとロールは文字列として定義されるため、管理者は期待されるすべてのロールとパーミッションで Auth データベースを初期化し、それらをユーザーに割り当てて、アプリケーションのサービスへのアクセスを許可または拒否することができる。
先ほどのコードで定義したルートに使用するインターセプターは、次の例のように help-hands.auth.core 名前空間で実装されている。validate インターセプターは :tx-data パラメーターに利用可能なすべてのリクエストパラメーターを準備し、 uid と pwd、あるいは authorization ヘッダーが存在するかどうかを検証し、存在しない場合は HTTP 400 Bad Request レスポンスを返す。
(ns helping-hands.auth.core
"Initializes Helping Hands Auth Service"
(:require [cheshire.core :as jp]
[clojure.string :as s]
[helping-hands.auth.jwt :as jwt]
[helping-hands.auth.persistence :as p]
[helping-hands.auth.state :refer [auth-db]]
[io.pedestal.interceptor.chain :as chain])
(:import [com.nimbusds.jwt.proc BadJWTException]
[java.io IOException]
[java.text ParseException]
[java.util Arrays UUID]))
;; --------------------------------
;; Validation Interceptors
;; --------------------------------
(defn- prepare-valid-context
"Applies validation logic and returns the resulting context"
[context]
(let [params (merge (-> context :request :form-params)
(-> context :request :query-params)
(-> context :request :headers)
(if-let [pparams (-> context :request :path-params)]
(if (empty? pparams) {} pparams)))]
(if (or (and (params :uid) (params :pwd))
(params "authorization"))
(assoc context :tx-data params)
(chain/terminate
(assoc context
:response {:status 400
:body "Invalid Creds/Token"})))))
(def validate
{:name ::validate
:enter
(fn [context]
(prepare-valid-context context))
:error
(fn [context ex-info]
(assoc context
:response {:status 500
:body (.getMessage ex-info)}))})
get-tokenインターセプターは、有効なuidとpwdを探し、認証に成功した場合はJWTを発行する。uidとpwdが存在しない場合は、Bearerタイプの有効な認可ヘッダを探し、トークンが有効であれば、認証されたユーザーIDと、そのユーザーに関連する割り当てられたロールを返す。
(defn- extract-token
"Extracts user and roles map from the auth header"
[auth]
(select-keys
(jwt/read-token
(second (s/split auth #"\s+")) (auth-db :secret))
["user" "roles"]))
(def get-token
{:name ::token-get
:enter
(fn [context]
(let [tx-data (:tx-data context)
uid (:uid tx-data)
pwd (:pwd tx-data)
auth (tx-data "authorization")]
(cond
(and uid pwd (Arrays/equals
(-> auth-db :users (get uid) :pwd)
(p/get-hash pwd)))
(let [token (jwt/create-token
{:roles (-> auth-db :users (get uid) :roles)
:user uid} (auth-db :secret))]
(assoc context :response
{:status 200
:headers {"authorization" (str "Bearer " token)}}))
(and auth (= "Bearer" (-> (s/split auth #"\s+") first)))
(try
(assoc context :response
{:status 200
:body (jp/generate-string (extract-token auth))})
(catch BadJWTException e
(assoc context :response
{:status 401 :body "Token expired"})))
:else (assoc context :response {:status 401}))))
:error
(fn [context ex-info]
(assoc context
:response {:status 500
:body (.getMessage ex-info)}))})
以下の例に示すvalidate-tokenインターセプターの実装は、authorizationヘッダーとして送られたJWTとpermsフォームパラメーターとして指定されたパーミッションのCSVに関連付けられたユーザーを認可する。
(def validate-token
{:name ::token-validate
:enter
(fn [context]
(let [tx-data (:tx-data context)
auth (tx-data "authorization")
perms (if-let [p (tx-data :perms)]
(into #{} (map s/trim (s/split p #","))))]
(if (and auth (= "Bearer" (-> (s/split auth #"\s+") first)))
(try
(if (p/has-access? ((extract-token auth) "user") perms)
(assoc context :response {:status 200 :body "true"})
(assoc context :response {:status 200 :body "false"}))
(catch BadJWTException e
(assoc context :response
{:status 401 :body "Token expired"}))
(catch ParseException e
(assoc context :response
{:status 401 :body "Invalid JWT"})))
(assoc context :response {:status 401}))))
:error
(fn [context ex-info]
(assoc context
:response {:status 500
:body (.getMessage ex-info)}))})
ルートをテストするには、lein run コマンドを使用して Auth サービスを起動するか、次の例に示すように REPL 内で起動する。アプリケーションが起動するとすぐに mount が起動し、トークンを発行したり、認証のためにそれを読み込むのに使用する秘密鍵が初期化される。
helping-hands.auth.server> (def server (run-dev))
Creating your [DEV] server...
Omniconf configuration:
{:conf #object[java.io.File 0x979c2d2 "config/conf.edn"]}
#'helping-hands.auth.server/server
helping-hands.auth.server>
サーバーが立ち上がったら、次の例のように cURL を使ってさまざまなシナリオを試してみる。認証ヘッダや有効な認証情報が指定されていない場合は、 Validate インターセプターが起動し、以下のように不正なリクエストとしてマークされる。
> curl -i "http://localhost:8080/tokens"
HTTP/1.1 400 Bad Request
Date: Sun, 14 Jan 2018 20:49:48 GMT
...
Invalid Creds/Token
> curl -i -XPOST -d "perms=notify:email"
"http://localhost:8080/tokens/validate"
HTTP/1.1 400 Bad Request
Date: Sun, 14 Jan 2018 20:50:21 GMT
...
Invalid Creds/Token
指定した認証情報が無効な場合は、次の例に示すように、HTTP 401 Unauthorized ステータスを持つレスポンスを投げる。
> curl -i "http://localhost:8080/tokens?uid=hhuser&pwd=hello"
HTTP/1.1 401 Unauthorized
Date: Sun, 14 Jan 2018 20:53:16 GMT
...
> curl -i -H "Authorization: Bearer abc" -XPOST -d "perms=notify:email" "http://localhost:8080/tokens/validate"
HTTP/1.1 401 Unauthorized
Date: Sun, 14 Jan 2018 20:55:35 GMT
...
Invalid JWT
パラメータが有効な場合、エンドポイントは期待通りに動作し、hhuser ユーザーの場合はこのようになる。
> curl -i "http://localhost:8080/tokens?uid=hhuser&pwd=hhuser" HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 20:59:48 GMT
...
Authorization: Bearer
eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7
0tCaUvCjjPKYNFhuA2ppOD-
Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu
BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH--
ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy
DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw
Transfer-Encoding: chunked
> curl -i -H "Authorization: Bearer eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7 0tCaUvCjjPKYNFhuA2ppOD- Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH-- ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw" "http://localhost:8080/tokens"
HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 21:00:11 GMT
...
{"user":"hhuser","roles":["hh/notify"]}
> curl -XPOST -i -H "Authorization: Bearer eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7 0tCaUvCjjPKYNFhuA2ppOD- Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH-- ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw" -d "perms=notify:email" "http://localhost:8080/tokens/validate"
HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 21:00:38 GMT
...
true%
> curl -XPOST -i -H "Authorization: Bearer eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiQTEyOEtXIn0.YY_dMY8qoqfTHeZwGacsFY7 0tCaUvCjjPKYNFhuA2ppOD- Deaj5zzw.BbK36SSYyuUeVVS9jpyIXw.6UNkLFMVF5Foj5qFX5vLdKcyOoU2G2eeVtHskSWBoZu BnnAwI1NGrPc3PvQqKF4QkzlrbFfOYD2Vxd4YqYmj8Hcb1qVUQD1QgtYKiStIMujH-- ZRltPfy7m8VW1D31ToeqAYU1LLlXYSC1W3kSjZQiMFMU1LXkMqZVdmJyfQIL_SvizfWbYuZQPcy DCxG5-XtVeG2r09vnvUybw8tKdafg.WML7xCZ-lZur1GXpNFNKrw" -d "perms=notify:random" "http://localhost:8080/tokens/validate"
HTTP/1.1 200 OK
Date: Sun, 14 Jan 2018 21:00:49 GMT
...
Auth サービスを単独でデプロイし、Helping Hands アプリケーションの残りのサービスの Auth インターセプターを介して接続することで、ユーザーを認証することが可能となる。ユーザーは Auth サービスの /tokens エンドポイントを直接呼び出してトークンを取得し、同じトークンを使用して他のサービスで自分自身を認証および承認することができる。
Buddy もClojureのライブラリで、Buddy Sign ライブラリもJSON Web Tokensの生成に利用できる。
次回はマイクロサービスシステム運用監視の為のElasticStashの活用について述べる。
コメント
[…] マイクロサービスでのセキュリティ- ClojureでのAuthとPedestalを使ったAPI […]
[…] マイクロサービスでのセキュリティ- ClojureでのAuthとPedestalを使ったAPI […]