0. はじめに
サービスを作っていると、避けて通れないのがログイン認証です。無料で誰でも使えるサービスであっても、ユーザーの一意性を確保するために多くのサービスではログインを求められることが多いです。その場合のログイン方法として古くからメールアドレスとパスワードによる認証がありますが、昨今ではそれらに加え、GoogleやFacebookなどのアカウントでログインするソーシャルログインという方法も用いられるようになってきました。ソーシャルログインは各サービスごとに個別に実装することもできますが、今はFirebase Authentication(以下、Firebase Auth)やAuth0などのIDaaS(Identity as a Service)を用いることも多くなっています。加えて、クライアントサイド(フロントエンドやネイティブアプリ)側とサーバーサイド(バックエンド)が分かれる構成をとることも多く、その際の登場人物は、クラウアントサイド、サーバーサイドに加え、認証のためのIDaaSが加わります。登場人物が増えることにより、連携する際にも考慮すべき事項が増えることになります。
本記事では、「Cookieとsessionを用いたWebにおけるシンプルなログインフロー」という従来からあるメールアドレスとパスワードによる認証のフローから始まり、主にネイティブアプリとの連携の様に「Cookieを利用できない場合にJWTを利用した場合のフロー」、さらには、「Firebase AuthなどのIDaaSを用いたログインフロー」(クライアントサイド、サーバーサイド、IDaaSの連携フロー)について自分なりに整理したものを記載します。
また、初回登録時やログイン時だけでなく、ログイン後にセッションを維持したままの画面遷移時やAPIでの情報取得時のフローについても記載します。多くの場合、ログイン後一定時間は再度ログイン認証を必要とせずにサービスを利用することができますが、サーバーサイド側としてはユーザーが正しくログインしたかどうかを検証した上で情報を返す必要があります。その場合のフローをしっかり把握しておくことはログイン認証を実装する上で非常に重要であり、おろそかにするとセキュリティリスクにもなる箇所だと思います。
本記事で私の認識を表に出すことにより、認識が間違っている部分を指摘していただいて修正することも意図しておりますので、間違っている点があれば優しくご教授いただけましたら幸いです。
1. Cookieとsessionを用いたWebにおけるシンプルなログインフロー
昔ながらのメールアドレスとパスワードによる認証フローです。Railsの場合だと、deviseやsorceryを用いた場合のフローになります。
1-1. 未ログイン時にログインする場合(既に新規登録済)
1-2. ログイン後の再アクセス時(Cookieの有効期限が切れる前)
すでにCookieが存在するので、「1-1. 未ログイン時にログインする場合(既に新規登録済)」の最初の「ログイン」部分がないだけのフローになります。
Cookie情報は、ChromeであればディベロッパーツールのApplication→Storage→Cookiesで確認することができます。
以下画像でNameが_myapp_sessionsとなっていて、bc14から始まるValueがCookieに保存されたSession情報です。1
大切なのは、redis_store
やactive_record_store
では備考、このCookie内に保存されているsession情報はsession情報そのものではなく、セッションサーバーに保存してあるsession情報のキーとなるもの(private_id)を変換したもの(public_id)です。CookieのValue(上画像でbc14
から始まるもの)からprivate_idを復元するには、Digest::SHA256.hexdigest
を利用します1。この処理からもわかるように、private_idが把握できれば、public_idがわかってしまいます。
Digest::SHA256.hexdigest("bc1483d7e368e27b734exxxxxxxxxxxx")
=> "bac8aa16202e44df3c97441bfb734c5e396d90e44ecxxxxxxxxxxxxxxxxxxxxx"
実際に、active_record_store
を用いた場合に、セッション情報が保存されているデータベースを確認してみた結果がリスト2になります(config/application.rbで、ActiveRecord::SessionStore::Session.table_name = 'my_sessions'
のように設定し、ActiveRecord::Baseを継承したMySessionクラスを用意しています)。session_id
カラムにprivate_idが含まれた値が格納されているのがわかります(rack gem内で"#{ID_VERSION}::#{hash_sid(public_id)}"
となっているので2::
がついていますが2)
MySession.last
=> #<MySession:0x000000010eff76c0
id: 1,
session_id: "2::bac8aa16202e44df3c97441bfb734c5e396d90e44ecxxxxxxxxxxxxxxxxxxxxx",
data:
"{\"value\":{\"last_action_time\":\"2023-04-21T11:21:53.332+09:00\",\"_csrf_token\":\"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=\",\"user_id\":\"1\",\"login_time\":\"2023-04-21T11:21:49.982+09:00\"}}",
created_at: Fri, 21 Apr 2023 11:21:49.966523000 JST +09:00,
updated_at: Fri, 21 Apr 2023 11:21:55.357842000 JST +09:00>
Cookieにsecure属性をつけることで、httpsのときのみブラウザはサーバーにCookieを送るようになり、もし盗み取られたとしても暗号化されているので、攻撃者は読み取ることができなくなります。また、サーバー側でsessionを管理できているので、sessionを無効化することで、強制的にログアウトさせることも可能です。
2. Cookieを利用できない場合にJWTを利用した場合のフロー
「1」では、Webの場合のログインフローで、Cookieが利用できる場合のログインフローについて紹介しました。ここでは、モバイルアプリ向けなど、WebではなくCookieが利用できない場合のフローについて紹介します。認証周りのサーバーサイドとクライアントとのやり取りについては、昨今はJSON Web Token(JWT)と呼ばれるものがよく利用されます。JWTは認証の文脈でよく出てきますが、JWT自体は、JSONデータをURLセーフかつ、コンパクトにする方法として規定されたものになります3ので認証以外でも用いられますし、用いることができます。
ここでは一例として、Railsで認証でJWTを用いる場合に比較的採用されるdevise-jwtというgemを用いた場合の挙動について説明したいと思います。devise-jwtは内部的には、warden-jwt_authを利用しており、
This gem is just a replacement for cookies when these can't be used. As cookies, a token expired with warden-jwt_auth will mandatorily have an expiration time. If you need that your users never sign out, you will be better off with a solution using refresh tokens, like some implementation of OAuth2.
とあるように、Cookieを置き換えできるものであり、Cookieが使えない時に利用するものと言えます。
ここで改めて、JWTについての詳細は記載しませんが、ネット上の情報を見ているとnode.jsでの例ですが、以下のようにメールアドレスを用いてJWTにしていたりします。この場合、一般に、JWT自体は改ざんについては何も規定されておらず、改ざんされてしまってはセキュリティリスクになってしまうため、JSON Web Signature(JWS)という規定を用います。
JWT.sign({ email: "test@example.com" }, "SECRET_KEY", { expiresIn: "24h" })
リスト3において生成された値は、例えば、以下のようになり、JWSの値となります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QxQHRlc3QuY29tIiwiaWF0IjoxNjg0MTMxOTI4LCJleHAiOjE2ODQyMTgzMjh9.PU3g4lJnllhX5Oz8no0kbf6Bniceq0pr8T24miUUDeQ
リスト4の値は、.
(ドット)で区切られており、ドットで区切られた一つ目の部分(下記の赤い部分)がヘッダ部、2つ目の部分(同青い部分)がペイロード部(もともとのJWT)、3つ目の部分(同緑の部分)がシグニチャと呼ばれ、改竄されていないかをチェックするためのものになります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3QxQHRlc3QuY29tIiwiaWF0IjoxNjg0MTMxOTI4LCJleHAiOjE2ODQyMTgzMjh9.PU3g4lJnllhX5Oz8no0kbf6Bniceq0pr8T24miUUDeQ
経験的に、このドットで連結された値(JWS)をJWTと呼ぶケースもあるので、文脈にって認識を正しくしておくことが必要です。
JWS自体は、改竄を検知することはできるものの、デコードされてしまえば、ヘッダ部、ペイロード部は中身を見ることができます。試しに、リスト4をデコードしてみます。簡単に行うには、https://jwt.io/ へアクセスしてリスト4の値を貼り付けます。
このように、JWSが盗まれデコードされた場合、メールアドレスの値が把握されてしまいます。繰り返しになりますが、JWS自体は暗号化して読み取り不可にするものではなく、SECRET_KEY
を用いて、改竄されていないかをチェックするものになります。したがって、SECRET_KEY
が漏れてしまうと意味をなさないので取り扱いに注意が必要です。
devise-jwtでは、メールアドレスのような個人情報はJWSには含めません。代わりに、jti
という値を用いています。試しに
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwic2NwIjoidXNlciIsImF1ZCI6bnVsbCwiaWF0IjoxNjIwNDkzOTUzLCJleHAiOjE2MjA0OTc1NTMsImp0aSI6IjlmZjkzMDA2LTAxNTMtNDc5YS1hYjY2LTZiMDBhOWU2NjM1ZCJ9.K6oHIUI0AuZ4HfDV1iElFe9OZoMh_st3l1rfhD0PIqY
というJWSをデコードしてみると、ペイロード部は次のようになります。
{
"sub": "1",
"scp": "user",
"aud": null,
"iat": 1620493953,
"exp": 1620497553,
"jti": "9ff93006-0153-479a-ab66-6b00a9e6635d"
}
という値になります。jtiはJWT IDのことで、認証させるモデル(多くの場合、User
)の属性(テーブルのカラム)に、jti
というカラムを追加され、JWTをデコードしたpayload部のjtiの値からuserを引っ張ってきます。したがって、JWTがデコードされても、センシティブな情報を取得することはできません。
2-1 JWTによるログインフロー
devise-jwtを用いた場合のログインフローについて下図に示します。
- (1)のリクエストおよびレスポンス例が以下になります。レスポンスヘッダーの
Authorization
内でBearer #{token}
の形で返却され、クライアントサイドはこの値をCookieやSharedPreferencesに格納しておきます。(devise-jwt自体はCookieが利用できない場合に利用することが多いと思いますが、Cookieが利用できる場合でもdevise-jwtを利用することは可能です。)
curl -XPOST -i -H "Content-Type: application/json" -d '{ "user": { "email": "test@example.com", "password": "12345678" } }' http://localhost:3000/users/sign_in
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: application/json; charset=utf-8
Vary: Accept, Origin
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwic2NwIjoidXNlciIsImF1ZCI6bnVsbCwiaWF0IjoxNjIwNDkzOTUzLCJleHAiOjE2MjA0OTc1NTMsImp0aSI6IjlmZjkzMDA2LTAxNTMtNDc5YS1hYjY2LTZiMDBhOWU2NjM1ZCJ9.K6oHIUI0AuZ4HfDV1iElFe9OZoMh_st3l1rfhD0PIqY
ETag: W/"4f46b654dd4b5ef6187f2663ef5a55c4"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: 498ef50b-4bbb-44b6-9b39-dd45d03aa7b4
X-Runtime: 0.293374
Transfer-Encoding: chunked
{"message":"ログインに成功しました。"}
-
(2) クライアントサイドがnext.jsなどWebで利用しているのであればCookie、ネイティブアプリであれば、SharedPreferences等に入れることになると思います。
-
(3)
Authorization
ヘッダーにBear
をつけてJWS
を送信することで、サーバーサイド側はwarden-jwt_auth
にて認証させることができます4。
curl -XGET -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwic2NwIjoidXNlciIsImF1ZCI6bnVsbCwiaWF0IjoxNjIwNDkzOTUzLCJleHAiOjE2MjA0OTc1NTMsImp0aSI6IjlmZjkzMDA2LTAxNTMtNDc5YS1hYjY2LTZiMDBhOWU2NjM1ZCJ9.K6oHIUI0AuZ4HfDV1iElFe9OZoMh_st3l1rfhD0PIqY" -H "Content-Type: application/json" http://localhost:3000/private_data
2-2 ログイン後の再アクセス時
ログイン後の再アクセス時には、CookieやSharedPreferencesで保存しているJWSをAuthorization
ヘッダーにBear
をつけてJWS
を送信することで、セッション情報を取得します。
3. Firebase AuthなどのIDaaSを用いたログインフロー
「1」「2」では、主にクライアントサイドとサーバーサイドのやり取りについて紹介しましたが、ここでは、Firebase AuthやAuth0などのIDaaSを用いた場合のフローについて考えていきたいと思います。シンプルなメールアドレスとパスワードによる認証であれば、自前で実装したほうが楽な場合が多いと思いますが、IDaaSを用いる利点としては、ソーシャルログインやSSOなど、多様なログイン方法に対応する必要がある場合など、自前で実装するよりも実装コストを削減できると思います。
さらに、ユーザー管理以外にもサービスを提供するにあたってさまざまなデータやロジックが存在し、その場合はサーバーサイドにRailsやLaravel, Spring Bootといったフレームワークを用いて構築することになると思います。その場合の認証、さらに、認証後にリクエストごとに毎回認証しなくても済むようにセッション管理する場合のフローはどうなるでしょうか。
ここでは、クライアントサイド、サーバーサイドに加え、認証でFirebase AuthなどのIDaaSを用いた場合の認証やログインフローについて考えてみたいと思います。とりあげるフローは初回登録時の認証および、ログイン後にセッションを維持し続けるケースになります。他の記事を見ていると、初回登録時の認証およびサーバーサイドでのJWSの検証に関しての記事が多く、認証してログインした後、毎回認証させるわけにはいかないので、本記事では、セッションを維持して一定期間は再度ログインしなくても済むようにするための処理フローについても考えていきます。
なお、ちょっとでもセキュリティに自信がないなら、 Firebase Authentication を検討しようという意見も頷けますが(特にバックエンドを別に用意せず、フロントエンドだけの場合など)、Firebase Authを使う場合でも気をつけなければいけないことはあります。Flatt Securityさんが、
Firebase Authentication 7つの落とし穴 - 脆弱性を生むIDaaSの不適切な利用という記事で記載していることも確認の上、加えてセキュリティ診断サービスを利用するなどして自分たちが作ったサービスがセキュリティ的に問題がないか確認してからローンチすることが賢明です。
3-1. 初回登録時
初回登録時のフローは以下のようになります。
初回登録時は、ClientにFirebaseAuthenticationなどのIDaaSを実装し、例えば、GoogleアカウントやFacebookなどで認証します(1)。認証に成功したら、FirebaseAuthからClientには、JWSやユーザー情報などがClientに返却されます(2)。ClientはIDaaSから返却されたJWSをServerにPostします(3)。
JWSを受け取ったServerは、そのJWSが正しいかどうかを検証します。FirebaseAuthの場合、検証するためには公開鍵(JSON Web Keys; JWKs)を取得する必要があり、https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com
へリクエストして取得します5。JWTライブラリを用いて、Clientから送られたJWSを検証します。rubyの場合、ruby-jwt gemを利用するといいと思います。
この時、Firebaseの公式ドキュメントによると、ruby-jwtのJWT.decode
における第三引数をtrueにして署名を確認する(nodeのjsonwebtokenであれば、const JWT = require("jsonwebtoken");JWT.verify
だけでなく、exp
やiat
なども制約に従っているか確認する必要があるようです5。参考までに、exp
やiat
なども制約の確認を省いたruby-jwtを用いた場合のJWSの検証のための実装例をリスト10に示します。
# token: リクエストから取得したJWS。
# kidやalgorithmを把握するため、検証なしで(第三引数をfalseにして)デコードする。
decoded_token = JWT.decode(token, nil, false)
# ruby-jwtを使うと、JWTのHeader部が配列の2番目に入ってくる
kid = decoded_token.dig(1, "kid")
algorithm = decoded_token.dig(1, "alg")
# 公開鍵(JWKs)の取得
jwks_url = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
response = Typhoeus.get(jwks_url) # 好きなHTTPクライアントをご利用ください。
# 公開鍵をデコードするために不要な文字を削除。この時、複数の公開鍵がGoogleのAPIから返却されるので、
# kidから利用する方を取得する。
jwks_string = JSON.parse(response.body)[kid].gsub(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----/, '').strip
# JWSの検証
JWT.decode(token, OpenSSL::X509::Certificate.new(Base64.decode64(jwks_string)).public_key, true, { algorithm: algorithm })
検証後は、デコードしたJWS内に入っているユニークなidを元にユーザーのレコードを生成します。
3-2. ログイン後および再アクセス時(Webの場合)
次に、初回会員登録後、セッションを維持したままリクエストした場合のWebにおけるフローになります。
ログイン認証部分に関しては、初回登録時のフローと大きな違いはありません。違いとしては、ユーザーが既に登録済みであるので、レコードを生成するのではなく、RDBから情報を取得するというところになります。
その後のフローが変わっており、Set-Cookieヘッダーフィールドを用いてClient(ブラウザ)にsession情報を保持します(6)。ここから先は、「1. Cookieとsessionを用いたWebにおけるシンプルなログインフロー」と変わらないので説明はそちらをご覧ください。なお、このパターンの場合、ClientとサーバーサイドでFQDNが異なるため、CORSの設定などを正しく行う必要がある場合があります。
3-3. ログイン後の再アクセス時(スマホアプリなどCookieを使わない場合)
最後に、初回会員登録後、セッションを維持したままリクエストした場合のCookieを使わない場合のフローになります。
こちらも、ログイン認証部分に関しては、初回登録時のフローと大きな違いはありませんし、ユーザーが既に登録済みであるので、レコードを生成するのではなく、RDBから情報を取得するというところも、Cookieを利用する場合と変わりません。
ログイン認証後、HttpのAuthenticationヘッダーでsession情報(private_id)を送るのではなく、JWTを送るようにします。これは、「2.1 JWTによるログインフロー」での仕組みと同じものになります。従って、ここから先の流れも、「2.1 JWTによるログインフロー」と変わらないのでフローの説明はそちらをご覧ください。
本記事では、具体的な実装については記載しませんが、私の手元では、devise-jwt gemを用いてこの仕組みを実現しました。つまり、IDaaSから取得したJWTをClient経由で渡してもらい、その検証をした後のセッション管理は、サーバーサイド側で行うということです。その際に、セッション管理用に、IDaaSから取得したものとは異なるJWTを用いてセッション管理(private_idを発行しJWTに格納し、HTTPのAuthenticationヘッダーに格納して渡す)をするというわけです。私は主にRailsを主戦場として仕事をしていますので、Railsにおける実際の実装法についてはまた別の記事で紹介したいと思います。
4. 最後に
認証機能というのは一度作ってしまうとあまり触る機会はないかもしれません。特にセキュリティが経験が浅いエンジニアに手放しで任せられるケースというのは少ないと思います。また、レビュアーも自ら実装する場合には気づけても、レビューでは漏れてしまう瑕疵というのも存在します。多くの場合、仕事で必要になったタイミングで学んだり考えたりすれば済むケースも多いのですが、じっくり考える時間を作ったほうがいい場合もあります。私の場合、IDaaSを利用した場合のフローについて腰を据えて考えたことがなかったので、考える時間を作ってみました。ですので、間違っている可能性やより良いフローもあると思います。その場合はぜひ優しくご教授いただけましたら幸いです。
備考
Railsにおけるsession_storeの設定については、Rails.application.config.session_store
で設定されており、私がこの記事を書くために準備したリポジトリでは、active_record_store
を使っているので、以下のような設定になっています。
Rails.application.config.session_store :active_record_store, key: '_myapp_sessions', secure: Rails.env.production?, expire_after: 12.hour
他の設定については、Rails のキャッシュ機構をご覧ください。簡易的な説明を以下の表にまとめますが、本番環境では、redis_store
、mem_cache_store
、active_record_store
のどれかを使うと思います。開発環境やtest環境では、この3つ以外を利用するケースもあると思います。
種類 | 説明 |
---|---|
redis_store | session情報の保持にredisを利用する。redis gemを利用。 |
mem_cache_store | session情報の保持にmemcachedを利用する。dalli gemを利用。 |
active_record_store | session情報をRDBに保存する。activerecord-session_storeを利用。 |
cookie_store | session情報をCookieに保存する。Cookie情報が盗まれるとログインできてしまうので本番環境で利用するのはお勧めされない。session情報はサーバー側で保持しておく。 |
file_store | session情報をサーバー上のファイルに保存する。本番環境では複数サーバーを立てるケースがほとんどで、共有ファイルシステムを利用することもできるが、本番で利用することはほぼない。 |
memory_store | session情報をサーバー上のメモリ上に保存する。本番環境では複数サーバーを立てるケースがほとんどなので本番で利用することはほぼない。 |
参考
- https://medium.com/ruby-daily/a-devise-jwt-tutorial-for-authenticating-users-in-ruby-on-rails-ca214898318e
- https://qiita.com/mr-hisa-child/items/5ed2ae2fe4c86d4bb5c7
-
https://github.com/rack/rack/blob/v2.2.7/lib/rack/session/abstract/id.rb#L25 ↩
-
https://developer.mamezou-tech.com/blogs/2022/12/08/jwt-auth/ ↩
-
https://github.com/waiting-for-dev/warden-jwt_auth/blob/v0.8.0/lib/warden/jwt_auth/header_parser.rb#L16-L22 ↩
-
https://firebase.google.com/docs/auth/admin/verify-id-tokens ↩ ↩2