برنامه نویسی

ویژگی جعل هویت با استفاده از Rails و NextJS

در این مقاله به شما نشان خواهم داد که چگونه می توانید این ویژگی را پیاده سازی کنید به ادمین های داخلی خود اجازه دهید جعل هویت یک کاربر را جعل کنند. 🥷

این یک ویژگی فوق‌العاده ارزشمند است که اکثر شرکت‌های SaaS برای پشتیبانی بهتر از درخواست‌های مشتری، با آن حساب می‌کنند. این ویژگی به ادمین ها اجازه می دهد آنچه را که مشتری می بیند ببینند و به آنها کمک کنند. برای استارت‌آپ‌های در مراحل اولیه، این ویژگی اهمیت بیشتری دارد زیرا نیاز به توسعه قابلیت‌های ویرایشی متعدد مدیریت را فقط برای تکرار عملکردهای اولیه کاربر از بین می‌برد. علاوه بر این، با توجه به درک روشنی از معماری مورد نیاز، که در این پست وبلاگ به بررسی آن می‌پردازیم، پیاده‌سازی این ویژگی را می‌توان در یک روز کاری انجام داد. 😘

معیارهای پذیرش ممکن است به شرح زیر باشد:

به عنوان یک کاربر مدیر، باید بتوانم به دکمه “جعل هویت” در نمایه کاربر دسترسی داشته باشم. وقتی روی این دکمه کلیک می‌کنم، باید برنامه را در یک تب جدید باز کند، جایی که من به عنوان کاربر انتخابی وارد شده‌ام و به من اجازه می‌دهد از طرف او عمل کنم. به دلایل امنیتی، جلسه جعل هویت باید 20 دقیقه طول بکشد که در مقایسه با جلسات ورود به سیستم معمولی کوتاه‌تر است و معمولاً تمام چیزی است که من نیاز دارم.

توضیحات تصویر


معماری سطح بالا:

من ابتدا معماری کلی را شرح می دهم، که امیدواریم بتواند به عنوان الهام بخش برای ساخت این ویژگی در هر چارچوبی باشد.

این راه حل برای سیستمی طراحی شده است که متشکل از یک برنامه کاربردی پشتیبان با یک پایگاه داده رابطه ای است و با یک برنامه مشتری جداگانه از طریق HTTP ارتباط برقرار می کند و از JWT برای احراز هویت استفاده می کند.

سپس، پیاده سازی را برای سیستمی که از a استفاده می کند مثال می زنم روبی روی ریل GraphQL API، جواهر ActiveAdmin برای داشبورد ادمین، و فرانت اند ساخته شده با NextJS (React).

پس اجازه بدهید به این کار برسیم!


ابتدا باید خود را ایجاد کنید impersonations جدول. مدل داده شما ممکن است در نهایت چیزی شبیه به این باشد:

توضیحات تصویر

ممکن است بخواهید یک شاخص و محدودیت برای اطمینان از آن اضافه کنید token است منحصر بفرد.

دنباله به شرح زیر است:
هنگامی که مدیر روی دکمه “جعل هویت” کلیک می کند:

  1. باطن یک ردیف جدید را در قسمت قرار می دهد impersonations جدول، که یک مرجع به کاربر هدف و یک نشانه تصادفی را ذخیره می کند.
  2. ادمین به مسیر خاصی در فرانت‌اند هدایت می‌شود که شامل آن رمز موقت در پارامترهای پرس و جو می‌شود.
  3. مسیر فرانت‌اند فوراً از پشتیبان درخواست می‌کند تا این رمز جعل هویت موقت را با یک 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 دارند. شما نیازی به تخیل و اختراع مجدد چرخ ندارید.

اما صادقانه بگویم، من فقط از این پست به عنوان بهانه‌ای استفاده کردم تا مثالی بزنم که فکر می‌کنم اشیاء خدماتی خوب چگونه هستند و رویکردم به جهش‌های تست واحد. به سلامتی! ✨

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا