Clojure: webアプリで「Googleアカウントで登録」を作る

More than 1 year has passed since last update.

表題を目的に色々調べていたのですが、そのまま参考にできる資料がなかったためOAuthの勉強含めて実装してみました。

良し悪しはわかりませんが、ひとまず共有させていただきます。

大きな流れとしては、

1. Developer Consoleでプロジェクト作成

2. 作成したプロジェクトの設定を行い、client-idclient-secretの取得とcallback先の登録

3. プログラムの実装

となります。

1.と2.に関しては検索エンジンを使えばいくらでも良い資料が見つかるのでスキップさせていただきまして、3.を重点的に記述します。

2.に関して一言だけ添えると、開発用と本番用で2つのプロジェクトを作成し、それぞれのclient-id等を環境変数に設定しておくとよいです。

実装についてはGoogle OAuth 2.0 を使い、Web アプリケーションに認証機能を追加する(Ruby)を大いに参考にさせて頂きました。

先にプログラムの全体を記述します。


auth/google.clj

(ns auth.google

(:require
[clojure.data.json :as json]
[compojure.core :refer [defroutes GET]]
[config.core :refer [env]]
[ring.util.response :refer [redirect]]
[clj-http.client :as client]))

(defn rand-url-str [len]
(let [chars (concat (range 48 58) (range 97 123))]
(apply str (repeatedly len #(char (rand-nth chars))))))

(def google-base-uri "https://accounts.google.com")

(defn build-scopes
[scopes]
(->> scopes
(interpose "%20") ;%20 が区切り文字
(apply str)))

(def scopes
["email" "profile"])

(defn google-request-code
[]
(let [{:keys [client-id client-secret callback]} (env :google)
state (rand-url-str 32)]
{:uri (str google-base-uri "/o/oauth2/auth?"
"client_id=" client-id "&"
"response_type=code&"
"scope=" (build-scopes scopes) "&"
"redirect_uri=" callback "&"
"state=" state)
:state state}))

(defn exchange-token
[code]
(let [{:keys [client-id client-secret callback]} (env :google)]
(-> (client/post (str google-base-uri "/o/oauth2/token")
{:form-params {"code" code
"client_id" client-id
"client_secret" client-secret
"redirect_uri" callback
"grant_type" "authorization_code"}})
:body
json/read-str)))

(defn token-info
[id-token]
(-> (client/get "https://www.googleapis.com/oauth2/v1/tokeninfo"
{:query-params {"id_token" id-token}})
:body
json/read-str))

(defroutes auth-google-routes
(GET "/google" req ;①
(let [{:keys [session]} req
{:keys [uri state]} (google-request-code)]
(-> (redirect uri)
(assoc :session (assoc session :state state)))))
(GET "/google-callback" {params :params session :session};②
(let [{:keys [code state]} params
session-state (:state session)]
(when (and state (= session-state state)); check CSRF
(let [{:strs [expires_in id_token]} (exchange-token code)];③
(when (and id_token (> expires_in 0))
(let [api-result (token-info id_token);④
{:strs [issuer issued_to audience user_id]} api-result]
(when (and (= issuer "accounts.google.com")
(= issued_to audience (:client-id (env :google))))
(if "登録済み?"
"ログイン処理"
"登録処理"))))))))))



project.clj

...

{:dev {:env {:google {:client-id "XXX"
:client-secret "YYY"
:callback "http://127.0.0.1:3449/auth/google-callback"}}}}
...

上記の例で使用している環境変数についてはEnvironを参照してみて下さい。

処理の流れに合わせて順に説明します(上のコードに①,②などの番号を振らせていただきました)。

まずwebアプリの場合、ボタンなどで/auth/googleに遷移するようにします(もちろん開発中はアドレスバーにそのままリンク先を入力してもok)。

ローカルサーバのportが3449だとして、dev環境なのでhttp://127.0.0.1:3449/auth/googleでアクセスできると仮定します。

この時①に飛び、client-idなどの設定した情報から、googleへリダイレクトし、ユーザはブラウザでgoogleの認証画面に飛びます。

次にユーザがgmailへログインおよび認証を終えると、設定しておいたhttp://127.0.0.1:3449/auth/google-callbackへコールバックします。

Developer Consoleで作成したプロジェクトのcallback先と同一になることを確認してください。

ここで②になるのですが、最初に①の最後に用意しておいたセッションの内容と②で送られてきたパラメータが一致するかどうかを確認してCSRF対策を行います。

#'rand-url-strはurlsafeな乱数を作成する関数でpgaertig/rand-url-str.cljをそのまま参考にさせていただきました。

次に③になりますが、送られてきたauthorization codeからid_tokenを取得します。

④ではそのトークンから次のようなユーザの情報を取得することができます。

{

"issuer" : "accounts.google.com",
"issued_to" : "CLIENT-ID",
"audience" : "CLIENT-ID",
"user_id" : "xxx",
"expires_in" : 3599,
"issued_at" : yyy,
"email" : "xxx@gmail.com",
"email_verified" : true
}

このuser_idで個人を識別することが可能になります。

最後に発行元情報などを確認した上で、ユーザの登録やログイン処理に移る流れになります。


注意点

上の例は権限の許諾が必要のない範囲での設定になります。

例えばユーザのgmailの内容をあれこれ操作したりしたい場合は、scopeの設定でhttps://www.googleapis.com/auth/gmail.sendhttps://www.googleapis.com/auth/gmail.composeなどを追加する必要があります。

この場合、ユーザによる権限の許諾が必要になり、exchange-tokenの処理結果に含まれるものがid_tokenではなくaccess_tokenに変わります。

従って、今回のこの記事はあくまでGoogleアカウントを使うことによるログインの例として参考にしていただければ幸いです。

また、web系のプログラミングは思い出したかのようにしている人間なので、セキュリティの観点含めてよりよいやり方をご教授賜れば幸いです。