TL, DR: 本文简述如何用 JWT 来实现不需要数据库 columns 的找回忘记密码解决方法
前记: 虽然 Devise 提供了成熟的登陆认证,找回忘记密码的支持,但在纯 REST API 开发的情况下不适用。使用 cookie 和 session 不利于服务的拓展。
所以在最近的项目中,我们采用了 token-based authorization,运用了 JWT 这样一个小但是优雅的标准。 简单来说 JWT (JSON Web Token) 定义了高可靠的数字签名解决标准。它可以携带自定义用户信息,经过 base64 编码,hamc SHA256 加密生成 token, 然后通过 http authorization 请求头传递作为登陆凭证。
具体实现如下:
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
# 设置令牌7天过期
def set_auth_token_expired_time
7.days.from_now.to_i
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
这样做的好处有三方面:
正文: 那么 JWT 是否可以解决找回忘记密码的问题呢? 参考 Devise 的实现,它是这样做的:
这样看来,我们只需要一个唯一的 reset_token,同时需要设置重置链接的过期时间。以下是我们的解决方法,不需要任何数据库 columns。
# 重置密码的 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, 服务器端可以知道请求是否合法,这样就不需要数据库的介入。
(完)