🐘

devise_token_auth gem を使って、認証されたユーザーのみデータ操作をできるようにする

2023/12/28に公開

概要

devise_token_auth を使った認証機能の実装について書いていきます。

仕様として、GET /api/v1/friends で、認証されたユーザーの友達一覧が返ってくるような API を実装していきます。

新規登録 API、ログイン API の実装については書いていません。
最後に、「そもそもなんで devise_token_auth を使うねん」という話もしているので、参考になったら嬉しいです。

環境

ruby 3.1.2p20
rails (7.0.8)
devise (4.9.3)
devise_token_auth (1.2.2)

モデルの定義

1人のユーザーに複数人が紐づくようにしたいので、以下のような association になります。

class User < ActiveRecord::Base
  has_many :friends
end

class Friend < ApplicationRecord
  belongs_to :user
end

ルーティングの設定

GET /api/v1/friends というエンドポイントで、友達の一覧を返すようにしたいので、
以下のように routes.rb に追記します。

  namespace :api do
    namespace :v1 do
      resources :friends, only: [:index]
    end
  end

コントローラの作成

app/controllers/api/v1frineds_controller.rb を作成します。

module Api
  module V1
    class FriendsController < ApplicationController
      before_action :authenticate_api_v1_auth_user!

      def index
        friends = current_api_v1_auth_user.friends

        render json: { friends: friends }
      end
    end
  end
end

ここで注目してほしいのは以下の2つのメソッドです。

  • authenticate_api_v1_auth_user!
  • current_api_v1_auth_user

authenticate_api_v1_auth_user!

こちらはリクエストヘッダーに含まれるヘッダー情報から、認証の処理を行います。
必要なヘッダー情報が足りていなかったり、ヘッダー情報の値が適切じゃないと、
「お前認証できてないから、ログインもしくはサインインしてね」的な401レスポンスを返します。

{
    "errors": [
        "You need to sign in or sign up before continuing."
    ]
}

認証に必要なヘッダー情報は以下です。
https://github.com/lynndylanhurley/devise_token_auth/blob/master/docs/usage/controller_methods.md#token-header-format

このメソッド自体で流れている SQL は以下だけなので、おそらく内部的にリクエストヘッダーの access-tokenclient が有効かどうかを判定し、有効であれば uid からユーザーを特定するといった処理になっていると思います。

SELECT `users`.* FROM `users` WHERE `users`.`uid` = 'aaa@example.com' LIMIT 1

ヘッダー情報からユーザーを特定したことで、晴れて「認証された」状態になりました。

current_api_v1_auth_user

これは認証済みのユーザーの場合、そのユーザーの情報を返します。

> current_api_v1_auth_user
=> #<User id: 11, provider: "email", uid: "aaa@example.com", allow_password_change: [FILTERED], name: nil, nickname: nil, image: nil, email: "aaa@example.com", created_at: "2023-12-27 17:09:12.941186000 +0000", updated_at: "2023-12-28 02:14:30.289710000 +0000">

ルーティングで設定した名前空間によってメソッド名が変わる

最初、authenticate_user!や、current_user で同じことができると思っていたのですが、
どうやら routes.rb で名前空間を設けると、それにつられてメソッド名が変更されるようです。

  namespace :api do
    namespace :v1 do
      namespace :auth do
        mount_devise_token_auth_for 'User', controllers: {
          registrations: "api/v1/registrations",
          sessions: "api/v1/sessions"
        }
      end
    end
  end

ちゃんとコード読んでないが、おそらくこのへんでやっている。
https://github.com/lynndylanhurley/devise_token_auth/blob/6b0659f18c678b319913d0fb053e96aa555857aa/lib/devise_token_auth/controllers/helpers.rb#L35

動作確認

Postman で動作確認します。
まずは認証に必要なヘッダー情報を何も含めずにリクエストしてみます。
すると、「お前誰やねん」的な 401 Unauthorized レスポンスが返ってきます。

次に、前回作ったログイン API を使って、認証に必要なヘッダー情報を生成します。
先程の認証に必要なヘッダー情報が返ってきました。

ちなみにこのときサーバー側ではユーザーのトークンを update するような 処理が行われています。

  User Load (1.5ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 11 LIMIT 1 FOR UPDATE
  User Update (2.5ms)  UPDATE `users` SET `users`.`tokens` = '{\"A1xqSprh8sZOGJ59Dkj2Ug\":{\"token\":\"$2a$10$9Vs6SL6ELXWV19y9KYusUumbzv57VN9/suSU2jMkagCRy594Ie5sa\",\"expiry\":1704935455,\"previous_token\":\"$2a$10$uGut.xnpJGMRDVSVXoQJD.PAihp6Zz3hWO7VSX98anvp510BqjiG6\",\"last_token\":\"$2a$10$Yq6vp2lr6C.IjH4T22XEWezzHm479EXhghFq.6v8bbFmuoJM3aRLq\",\"updated_at\":\"2023-12-28T01:10:55Z\"},\"-yn43Z-TK01MH1hgyraitQ\":{\"token\":\"$2a$10$nzvIFTnUaYvykwYmiYpuOuTD31fPmQr6Je8.kb/atrvGbunfoDg9u\",\"expiry\":1704935747,\"previous_token\":\"$2a$10$SNSlcSm53U.CSVhMh9o7VeN0JcJ3KqdbaB0dv4ecX6hPzMCIHuTJa\",\"last_token\":\"$2a$10$MQS.BZKFWaqv2Zn9ELlw9.onDhBuf6WyWfyo3fv2vBVPgbgpEa3e2\",\"updated_at\":\"2023-12-28T01:15:47Z\"},\"vANZhdes9xr1vwFgoaXg0A\":{\"token\":\"$2a$10$5HedtWEsnh0pJFEvLfRWw.py2T9N4qgug8Z4f/.ECQopFOt9qoE7K\",\"expiry\":1704935954,\"previous_token\":\"$2a$10$qSQ/R1xOJ9IV2UbCVrQ99uCDI9dunjqV1FzW8s7.3PRunLTg0yKZi\",\"last_token\":\"$2a$10$JYdspTV7eIejijhqheK1..SWJGqHGiZW3UoiBw65xBE6D5vIF/T.K\",\"updated_at\":\"2023-12-28T01:19:14Z\"},\"teF_9gcHr98d-GE7wL_PAA\":{\"token\":\"$2a$10$A.Jjd5Dc2ezSj2/WMR/cLevryoX8NsJeCUkdBqhIitmxQZ7Ugkk0q\",\"expiry\":1704936354},\"WKWF3DI02V0bNlqk6IC0LQ\":{\"token\":\"$2a$10$PpkUGmNS0K23Lul3ZMC6Ee/8BcAnuKyumWPn9xC7WpDykzMd/t99C\",\"expiry\":1704936412},\"PaIWkQY6iQwsnXN4sbm2xA\":{\"token\":\"$2a$10$xDu/6EJITMjUpOZHjponsODC/l1u/DbAKVogbbDABYw1CBcHgfLie\",\"expiry\":1704939329,\"previous_token\":\"$2a$10$g6IRPIV6ll.Kd2UKCo.68eBw6Z12DsM89qx.CDu/FlgqUEtEL1HZ2\",\"last_token\":\"$2a$10$XudGzUZ88NTrFe0c4Jh./ut1xB7lbjCj6/rU31MbDBanN3hWcCpz2\",\"updated_at\":\"2023-12-28T02:15:29Z\"},\"IQAOoLayfnt83Wlnm8X_FA\":{\"token\":\"$2a$10$dLxW/UFXCwOntNYaTvVNSOsBq9Z9zGSZEopv9cLY3ZOQHcl9nkPta\",\"expiry\":1704939605,\"previous_token\":\"$2a$10$UgAaTxf7I.GIpOZi054qa.4mquC9iYC0CNzVkt51TC/L.G2JkYBAC\",\"updated_at\":\"2023-12-28T02:20:05Z\"},\"-VWiAdNbgxL_B5Eebkc-1g\":{\"token\":\"$2a$10$7gkpb9qOk9G6yQHXsPnn1.atDlYq0neIn4pDG12NrHPzoKn/7FaNa\",\"expiry\":1704939615,\"previous_token\":\"$2a$10$O3gcV94NtJyHl/ctHB6gFu6tNkbgq/z2gNj0DPFbC3EPvlCsWgBEK\",\"updated_at\":\"2023-12-28T02:20:15Z\"},\"2I4DHbtzVBPp98ihliPoUg\":{\"token\":\"$2a$10$ewXfaj/ZKuejk2m7lQn.4uC66lP.5zs8pfpvdbZTK9FI//AC8P77a\",\"expiry\":1704939621,\"previous_token\":\"$2a$10$omPCzJpETCXgNOabAEUYM.6EU6wCNHNjNVC5/36rW116AhtiRsTfm\",\"updated_at\":\"2023-12-28T02:20:21Z\"},\"hyzqAcbhaibmRbnItz9xkQ\":{\"token\":\"$2a$10$eTeVmChfCCbfFHVzRYt8iuUuXUbYpBpIpRo9Lqa3KMSTjAjo2nzlu\",\"expiry\":1704939907}}', `users`.`updated_at` = '2023-12-28 02:25:07.080176' WHERE `users`.`id` = 11

そして次に、作成されたヘッダー情報を、リクエストヘッダーに詰め込み、リクエストします。
※1回ログイン叩いてしまって access-token とか変わりましたがお気になさらず。

するとユーザーの友達情報が無事返ってきました。

毎回ヘッダー情報が変わらないようにする

今のままだとレスポンスヘッダーの情報がリクエストのたびに更新されてしまい、開発しにくいので
トークンを変わらないようにします。
セキュリティ的にどうとかは一旦無視します。
フロント開発の時にいちいちリクエストヘッダーを気にするのも面倒なので。

念のため同じトークンは2週間の有効期限を設定しておきます。
config/initializers/devise_token_auth.rb

DeviseTokenAuth.setup do |config|
  config.change_headers_on_each_request = false
  config.token_lifespan = 2.weeks
end

そもそもなんで devise_token_auth をつかうの?

認証自体は「このリクエストは誰?」というのが分かればいいので、例えば以下のようなコードでもできます。

class FriensController < ApplicationController

  def index 
    friends = current_user.friends
    render json: { friends: friends }
  end
  
  private
    def current_user
      User.find(params[:user_id])
    end
end

このコードのだめなところは何かというと、 current_user が params で渡された user_idで特定できてしまうところですね。
例えば/api/v1/friends?user_id=1 とかでアクセスすると、サーバ側は
「こいつは id 1番のユーザーなのね」と簡単に分かってしまいます。

つまり user の id さえわかっていれば、友達の情報を取得できてしまうし、API によっては
作成したり更新したり削除できたりできるわけです。

このような認証方法だと、セキュリティ的にガバガバすぎるので、推測されにくい access-tokenclient などのヘッダー情報と、email などのユーザーが持っている情報を照らし合わせて、
「こいつは〇〇だな」とサーバーに分からせる必要があるのです。

Discussion