ویژگی جعل هویت با استفاده از Rails و NextJS
در این مقاله به شما نشان خواهم داد که چگونه می توانید این ویژگی را پیاده سازی کنید به ادمین های داخلی خود اجازه دهید جعل هویت یک کاربر را جعل کنند. 🥷
این یک ویژگی فوقالعاده ارزشمند است که اکثر شرکتهای SaaS برای پشتیبانی بهتر از درخواستهای مشتری، با آن حساب میکنند. این ویژگی به ادمین ها اجازه می دهد آنچه را که مشتری می بیند ببینند و به آنها کمک کنند. برای استارتآپهای در مراحل اولیه، این ویژگی اهمیت بیشتری دارد زیرا نیاز به توسعه قابلیتهای ویرایشی متعدد مدیریت را فقط برای تکرار عملکردهای اولیه کاربر از بین میبرد. علاوه بر این، با توجه به درک روشنی از معماری مورد نیاز، که در این پست وبلاگ به بررسی آن میپردازیم، پیادهسازی این ویژگی را میتوان در یک روز کاری انجام داد. 😘
معیارهای پذیرش ممکن است به شرح زیر باشد:
به عنوان یک کاربر مدیر، باید بتوانم به دکمه “جعل هویت” در نمایه کاربر دسترسی داشته باشم. وقتی روی این دکمه کلیک میکنم، باید برنامه را در یک تب جدید باز کند، جایی که من به عنوان کاربر انتخابی وارد شدهام و به من اجازه میدهد از طرف او عمل کنم. به دلایل امنیتی، جلسه جعل هویت باید 20 دقیقه طول بکشد که در مقایسه با جلسات ورود به سیستم معمولی کوتاهتر است و معمولاً تمام چیزی است که من نیاز دارم.
معماری سطح بالا:
من ابتدا معماری کلی را شرح می دهم، که امیدواریم بتواند به عنوان الهام بخش برای ساخت این ویژگی در هر چارچوبی باشد.
این راه حل برای سیستمی طراحی شده است که متشکل از یک برنامه کاربردی پشتیبان با یک پایگاه داده رابطه ای است و با یک برنامه مشتری جداگانه از طریق HTTP ارتباط برقرار می کند و از JWT برای احراز هویت استفاده می کند.
سپس، پیاده سازی را برای سیستمی که از a استفاده می کند مثال می زنم روبی روی ریل GraphQL API، جواهر ActiveAdmin برای داشبورد ادمین، و فرانت اند ساخته شده با NextJS (React).
پس اجازه بدهید به این کار برسیم!
ابتدا باید خود را ایجاد کنید impersonations
جدول. مدل داده شما ممکن است در نهایت چیزی شبیه به این باشد:
ممکن است بخواهید یک شاخص و محدودیت برای اطمینان از آن اضافه کنید token
است منحصر بفرد.
دنباله به شرح زیر است:
هنگامی که مدیر روی دکمه “جعل هویت” کلیک می کند:
- باطن یک ردیف جدید را در قسمت قرار می دهد
impersonations
جدول، که یک مرجع به کاربر هدف و یک نشانه تصادفی را ذخیره می کند. - ادمین به مسیر خاصی در فرانتاند هدایت میشود که شامل آن رمز موقت در پارامترهای پرس و جو میشود.
- مسیر فرانتاند فوراً از پشتیبان درخواست میکند تا این رمز جعل هویت موقت را با یک JWT واقعی برای کاربر جعلشده مبادله کند. آن را در ذخیرهسازی جلسه ذخیره میکند و بهعنوان یک ورود به سیستم عادی ادامه میدهد.
(توجه: نمودار توالی اقدامات را نشان میدهد و استفاده از یک REST API برای نقاط انتهایی مدیریت و یک API GraphQL برای برنامه اصلی frontend را نشان میدهد. با این حال، تمرکز اینجا بر روی درک دنباله به جای جزئیات رابطهای نقاط پایانی است. )
حالا بیایید به اجرای این کار ادامه دهیم.
مرحله 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: مسیر جعل هویت Frontend
حالا قسمت اول سکانس را آماده کرده ایم. ما باید به سمت جلو حرکت کنیم. ما می خواهیم آن را به طوری که ضربه /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: نقطه پایانی Backend برای تبادل نشانه جعل هویت
جهش می تواند چیزی شبیه به این باشد:
# 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
مشاهده کنید که چگونه از اهرم استفاده می کنیم context[:impersonation]
که روی جهش تنظیم کردیم، تا آن را طوری کنیم که جلسات جعل هویت بهجای حالت پیشفرض، فقط 20 دقیقه معتبر باشند.
من کلاس خدمات دارم AuthToken
که من برای ایجاد JWT های خود استفاده می کنم. من وارد جزئیات آن نمی شوم زیرا خارج از موضوع است.
و این باید باشد!
مرحله 5: تست ها!
تست های ActiveAdmin: تاکید کنید که دکمه نشان می دهد:
# 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
پاداش: کارگر برای پاک کردن سوابق جعل هویت قدیمی
داشتن سوابق جعل هویت قدیمی در پایگاه داده یک مسیر حسابرسی خوبی ایجاد می کند که ممکن است برای اشکال زدایی و ممیزی مفید باشد. با این حال، اگر گزارشها برای شما کافی هستند، شاید ترجیح میدهید به صورت دورهای این سوابق را پاک کنید و مقداری فضای DB را آزاد کنید. در آن صورت، میتوانید یک کار زمانبندی را اجرا کنید که به صورت دورهای اجرا میشود.
در اینجا نحوه انجام کارم با استفاده از 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
امیدوارم این موضوع به شما الهام دهد که چگونه می توانید ویژگی جعل هویت را پیاده سازی کنید. این واقعاً یک ویژگی بسیار ساده است که اکثر ابزارهای SaaS دارند. شما نیازی به تخیل و اختراع مجدد چرخ ندارید.
اما صادقانه بگویم، من فقط از این پست به عنوان بهانهای استفاده کردم تا مثالی بزنم که فکر میکنم اشیاء خدماتی خوب چگونه هستند و رویکردم به جهشهای تست واحد. به سلامتی! ✨