Active Admin 登录不起作用(Devise + ActiveAdmin + Devise JWT)

Active Admin login not working (Devise + ActiveAdmin + Devise JWT)

我在 API 模式下使用 rails,使用 Devise 和 Devise JWT(针对 API)和 ActiveAdmin。我一切正常,但我一直在构建 API 控制器,现在 ActiveAdmin 身份验证已损坏,我无法弄清楚发生了什么。

所以我尝试直接转到 /admin/login 并且成功了。我输入我的用户名和密码,当我点击登录时,出现以下错误:

NoMethodError in ActiveAdmin::Devise::SessionsController#create
private method `redirect_to' called for #<ActiveAdmin::Devise::SessionsController:0x0000000001d420>

我不太确定为什么它会被破坏,因为它主要使用默认设置。

我的路线文件:

Rails.application.routes.draw do
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)
  ...

我没有更改 ActiveAdmin::Devise 中的任何内容,我什至没有在我的代码库中显示文件。

在我的 Devise 配置中:

config.authentication_method = :authenticate_admin_user!
config.current_user_method = :current_admin_user

我的非活动管理员会话控制器看起来像:

# frozen_string_literal: true

module Users
  class SessionsController < Devise::SessionsController
    respond_to :json

    private

    def respond_with(resource, _opts = {})
      render json: {
        status: { code: 200, message: 'Logged in sucessfully.' },
        data: UserSerializer.new(resource).serializable_hash
      }, status: :ok
    end

    def respond_to_on_destroy
      if current_user
        render json: {
          status: 200,
          message: 'logged out successfully'
        }, status: :ok
      else
        render json: {
          status: 401,
          message: 'Couldn\'t find an active session.'
        }, status: :unauthorized
      end
    end
  end
end

这是我的管理员用户模型:

# frozen_string_literal: true

class AdminUser < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable,
         :recoverable, :rememberable, :validatable
end

当我忽略重定向错误时,我不相信登录确实有效。我尝试转到任何页面,但收到相同的消息 You need to sign in or sign up before continuing.

这是我的应用程序配置:

    config.load_defaults 7.0
    config.api_only = true
    config.session_store :cookie_store, key: '_interslice_session'

    # Required for all session management (regardless of session_store)
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Session::CookieStore

    config.middleware.use config.session_store, config.session_options

我做错了什么?

更新代码:

class ApplicationController < ActionController::API
  # https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/api.rb#L104
  # skip modules that we need to load last
  ActionController::API.without_modules(:Instrumentation, :ParamsWrapper).each do |m|
    include m
  end

  # include what's missing
  include ActionController::ImplicitRender
  include ActionController::Helpers
  include ActionView::Layouts
  include ActionController::Flash
  include ActionController::MimeResponds

  # include modules that have to be last
  include ActionController::Instrumentation
  include ActionController::ParamsWrapper
  ActiveSupport.run_load_hooks(:action_controller_api, self)
  ActiveSupport.run_load_hooks(:action_controller, self)

  respond_to :json, :html

  def redirect_to(options = {}, response_options = {})
    super
  end
module Users
  class SessionsController < Devise::SessionsController
    respond_to :html
Rails.application.routes.draw do
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)

  devise_for :users, defaults: { format: :json }, path: '', path_names: {
    sign_in: 'login',
    sign_out: 'logout',
    registration: 'signup'
  },
                     controllers: {
                       sessions: 'users/sessions',
                       registrations: 'users/registrations'

应用程序配置:

  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 7.0
    config.api_only = true
    config.session_store :cookie_store, key: '_interslice_session'

    # Required for all session management (regardless of session_store)
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use ActionDispatch::Session::CookieStore
    config.middleware.use config.session_store, config.session_options

根据您的错误信息。 active_admin.

好像没有设置 root

在登录用户、确认帐户或更新密码后,Devise 将查找范围内的根路径以重定向到。例如,当使用 :user 资源时,如果存在 user_root_path 将被使用;否则,将使用默认值 root_path。这意味着您需要在路由中设置根目录:

root to: 'home#index'

您还可以覆盖 after_sign_in_path_forafter_sign_out_path_for 来自定义您的重定向挂钩。

参考了 here

我遇到了类似的问题。在我的例子中,它链接到 gem responders。这是我的issue,还没有解决。

这是我正在使用的设置,希望它是 self-explanatory 这样我们就可以找到实际的错误。

# Gemfile
# ...
gem "sprockets-rails"
gem "sassc-rails"
gem 'activeadmin'
gem 'devise'
gem 'devise-jwt'
# config/application.rb
require_relative "boot"
require "rails/all"
require "action_controller/railtie"
require "action_view/railtie"
require "sprockets/railtie"
Bundler.require(*Rails.groups)
module Rails7api
  class Application < Rails::Application
    config.load_defaults 7.0
    config.api_only = true
    config.session_store :cookie_store, key: '_interslice_session'
    config.middleware.use ActionDispatch::Cookies
    config.middleware.use Rack::MethodOverride
    config.middleware.use ActionDispatch::Flash
    config.middleware.use config.session_store, config.session_options
  end
end

# config/routes.rb
Rails.application.routes.draw do
  # Admin
  devise_for :admin_users, ActiveAdmin::Devise.config
  ActiveAdmin.routes(self)

  # Api (api_users, name is just for clarity)
  devise_for :api_users, defaults: { format: :json }
  namespace :api, defaults: { format: :json } do
    resources :users
  end
end

# config/initializers/devise.rb
Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    # jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
    jwt.secret = Rails.application.credentials.devise_jwt_secret_key!
  end
end
# db/migrate/20220424045738_create_authentication.rb
class CreateAuthentication < ActiveRecord::Migration[7.0]
  def change
    create_table :admin_users do |t|
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""
      t.timestamps null: false
    end
    add_index :admin_users, :email, unique: true

    create_table :api_users do |t|
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""
      t.timestamps null: false
    end
    add_index :api_users, :email, unique: true

    create_table :jwt_denylist do |t|
      t.string   :jti, null: false
      t.datetime :exp, null: false
    end
    add_index :jwt_denylist, :jti
  end
end
# app/models/admin_user.rb
class AdminUser < ApplicationRecord
  devise :database_authenticatable
end

# app/models/api_user.rb
class ApiUser < ApplicationRecord
  devise :database_authenticatable, :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
  self.skip_session_storage = [:http_auth, :params_auth] # https://github.com/waiting-for-dev/devise-jwt#session-storage-caveat
end

# app/models/jwt_denylist.rb
class JwtDenylist < ApplicationRecord
  include Devise::JWT::RevocationStrategies::Denylist
  self.table_name = 'jwt_denylist'
end
# app/application_controller.rb
class ApplicationController < ActionController::Base      # for devise and active admin
  respond_to :json, :html
end

# app/api/application_controller.rb
module Api
  class ApplicationController < ActionController::API     # for api
    before_action :authenticate_api_user!
  end
end

# app/api/users_controller.rb
module Api
  class UsersController < ApplicationController
    def index
      render json: User.all
    end
  end
end

人们有几种不同的方式得到这个错误,但它们似乎是同一个问题的变体。我只能找到一种私有 redirect_to 方法,它甚至在文档中

https://api.rubyonrails.org/classes/ActionController/Flash.html#method-i-redirect_to

active_admindevise 都继承自 ApplicationController

# ActiveAdmin::Devise::SessionsController < Devise::SessionsController < DeviseController < Devise.parent_controller.constantize # <= @@parent_controller = "ApplicationController"

# ActiveAdmin::BaseController < ::InheritedResources::Base < ::ApplicationController

ApplicationController 继承自 ActionController::API 时,活动管理员因缺少依赖项而中断。所以我们必须一一包含它们,直到 rails 靴子和控制器看起来像这样

class ApplicationController < ActionController::API
  include ActionController::Helpers       # FIXES undefined method `helper' for ActiveAdmin::Devise::SessionsController:Class (NoMethodError)
  include ActionView::Layouts             # FIXES undefined method `layout' for ActiveAdmin::Devise::SessionsController:Class (NoMethodError)
  include ActionController::Flash         # FIXES undefined method `flash' for #<ActiveAdmin::Devise::SessionsController:0x0000000000d840>):

  respond_to :json, :html                 # FIXES ActionController::UnknownFormat (ActionController::UnknownFormat):
end

这一直有效,直到您尝试登录并收到 private method 'redirect_to' 错误。稍微调试一下,back-tracing 指向 responders gem,它以 html 响应,这没问题,即使我们的控制器是 api 并调用redirect_to 但点击 Flash#redirect_to 而不是 Redirecting#redirect_to

  #0    ActionController::Flash#redirect_to(options="/admin", response_options_and_flash={}) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/flash.rb:52
  #1    ActionController::Responder#redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:147
  #2    ActionController::Responder#navigation_behavior(error=#<ActionView::MissingTemplate: Missing t...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:207
  #3    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:174
  #4    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:171
  #5    ActionController::Responder#respond at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:165
  #6    ActionController::Responder.call(args=[#<ActiveAdmin::Devise::SessionsControll...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:158
  #7    ActionController::RespondWith#respond_with(resources=[#<AdminUser id: 1, email: "admin@user.c..., block=nil) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/respond_with.rb:213
  #8    Devise::SessionsController#create at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/devise-4.8.1/app/controllers/devise/sessions_controller.rb:23

因为 API 控制器比较纤薄

https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/api.rb#L112

Base控制器

https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/base.rb#L205

好像少了什么。所以一点点调试和 back-tracing 与 Base 控制器确实揭示了一个小小的区别。

  #0    ActionController::Flash#redirect_to(options="/admin", response_options_and_flash={}) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/flash.rb:52
  #1    block {|payload={:request=>#<ActionDispatch::Request POS...|} in redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/instrumentation.rb:42
  #2    block in instrument at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2.3/lib/active_support/notifications.rb:206
  #3    ActiveSupport::Notifications::Instrumenter#instrument(name="redirect_to.action_controller", payload={:request=>#<ActionDispatch::Request POS...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2.3/lib/active_support/notifications/instrumenter.rb:24
  #4    ActiveSupport::Notifications.instrument(name="redirect_to.action_controller", payload={:request=>#<ActionDispatch::Request POS...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/activesupport-7.0.2.3/lib/active_support/notifications.rb:206
  #5    ActionController::Instrumentation#redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/actionpack-7.0.2.3/lib/action_controller/metal/instrumentation.rb:41
  #6    ActionController::Responder#redirect_to at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:147
  #7    ActionController::Responder#navigation_behavior(error=#<ActionView::MissingTemplate: Missing t...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:207
  #8    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:174
  #9    ActionController::Responder#to_html at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:171
  #10   ActionController::Responder#respond at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:165
  #11   ActionController::Responder.call(args=[#<ActiveAdmin::Devise::SessionsControll...) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/responder.rb:158
  #12   ActionController::RespondWith#respond_with(resources=[#<AdminUser id: 1, email: "admin@user.c..., block=nil) at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/responders-3.0.1/lib/action_controller/respond_with.rb:213
  #13   Devise::SessionsController#create at ~/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/gems/devise-4.8.1/app/controllers/devise/sessions_controller.rb:23

我想我们应该先打 Instrumentation#redirect_to。注意 Instrumentation 需要比其他模块晚加载。在 Base 控制器中 Flash 模块出现在 Instrumentation 之前。但是我们最后包含了 Flash 并且把事情搞砸了。不知道有没有更好的方法改变这些模块的顺序:

class ApplicationController < ActionController::Metal
  # https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/api.rb#L104
  # skip modules that we need to load last
  ActionController::API.without_modules(:Instrumentation, :ParamsWrapper).each do |m|
    include m
  end

  # include what's missing
  include ActionController::ImplicitRender
  include ActionController::Helpers
  include ActionView::Layouts
  include ActionController::Flash
  include ActionController::MimeResponds

  # include modules that have to be last
  include ActionController::Instrumentation
  include ActionController::ParamsWrapper
  ActiveSupport.run_load_hooks(:action_controller_api, self)
  ActiveSupport.run_load_hooks(:action_controller, self)

  respond_to :json, :html
end

它修复了错误。但我觉得 ApplicationController 应该继承自 Base,这让事情变得简单多了,因为它被 devise 和 active admin 使用,使用 API 并为 active admin 添加模块看起来像 运行转圈。

@brcebn 解决方法确实有效。就像所有酷孩子一样,使用那种私有方法。 https://github.com/heartcombo/responders/issues/222#issue-661963658

def redirect_to(options = {}, response_options = {})
  super
end

这也有点麻烦,所以我不得不写一些测试。这些仅在 ApplicationController 继承自 Base 时有效。

# spec/requests/authentication_spec.rb
require 'rails_helper'

RSpec.describe 'Authentication', type: :request do
  describe 'Edge case for Devise + JWT + RailsAPI + ActiveAdmin configuration' do
    # This set up will raise private method error
    #
    #   class ApplicationController < ActionController::API
    #     include ActionController::Helpers
    #     include ActionView::Layouts
    #     include ActionController::Flash    # <= has private.respond_to
    #
    #     respond_to :json, :html # when responding with html in an api controller
    #   end
    #
    before { AdminUser.create!(params) }
    let(:params) { { email: 'admin@user.com', password: '123456' } }

    it do
      RSpec::Expectations.configuration.on_potential_false_positives = :nothing
      expect{
        post(admin_user_session_path, params: { admin_user: params })
      }.to_not raise_error(NoMethodError)
    end

    it do
      expect{
        post(admin_user_session_path, params: { admin_user: params })
      }.to_not raise_error
    end
  end

  describe 'POST /api/users/sign_in' do
    before { ApiUser.create!(params) }
    before { post api_user_session_path, params: { api_user: params } }

    let(:params) { { email: 'api@user.com', password: '123456' } }

    it { expect(response).to have_http_status(:created) }
    it { expect(headers['Authorization']).to include 'Bearer' }
    it 'should not have admin access' do
      get admin_dashboard_path
      expect(response).to have_http_status(:redirect)
      follow_redirect!
      expect(request.path).to eq '/admin/login'
    end
  end

  describe 'GET /api/users' do
    context 'when signed out' do
      before { get api_users_path }

      it { expect(response.body).to include 'You need to sign in or sign up before continuing.' }
    end

    context 'when signed in' do
      before { ApiUser.create!(params) }
      before { post api_user_session_path, params: { api_user: params } }

      let(:params) { { email: 'api@user.com', password: '123456' } }

      it 'should not authorize without Authorization header' do
        get api_users_path
        expect(response.body).to include 'You need to sign in or sign up before continuing.'
      end

      it 'should authorize with Authorization header' do
        get api_users_path, headers: { 'Authorization': headers['Authorization'] }
        expect(response.body).to_not include 'You need to sign in or sign up before continuing.'
      end
    end
  end

  describe 'GET /admin' do
    it do
      get admin_root_path
      expect(response).to have_http_status(:redirect)
    end

    context 'when api_user is authorized' do
      before { ApiUser.create!(params) }
      before { post api_user_session_path, params: { api_user: params } }

      let(:params) { { email: 'api@user.com', password: '123456' } }

      it 'should redirect without raising' do
        get admin_root_path
        expect(response).to have_http_status(:redirect)
      end
    end
  end

  describe 'POST /admin/login' do
    before { AdminUser.create!(params) }
    before { post admin_user_session_path, params: { admin_user: params } }

    let(:params) { { email: 'admin@user.com', password: '123456' } }

    it do
      expect(response).to have_http_status(:redirect)
      follow_redirect!
      expect(response.body).to include 'Signed in successfully.'
    end
  end

  describe 'DELETE /admin/logout' do
    before { AdminUser.create!(params) }
    before { post admin_user_session_path, params: { admin_user: params } }

    let(:params) { { email: 'admin@user.com', password: '123456' } }

    it 'should sign out' do
      delete destroy_admin_user_session_path
      expect(response).to have_http_status(:redirect)
      follow_redirect!
      expect(request.path).to eq '/unauthenticated' # <= what?
      follow_redirect!
      expect(response.body).to include 'Signed out successfully.'
      expect(request.path).to eq '/admin/login'
    end
  end
end
$ rspec spec/requests/authentication_spec.rb
...........

Finished in 0.48745 seconds (files took 0.83 seconds to load)
11 examples, 0 failures

更新

上述 ActionController::API.without_modules 的解决方案似乎有很多问题,或者不是正确的方法,或者 ActiveSupport 钩子不应该在 ApplicationController 中 运行 .

我发现的唯一其他方法是定义完整的自定义控制器并从中继承。继承部分似乎很重要(如果你知道为什么,请留下评论)。

# app/controllers/base_controller.rb

class BaseController < ActionController::Metal
  abstract!

  # Order of modules is important
  # See: https://github.com/rails/rails/blob/v7.0.2.3/actionpack/lib/action_controller/base.rb#L205
  MODULES = [
    AbstractController::Rendering,

    # Extra modules #################

    ActionController::Helpers,
    ActionView::Layouts,
    ActionController::MimeResponds,
    ActionController::Flash,
    
    #################################

    ActionController::UrlFor,
    ActionController::Redirecting,
    ActionController::ApiRendering,
    ActionController::Renderers::All,
    ActionController::ConditionalGet,
    ActionController::BasicImplicitRender,
    ActionController::StrongParameters,

    ActionController::DataStreaming,
    ActionController::DefaultHeaders,
    ActionController::Logging,

    # Before callbacks should also be executed as early as possible, so
    # also include them at the bottom.
    AbstractController::Callbacks,

    # Append rescue at the bottom to wrap as much as possible.
    ActionController::Rescue,

    # Add instrumentations hooks at the bottom, to ensure they instrument
    # all the methods properly.
    ActionController::Instrumentation,

    # Params wrapper should come before instrumentation so they are
    # properly showed in logs
    ActionController::ParamsWrapper
  ]

  MODULES.each do |mod|
    include mod
  end

  ActiveSupport.run_load_hooks(:action_controller_api, self)
  ActiveSupport.run_load_hooks(:action_controller, self)
end
# app/application_controller.rb
class ApplicationController < BaseController   # use for everything
  respond_to :json, :html
end

# app/api/users_controller.rb
module Api
  class UsersController < ApplicationController
    before_action :authenticate_api_user!
    def index
      render json: User.all
    end
  end
end

已测试!

12 examples, 0 failures