データ移行せずに、登録済みのユーザがそのままAPIを使ったアプリでも認証できるようにDevise Token Authを入れて共存できるようにしてみました。
今まで通りDeviseで登録・認証できる事、APIでも登録・認証できるようにするのが、今回のゴールです。

Devise Token Auth導入

TODO: rack-corsの設定(APIのみ許可するようにした方が良さそう)

Gemfile

gem 'devise_token_auth'
gem 'rack-cors'
% bundle install

% rails g devise_token_auth:install User auth
Running via Spring preloader in process 63548
      create  config/initializers/devise_token_auth.rb
      insert  app/controllers/application_controller.rb
        gsub  config/routes.rb
      create  db/migrate/20210806000715_devise_token_auth_create_users.rb
      insert  app/models/user.rb

ここまでのコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/0cc962b6ca917b96fedfc5ef580ff5ea8121d2c9

migrateファイル書き替え

新規作成のスキーマになっているので、追加するカラムとインデックスのみに変更する。

db/migrate/20210806000715_devise_token_auth_create_users.rb を削除
db/migrate/20210806000715_devise_token_auth_change_users.rb を作成

class DeviseTokenAuthChangeUsers < ActiveRecord::Migration[6.1]
  def change
    ## Required
    add_column :users, :provider, :string, null: false, default: 'email', comment: '認証方法'
    add_column :users, :uid,      :string, null: false, default: '',      comment: 'UID'
    ActiveRecord::Base.connection.execute('UPDATE users SET uid = email')

    ## Recoverable
    add_column :users, :allow_password_change, :boolean, default: false, comment: 'パスワード変更許可'

    ## User Info
    add_column :users, :nickname, :string, comment: 'ニックネーム'

    ## Tokens
    add_column :users, :tokens, :text, comment: 'トークン'

    add_index :users, [:uid, :provider], unique: true, name: 'index_users7'
  end
end

「UPDATE users SET uid = email」は、下記でも同じですが、1回の方が早いので、SQL直書きにしています。
uidは、providerが'email'の場合、メールアドレスが入る。

    User.reset_column_information
    User.find_each do |user|
        user.update_column(:uid, user.email)
    end

nameは既に追加済みなので記載していません。無い場合は追加してください。

    add_column :users, :name, :string, comment: '氏名'

db:migrate

% rails db:migrate
Could not load 'omniauth'. Please ensure you have the omniauth gem >= 1.0.0 installed and listed in your Gemfile.
rails aborted!
LoadError: cannot load such file -- omniauth

元々、omniauth使ってないので、Gemが無いと言われました。

app/models/user.rb

class User < ApplicationRecord
-            # Include default devise modules.
-            devise :database_authenticatable, :registerable,
-                    :recoverable, :rememberable, :trackable, :validatable,
-                    :confirmable, :omniauthable
-            include DeviseTokenAuth::Concerns::User

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :timeoutable, :trackable
+  include DeviseTokenAuth::Concerns::User

% rails db:migrate
rails aborted!
ArgumentError: Invalid route name, already in use: 'new_user_registration'

既に存在するモデル(今回はUser)にしたので、routeの名前がバッティングしました。
一旦、コメントアウトして、後ほど直します。

config/routes.rb

Rails.application.routes.draw do
-  mount_devise_token_auth_for 'User', at: 'auth'
+  # mount_devise_token_auth_for 'User', at: 'auth'

% rails db:migrate      
== 20210806000715 DeviseTokenAuthChangeUsers: migrating =======================
-- add_column(:users, :provider, :string, {:null=>false, :default=>"email", :comment=>"認証方法"})
   -> 0.2292s
-- add_column(:users, :uid, :string, {:null=>false, :default=>"", :comment=>"UID"})
   -> 0.1201s
-- add_column(:users, :allow_password_change, :boolean, {:default=>false, :comment=>"パスワード変更許可"})
   -> 0.1629s
-- add_column(:users, :nickname, :string, {:comment=>"ニックネーム"})
   -> 0.1939s
-- add_column(:users, :tokens, :text, {:comment=>"トークン"})
   -> 0.2547s
-- add_index(:users, [:uid, :provider], {:unique=>true, :name=>"index_users7"})
   -> 0.1820s
== 20210806000715 DeviseTokenAuthChangeUsers: migrated (1.1431s) ==============

成功!

確認

MySQL

% rails db
> SELECT id, provider, uid, allow_password_change, nickname, tokens FROM users;
+----+----------+----------------+-----------------------+----------+--------+
| id | provider | uid            | allow_password_change | nickname | tokens |
+----+----------+----------------+-----------------------+----------+--------+
|  1 | email    | user1@mydomain |                     0 | NULL     | NULL   |
|  2 | email    | user2@mydomain |                     0 | NULL     | NULL   |
|  3 | email    | user3@mydomain |                     0 | NULL     | NULL   |
+----+----------+----------------+-----------------------+----------+--------+
3 rows in set (0.005 sec)

UPDATE文で、uidにemailの値が入りました。
defaultのemail(provider)や0(allow_password_change)も入っているので、
add_columnでのdefault指定は、既存データも更新してくれる。

PostgreSQL

こちらも同様でOK!

% rails db
# SELECT id, provider, uid, allow_password_change, nickname, tokens FROM users;
 id | provider |      uid       | allow_password_change | nickname | tokens 
----+----------+----------------+-----------------------+----------+--------
  1 | email    | user1@mydomain | f                     |          | 
  2 | email    | user2@mydomain | f                     |          | 
  3 | email    | user3@mydomain | f                     |          | 
(3 rows)

SQLite3

こちらも同様でOK!

% rails db
> SELECT id, provider, uid, allow_password_change, nickname, tokens FROM users;
1|email|user1@mydomain|0||
2|email|user2@mydomain|0||
3|email|user3@mydomain|0||

db:seed修正

db/seed/development/users.yml
(複数ある場合は、同様に修正)

-  email: 'user1@mydomain'
+  email: 'user1@mydomain.com'
+  uid: 'user1@mydomain.com'

メアドのドメインをmydomain.comに変えたのは、失敗(rails aborted!)する為。

確認

% rails c
> User.find(1).destroy
  TRANSACTION (20.0ms)  COMMIT

% rails db:seed
"[1/3] id: 1 ... Create"

ここまでのコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/45515f954105b5f6d6fb2ca35215d1e9194dd8ef

routeを通す

そのままだとバッディングするのと、
パスを/users/authにしたいので、namespaceの中に入れます。

config/routes.rb

-  # mount_devise_token_auth_for 'User', at: 'auth'
+  namespace :users do
+    mount_devise_token_auth_for 'User', at: 'auth'
+  end

確認

% rails routes
         new_users_user_session GET    /users/auth/sign_in(.:format)           devise_token_auth/sessions#new
             users_user_session POST   /users/auth/sign_in(.:format)           devise_token_auth/sessions#create
     destroy_users_user_session DELETE /users/auth/sign_out(.:format)          devise_token_auth/sessions#destroy
        new_users_user_password GET    /users/auth/password/new(.:format)      devise_token_auth/passwords#new
       edit_users_user_password GET    /users/auth/password/edit(.:format)     devise_token_auth/passwords#edit
            users_user_password PATCH  /users/auth/password(.:format)          devise_token_auth/passwords#update
                                PUT    /users/auth/password(.:format)          devise_token_auth/passwords#update
                                POST   /users/auth/password(.:format)          devise_token_auth/passwords#create
 cancel_users_user_registration GET    /users/auth/cancel(.:format)            devise_token_auth/registrations#cancel
    new_users_user_registration GET    /users/auth/sign_up(.:format)           devise_token_auth/registrations#new
   edit_users_user_registration GET    /users/auth/edit(.:format)              devise_token_auth/registrations#edit
        users_user_registration PATCH  /users/auth(.:format)                   devise_token_auth/registrations#update
                                PUT    /users/auth(.:format)                   devise_token_auth/registrations#update
                                DELETE /users/auth(.:format)                   devise_token_auth/registrations#destroy
                                POST   /users/auth(.:format)                   devise_token_auth/registrations#create
    new_users_user_confirmation GET    /users/auth/confirmation/new(.:format)  devise_token_auth/confirmations#new
        users_user_confirmation GET    /users/auth/confirmation(.:format)      devise_token_auth/confirmations#show
                                POST   /users/auth/confirmation(.:format)      devise_token_auth/confirmations#create
          new_users_user_unlock GET    /users/auth/unlock/new(.:format)        devise_token_auth/unlocks#new
              users_user_unlock GET    /users/auth/unlock(.:format)            devise_token_auth/unlocks#show
                                POST   /users/auth/unlock(.:format)            devise_token_auth/unlocks#create
      users_auth_validate_token GET    /users/auth/validate_token(.:format)    devise_token_auth/token_validations#validate_token

CSRFトークン

% rails s
% curl http://localhost:3000/users/auth -H "Content-Type: application/json" -X POST -d '{
	"email":"test@mydomain.com",
	"password":"abc12345"}'
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Action Controller: Exception caught</title>
〜省略〜
<header>
  <h1>
    ActionController::InvalidAuthenticityToken
      in DeviseTokenAuth::RegistrationsController#create
  </h1>
</header>

InvalidAuthenticityToken → CSRFトークンチェックでエラー
APIではページがないので、チェックできない。(認証トークンとかで担保かな)

CSRFトークンの仕組みはこちら
ActionController::InvalidAuthenticityTokenの解消(解決済み)

部分的にCSRFチェックを外す

ApplicationControllerでやったり、devise_controller?で判断すると、
既存のページやDeviseも外れちゃうので、それぞれのControllerを作る事にしました。

序でにこれも移動
app/controllers/application_controller.rb

-  include DeviseTokenAuth::Concerns::SetUserByToken

config/routes.rb

  namespace :users do
-    mount_devise_token_auth_for 'User', at: 'auth'
+    mount_devise_token_auth_for 'User', at: 'auth', controllers: {
+      registrations: 'users/auth/registrations',
+      confirmations: 'users/auth/confirmations',
+      sessions: 'users/auth/sessions',
+      unlocks: 'users/auth/unlocks',
+      passwords: 'users/auth/passwords',
+      token_validations: 'users/auth/token_validations'
+    }
  end

app/controllers/users/auth/registrations_controller.rb を作成

class Users::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token
end

app/controllers/users/auth/confirmations_controller.rb を作成

class Users::Auth::ConfirmationsController < DeviseTokenAuth::ConfirmationsController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token
end

app/controllers/users/auth/sessions_controller.rb を作成

class Users::Auth::SessionsController < DeviseTokenAuth::SessionsController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token
end

app/controllers/users/auth/unlocks_controller.rb を作成

class Users::Auth::UnlocksController < DeviseTokenAuth::UnlocksController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token
end

app/controllers/users/auth/passwords_controller.rb を作成

class Users::Auth::PasswordsController < DeviseTokenAuth::PasswordsController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token
end

app/controllers/users/auth/token_validations_controller.rb を作成

class Users::Auth::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
  include DeviseTokenAuth::Concerns::SetUserByToken
  skip_before_action :verify_authenticity_token
end

確認

% rails routes
         new_users_user_session GET    /users/auth/sign_in(.:format)           users/auth/sessions#new
             users_user_session POST   /users/auth/sign_in(.:format)           users/auth/sessions#create
     destroy_users_user_session DELETE /users/auth/sign_out(.:format)          users/auth/sessions#destroy
        new_users_user_password GET    /users/auth/password/new(.:format)      users/auth/passwords#new
       edit_users_user_password GET    /users/auth/password/edit(.:format)     users/auth/passwords#edit
            users_user_password PATCH  /users/auth/password(.:format)          users/auth/passwords#update
                                PUT    /users/auth/password(.:format)          users/auth/passwords#update
                                POST   /users/auth/password(.:format)          users/auth/passwords#create
 cancel_users_user_registration GET    /users/auth/cancel(.:format)            users/auth/registrations#cancel
    new_users_user_registration GET    /users/auth/sign_up(.:format)           users/auth/registrations#new
   edit_users_user_registration GET    /users/auth/edit(.:format)              users/auth/registrations#edit
        users_user_registration PATCH  /users/auth(.:format)                   users/auth/registrations#update
                                PUT    /users/auth(.:format)                   users/auth/registrations#update
                                DELETE /users/auth(.:format)                   users/auth/registrations#destroy
                                POST   /users/auth(.:format)                   users/auth/registrations#create
    new_users_user_confirmation GET    /users/auth/confirmation/new(.:format)  users/auth/confirmations#new
        users_user_confirmation GET    /users/auth/confirmation(.:format)      users/auth/confirmations#show
                                POST   /users/auth/confirmation(.:format)      users/auth/confirmations#create
          new_users_user_unlock GET    /users/auth/unlock/new(.:format)        users/auth/unlocks#new
              users_user_unlock GET    /users/auth/unlock(.:format)            users/auth/unlocks#show
                                POST   /users/auth/unlock(.:format)            users/auth/unlocks#create
      users_auth_validate_token GET    /users/auth/validate_token(.:format)    users/auth/token_validations#validate_token

devise_token_auth/auth/になっていたのが、users/auth/に変わりました。
作成したControllerが呼ばれています。

パラメータ追加

% curl http://localhost:3000/users/auth -H "Content-Type: application/json" -X POST -d '{
	"email":"test@mydomain.com",
	"password":"abc12345"}'
{"success":false,
"errors":["'confirm_success_url' パラメータが与えられていません。"],"status":"error","data":{"id":null,"code":"aaa0eb67dd9db897927ee58b8d6bf2e3","image":{"url":null,"mini":{"url":null},"small":{"url":null},"medium":{"url":null},"large":{"url":null},"xlarge":{"url":null}},"name":null,"email":"test@mydomain.com","destroy_requested_at":null,"destroy_schedule_at":null,"created_at":null,"updated_at":null,"provider":"email","uid":"test@mydomain.com","allow_password_change":false,"nickname":null}}

confirm_success_urlを指定してみる。用途は後ほど。

% curl http://localhost:3000/users/auth -H "Content-Type: application/json" -X POST -d '{
	"email":"test@mydomain.com",
	"password":"abc12345",
	"confirm_success_url":"http://localhost:3000/users/auth/sign_in"}'
{"status":"error","data":{"id":null,"code":null,"image":{"url":null,"mini":{"url":null},"small":{"url":null},"medium":{"url":null},"large":{"url":null},"xlarge":{"url":null}},"name":null,"email":"test@mydomain.com","destroy_requested_at":null,"destroy_schedule_at":null,"created_at":null,"updated_at":null,"provider":"email","uid":"","allow_password_change":false,"nickname":null},
"errors":{"code":["お問い合わせ頂くか、最初からやり直してください。"],"name":["入力してください。"],"full_messages":["ユーザーコード お問い合わせ頂くか、最初からやり直してください。","氏名 入力してください。"]}}

独自にcodeを割り当てたり、nameにvalidates掛けているので、修正とパラメータ追加。

app/controllers/users/auth/registrations_controller.rb に追加

  before_action :configure_sign_up_params, only: %i[create]

  def create
    params[:code] = create_unique_code(User, 'code', "Users::RegistrationsController.create #{params}")
    ActiveRecord::Base.transaction do # Tips: エラーでROLLBACKされない為
      super
    end
  end

  protected

  # If you have extra params to permit, append them to the sanitizer.
  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: %i[code name])
  end

パラメータ追加は、下記メソッドを追加しちゃっても出来るけど、
Deviseと同様に、devise_parameter_sanitizerに設定すれば効きます。
gems/devise_token_auth-1.2.0/app/controllers/devise_token_auth/registrations_controller.rb

    def sign_up_params
      params.permit(*params_for_resource(:sign_up))
    end
% curl http://localhost:3000/users/auth -H "Content-Type: application/json" -X POST -d '{
	"name":"test",
	"email":"test@mydomain.com",
	"password":"abc12345",
	"confirm_success_url":"http://localhost:3000/users/auth/sign_in"}'
{"status":"success","data":{"id":30,"code":"31e210e8ede22e1a7053107db1dcd5ea","image":{"url":null,"mini":{"url":null},"small":{"url":null},"medium":{"url":null},"large":{"url":null},"xlarge":{"url":null}},"name":"test","email":"test@mydomain.com","destroy_requested_at":null,"destroy_schedule_at":null,"created_at":"2021-08-08T14:52:16.934+09:00","updated_at":"2021-08-08T14:52:16.934+09:00","provider":"email","uid":"test@mydomain.com","allow_password_change":false,"nickname":null}}

成功!

確認

% rails db
> SELECT * FROM users WHERE email = 'test@mydomain.com'\G
*************************** 1. row ***************************
                  code: cdb<省略>
                  name: test
                 email: test@mydomain.com
    encrypted_password: $2a<省略>
    confirmation_token: sKx<省略>
          confirmed_at: NULL
  confirmation_sent_at: 2021-08-08 06:01:04
              provider: email
                   uid: test@mydomain.com
 allow_password_change: 0
              nickname: NULL
                tokens: NULL

rails sのコンソールやLetterOpenerWebで確認

Subject:【開発環境】【RailsAppOrigin】メールアドレス確認のお願い
To: test@mydomain.com

メールのURLアクセスで、認証OK
既存画面でのログイン・ログアウトもOK

RSpecで既存テスト

% rspec

Failures:

  1) Users::Registrations PUT #update ログイン中 behaves like [ログイン中]有効なパラメータ behaves like ToTop トップページにリダイレクト
     Failure/Error: expect(flash[:notice]).to notice.present? ? eq(I18n.t(notice)) : be_nil
     
       expected: "メールアドレス確認のメールを送信しました。メールを確認してください。"
            got: "登録情報を変更しました。"
     
       (compared using ==)
     Shared Example Group: "ToTop" called from ./spec/requests/users/registrations_spec.rb:227
     Shared Example Group: "[ログイン中]有効なパラメータ" called from ./spec/requests/users/registrations_spec.rb:259
     # ./spec/requests/users/registrations_spec.rb:206:in `block (4 levels) in 
' Finished in 1 minute 9.63 seconds (files took 4.05 seconds to load) 807 examples, 1 failure Failed examples: rspec './spec/requests/users/registrations_spec.rb[1:4:2:1:2:1]' # Users::Registrations PUT #update ログイン中 behaves like [ログイン中]有効なパラメータ behaves like ToTop トップページにリダイレクト

config/initializers/devise_token_auth.rb

+  config.send_confirmation_email = true
% rspec spec/requests/users/registrations_spec.rb
Finished in 5.28 seconds (files took 1.3 seconds to load)
63 examples, 0 failures

OK。テストがあると便利ですね!

但し、現状、メールが送信されたかまではテストしていない。
メッセージは変わったけど、実際には送信されていない。

Modelで対応:NG

APIだと、同じメールが2通送信されちゃいました。
上記のconfig.send_confirmation_emailをfalseにしても変わらず。

app/models/user.rb

+  after_create :send_confirmation_instructions

Controllerで対応:OK

ベストでは無いけど、既存のRegistrationsControllerで対応。
1通送信されました。
APIはここに来ないので影響なし。1通送信。

app/controllers/users/registrations_controller.rb

  def create
    params[:user][:code] = create_unique_code(User, 'code', "Users::RegistrationsController.create #{params[:user]}")
    super
    flash[:alert] = resource.errors[:code].first if resource.errors[:code].present?
+    resource.send_confirmation_instructions if resource.errors.blank? # Tips: devise_token_auth導入後、送信されなくなった為
  end

設定ファイル確認

上記で変更した以外は、デフォルトでも問題なさそうなので、後は必要になってから変える事にします。

下記だけ気になりましたが、Controllerで明確に分けているので、falseのままで良さそう。
experimental(実験的)ってのも避けたくなる記載。

  # By default, only Bearer Token authentication is implemented out of the box.
  # If, however, you wish to integrate with legacy Devise authentication, you can
  # do so by enabling this flag. NOTE: This feature is highly experimental!
  # config.enable_standard_devise_support = false
デフォルトでは、BearerToken認証のみがすぐに実装されます。
ただし、従来のDevise認証と統合する場合は、このフラグを有効にすることで統合できます。
注:この機能は非常に実験的なものです。

gems/devise_token_auth-1.2.0/app/controllers/devise_token_auth/concerns/set_user_by_token.rb

    # check for an existing user, authenticated via warden/devise, if enabled
    if DeviseTokenAuth.enable_standard_devise_support
      devise_warden_user = warden.user(mapping)
      if devise_warden_user && devise_warden_user.tokens[@token.client].nil?
        @used_auth_by_token = false
        @resource = devise_warden_user
        # REVIEW: The following line _should_ be safe to remove;
        #  the generated token does not get used anywhere.
        # @resource.create_new_auth_token
      end
    end

ここまでのコミット内容
https://dev.azure.com/nightonly/rails-app-origin/_git/rails-app-origin/commit/843e524a017edb5ed968b0326e2241a354d659f7

最後に

長くなったので、登録以外のAPIの疎通確認と処理フローの確認は次回、記載したいと思います。
最後に、失敗メモを記載しておきます。(誰かの参考になればと)

失敗メモ1

app/models/user.rb に下記が無いと、

  include DeviseTokenAuth::Concerns::User

引数エラーになる。

ArgumentError (wrong number of arguments (given 1, expected 0)):

下記メソッドをoverrideしてるから。
gems/devise_token_auth-1.2.0/app/models/devise_token_auth/concerns/user.rb

    # override devise method to include additional info as opts hash
    def send_confirmation_instructions(opts = {})

失敗メモ2

app/controllers/application_controller.rb に下記があると、

  include DeviseTokenAuth::Concerns::SetUserByToken

user_signed_in?がエラーになる。current_userも。
signed_in?('user')と変えれば取れるけど、Controllerで明確に分けた方が良い。

ActionView::Template::Error (wrong number of arguments (given 1, expected 0)):
    23:           </button>
    24:           <div class="collapse navbar-collapse" id="navbarNavAltMarkup">
    25:             <ul class="navbar-nav" style="margin-left: auto">
    26: <% if user_signed_in? %>
    27:               <li class="nav-item text-nowrap" style="height: 45px"><%= link_to (image_tag current_user.image_url(:small)).html_safe + ' ' + current_user.name, edit_user_registration_path, class: 'nav-link d-inline-block text-truncate', style: 'max-width: 400px' %></li>
    28:               <li class="nav-item text-nowrap"><%= link_to 'ログアウト', destroy_user_session_path, class: 'nav-link', style: 'padding-top: 10px; padding-bottom: 10px' %></li>
    29: <% else %>

Devise導入済みのアプリにDevise Token Authを入れて共存させる” に対して1件のコメントがあります。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です