安安你好給電話嗎?使用 Authy 快速打造二階段驗證

Photo by Jude Al-Safadi

前言

為了增加資訊安全,很多線上服務如購物網站或銀行使用二階段驗證加強保護使用者個資。目前市面上較知名提供簡訊寄送/驗證的服務有 Twilio(與 Authy 同公司)、Nexmo、Message Bird … 等,提供的服務內容大同小異,各自也都有大型企業採用。

個人較為偏好 Authy 的主因是可以將使用者電話號碼在 Authy 平台註冊獨一的 Authy ID 而無需將電話號碼敏感資料儲存在自站。另外,除了完整的 API 說明與範例文件, Authy 提供多種語言的套件,有助於開發速度與便利性。

以下範例將使用 Authy 搭配 Rails 在會員註冊流程中加入簡訊二階段驗證,並且限制一手機號碼僅限一有效會員。大致流程是:

  1. 使用者填寫 email、電話號碼
  2. Authy API 判斷號碼是否有效,如是,註冊為 Authy User 並回傳 Authy ID
  3. Authy API 寄送簡訊驗證碼至使用者電話
  4. 使用者輸入驗證碼
  5. Authy API 判斷驗證碼是否有效,如是,使用 email 與 Authy ID 建立 user

Authy 環境設置

註冊 Twilio 後前往在 dashboard 底下的 Authy 、建立一新的應用程式後取得 API Key。因為使用簡訊驗證實作,非相關的預設設定建議一併更改掉,例如禁止使用電話通話與 Authy App 驗證。

Rails 環境設置

起始一新的 Rails 專案後在 Gemfile 內加入 authy 與 simple_form 套件,別忘了下 bundle 指令安裝。

# Gemfile

gem 'authy', '~> 2.7.5'
gem 'simple_form'

config/initializer 底下建立 authy.rb,並在 api_key 的部分貼上 Authy Production API Key(如上圖)。

Api key 建議使用 ENV 或 Config 之類套件存取,不管怎麼做,別將 key 裸露在非相關的人看得到的地方。

# config/initializer/authy.rb

Authy.api_uri = 'https://api.authy.com'
Authy.api_key = 'YOUR_API_KEY'

建立 users routes、model、controller

因本文主要介紹內容為二階段驗證,將大幅簡化 user 部分。如要完整的功能,建議使用 Rails 社群中廣為使用的套件 devise,相關設置請參考deviseauthy-devise

路徑部分只需要 index 頁面呈現所有成功建立的使用者。

# config/routes.rb

Rails.application.routes.draw do
  resources :users, only: :index
end

建立 User model。

$ rails g model user email authy_id
$ rails db:migrate

在 User model 內加上 presenceuniqueness validation。當中 authy_id 也使用 uniqueness 是為了確保使用者無法使用同一號碼註冊多個帳號,在後面流程中會有相關說明。

# app/models/user.rb

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  validates :authy_id, presence: true, uniqueness: true
end

Controller 與 View 的部分相當簡易,query 所有 users 並逐一印到 index 頁面。

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.all
  end
end

# app/views/users/index.html.erb

<% @users.each.with_index do |user, index| %>
  <div><%= "#{index + 1}: #{user.email}" %></div>
<% end %>

使用者輸入 email 與電話號碼

下一個步驟需要建立一個表單讓使用者填寫 email 與電話號碼,後端部分要處理的是檢查號碼是否有效並以該資料使用 Authy API 建立 Authy User 後發送簡訊驗證碼到該用戶號碼。由於不打算儲存電話號碼在資料庫內,使用 form object 設計模式來處理,也便於驗證輸入資料正確性。

在 routes 內新增這個階段所需要的兩個路徑。 /sms_auth/registration/new 用來放置表單、 /sms_auth/registration 用來處理註冊 Authy User 與發送簡訊驗證碼。

#config/routes.rb

Rails.application.routes.draw do
  resources :users, only: :index

  namespace :sms_auth do
    resource :registration,
             controller: :registration,
             only: %i[new create]
  end
end

新增 SmsRegistration,三個屬性是註冊 Authy User 所需要的資料。當中 country_codecellphone 除了檢查必填外,增加 validation 檢查使用者輸入的確定為整數。

# app/forms/sms_registration.rb

class SmsRegistration
  include ActiveModel::Model

  attr_accessor :email, :country_code, :cellphone

  validates :email, presence: true
  validates :country_code, presence: true, numericality: { only_integer: true }
  validates :cellphone, presence: true, numericality: { only_integer: true }
end

Controller create action 部分, register_authy_user 使用 Authy 套件的 register_user 方法,帶入使用者輸入的 email 與電話資料註冊 Authy User,Authy API 判斷號碼有效後會回傳 id

在 Authy 端,如果一個號碼從來沒註冊過,Authy 會建立一個並回傳新的 ID。如果嘗試用已註冊過的號碼再次註冊,Authy 回傳第一次註冊的 ID。

確認 Authy User 註冊成功後,將回傳的 ID 與使用者輸入的 email 寫入 session 供後面建立使用者階段使用。最後,使用 authy 套件的方法 request_sms 傳送驗證碼後帶使用者前往簡訊驗證頁面。

# app/controllers/sms_auth/registration_controller.rb

module SmsAuth
  class RegistrationController < ApplicationController
    def new
      @registration = SmsRegistration.new
    end

    def create
      @registration = SmsRegistration.new(registration_params)

      if @registration.valid?
        register_authy_user(@registration)
        redirect_to new_sms_auth_verification_path
      else
        render :new
      end
    end

    private
    def register_authy_user(registration)
      authy = Authy::API
      response = authy.register_user(
        email: registration.email,
        country_code: registration.country_code,
        cellphone: registration.cellphone
      )

      if response.ok?
        session[:authy_id] = response.id.to_s
        session[:user_email] = registration.email
        authy.request_sms(id: response.id)
        return
      end

      redirect_to new_sms_auth_registration_path
      flash[:error] = '請確認手機號碼有效'
    end

    def registration_params
      params.require(:sms_registration).permit(:email, :country_code, :cellphone)
    end
  end
end

如果使用 simpleform 套件處理表單,需要稍微留意 `countrycode的部分。simple_form 自動判斷country相關欄位命名應為 select 然後就抱怨undefined method ‘country_select’ for …。 相關討論請見其官方 [issue](https://github.com/heartcombo/simple_form/issues/757)。為了 demo 便利,在這直接將欄位設為as: :string` 改為一般文字輸入。

# app/views/sms_auth/registration/new.html.erb

<%= simple_form_for @registration, url: sms_auth_registration_url do |f| %>
  <%= f.input :email %>
  <%= f.input :country_code, as: :string %>
  <%= f.input :cellphone %>
  <%= f.submit '送出' %>
<% end %>

使用者輸入驗證碼與建立 User

到這個階段,測試用的手機號碼內應該已經收到一組七位數驗證碼(可在 Authy console內調整位數),尚未完成的部分為前端表單供輸入驗證碼,後端處理查詢驗證碼是否正確與最後建立 user。

在 routes.rb 內新增這個階段所需要的兩個路徑。 /sms_auth/verification/new 用來放置輸入驗證碼表單、 /sms_auth/verification 用來處理檢查驗證碼正確性與建立 user。

#config/routes.rb

Rails.application.routes.draw do
  resources :users, only: :index

  namespace :sms_auth do
    ...  ...

    resource :verification,
             controller: :verification,
             only: %i[new create]
  end
end

驗證碼表單內只需要一個 pin_number 欄位,一樣用 form object 方式處理。Validation 除了 presence 外檢查輸入值是否為七碼整數。

# app/forms/sms_verification.rb

class SmsVerification
  include ActiveModel::Model

  attr_accessor :pin_number

  validates :pin_number, presence: true
  validates :pin_number,
            numericality: {
              only_integer: true,
              message: 'PIN碼錯誤'
            }
  validates :pin_number,
            length: {
              is: 7,
              message: 'PIN碼錯誤'
            }
end

驗證 controller 部分,在 verify_pin 中使用 Authy 所提供的 verify 方法,帶入使用者輸入的驗證碼與前一個階段設置的 session[:authy_id] 值。如果 Authy 回覆該驗證碼無效,將使用者帶回驗證碼輸入頁並提示錯誤,若正確,使用 session[:user_email]session[:authy_id] 建立 user。

這裡使用 create! 的用意是刻意在建立使用者時如果有重複 eamilauthy_id 的情況時的例外能夠使用 rescue_from 處理,要求使用者提供其他 email 或電話號碼,而不是 silent fail,另外也確保使用者無法使用一個手機號碼申請多個帳號。

# app/controllers/sms_auth/verification_controller.rb

module SmsAuth
  class VerificationController < ApplicationController
    rescue_from ActiveRecord::RecordInvalid do |_exception|
      redirect_to new_sms_auth_registration_path
      flash[:error] = 'Eamil 或號碼已由其他帳號使用'
    end

    def new
      @verification = SmsVerification.new
    end

    def create
      @verification = SmsVerification.new(verification_params)

      if @verification.valid?
        verify_pin(@verification)
        create_user
        redirect_to users_path
      else
        render :new
      end
    end

    private

    def verify_pin(verification)
      authy = Authy::API
      response = authy.verify(
        id: session[:authy_id],
        token: verification.pin_number
      )

      return if response.ok?

      redirect_to new_sms_auth_verification_path
      flash[:error] = '驗證碼無效'
    end

    def create_user
      User.create!(
        email: session[:user_email],
        authy_id: session[:authy_id]
      )
    end

    def verification_params
      params.require(:sms_verification).permit(:pin_number)
    end
  end
end

最後表單的部分。

# app/views/sms_auth/verification/new.html.erb

<%= simple_form_for @verification, url: sms_auth_verification_path do |f| %>
  <%= f.input :pin_number %>
  <%= f.submit '送出' %>
<% end %>

做到這裡,使用 Authy 實作二階段驗證的基本功能已經完成。然而,還有很多可以進一步優化的部分,例如:非 ok 的 Authy response 處理、將使用到 api 相關的 method 包裝成 service,以免 controller 越來越複雜肥大 … 等。

最後,除了本文使用到的 Auhty 套件,如果會員系統使用的是 Devise,可以參考也是 Twilio 官方做的 authy-devise extension。