JWT 构建Rails API 授权登录

Java基础

浏览数:290

2019-3-14

AD:资源代下载服务

移动应用开发中,令牌授权(token-based) 是一种常用的移动端与服务端的授权登录方式 ,但是使用它,需要面临着一些问题,如:令牌的过期时间,令牌状态在服务器端的维护,服务端多子系统同步等问题。本文要说到的JWT(JSON Web Token) 轻量级的验证规范,就是一种非常好的解决方案。

JWT

在JWT的规范定义中,它由头部,载荷和签名,三部分字符串组成其中前两部分是用JSON对象进过Base64编码而来的。

头部 是由typ和alg两部分组成,typ 表示自己是一个JWT,alg表示签名使用了什么算法。

{
  "typ": "JWT",
  "alg": "HS256"
}

经过base64编码后的结果就是:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

载荷 是JWT中真正承载用户信息的部分,它也是一个json对象,由自定义部分和规范定义部分组成

JWT规范定义 中描述的几个可选的信息

{
    "iss": "JWT-Rails-Server", // 签发者
    "aud": "www.baidu.com", // 接收者
    "iat": 1472263256, // JWT 签发的时间
    "exp": 1472522525, // 过期时间
    "sub": "jwt@baidu.com" // JWT对应的用户 
    "user_id": 1211 // 自定义
}

我们还可以在上面的JSON中添加我们自定义的部分。

最后载荷也是需要通过Base64进行编码的:

eyJpc3MiOiJKV1QtUmFpbHMtU2VydmVyIiwiYXVkIjoid3d3LmJhaWR1LmNv\nbSIsImlhdCI6MTQ3MjI2MzI1NiwiZXhwIjoxNDcyNTIyNTI1LCJzdWIiOjEx\nMjF9\n

签名 就是将 头部和载荷使用 “.” 连接成的字符串 再使用我们自己提供的一个密钥 进行HS256加密后的字符串。

如果是用 “jwt-rails” 作为密钥的话,签名:

cd5a6c7a135e811477918c5c0f864582bced820ff6b5ed6766974c3ef8ca9773

JWT的 安全重点就是在签名的密钥上,如果仅仅有服务器端知道密钥的话,其他人如果获得了 JWT字符串并对它进行了篡改,那么它发送到服务端后就无法通过密钥加密的签名验证,这样就有效的阻止这类安全问题。但是要注意的是,载荷部分所携带的信息是Base64编码”非加密”,所以我们不要把有关用户的敏感信息存放在其中,一般在API接口开发中仅需要存放,能够标识用户的ID或UUID即可。

JWT in Rails API

JWT-Ruby gem 已经帮我们实现JWT规范的库,现在只有使用它提供的API就可以使用 JWT 进行开发了。

我们接下来就,开发一个具有rails 5 API的后端示例应用。

rails new jwt_rails --api

再添加gem 到 Gemfile

gem 'jwt'
gem 'bcrypt'

我们先创建一个users controller,users_controller 会返回有关用户的信息,但是求助这个

rails g controller users

然后在创建 User 模型

rails g model User username:string email:string password_digest:string

填充User模型的代码

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
end

console中创建一个用户

2.3.0 :003 > User.create(username: 'json', email: 'json@gmail.com', password: '12345', password_confirmation: '12345')
 => #<User id: 1, username: "json", email: "json@gmail.com", created_at: "2016-08-27 03:19:44", updated_at: "2016-08-27 03:19:44", password_digest: "$2a$10$3KrwpUYEgYfBJTBJJMX.5uU9d14hs91rf5Fnt8cUEvZ...">

接下来就是把JWT集成到项目中,先创建叫Token的包装类,其中使用了 Rails的secret key 作为JWT的加密密钥。

# app/models/token.rb
class Token
  def self.encode(payload)
    JWT.encode(payload, Rails.application.secrets.secret_key_base)
  end

  def self.decode(token)
    HashWithIndifferentAccess.new(JWT.decode(token, Rails.application.secrets.secret_key_base)[0])
  rescue
    nil
  end
end

再修改User模型,让其支持通过id作为承载信息,然后生成的token的方法。

class User < ApplicationRecord
  has_secure_password

  def token
    {
      token: Token.encode(user_id: self.id)
    }
  end

  def to_json
    self.slice(:username, :email)
  end
end

app/controllers/application_controller.rb 中添加验证token是否有效的方法。

class ApplicationController < ActionController::API

  attr_accessor :current_user

  protected

  def authenticate!
    render_failed and return unless token?
    @current_user = User.find_by(id: auth_token[:user_id])
  rescue JWT::VerificationError, JWT::DecodeError
    render_failed
  end

  private

  def render_failed(messages = ['验证失败'])
    render json: { errors: messages}, status: :unauthorized
  end

  def http_token
    @http_token ||= if request.headers['Authorization'].present?
                      request.headers['Authorization'].split(' ').last
                    end
  end

  def auth_token
    @auth_token ||= Token.decode(http_token)
  end

  def token?
    http_token && auth_token && auth_token[:user_id].to_i
  end

end


app/controllers/authentication_controller.rb 中处理用户登录然后返回授权token。

class AuthenticationController < ApplicationController

  def create
    if user = User.find_by(username: params[:username]).try(:authenticate, params[:password])
      render json: user.token
    else
      render json: {errors: ['用户名或密码错误']}, status: :unauthorized
    end
  end

end

然后通过授权的token 访问用户信息 app/controllers/users_controller.rb 其中使用了我们在application_controller定义的验证方法,作为前置过滤器。

class UsersController < ApplicationController
  before_action :authenticate!

  def index
    render json: current_user.to_json
  end

end

最后添加路由:

Rails.application.routes.draw do
  resources :users, only: :index
  resources :authentication, only: :create
end

启动服务

rails s

下面我们使用curl来请求验证一下我们刚刚写的API。

登录验证:

curl -X POST -d username="json" -d password="12345" http://localhost:3000/authentication

返回结果:

{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.j8-GAiEQ2LIzC8GdbqZ6H5aUA32Mux07uaY9RfOQrx8"}

如果不用Token直接访问用户信息的话。

curl http://localhost:3000/users

会直接返回验证失败:

{"errors":["验证失败"]}

使用Token请求用户信息

curl --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.j8-GAiEQ2LIzC8GdbqZ6H5aUA32Mux07uaY9RfOQrx8" http://localhost:3000/users

返回结果:

{
  "username":"json",
  "email":"json@gmail.com"
}

通过验证。

注销

JWT 对应注销已签发的token有三种方式:

  • payload中的exp过期时间
  • 客户端丢弃本地缓存的token
  • 服务端维护一个token废弃池

exp

使用JWT规范定义中payload可以携带的过期时间键值对,我们可以对上面的程序做一些修改。

首先在app/models/token.rb 中修改encode方法:

  def self.encode(payload)
    payload.merge!(exp: (Time.now.to_i + 3600)) # 添加过期时间为一小时
    JWT.encode(payload, Rails.application.secrets.secret_key_base)
  end

然后再修改验证过滤器,让它支持捕获token过期异常

rescue JWT::ExpiredSignature
  render_failed ['授权已过期']
end

最后如果请求发送的token过期结果就是:

{"errors":["授权已过期"]}

废弃池

在严格要求废弃指定的token的场景下,推荐使用Redis维护这样一个废弃池,在每次需要验证的请求中,过滤掉已经废弃的token。

客户端丢弃

这是成本最低的方式,把任务分散到各个客户端,可以很好的与现在的移动端开发配合,每次用户注销只要删除本地存放的token即可。

结论

JWT作为一种轻量级的令牌验证方案,是很轻便的,使用它,服务端就可以无需维护令牌的状态,同时也解决了多系统的同步登录问题。