世界上最伟大的投资就是投资自己的教育

首页Ruby
随风 · 练气

认证系统之登录认证系统的进阶使用 (二)

随风发布于2964 次阅读

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 插件。

可以研究其背后是如何实现的。

本站文章均为原创内容,如需转载请注明出处,谢谢。

0 条回复
暂无回复~~
喜欢
统计信息
    学员: 29003
    视频数量: 1973
    文章数量: 489

© 汕尾市求知科技有限公司 | Rails365 Gitlab | Qiuzhi99 Gitlab | 知乎 | b 站 | 搜索

粤公网安备 44152102000088号粤公网安备 44152102000088号 | 粤ICP备19038915号

Top