安安你好給電話嗎?使用 Authy 快速打造二階段驗證
Photo by Jude Al-Safadi
前言
為了增加資訊安全,很多線上服務如購物網站或銀行使用二階段驗證加強保護使用者個資。目前市面上較知名提供簡訊寄送/驗證的服務有 Twilio(與 Authy 同公司)、Nexmo、Message Bird … 等,提供的服務內容大同小異,各自也都有大型企業採用。
個人較為偏好 Authy 的主因是可以將使用者電話號碼在 Authy 平台註冊獨一的 Authy ID
而無需將電話號碼敏感資料儲存在自站。另外,除了完整的 API 說明與範例文件, Authy 提供多種語言的套件,有助於開發速度與便利性。
以下範例將使用 Authy 搭配 Rails 在會員註冊流程中加入簡訊二階段驗證,並且限制一手機號碼僅限一有效會員。大致流程是:
- 使用者填寫 email、電話號碼
- Authy API 判斷號碼是否有效,如是,註冊為 Authy User 並回傳 Authy ID
- Authy API 寄送簡訊驗證碼至使用者電話
- 使用者輸入驗證碼
- 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,相關設置請參考devise與 authy-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 內加上 presence
與 uniqueness
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_code
與 cellphone
除了檢查必填外,增加 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!
的用意是刻意在建立使用者時如果有重複 eamil
或 authy_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。