#重新思考找回忘记密码解决方法
####前记
虽然 Devise 提供了成熟的登陆认证,找回忘记密码的支持,但在纯 REST API 开发的情况下不适用。使用 cookie 和 session 不利于服务的拓展。所以在最近的项目中,我们采用了 token-based authorization,运用了 JWT 这样一个小但是优雅的标准。简单来说 JWT (JSON Web Token) 定义了高可靠的数字签名解决标准。它可以携带自定义用户信息,经过 base64 编码, hamc SHA256 加密生成 token, 然后通过 http authorization 请求头传递作为登陆凭证。
####实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
require gem 'jwt' in Gemfile
# 返回登陆认证令牌
class Api::V1::AuthTokenController < ApplicationController
include Concerns::AuthTokenConcern
def create
@account = Account.authenticate(params[:email], params[:password])
if @account && @account.is_activated
@jwt = create_jwt(@account) # 验证成功,生成并返回登陆令牌
respond_with @jwt, status: :created
elsif @account && !@account.is_activated
# 处理账户没激活
else
# 处理验证失败
end
end
end
# 生成认证令牌
module Concerns::AuthTokenConcern
extend ActiveSupport::Concern
included do
# 携带用户的邮箱和令牌过期时间作为 token body
def create_jwt(account)
secret_key = account.password_salt # 签发令牌的密钥
payload = { email: account.email }
expire_at = set_auth_token_expired_time
payload.merge!("exp" => expire_at)
payload.merge!({id: account.id, telephone: account.telephone })
JWT.encode(payload, secret_key)
end
def set_auth_token_expired_time
7.days.from_now.to_i # 设置令牌7天过期
end
end
end
# 验证认证令牌
class ApplicationController < ActionController::API
before_action :verify_auth_token
private
def verify_auth_token
handle_signin_excaption
end
def handle_signin_excaption
unless get_current_account!
# 处理令牌为空
end
rescue JWT::ExpiredSignature => e
# 处理令牌过期
rescue JWT::DecodeError => e
# 处理令牌非法
end
def get_current_account!
# 从请求头获取令牌
auth_type, jwt = request.headers["HTTP_AUTHORIZATION"].try(:split, ' ')
return false unless jwt
# 读取令牌携带用户信息,此处不作令牌的验证,不会抛出异常
payload, header = JWT.decode(jwt, nil, false, verify_expiration: false)
account = Account.find_by_email(payload["email"])
# 获取验证令牌的密钥
secret = account ? account.password_salt : ""
# 用秘钥验证令牌,会抛出 JWT::ExpiredSignature 或 JWT::DecodeError 异常
payload, header = JWT.decode(jwt, secret)
# 验证成功,设置当前用户
@current_account = account
end
end
这样做的好处有三方面:
- 不需要再存储 auth_token,因为 token 只会存在客户端,服务器端只需要验证传来的 token 是否合法有效。
- 支持服务拓展。服务器不需要存储 session 信息,和客户状态松耦合。
- 实现和维护都简单。
####解决方案
那么 JWT 是否可以解决找回忘记密码的问题呢? 参考 Devise 的实现,它是这样做的:
- 运用 password controller 来处理找回功能。create action是发出找回忘记密码指示 邮件;update action 是重置密码。
- 在 account 表下 (或任何存储用户信息的表格),存储 reset_password_token 和 reset_password_at 两个字段。reset_password_token 是一个全局唯一的随机的字符串。reset_password_token 会被包含到重置密码到链接里面,然后和新密码一起作为 update action 的参数传到后台,后台再验证这个 token 是否合法和这个请求是否过期。
这样看来,我们只需要一个唯一的 reset_token, 同时需要设置重置链接的过期时间。以下是我们的解决方法,不需要任何数据库 columns。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# 重置密码的 controller
class Api::V1::PasswordsController < ApplicationController
skip_before_action :verify_auth_token
PARAMS_ACCESSOR = [:email, :new_password, :reset_password_token]
PARAMS_ACCESSOR.each do |param|
define_method param do
params[param]
end
end
# create action 发送重置忘记密码邮件
def create
account = get_account
# 运用列队处理邮件发送
ResetPasswordMailWorker.perform_async(account.id) if account
end
# update action 验证重置密码令牌,重置密码
def update
handle_email_account_reset
end
private
def get_account
Account.find_by_email(email)
end
def handle_email_account_reset
begin
# 获取重置密码中的用户信息,不验证令牌,此处不会抛出异常
payload, header =
JWT.decode(reset_password_token, nil, false, verify_expiration: false)
account = Account.find_by_email(payload["email"])
# 验证令牌,抛出异常如果验证失败
JWT.decode(reset_password_token, account.password_salt)
# 验证成功,重置密码
if account.update(password: new_password)
# 返回成功信息
render_message I18n.t('password.reset_password_success'), :ok
end
rescue JWT::ExpiredSignature => e
# 处理重置令牌过期
rescue JWT::DecodeError => e
# 处理重置令牌非法
end
end
end
# 发送重置密码邮件
class ResetPasswordMailWorker
include Sidekiq::Worker
sidekiq_options :retry => 1
def perform(account_id)
account = Account.find(account_id)
token = get_reset_password_token account
# 发送重置密码邮件,里面会携带参有合法重置密码令牌的连接
AccountMailer.reset_password_instructions(account, token).deliver
end
def get_reset_password_token(account)
payload = { email: account.email }
payload.merge!("exp" => expired_at)
JWT.encode(payload, secret_key(account))
end
# 设置令牌两天内过期
def expired_at
2.days.from_now.to_i
end
def secret_key(account)
account.password_salt
end
end
发送给用户的重置密码的连接会有如下格式: >https://www.example.com/password?reset_password_token=any_valid_reset_password_token
所以通过签发和验证 JWT 格式的 reset password token, 服务器端可以知道请求是否合法,也就不需要数据库的介入。