APIの認証について学んで実装してみた(by rails API)
IT技術
きっかけ
業務でrails APIの環境に出会い、railsの勉強がてら個人で同じ構成で何か作ってみようとした時に、APIでの認証へ具体的な理解が浅いなと思ったので、調べてみることにしました。
この記事では、APIでの認証の基本的な仕組みを理解した上で簡単な実装ができるまでを目指します。
認証とリソースアクセスのフロー
さっそくですが、APIサービスでの認証と認証後のリソースへのアクセスフロー(一例)を図にしてみました。
- メールアドレスとパスワードを送信する
- サーバ側でユーザーの存在を検証する
- 検証に問題なければ、クライアントにトークンを返す
- クライアントはサーバから送られてきたトークンをリクエストヘッダーに含めてリクエストを送る
- サーバはトークンをデコード・検証し、ユーザーの確認が取れた場合、リソースへのアクセスを行う
- リクエストに対するレスポンスを返却する
(RESTfulを意識した)APIはステートレスであることが望ましく、各通信が独立している必要があります。つまり、サーバ側はそのリクエストが誰のものか特定する情報(ここではトークン)を保持しません。
比較としてセッション方式のフローを図にしてみました。
この方式だとサーバはセッションという形でユーザーの情報を保持していますが、上のトークン方式の場合、サーバーはトークンを払い出すだけでトークン自体の管理はしていません。
サーバ側で状態を持たずに認証を可能にする仕組みがトークンベースの認証になります。
認証処理の鍵は「トークン」
ここまでで、トークンを使ったAPIの認証フローはイメージできたと思います。
ここからは、トークン自体に焦点を当てていきます。
JWT
APIの認証で一般的に使われるトークンには規格が存在します。それがRFC7519で定義されている「JWT」です。
JWTは「JSON Web Token」の略で文字通りJSON形式で表現されたトークンです。JWTは大きく3つの構造から成り立ちます。
- ヘッダー
- ペイロード
- 署名
1つずつ見ていきましょう。
ヘッダー
トークンの処理方法に関するメタデータを含みます。具体的にはトークンタイプや署名生成に使われるアルゴリズムを指定します。
1{
2 "alg": "HS256",
3 "typ": "JWT"
4}
ペイロード
トークンの本体で、ユーザー識別情報、有効期限など、様々なクレームを含みます。
ここでのクレームとは1つの「キー:値」1セットのイメージで大丈夫です。
JSONのキーが「クレーム名」、値が「クレーム値」という表現がされます。
ペイロード内のクレームには3種類あります。
Registered Claim Name
RFC7519で予約されているクレーム名です。
IANAの”JSON Web Token Claims”で定義されているクレーム名の内、特に限定して指定されています。
具体的には以下になります。
- iss(発行者)
- sub(件名)
- aud(オーディエンス)
- exp(有効期限)
- nbf(有効になる日時)
- iat(発行日時)
- jti(JWTを一意に特定する値)
Public Claim Name
IANAの”JSON Web Token Claims”で定義されているクレーム名。
JWT使用者間で共有される情報(=外部サービスでも使われ得る情報)が該当します。
Private Claim Name
特定のアプリケーション間でのみ意味を持ち、一般に公開しないものが該当し、一般的にはこれを利用します。
ペイロードの具体例は以下になります。
1{
2 "jti": "92f46647-90a2-4174-bca9-27d7f69a8fb7",
3 "exp": 1485320878,
4 "user_id": 123,
5 "email": "test@example.com
6}
署名
署名は
- 誰がそのJWTを発行したか
- ヘッダーとペイロードは改ざんされていないか
を保証するために付け加えられます。
署名生成の流れは以下のイメージです。
- ヘッダー・ペイロードをBase64 URLエンコード
- それぞれを「.」で連結
- 連結文字列をヘッダー「alg」の方式でハッシュ化
- ハッシュ化した文字列をサーバの秘密鍵で署名
- 署名した値をBase64 URLでエンコード
JWTの生成とセキュリティ面の考慮
ここまで見てきたJWTの3つの構造はエンコードされると「.」で繋いだ文字列になります。
1eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDAwNTQzOTB9.uIALzjUXmOdsFy0vMetZivYoWq5haiufCKeeNQYzFWw
.区切りで「ヘッダー」「ペイロード」「署名」の構造となっており、この文字列をデコードするとJSON形式になります。
ここでのエンコードとは、URLにくっつけても大丈夫な形式Base64 URLというフォーマットで行われますが、これは暗号化しているわけでなく、単に形式を変えているだけであり、セキュリティ面を考慮するならHTTPSで通信する必要があります。
つまり、署名を利用すると改ざんの検知が可能にはなりますが、JWT自体は丸見えのデータです。
トークン失効戦略
ここまで、トークン認証のフロートJWTの中身について見てきました。
冒頭で、サーバはトークンを生成するが、それ自体を管理しないと述べました。ということは、有効期限切れ以外でトークンを無効化させる方法は自前で用意する必要があります。
そのための方法は複数あり、今回は3つ取り上げます。
ブラックリスト方式
トークンに付与される一意のID「jti」を保存するテーブルを用意し、無効化したいトークンのjtiをテーブルに格納していきます。
このテーブルに保存されているjtiに紐づくトークンは利用できないものとして扱います。
ホワイトリスト方式
ブラックリスト方式とは逆に、許可したいトークンのjtiを保存し、このテーブルに紐づくトークンのみ利用可能とします。
JTI一致方式
ユーザーテーブルのカラムにjti列を生成し、トークン生成時に現在設定されているjtiの値をJWTに含めます。
ログアウト時に、ユーザーテーブルのjtiカラムの値を更新することで、既存トークンが送られてきた際に、一致しないため無効と判断できます。
次回トークンを生成するときは、更新後のjtiの値をトークンに含め、以降ログアウト→ログインで繰り返します。
※この記事ではJTI一致方式で実装していきます。
実装
ここからは実際にruby on railsでAPIの認証を実装していきます。
※前提としてrailsのAPIモードでプロジェクトを作成しているものとします。
gemのインストール
Gemfileに以下を記入し、bundle installを実行します。
1gem 'jwt'
UserモデルとPostモデルの作成
各マイグレーションファイルとモデルを作成し、rails db:migrate
を実行します。
- User
- パスワード暗号化のために、モデルにhas_secure_passwordを書きます。
- パスワードカラムはpassword_digestという名前にします。
1## Userモデル
2class User < ApplicationRecord
3 has_secure_password
4 has_many :posts
5end
6
7## usersテーブルマイグレーションファイル
8class CreateUsers < ActiveRecord::Migration[7.1]
9 def change
10 create_table :users do |t|
11 t.string :name
12 t.string :email
13 t.string :password_digest
14 t.string :jti
15 t.timestamps
16 end
17 end
18end
- Post
1### Postモデル
2class Post < ApplicationRecord
3 belongs_to :user
4end
5
6### postsテーブルのマイグレーションファイル
7class CreatePosts < ActiveRecord::Migration[7.1]
8 def change
9 create_table :posts do |t|
10 t.references :user, foreign_key: true
11 t.string :title
12 t.timestamps
13 end
14 end
15end
ついでにseederも作成し、rails db:seed
を実行してダミーデータを作成します。
1# usersテーブルにデータを追加
2user = User.create!(
3 user_name: 'admin_user',
4 email: 'test@example.com',
5 password: 'password'
6)
7
8# postsテーブルにデータを追加
93.times { |n| Post.create(user_id: 1, title: "テストタイトル#{n + 1}") } if user.user_id == 1
ログイン処理
ルーティングとコントローラでログイン処理(トークン生成)を実装します。
1### config/routes.rb
2Rails.application.routes.draw do
3 post "/login", to: 'sessions#create'
4end
5
6### app/controllers/sessions_controller.rb
7class SessionsController < ApplicationController
8 def create
9 user = User.find_by(email: params['email'])
10
11 if user && user&.authenticate(params[:password])
12 jti = user.jti
13 exp = (Time.now + 1.hour).to_i
14 payload = {jti: jti, user_id: user.id}
15 token = JWT.encode(payload, Rails.application.credentials[:secret_key_base])
16 end
17 render json: {token: token}
18 end
19end
この部分で行っていることは以下です。
- メールアドレス、パスワードの検証(ユーザー情報取得)
- 1が成功の場合ペイロード生成
- jti
- exp
- user_id
- gem 'jwt'を使ってJWTトークンを生成&Base64 URLエンコード
- トークンをレスポンスに含めて返す
ここまでできたら、http://localhost:3000/login
へリクエストを飛ばしてみます。
1curl -X POST http://localhost:3000/login \
2 -H "Content-Type: application/json" \
3 -d '{"email": "test@example.com", "password": "password"}'
以下のようなレスポンスが返ってきていたら成功です。
1{
2 "token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3MmEwMjgzNi01Mjk1LTQ4ZWUtYjBYTQ2NDUiLCJleHAiOjE3MDA1MDU0MzcsInVzZXJfaWQiOjF9.bmLssfADwPtbWgzCqTi2p4QaXY"
3}
トークン検証とリソースアクセス
生成したトークンを使って今度はリソースへのアクセスを行います。
app/controllers/application_controller.rb
を修正し、各コントローラ処理の前にトークン検証処理をはさみます。(veryfiy_tokenの処理)
1class ApplicationController < ActionController::API
2 before_action :verify_token
3
4 private
5
6 def verify_token
7 token = request.headers['Authorization'].split(" ")[1]
8
9 unless token.present?
10 render json: { error: 'Authorization header is missing' }, status: :unauthorized
11 return
12 end
13 begin
14 # トークンをデコード
15 decoded = HashWithIndifferentAccess.new (
16 JWT.decode(token, Rails.application.credentials[:secret_key_base])[0]
17 )
18
19 # expが切れているかチェック
20 if decoded[:exp].nil? || decoded[:exp] < Time.now.to_i
21 render json: { error: 'Token has expired' }, status: :unauthorized
22 return
23 end
24 # userが存在するかチェック
25 @current_user = User.find_by(id: decoded[:user_id])
26 unless @current_user
27 render json: { error: 'Invalid token' }, status: :unauthorized
28 return
29 end
30 # jtiが有効かチェック
31 unless decoded[:jti] == @current_user.jti
32 render json: { error: 'Invalid token' }, status: :unauthorized
33 return
34 end
35 rescue ActiveRecord::RecordNotFound, JWT::DecodeError => e
36 render json: { error: e.message }, status: :unauthorized
37 end
38 end
39
40 def current_user
41 @current_user
42 end
43
44end
ここで地味に罠なのが、request.headers['Authorization']
の値はBearer XXXXXXXXの形式になっていることです。"Bearer"が邪魔なので、半角スペースを区切りに分離し、トークン文字列のみ抽出しています。(しばらくこれに気づかずにエラーと格闘していました。)
次に、posts_controller.rbを作成します。
1class PostsController < ApplicationController
2 def index
3 posts = @current_user.posts
4
5 render json: posts.as_json(only: [:id, :title])
6 end
このコントローラーはApplicationControllerを継承しているので、上のトークン検証処理通過して実行されます。
また、このままだとログイン処理時にトークン検証が強制されてしまうのでスキップする処理を追加します。
1class SessionsController < ApplicationController
2 ## これを追加
3 skip_before_action :verify_token, only: [:create]
:createアクションのみ、トークン検証処理をスキップする記述です。
ここで、上で生成されたトークンをAuthorization BearerTokenに指定して/postsにリクエストをしてみます。
1curl -X GET http://localhost:3000/posts \
2 -H "Authorization: Bearer トークン文字列"
このようなレスポンスが戻ってきたら成功です。
1[
2 {
3 "id": 1,
4 "title": "post1"
5 },
6 {
7 "id": 2,
8 "title": "post2"
9 },
10 {
11 "id": 3,
12 "title": "post3"
13 }
14]
ログアウト処理
最後にログアウト処理を実装します。
app/controllers/sessions_controller.rbを開き、以下を追加します。
1 def delete
2 @current_user.update(jti: SecureRandom.uuid)
3 render json: {message: 'logout successfully.'}
4 end
これは、ログアウト処理時に現在ログインしているユーザーのjtiカラムの値を更新しています。
そうすることで、前回のjtiをペイロードに持つトークンが送られてきた場合、verify_tokenメソッドで弾くことができます。
/logoutへリクエストを送り、その後前回のトークンで/postsにリクエストを送ってみるとエラーが返ってくると思います。
おわりに
jwt使ったトークン認証について実際に実装してみることで流れのイメージが鮮明になりました。
ここからリフレッシュトークンの実装やサードパーティの認証機能の組み込みなど展開していくのも面白そうだなと思ったので、引き続き調べてみようと思います。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
2022年7月に入社しました。開発未経験で未知の領域だらけですが、楽しく学びつつ、早く戦力になれるようにがんばります!
おすすめ記事
immichを知ってほしい
2024.10.31