会员系统的要点
昨天把 Ruby on Rails 教程 读完了。这本书花了非常大的篇幅详细讲述了会员系统的建立。在此前的实践中,我都是用 devise 这个 gem ,直接完成了这部分的工作,其实对于其中的细节知之甚少,甚至想要定制 devise 的时候,也觉得很头疼。了解了一个典型会员系统的建立细节,帮助很多。
一个典型的会员系统,难点主要在于「密码校验」,包括
- 用户密码的校验
- 「记住我」功能
- 激活功能
- 密码重设功能
以上的功能实现,都要用到哈希加密算法。所谓的哈希加密算法,简单来理解,就是对某一个字符串进行运算,得到一个哈希密码值,两者是一一对应的,可以进行匹配校验。一个典型的哈希加密函数有5个主要特点:
- 同样的信息会得到同样的哈希密码值;
- 哈希运算速度非常快;
- 从哈希密码值反向破译出原始信息几乎不可能;
- 原始信息发生一点小变化,哈希密码值的变化非常大,也就是说哈希密码值之间是找不到关联关系的;
- 不同的信息不会出现相同的哈希密码值。
用户密码的校验
用户密码的校验实现起来相对比较简单,因为 Rails 内建了一个方法,也就是 has_secure_password
。使用这个方法的条件是,安装 bcrypt
这个 gem,还有增加一个 password_digest
的栏位。
has_secure_password
这个方法主要为我们做了三件事情:
- 生成了两个虚拟属性(只在 ActiveRecord 存在,不保存在数据库),
password
和password_confirmation
; - 用户注册时,为用户输入的
password
进行哈希运算,得到其对应的哈希密码值,并保存到password_digest
这个栏位当中; - 提供了
authenticate
方法,对用户登录时输入的密码和数据库中的哈希密码值进行匹配。
这个方法非常聪明,实际上,我们并没有把用户的密码保存到我们的数据库当中,只是保存了用户密码对应的哈希密码值,由于这个哈希密码值几乎无法反向破译,那即使数据库泄露,也无法得知用户的密码。
以前总听说过一些密码泄露,不知道那些网站的用户系统是怎样构建的,是把密码直接保存到数据库了吗?
「记住我」功能
HTTP 协议是无状态的,也就是说,页面跟页面之间是无法传递状态信息的。那用户的登录状态是如何被服务器识别出来呢?靠的是 session
这个方法。session
是用来保存某个用户在两次请求之间的一些少量数据的。
用户登录时,通过密码校验之后,我们就把该用户的 user_id
保存到 session
里面去,只要服务器检查到 session
里有该用户的 user_id
,就知道该用户是在登录状态。当用户登出的时候,就把 user_id
从 session
当中删除。
但是 session
有个缺点,就是当用户关掉浏览器的时候,session
就会被清空掉了,登录状态也随之消失。很多网站都有「记住我」的功能,也就是在一段时间内,在同一台电脑访问的时候,不需要重复验证了。
这个功能的实现,其实就是要把登录状态的信息保存到用户浏览器的 cookie 里面,然后服务器去校验 cookie 里的登录信息,如果匹配,则自动登录。那到底要在 cookie 里保存些什么呢?
这个校验过程,其实也是一个哈希密码值的校验过程。Rails 提供了 has_secure_password
的方法,我们可以仿照这个方法的原理,实现「记住我」 的功能。
首先要给 User 新增一个栏位,remember_digest
,用来记录哈希密码值的,另外需要增加一个虚拟属性 remember_token
。
class User < ApplicationRecord
attr_accessor :remember_token
#...
end
remember_token
相当于一个密码,可以用随机方法生成,比如
def User.new_token
SecureRandom.urlsafe_base64
end
我们还要有一个计算哈希密码值的方法。
def User.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
然后我们用 remember_token
去运算出一个 remember_digest
,并保存到数据库中,成为 remember
方法。
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
接着,我们就把 remember_token
和用户的 user_id
存入用户浏览器的 cookie
cookies.permanent[:remember_token] = remember_token
cookies.permanent.signed[:user_id] = user.id
当用户再次访问的时候,就可以从用户的浏览器 cookie 中取出remember_token
和 user_id
来进行哈希密码值的校验。
User.find_by(id: cookies.signed[:user_id])
cookies[:remember_token]
在用户密码校验的时候,Rails 内建的 has_secure_password
为我们提供了 authenticate
的校验方法。现在我们要自己新建一个校验方法,用来校验「记住我」。
def authenticated?(attribute, token)
digest = self.send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
注:这里是一个通用型的校验方法,用到了 send
方法,主要是为了让激活功能、密码重设功能的时候也可以用一个校验方法。
只要校验通过,就能确认用户,记录为登录状态。
总的方法就是
- 随机生成一个 token;
- 用哈希加密方法为这个 token 算出一个哈希加密值 digest,存入数据库;
- 把 token 存入 cookie;
- 从 cookie 中获取 token,与用户数据库中的 digest 进行匹配
激活功能
有了「记住我」的功能做铺垫,激活功能也大同小异,总的思路是
- 用户注册时,默认为未激活状态;
- 用户注册完成时,随机生成一个 token,比如说
activation_token
; - 用哈希加密算法为 token 算出一个加密值,比如说
activation_digest
,并存入数据库; - 将用户的 email 和 token 形成一个激活链接,用邮件的形式发送给用户;
- 用户点击激活链接,从链接里取出 token 与数据库中的 digest 进行哈希匹配;
- 匹配成功,则将用户的状态修改为已激活状态。
密码重设功能
密码重设功能其实就是不通过用户原来密码的校验来修改自己的密码。那如果来验证用户的身份呢?还是用哈希加密算法来实现。
- 用户输入注册邮箱,点击密码重设
- 随机生成一个 token,比如说
reset_token
- 用哈希加密算法为 token 算出一个加密值,比如说
reset_digest
,并存入数据库; - 将 token 以邮件的形式发送到用户的邮箱;
- 用户点击密码重设链接,从链接取出 token 与数据库中的 digest 进行哈希匹配;
- 匹配成功,则进入密码修改表单页面;
- 同时将
reset_digest
值更新为nil
通常,一个密码重设的链接都会有时间期限,超时之后就要失效。比如说2个小时之后超时,实现的方法是,增加一个 reset_sent_at
的栏位记录邮件发送时间。然后增加一个匹配条件,就是当前时间比 reset_sent_at
不能晚超过 2 个小时。
reset_sent_at < 2.hours.ago
计算机技术发展到今天,很多功能的实现有了非常成熟的方法,这些方法往往都是凝聚了人类最高智商的成果。我们在实际中的应用,更多的是要想办法用好这些成果,包括当中的方法论。多想一想,这个方法还能怎么来使用?
密码校验的过程其实非常有意思,简单的说,可以这么来描述
作为系统,我不知道你的密码是什么,但我知道你的密码是否正确。