認證系統之登入認證系統的進階使用 (二)

weixin_39637597發表於2020-12-14

1.如何思考

突然有一天,你在一個專案中,老闆給你一個需求,你需要在後臺登入系統中,新增超時的功能,所謂超時,就是管理員登入超過一定時間後,訪問頁面時就會自動要求其登出,並要重新登入。這個需求是符合邏輯的,因為,管理員總有離開電腦的時候,離開後回來要求其輸入密碼重新登入,這也是為了安全。或者說,另一個需求是這樣的。假如有人寫一些機器人程式來列舉你的使用者名稱和密碼,一般來說,很多網站,或許都有admin使用者,或者這樣說,攻擊者事先知道了一些使用者,那它就可以寫指令碼,來列舉你的使用者名稱和密碼,剛好你的密碼很簡單,說不定就給破解了。這個時候有個解決方法,當然未必是最好的,但有時候很適合,也很有效,就是像銀行卡賬號那樣,輸錯固定次數的密碼就把賬號鎖定。真正要解鎖就得通過客服或者固定時間後自動解釋。這樣攻擊的次數就有限了,由於有鎖定,就算固定時間後解鎖,一天內再怎麼用機器人,次數也是被限制得很少。

或許你就攤上了這樣的任務。或許你剛好是新手,面對這些問題無從下手,不知所措。有時候google也很難搜出答案。或許我能給你思路,你就搜搜看有沒有類似的gem來解決這個問題。有的話如果合適就直接用,沒有呢。其實devise就有這樣的功能,但是你專案不一定用啊。這個時候,你就可以去研究devise的原始碼抽出那個功能。其實這樣很慢的,因為devise原始碼你要從頭研究是需要時間的,專案需求可不等人。一般來說,好的原始碼都是低耦合的,模組化的。你就找到相應的程式碼,能理解就好了。通過優秀專案的原始碼去找解決方案,是很有好處,不僅能學習好的程式碼和設計思想,也能讓你走不少彎路。

2.具體例項

在devise的官方github庫readme文件中就列出了devise預設的10個module的名字和說明了。我們挑上面所講的兩個來說一下。第一個是Timeoutable,另一個是Lockable,我們先來說Timeoutable

2.1 Timeoutable

首先,要在devise使用Timeoutable也是很簡單的。在wiki中就可以找到一篇文章就是說明怎麼用它的。
How-To:-Add-timeout_in-value-dynamically
其實很簡單,就一個方法,用在model上的,例如user.rb

def timeout_in
  30.minutes
end

這樣就好了,30分鐘後退出,簡單明瞭,一切搞定。

好吧。如果我們要自己實現呢。

你翻看devise的原始碼就可以發現,它的所有module的功能都是分開放在一起的。就在這裡lib/devise/models

找到timeoutable.rb這個檔案,開啟來看看。

沒多少東西,我複製其中較為重要的三個方法

def timedout?(last_access)
  return false if remember_exists_and_not_expired?
  !timeout_in.nil? && last_access && last_access <= timeout_in.ago
end

def timeout_in
  self.class.timeout_in
end

private

def remember_exists_and_not_expired?
  return false unless respond_to?(:remember_created_at) && respond_to?(:remember_expired?)
  remember_created_at && !remember_expired?
end

就是那個timeout_in啦,我們在model用的就是它。它不過就是定義超時的時間罷了,真正發揮作用的是timedout?方法,判斷是否超時的,看該方法最後一行last_access && last_access <= timeout_in.ago

last_access就是最後訪問的意思嘛,最後訪問的時間跟timeout_in前的時間比,大概這樣,例如,最後訪問的時間跟現在時間的20分鐘之前相比,自己具體想一下就清楚啦。這個就是主要邏輯。具體使用timedout?這個方法的程式碼在這裡timeoutable.rb
大概看一下就好了。

具體的邏輯總結一下就是,最後一次訪問的時間,跟當前時間的規定時間之前相比,例如,當前時間的二十分鐘之前相比,就能判斷是否超時啦。不管怎樣,你就是要不斷地存當前的時間,才能和當前時間的二十分鐘之前相比。每訪問一次就存一次。那就存session再加上一個before_action放application_controller.rb就好了。

可以這樣做。

def expire_user_session
  return if ! current_user

  if session[:last_active_at].present? && session[:last_active_at].to_time < 30.minutes.ago
    logout
    redirect_to login_path, notice: '登入超時,請重新登入'
    return
  end

  session[:last_active_at] = TimeCalculator.current_time
end

具體地自己慢慢領悟吧。

2.1 Lockable

這裡有一篇關於Lockable的文章 how-to-lock-users-using-devise

先看一下,接下來,清空你的腦袋,思考一下。

假如就5次輸錯密碼自動鎖定。那總得有一個欄位來儲存使用者輸錯的次數吧。輸錯1次要存資料庫,2次也存,到5次時,就得把使用者鎖定。還有,假設半個鍾後解除鎖定。那總得存鎖定的時間吧,才好和現在時間進行比較,看是不是真的超過了半個鍾。有存了鎖定的時間,也就是證明被鎖定了。

還是跟上面一樣的分析方法,我在程式碼上加上註釋,自己慢慢分析吧。學習在個人。

module Lockable
  def self.included(base)
    base.include  InstanceMethods
    base.class_eval do
      class_attribute :maximum_attempts, :unlock_in
      # 最多4次輸錯機會,每5次輸錯之後就會鎖定賬號
      self.maximum_attempts = 5
      # 設定30分鐘後自動解鎖
      self.unlock_in        = 30.minutes
    end
  end

  module InstanceMethods

    # 鎖定
    def lock_access!
      self.locked_at = TimeCalculator.current_time
      save(validate: false)
    end

    # 解鎖
    def unlock_access!
      self.locked_at = nil
      self.failed_attempts = 0
      save(validate: false)
    end

    # 認證的邏輯
    def authenticate(unencrypted_password)
      if BCrypt::Password.new(password_digest).is_password?(unencrypted_password)
        unlock_access! if lock_expired?
        true
      else
        self.failed_attempts ||= 0
        self.failed_attempts += 1
        if attempts_exceeded?
          lock_access! unless access_locked?
        else
          save(validate: false)
        end
        false
      end
    end

    # 判斷是否被鎖定中
    def access_locked?
      locked_at.present? && !lock_expired?
    end
    
    # 判斷是否是最後一次輸錯密碼
    def last_attempt?
      self.failed_attempts == self.class.maximum_attempts - 1
    end

    # 判斷是否到了最大輸錯密碼的次數
    def attempts_exceeded?
      self.failed_attempts >= self.class.maximum_attempts
    end
 
    # 還沒被鎖定,但是輸錯過密碼
    def attempts_dirty?
      !access_locked? && self.failed_attempts > 0
    end

    protected
 
      # 鎖定時間是否過期
      def lock_expired?
        locked_at && locked_at < self.class.unlock_in.ago
      end
  end
end # Lockable

以上就講兩個,其他的自己研究就好了。

3.各種devise外掛

下面介紹幾個devise的外掛,我們的目的,是通過外掛的用法或原始碼來學習程式碼之外的思想和知識。

3.1 devise-encryptable

這個是什麼外掛,為什麼選擇這個呢。這個gem是增強密碼用的,選擇它的理由有二,第一,它足夠簡單,第二,可以學習一些加密的技巧。

對於開發人員來說,一個常識就是,存使用者的登入密碼總不是明文儲存的,除非那些不保護使用者隱私,不負責任的網站。總得選擇一種加密演算法,把使用者輸入的密碼加密成密文之後再存進資料庫。而且就算使用者得到了密文也不能推匯出原來的密碼,這才是比較好的加密演算法。md5是一種方案,不過單純地用這種方法,在一定條件下,也是能根據密文推匯出原來的密碼。它的是原理是這樣, 把原來的密碼根據hash演算法,生成固定長度的字串,也就是說,你原來的密碼是什麼 ,就一定會生成同樣的密文。假如,有人事先通過,把一些常見單詞加上用md5加密後的密文存進資料庫,你的密碼剛好又是這些常見單詞(總有人這麼幹的),攻擊者,通過匹配就能輕易獲取你的密碼。再說,你用google搜尋一下md5,就能發現各種加密解密md5的網站。一般來說,md5常用來驗證檔案是否修改過。例如一些開源軟體的下載,都有附帶md5檔案,讓你驗證該檔案是否被修改過。通過下載後的檔案的md5值和下載的md5檔案的碼來對是否被修改過。rails中的編譯過後的application.js和application.css後面就有附帶md5值。這隻簡單瞭解一下。如果要求比較安全,md5不適合來加密密碼。那devise是如何做的呢。看這裡database_authenticatable.rb

我也不都列出來,就列出來其中關鍵的三個方法。

# Generates password encryption based on the given value.
# 生成密文
def password=(new_password)
  @password = new_password
  self.encrypted_password = password_digest(@password) if @password.present?
end

# Verifies whether a password (ie from sign in) is the user password.
# 驗證密碼
def valid_password?(password)
  Devise::Encryptor.compare(self.class, encrypted_password, password)
end

# Digests the password using bcrypt. Custom encryption should override
# this method to apply their own algorithm.
#
# See https://github.com/plataformatec/devise-encryptable for examples
# of other encryption engines.
# 產生密文的演算法
def password_digest(password)
  Devise::Encryptor.digest(self.class, password)
end

其實很簡單,資料表中有encrypted_password這個欄位,用Devise::Encryptor.digest加密使用者輸入的原密碼後存入資料庫表中。主要就是Devise::Encryptor.digest這個方法的邏輯。具體可以看這裡encryptor.rb了一下

我們來看devise-encryptable這個gem是做啥的

devise是預設用一個欄位來存加密後的密文。但這個是加了另一個欄位password_salt,這是一個加密領域演算法的詞,叫salt,中文名可以叫鹽。
原來也很簡單,不是說,像md5之類的東西 ,可以通過列舉破解嗎,那好,我的原文密碼和存到資料庫中的salt混合之後再加密存到密文中。這樣就比單一的加密好多了點,畢竟你要列舉就要多考慮一箇中間因素,而這個因素是變化的。因為slat是隨機生成的。假如你的密碼就是123456,存到資料庫的密文是xxxx,剛好很簡單就給列舉到了,但有salt就不一樣了,你要加上salt,也就是123456 + salt混合之後去列舉,由於salt是隨機的,並且是存到資料庫中的,你不可能知道,所以是列舉不到的。

這個gem既然是增強的功能,它也是重寫了devise的加密程式碼的部分,還是我們之前說了,混合salt再加密,gem的原始碼也很簡單,也就幾個檔案,對比devise,我列出四個方法

def password=(new_password)
  self.password_salt = self.class.password_salt if new_password.present?
  super
end

# Validates the password considering the salt.
def valid_password?(password)
  return false if encrypted_password.blank?
  encryptor_class.compare(encrypted_password, password, self.class.stretches, authenticatable_salt, self.class.pepper)
end

def password_digest(password)
  if password_salt.present?
    encryptor_class.digest(password, self.class.stretches, authenticatable_salt, self.class.pepper)
  end
end

def authenticatable_salt
  self.password_salt
end

一眼就能看出吧,慢慢體會。

現在推薦幾個devise外掛。

可以研究其背後是如何實現的。

相關文章