Bảo mật 2 lớp (2fa) trong Rails app

Bảo mật 2 lớp (2fa) thường được dùng trong những ứng dụng yêu cầu độ bảo mật cao. Vậy làm thế nào để thêm 2fa vào Rails app? Trong bài viết này mình sẽ đi chi tiết vào cách làm sử dụng gem devise-two-factor.

Setup

Bài viết này được build trên 1 Rails app đã có sẵn dùng gem devise, vì vậy bạn nên tham khảo thêm thông tin về gem này trước khi tiếp tục. Ở đây chúng ta sẽ build model AdminUser, tất nhiên model name nào cũng đc tùy bạn, như User chẳng hạn. Thêm các gem cần thiết vào Gemfile:

gem 'devise-two-factor' # for two factor
gem 'rqrcode_png' # for qr codes

Chạy bundle để cài đặt gem.

Việc cần làm tiếp theo là cần add thêm cột cần thiết vào database để lưu trữ mã OTP bí mật dùng cho việc authenticating. Gem trên sẽ cung cấp việc phát sinh mã để lưu trong cột này.

Chạy lệnh sau trong terminal:

rails generate devise_two_factor AdminUser TWO_FACTOR_SECRET_KEY_NAME

với TWO_FACTOR_SECRET_KEY_NAME là biến ENV dùng cho key mã hóa 2 lớp.

Khi chạy xong migrate, file migration sẽ có dạng như bên dưới:

class AddDeviseTwoFactorToAdminUsers < ActiveRecord::Migration
  def change
    add_column :admin_users, :encrypted_otp_secret,      :string
    add_column :admin_users, :encrypted_otp_secret_iv,   :string
    add_column :admin_users, :encrypted_otp_secret_salt, :string
    add_column :admin_users, :otp_required_for_login,    :boolean
    add_column :admin_users, :consumed_timestep,         :integer
  end
end

Edit file này để thêm 1 cột nữa, cột này nhằm lưu trữ opt_secret tạm thời trong suốt quá trình yêu cầu 2fa (sẽ đề cập sau).

add_column :admin_users, :unconfirmed_otp_secret, :string

Bây giờ kiểm tra lại model (AdminUser), bạn sẽ thấy phần thiết lập database_authenticatable đã được thay bởi two_factor_authenticatable.

class AdminUser < ActiveRecord::Base
  devise :rememberable, :trackable, :lockable,
         :session_limitable, :two_factor_authenticatable,
         :otp_secret_encryption_key => ENV['TWO_FACTOR_SECRET']
  # ...
end

Chạy rake db:migrate để hoàn tất.

Authentication

Trước tiên mình cần view mặc định của devise, chạy

 rails generate devise:views

để copy view của devise vào app.

Ở file app/views/devise/sessions/new.html.erb ta sẽ thêm trường otp_attempt

<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "off" %>
  </div>

  <div class="field">
    <%= f.label :otp_attempt %><br />
    <%= f.text_field :otp_attempt, autocomplete: "off" %>
  </div>

  <% if devise_mapping.rememberable? -%>
    <div class="field">
      <%= f.check_box :remember_me %>
      <%= f.label :remember_me %>
    </div>
  <% end -%>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

Sau đó, ở app/controllers/application_controller.rb cần thiết lập để cho phép params mới:

before_action :configure_permitted_parameters, if: :devise_controller?

protected

def configure_permitted_parameters
  devise_parameter_sanitizer.for(:sign_in) << :otp_attempt
end

OK vậy là phần authentication đã xong, nhưng mà hiện vẫn chưa activate 2fa được :v

Two Factor Activation

Giờ đến phần controller và views. Thêm phần code dưới đây vào model để giảm logic cho controller:

def activate_two_factor params
  otp_params = { otp_secret: unconfirmed_otp_secret }
  if !valid_password?(params[:password])
    errors.add :password, :invalid
    false
  elsif !validate_and_consume_otp!(params[:otp_attempt], otp_params)
    errors.add :otp_attempt, :invalid
    false
  else
    activate_two_factor!
  end
end

def deactivate_two_factor params
  if !valid_password?(params[:password])
    errors.add :password, :invalid
    false
  else
    self.otp_required_for_login = false
    self.otp_secret = nil
    save
  end
end

private

def activate_two_factor!
  self.otp_required_for_login = true
  self.otp_secret = current_admin_user.unconfirmed_otp_secret
  self.unconfirmed_otp_secret = nil
  save
end

Khi method này được gọi, params yêu cầu phải chứa password và otp attempt, nếu cả 2 đều hợp lệ thì method sẽ kích hoạt 2fa.

Với routes, ta sẽ có như bên dưới:

namespace :admin do
  get    '/two_factor' => 'two_factors#show', as: 'admin_two_factor'
  post   '/two_factor' => 'two_factors#create'
  delete '/two_factor' => 'two_factors#destroy'
end

Giờ quay lại với controller:

class Admin::TwoFactorsController < ApplicationController
  before_filter :authenticate_admin_user!

  def new
  end

  # Nếu user đã bật 2fa, chúng ta sẽ tạo mã otp_secret tạm thời
  #   và render `new`  template lên.
  # Ngược lại, sẽ render `show` templapte cho phép user tắt 2fa
  def show
    unless current_admin_user.otp_required_for_login?
      current_admin_user.unconfirmed_otp_secret = AdminUser.generate_otp_secret
      current_admin_user.save!
      @qr = RQRCode::QRCode.new(two_factor_otp_url).to_img.resize(240, 240).to_data_url
      render 'new'
    end
  end

  # AdminUser#activate_two_factor sẽ trả về 1 boolean. Nếu false thì có lỗi xảy ra
  def create
    permitted_params = params.require(:admin_user).permit :password, :otp_attempt
    if current_admin_user.activate_two_factor permitted_params
      redirect_to root_path, notice: "You have enabled Two Factor Auth"
    else
      render 'new'
    end
  end

  # Nếu password chính xác thì 2fa sẽ đc tắt
  def destroy
    permitted_params = params.require(:admin_user).permit :password
    if current_admin_user.deactivate_two_factor permitted_params
      redirect_to root_path, notice: "You have disabled Two Factor Auth"
    else
      render 'show'
    end
  end

  private

  def two_factor_otp_url
    "otpauth://totp/%{app_id}?secret=%{secret}&issuer=%{app}" % {
      :secret => current_admin_user.unconfirmed_otp_secret,
      :app    => "your-app",
      :app_id => "YourApp"
    }
  end
end

Cuối cùng là views:

<!-- app/views/admin/two_factors/new.html.erb -->

<div class="page-header"><h2>Enable Two Factor Auth</h2></div>

<p>To enable <em>Two Factor Auth</em>, scan the following QR Code:</p>

<p class="text-center"><%= image_tag @qr %></p>

<p>Then, verify that the pairing was successful by entering your password and a code below.</p>

<%= form_for current_admin_user, url: [:admin, :two_factor], method: 'POST' do |f| %>
  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "off" %>
  </div>

  <div class="field">
    <%= f.label :otp_attempt %><br />
    <%= f.text_field :otp_attempt, autocomplete: "off" %>
  </div>

  <div class="actions">
    <%= f.submit "Enable" %>
  </div>
<% end %>
<!-- app/views/admin/two_factors/show.html.erb -->

<div class="page-header"><h2>Disable Two Factor Auth</h2></div>

<p>Type your password to disable <em>Two Factor Auth</em></p>

<%= form_for current_admin_user, url: [:admin, :two_factor], method: 'DELETE' do |f| %>
  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "off" %>
  </div>

  <div class="actions">
    <%= f.submit "Disable" %>
  </div>
<% end %>

Bây giờ nếu user đã loggin vào /admin/two_factor mà chưa bật 2fa, họ sẽ thấy new template. Điền vào form active 2fa. Một khi đã bât 2fa, nếu vào lại trang /admin/two_factor sẽ render show template, ở đây họ có thể điền vào form để deactive 2fa.