高级体系结构:
我将首先描述整体架构,希望它可以作为在任何框架中构建此功能的灵感。
此解决方案专为由后端应用程序与关系数据库组成的系统而设计,该系统使用 JWT 进行身份验证,通过 HTTP 与单独的客户端应用程序通信。
然后,我将举例说明一个系统的实现,该系统使用 Ruby on Rails GraphQL API、ActiveAdmin gem 作为管理仪表板,以及使用 NextJS (React) 构建的前端。
所以,让我们开始吧!
首先,您需要创建表。您的数据模型最终可能如下所示:impersonations
您可能希望添加索引和约束以确保 是唯一的。token
顺序如下:当管理员单击“模拟”按钮时:
- 后端在表中插入一个新行,该行将存储对目标用户的引用和随机令牌。
impersonations
- 管理员将被重定向到前端中的特殊路由,该路由在查询参数中包含该临时令牌。
- 前端路由将立即向后端发出请求,以将此临时模拟令牌交换为模拟用户的实际 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
不要忘记在 和 模型上定义其他方向的关系。user
admin_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::Initiator
impersonation
url
# 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 突变,称为前端将调用。是返回的用户类型中的可用字段:ImpersonateUser
jwt
// 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
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。