Railsでdevise_token_auth使ってトークン認証する
Railsで認証を実装するのに有名なGemであるplataformatec/deviseを使ってトークン認証を実装するためのGemであるlynndylanhurley/devise_token_authを使ってトークンによる認証を実装する。
+αで少しカスタマイズする方法も書く。
ちなみにdeviseにもかつてはTokenAuthenticatable
があったらしいがなくなった。
バージョンはこちら。
$ bundle list | grep -e 'devise' -e ' rails ' * devise (4.7.1) * devise_token_auth (1.1.3) * rails (6.0.0)
インストールと初期設定
公式ドキュメントとかを見れば良い。
$ echo "gem 'devise_token_auth'" >> Gemfile $ bundle install $ rails g devise_token_auth:install Account accounts
これによって必要なファイル群(モデル、マイグレーション、initializerなど)が生成されるので、それを編集すれば良い。
これで終わってしまってはアレなので、以降では自分がやってみたことについて書く。
モデル
今回はAccout
とした。
class Account < ActiveRecord::Base devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :lockable include DeviseTokenAuth::Concerns::User end
まあ普通でdeviseの機能がそれっぽく使える感じ。
schema
Migrationファイルを弄った結果このようなaccountsテーブルとなっている。
create_table "accounts", force: :cascade do |t| t.string "provider", default: "email", null: false t.string "uid", default: "", null: false t.string "encrypted_password", default: "", null: false t.string "reset_password_token" t.datetime "reset_password_sent_at" t.boolean "allow_password_change", default: false t.datetime "remember_created_at" t.string "confirmation_token" t.datetime "confirmed_at" t.datetime "confirmation_sent_at" t.integer "failed_attempts", default: 0, null: false t.string "unlock_token" t.datetime "locked_at" t.string "name", null: false t.string "email", null: false t.json "tokens" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["confirmation_token"], name: "index_accounts_on_confirmation_token", unique: true t.index ["email"], name: "index_accounts_on_email", unique: true t.index ["reset_password_token"], name: "index_accounts_on_reset_password_token", unique: true t.index ["uid", "provider"], name: "index_accounts_on_uid_and_provider", unique: true t.index ["unlock_token"], name: "index_accounts_on_unlock_token", unique: true end
routes
自動生成されるものだと/accounts
がベースになってしまうが/api/accounts
にしたい、という場合はこんな感じ。
scope :api do mount_devise_token_auth_for 'Account', at: 'accounts' end
namespace
を使うとうまく行かなかったのでscope
を使っている。
これでrails routes
するとこんな風になる。
$ bundle exec rails routes | grep accounts new_account_session GET /api/accounts/sign_in(.:format) devise_token_auth/sessions#new account_session POST /api/accounts/sign_in(.:format) devise_token_auth/sessions#create destroy_account_session DELETE /api/accounts/sign_out(.:format) devise_token_auth/sessions#destroy new_account_password GET /api/accounts/password/new(.:format) devise_token_auth/passwords#new edit_account_password GET /api/accounts/password/edit(.:format) devise_token_auth/passwords#edit account_password PATCH /api/accounts/password(.:format) devise_token_auth/passwords#update PUT /api/accounts/password(.:format) devise_token_auth/passwords#update POST /api/accounts/password(.:format) devise_token_auth/passwords#create cancel_account_registration GET /api/accounts/cancel(.:format) devise_token_auth/registrations#cancel new_account_registration GET /api/accounts/sign_up(.:format) devise_token_auth/registrations#new edit_account_registration GET /api/accounts/edit(.:format) devise_token_auth/registrations#edit account_registration PATCH /api/accounts(.:format) devise_token_auth/registrations#update PUT /api/accounts(.:format) devise_token_auth/registrations#update DELETE /api/accounts(.:format) devise_token_auth/registrations#destroy POST /api/accounts(.:format) devise_token_auth/registrations#create new_account_unlock GET /api/accounts/unlock/new(.:format) devise_token_auth/unlocks#new account_unlock GET /api/accounts/unlock(.:format) devise_token_auth/unlocks#show POST /api/accounts/unlock(.:format) devise_token_auth/unlocks#create api_accounts_validate_token GET /api/accounts/validate_token(.:format) devise_token_auth/token_validations#validate_token
トークンによる認証の実装
みんな大好きApplicationControllerに書く。
class ApplicationController < ActionController::API include DeviseTokenAuth::Concerns::SetUserByToken before_action :authenticate_account!, unless: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller? respond_to :json private def devise_token_auth_controller? params[:controller].split('/')[0] == 'devise_token_auth' end def configure_permitted_parameters # DBにaccounts.nameカラムがある場合 devise_parameter_sanitizer.permit(:sign_up, keys: [:name]) devise_parameter_sanitizer.permit(:account_update, keys: [:name]) end end
必要最小限でこんな感じ。
- deviseにある
:devise_controller?
がdevise_token_authでも使える devise_parameter_sanitizer.permit
で必要なパラメータを追加できる
というところがポイント。
routesをカスタマイズする
上の方に書いたやつでもいいけど、少しカスタマイズする。
具体的にはsign up用のパスをPOST /api/accounts
ではなくPOST /api/accounts/sign_up
にしたい。
mount_devise_token_auth_for
の:skip
オプションと、devise_scope :account
の組み合わせで実現できる。
scope :api do mount_devise_token_auth_for 'Account', at: 'accounts', skip: [:registrations] devise_scope :account do post '/accounts/sign_up', to: 'devise_token_auth/registrations#create' put '/accounts', to: 'devise_token_auth/registrations#update' delete '/accounts', to: 'devise_token_auth/registrations#destroy' end end
この結果、こんなroutesが生成される。
$ be rails routes | ag registration accounts_sign_up POST /api/accounts/sign_up(.:format) devise_token_auth/registrations#create accounts PUT /api/accounts(.:format) devise_token_auth/registrations#update DELETE /api/accounts(.:format) devise_token_auth/registrations#destroy
curlでリクエストを送ってみる
ここまで実装してきたRailsアプリに対してcurlでリクエストを送る。
sign_up
まずはアカウント登録処理。
$ curl -H "Content-Type: application/json" \ -d "$(jo name=hoge email=hoge@example.com password=password password_confirmation=password)" \ 'localhost:3000/api/accounts/sign_up' \ | jq -S . { "data": { "allow_password_change": false, "created_at": "2019-10-31T14:21:54.026Z", "email": "hoge@example.com", "id": 1, "name": "hoge", "provider": "email", "uid": "hoge@example.com", "updated_at": "2019-10-31T14:21:54.120Z" }, "status": "success" }
いい感じ。
sign_in
今作成したアカウントの情報を使ってemail/passwordでsign_inする。
$ curl -H "Content-Type: application/json" \ -d "$(jo email=hoge@example.com password=password)" \ 'localhost:3000/api/accounts/sign_in' \ -i 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 access-token: twkb92Ycy28DuMyfBYn4GA token-type: Bearer client: D1cGRIzfDhxYgoRSN8xsIw expiry: 1573741507 uid: hoge@example.com ETag: W/"ed6728748b7589356745b69594dc77f9" Cache-Control: max-age=0, private, must-revalidate X-Request-Id: abc3e906-3f87-44b3-849a-19ce4cf3418c X-Runtime: 0.229688 Vary: Origin Transfer-Encoding: chunked {"data":{"id":1,"email":"hoge@example.com","provider":"email","uid":"hoge@example.com","allow_password_change":false,"name":"hoge"}}
ここで大事なのはヘッダのaccess-token
, client
, uid
の3つ。
少なくともこの3つが認証処理に必要な文字列となる。
認証する
認証してただその情報を返してくれるcontrollerとrouteを用意する。
controllerがこんな感じ
class Api::SessionsController < ApplicationController def whoami render json: current_user, status: :ok end end
routesはこんな感じ
namespace :api, defaults: { format: :json } do get '/whoami', to: 'sessions#whoami' end
これに対して、先程sign_inで手に入れた認証情報を乗せたリクエストを送ってみる。
$ curl -H "Content-Type: application/json" \ -H "access-token: twkb92Ycy28DuMyfBYn4GA" \ -H "client: D1cGRIzfDhxYgoRSN8xsIw" \ -H "uid: hoge@example.com" \ 'localhost:3000/api/whoami' \ | jq -S . { "allow_password_change": false, "created_at": "2019-10-31T14:21:54.026Z", "email": "hoge@example.com", "id": 1, "name": "hoge", "provider": "email", "uid": "hoge@example.com", "updated_at": "2019-10-31T14:25:07.999Z" }
誤ったリクエストを送るとちゃんと怒られる
$ curl -H "Content-Type: application/json" \ 'localhost:3000/api/whoami' \ | jq -S . { "errors": [ "You need to sign in or sign up before continuing." ] }
認証情報を1つの文字列にぶち込む
とはいえ毎回access-token
, client
, uid
の3つをヘッダに乗せるのはめんどくさい。
そこで1つの文字列に全部入れて扱えるようにヘルパーを書いてみる。
class ApplicationController < ActionController::API ... before_action :split_tokens prepend_after_action :join_tokens private def split_tokens return if request.headers['X-Access-Token'].nil? token = JSON.parse(Base64.decode64(CGI.unescape(request.headers['X-Access-Token']))) request.headers['access-token'] = token['access-token'] request.headers['client'] = token['client'] request.headers['uid'] = token['uid'] end def join_tokens return if response.headers['access-token'].nil? auth_json = { 'access-token' => response.headers['access-token'], 'client' => response.headers['client'], 'uid' => response.headers['uid'], } response.headers.delete_if{|key| auth_json.include? key} response.headers['X-Access-Token'] = CGI.escape(Base64.encode64(JSON.dump(auth_json))) end ... end
ものすごく雑にaccess-token
, client
, uid
の3つの情報を、JSON + base64 + URLエンコードを組み合わせてX-Access-Token
ヘッダとして扱えるようにしている。
これによってX-Access-Token
ヘッダが返ってくるようになり、
$ curl -H "Content-Type: application/json" \ -d "$(jo email=hoge@example.com password=password)" \ 'localhost:3000/api/accounts/sign_in' -i \ | grep X-Access-Token X-Access-Token: eyJhY2Nlc3MtdG9rZW4iOiJ3WEZmeVo2TEZROGNJWDFTV2tMeFVBIiwiY2xp%0AZW50IjoieEd1U0pHVVNRaUtueE8yb1pfWGJuUSIsInVpZCI6ImhvZ2VAZXhh%0AbXBsZS5jb20ifQ%3D%3D%0A
X-Access-Token
ヘッダを送り返せば認証が成功するようになる。
$ curl -H "Content-Type: application/json" \ -H "X-Access-Token: eyJhY2Nlc3MtdG9rZW4iOiJ3WEZmeVo2TEZROGNJWDFTV2tMeFVBIiwiY2xp%0AZW50IjoieEd1U0pHVVNRaUtueE8yb1pfWGJuUSIsInVpZCI6ImhvZ2VAZXhh%0AbXBsZS5jb20ifQ%3D%3D%0A" \ 'localhost:3000/api/whoami' \ | jq -S . { "allow_password_change": false, "created_at": "2019-10-31T14:21:54.026Z", "email": "hoge@example.com", "id": 1, "name": "hoge", "provider": "email", "uid": "hoge@example.com", "updated_at": "2019-10-31T14:48:54.447Z" }