ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

使用 Rails 和 NextJS 的模拟功能

2023-07-16 18:16:27  阅读:84  来源: 互联网

标签:Rails NextJS 数据模型


高级体系结构:

我将首先描述整体架构,希望它可以作为在任何框架中构建此功能的灵感。

此解决方案专为由后端应用程序与关系数据库组成的系统而设计,该系统使用 JWT 进行身份验证,通过 HTTP 与单独的客户端应用程序通信。

然后,我将举例说明一个系统的实现,该系统使用 Ruby on Rails GraphQL API、ActiveAdmin gem 作为管理仪表板,以及使用 NextJS (React) 构建的前端。

所以,让我们开始吧!


首先,您需要创建表。您的数据模型最终可能如下所示:impersonations

图像描述

您可能希望添加索引和约束以确保 是唯一token

顺序如下:当管理员单击“模拟”按钮时:

  1. 后端在表中插入一个新行,该行将存储对目标用户的引用和随机令牌。impersonations
  2. 管理员将被重定向到前端中的特殊路由,该路由在查询参数中包含该临时令牌。
  3. 前端路由将立即向后端发出请求,以将此临时模拟令牌交换为模拟用户的实际 JWT。它会将其存储在会话存储中,并作为常规登录继续。

图像描述

(注意:该图说明了操作的顺序,展示了将 REST API 用于管理端点和将 GraphQL API 用于主前端应用程序。但是,此处的重点是了解序列,而不是终结点接口的详细信息。


现在让我们继续实现它。

步骤 1:模拟模型

首先,让我们从迁移开始创建表:impersonations

class CreateImpersonations < ActiveRecord::Migration[7.0]
  def change
    create_table :impersonations do |t|
      t.references :user, null: false, foreign_key: true
      t.references :admin_user, null: false, foreign_key: true
      t.string :token, null: false
      t.datetime :exchange_before, null: false
      t.boolean :used, null: false, default: false

      t.timestamps
    end

    add_index :impersonations, :token, unique: true
  end
end

现在,让我们定义模型:

class Impersonation < ApplicationRecord
  SESSION_DURATION = 20.minutes

  belongs_to :user
  belongs_to :admin_user

  validates :token, presence: true, uniqueness: true
  validates :exchange_before, presence: true

  scope :current, -> { where(used: false).where('exchange_before >= ?', Time.current) }

  def mark_used!
    update!(used: true)
  end

  def session_duration
    SESSION_DURATION
  end
end

不要忘记在 和 模型上定义其他方向的关系。useradmin_user

第 2 步:用于模拟的按钮

现在,让我们继续实现按钮以在管理仪表板中模拟用户。正如我所提到的,我正在为管理后台使用 ActiveAdmin gem,它有自己的 DSL 来向资源添加自定义操作。

# app/admin/users.rb

ActiveAdmin.register User do
  ...

  member_action :impersonate, method: :post do
    user = User.find(params[:id])

    impersonator = Users::Impersonations::Initiator.new(user: user,
                                                        admin_user: current_admin_user)
    impersonator.initiate!
    redirect_url = impersonator.redirect_url

    redirect_to redirect_url, allow_other_host: true
  end

  action_item :impersonate, only: :show do
    link_to('Impersonate', impersonate_admin_user_path(resource),
            method: :post, target: '_blank', rel: 'noopener')
  end
end

我喜欢使用 SRP 服务对象并按域对类进行分组,因此我创建了一个类,该类将负责创建记录并返回将用户重定向到的记录:Users::Impersonations::Initiatorimpersonationurl

# This class builds a url that to allow an admin user to impersonate a user.
# It creates a temporary token that the frontend will exchange for a more permanent JWT
# in a subsequent call.
# Since this temporary token travels in the query params, it is insecure and therefore
# only valid for a couple minutes and can be used only once.

module Users
  module Impersonations
    class Initiator
      EXCHANGE_EXPIRES_IN = 2.minutes

      def initialize(user:, admin_user:)
        @user = user
        @admin_user = admin_user
      end

      def initiate!
        ::Impersonation.create!(user: user,
                                admin_user: admin_user,
                                token: token,
                                exchange_before: EXCHANGE_EXPIRES_IN.from_now)
      end

      def redirect_url
        base_url = URI.parse(frontend_url)
        base_url.path = '/impersonate'
        base_url.query = { token: token }.to_query

        base_url.to_s
      end

      private

      attr_reader :user, :admin_user

      def token
        @token ||= SecureRandom.hex(32)
      end

      def frontend_url
        ENV.fetch('FRONTEND_URL', 'https://app.village.com')
      end
    end
  end
end

步骤 3:前端模拟路由

现在我们已经准备好了序列的第一部分。我们应该转向前端。我们希望点击向后端发出请求,存储返回的用户会话信息,并重定向到主页。如果上一个会话正在进行中,我们将首先触发注销,以确保完成所有注销后清理。/impersonate?token=xxx

例如,在我们的NextJS应用程序中,这可能看起来像这样:

// src/pages/impersonate/index.js

import * as userActions from "../../../store/actions/userActions";

const ImpersonatePage = () => {
  const dispatch = useDispatch();
  const router = useRouter();
  const madeRequest = useRef(false);

  useEffect(() => {
    const impersonate = async () => {
      const token = router.query.token;

      if(madeRequest.current) {
        return;
      }

      if (token) {
        madeRequest.current = true;

        await dispatch(userActions.logout({ skipToast: true })); 
        await dispatch(userActions.impersonate(token));
      }

      router.replace("/");
    };

    impersonate();
  }, [router.query.token]);

  return (
    <Head>
      <title>MyApp | Impersonating</title>
    </Head>
  );
};

export default ImpersonatePage;

我的后端将提供一个 graphQL 突变,称为前端将调用。是返回的用户类型中的可用字段:ImpersonateUserjwt

// store/actions/userActions.js

...

export const impersonate = (token) => async (dispatch, getState) => {
  const gqlQuery = {
    query: `
      mutation ImpersonateUser($token: String!) {
        impersonateUser(token: $token) {
          errors
          user {
            id 
            jwt
            name
            email
          }
        }
      }
    `,
    variables: {
      token,
    },
  };

  try {
    const data = await helper(gqlQuery);
    const responseData = data.impersonateUser;
    const errors = responseData.errors;
    const userData = responseData.user;

    if (!errors && userData) {
      dispatch({
        type: USER_IMPERSONATION,
        userInfo: userData,
      });

      await handleSuccessfulUserLogin(dispatch, getState, userData); // Do your thing here...
    } else {
      dispatch({
        type: USER_IMPERSONATION_FAIL,
        errors: errors,
      });

      errorMessageExtractor(errors);
    }
  } catch (err) {
    dispatch({
      type: USER_IMPERSONATION_FAIL,
      errors: err,
    });

    handleServerErrors(err);
  }
};

步骤 4:用于交换模拟令牌的后端终结点

突变可能看起来像这样:

# app/graphql/mutations/user/impersonate_user.rb

# Given a temporary impersonation token,
# allows retrieving a CurrentUser (and its corresponding longer-lasting JWT)

module Mutations
  module User
    class ImpersonateUser < Mutations::BaseMutation
      argument :token, String, required: true

      field :user, Types::CurrentUserType, null: true
      field :errors, Types::JsonType, null: true

      def resolve(token:)
        impersonator = ::Users::Impersonations::Authenticator.new(impersonation_token: token)
        impersonator.authenticate

        return { errors: impersonator.errors } unless impersonator.success?

        context[:current_user] = impersonator.user
        context[:impersonation] = impersonator.impersonation

        { user: impersonator.user }
      end

      def self.authorized?(_object, _context)
        true # needed because my BaseMutation requires a signed in user by default, so we're overriding. 
      end
    end
  end
end

我的可能看起来像这样:Types::CurrentUserType

# app/graphql/types/current_user_type.rb

module Types
  class CurrentUserType < ApplicationRecordType
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: false

    field :jwt, String, null: true

    def jwt
      if context[:impersonation].present?
        AuthToken.token(object, expires_in: context[:impersonation].session_duration)
      else
        AuthToken.token(object)
      end
    end

    def self.authorized?(object, context)
      context[:current_user] == object
    end
  end
end

观察我们如何利用我们在突变上设置的漏洞,使模拟会话的有效期仅为 20 分钟,而不是默认会话。context[:impersonation]

我有一个用于创建 JWT 的服务类。我不会深入研究它的细节,因为它跑题了。AuthToken

应该就是这样!


第 5 步:测试!

活动管理员测试:断言按钮显示:

# spec/controllers/admin/users_controller_spec.rb

RSpec.describe Admin::UsersController do
  render_views
  let(:page) { Capybara::Node::Simple.new(response.body) }

  describe 'GET show' do
    subject(:make_request) { get :show, params: { id: user.id } }
    let!(:user) { create(:user) }
    before do
      login_admin
    end

    it 'renders the user info and button to impersonate', :aggregate_failures do
      make_request

      expect(response).to have_http_status(:success)

      expect(page).to have_content(user.first_name)
      expect(page).to have_content(user.last_name)
      expect(page).to have_content(user.email)

      expect(page).to have_link('Impersonate', href: impersonate_admin_user_path(user))
    end
  end
end 

发起方服务类测试:

# spec/concepts/users/impersonations/initiator_spec.rb

RSpec.describe Users::Impersonations::Initiator do
  let(:initiator) { described_class.new(user: user, admin_user: admin_user) }

  let(:user) { create(:user) }
  let(:admin_user) { create(:admin_user) }

  before do
    ENV['FRONTEND_URL'] = 'https://my-frontend.com'
  end

  after do
    ENV['FRONTEND_URL'] = nil
  end

  it 'creates an impersonation and exposes a redirect url' do
    expect {
      initiator.initiate!
    }.to change(Impersonation, :count).by(1)

    impersonation = Impersonation.last

    expect(impersonation.user).to eq(user)
    expect(impersonation.admin_user).to eq(admin_user)
    expect(impersonation.token).to be_present
    expect(impersonation.exchange_before).to be_present

    expect(initiator.redirect_url)
      .to eq("https://my-frontend.com/impersonate?token=#{impersonation.token}")
  end
end

突变单元测试:

# spec/graphql/mutations/user/impersonate_user_spec.rb

RSpec.describe Mutations::User::ImpersonateUser, type: :request do
  subject(:make_request) do
    make_graphql_request(
      query: query,
      variables: variables
    )
  end

  let(:user) { nil }

  let(:query) do
    <<-GRAPHQL
      mutation impersonateUser($token: String!) {
        impersonateUser(token: $token) {
          errors
          user {
            email
            jwt
            isVerified
          }
        }
      }
    GRAPHQL
  end

  let(:variables) do
    {
      token: impersonation_token
    }
  end

  let(:impersonation_token) { 'sometoken' }

  let(:authenticator_double) do
    instance_double(Users::Impersonations::Authenticator,
                    authenticate: nil,
                    success?: true,
                    errors: nil,
                    user: impersonated_user,
                    impersonation: stubbed_impersonation)
  end

  let(:stubbed_impersonation) { create(:impersonation, user: impersonated_user) }
  let(:impersonated_user) { create(:user) }

  before do
    allow(Users::Impersonations::Authenticator)
      .to receive(:new).with(impersonation_token: impersonation_token)
      .and_return(authenticator_double)

    allow(AuthToken).to receive(:token)
      .with(impersonated_user, expires_in: 20.minutes)
      .and_return('somejwt')
  end

  it 'returns verified user' do
    make_request

    expect(json_body.dig('impersonateUser', 'errors')).to be_nil
    expect(json_body.dig('impersonateUser', 'user', 'jwt')).to eq('somejwt')
    expect(json_body.dig('impersonateUser', 'user', 'email')).to eq(impersonated_user.email)
  end

  context 'when the authenticator returns an error' do
    let(:error_message) { 'some error' }

    let(:authenticator_double) do
      instance_double(Users::Impersonations::Authenticator,
                      authenticate: nil,
                      success?: false,
                      errors: { impersonationToken: error_message })
    end

    it 'returns the error' do
      make_request

      expect(json_body.dig('impersonateUser', 'errors', 'impersonationToken')).to eq(error_message)
      expect(json_body.dig('impersonateUser', 'user')).to be_nil
    end
  end
end

发起方服务测试:

# spec/concepts/users/impersonations/initiator_spec.rb

RSpec.describe Users::Impersonations::Initiator do
  let(:initiator) { described_class.new(user: user, admin_user: admin_user) }

  let(:user) { create(:user) }
  let(:admin_user) { create(:admin_user) }

  before do
    ENV['FRONTEND_URL'] = 'https://village-frontend.com'
  end

  after do
    ENV['FRONTEND_URL'] = nil
  end

  it 'creates an impersonation and exposes a redirect url' do
    expect {
      initiator.initiate!
    }.to change(Impersonation, :count).by(1)

    impersonation = Impersonation.last

    expect(impersonation.user).to eq(user)
    expect(impersonation.admin_user).to eq(admin_user)
    expect(impersonation.token).to be_present
    expect(impersonation.exchange_before).to be_present

    expect(initiator.redirect_url)
      .to eq("https://village-frontend.com/impersonate?token=#{impersonation.token}")
  end
end

奖励:工作人员清理旧的模拟记录

在数据库中使用旧的模拟记录可以很好地进行审核跟踪,这对于调试和审核可能很有用。但是,如果日志对您来说足够了,您可能更喜欢定期清理这些记录并释放一些数据库空间。在这种情况下,您可以实现定期运行的计划程序作业。

以下是我使用 sidekiq 完成我的操作的方式:

app/concepts/users/impersonations/cleaner_worker.rb

# Removes expired or already used impersonation tokens in order to free database space.

module Users
  module Impersonations
    class CleanerWorker
      include Sidekiq::Worker

      def perform
        ::Impersonation.no_longer_valid.destroy_all
      end
    end
  end
end

我在以下位置添加了此范围:Impersonation

  scope :no_longer_valid, -> { where(used: true).or(where('exchange_before < ?', Time.current)) }

测试也非常简单:


RSpec.describe Users::Impersonations::CleanerWorker do
  let(:worker) { described_class.new }

  let!(:expired_impersonation) { create(:impersonation, :expired) }
  let!(:used_impersonation) { create(:impersonation, :used) }
  let!(:valid_impersonation) { create(:impersonation) }

  it 'removes expired or already used impersonation tokens' do
    expect {
      worker.perform
    }.to change(Impersonation, :count).by(-2)

    expect(Impersonation.all).to contain_exactly(valid_impersonation)
  end
end

标签:Rails,NextJS,数据模型
来源:

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有