移動(dòng)應(yīng)用開發(fā)中蹄梢,令牌授權(quán)(token-based) 是一種常用的移動(dòng)端與服務(wù)端的授權(quán)登錄方式 稽亏,但是使用它壶冒,需要面臨著一些問題,如:令牌的過期時(shí)間截歉,令牌狀態(tài)在服務(wù)器端的維護(hù)胖腾,服務(wù)端多子系統(tǒng)同步等問題。本文要說到的JWT(JSON Web Token) 輕量級(jí)的驗(yàn)證規(guī)范,就是一種非常好的解決方案咸作。
JWT
在JWT的規(guī)范定義中锨阿,它由頭部,載荷和簽名记罚,三部分字符串組成其中前兩部分是用JSON對(duì)象進(jìn)過Base64編碼而來的群井。
頭部 是由typ和alg兩部分組成,typ 表示自己是一個(gè)JWT毫胜,alg表示簽名使用了什么算法。
{
"typ": "JWT",
"alg": "HS256"
}
經(jīng)過base64編碼后的結(jié)果就是:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
載荷 是JWT中真正承載用戶信息的部分诬辈,它也是一個(gè)json對(duì)象酵使,由自定義部分和規(guī)范定義部分組成
在JWT規(guī)范定義 中描述的幾個(gè)可選的信息
{
"iss": "JWT-Rails-Server", // 簽發(fā)者
"aud": "www.baidu.com", // 接收者
"iat": 1472263256, // JWT 簽發(fā)的時(shí)間
"exp": 1472522525, // 過期時(shí)間
"sub": "jwt@baidu.com" // JWT對(duì)應(yīng)的用戶
"user_id": 1211 // 自定義
}
我們還可以在上面的JSON中添加我們自定義的部分。
最后載荷也是需要通過Base64進(jìn)行編碼的:
eyJpc3MiOiJKV1QtUmFpbHMtU2VydmVyIiwiYXVkIjoid3d3LmJhaWR1LmNv\nbSIsImlhdCI6MTQ3MjI2MzI1NiwiZXhwIjoxNDcyNTIyNTI1LCJzdWIiOjEx\nMjF9\n
簽名 就是將 頭部和載荷使用 "." 連接成的字符串 再使用我們自己提供的一個(gè)密鑰 進(jìn)行HS256加密后的字符串焙糟。
如果是用 "jwt-rails" 作為密鑰的話口渔,簽名:
cd5a6c7a135e811477918c5c0f864582bced820ff6b5ed6766974c3ef8ca9773
JWT的 安全重點(diǎn)就是在簽名的密鑰上,如果僅僅有服務(wù)器端知道密鑰的話穿撮,其他人如果獲得了 JWT字符串并對(duì)它進(jìn)行了篡改缺脉,那么它發(fā)送到服務(wù)端后就無法通過密鑰加密的簽名驗(yàn)證,這樣就有效的阻止這類安全問題悦穿。但是要注意的是攻礼,載荷部分所攜帶的信息是Base64編碼"非加密",所以我們不要把有關(guān)用戶的敏感信息存放在其中栗柒,一般在API接口開發(fā)中僅需要存放礁扮,能夠標(biāo)識(shí)用戶的ID或UUID即可。
JWT in Rails API
JWT-Ruby gem 已經(jīng)幫我們實(shí)現(xiàn)JWT規(guī)范的庫(kù)瞬沦,現(xiàn)在只有使用它提供的API就可以使用 JWT 進(jìn)行開發(fā)了太伊。
我們接下來就,開發(fā)一個(gè)具有rails 5 API的后端示例應(yīng)用逛钻。
rails new jwt_rails --api
再添加gem 到 Gemfile
gem 'jwt'
gem 'bcrypt'
我們先創(chuàng)建一個(gè)users controller僚焦,users_controller 會(huì)返回有關(guān)用戶的信息,但是求助這個(gè)
rails g controller users
然后在創(chuàng)建 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中創(chuàng)建一個(gè)用戶
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集成到項(xiàng)目中曙痘,先創(chuàng)建叫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 中添加驗(yàn)證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 = ['驗(yàn)證失敗'])
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 中處理用戶登錄然后返回授權(quán)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: ['用戶名或密碼錯(cuò)誤']}, status: :unauthorized
end
end
end
然后通過授權(quán)的token 訪問用戶信息 app/controllers/users_controller.rb 其中使用了我們?cè)赼pplication_controller定義的驗(yàn)證方法,作為前置過濾器。
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
啟動(dòng)服務(wù)
rails s
下面我們使用curl來請(qǐng)求驗(yàn)證一下我們剛剛寫的API惹苗。
登錄驗(yàn)證:
curl -X POST -d username="json" -d password="12345" http://localhost:3000/authentication
返回結(jié)果:
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.j8-GAiEQ2LIzC8GdbqZ6H5aUA32Mux07uaY9RfOQrx8"}
如果不用Token直接訪問用戶信息的話殿较。
curl http://localhost:3000/users
會(huì)直接返回驗(yàn)證失敗:
{"errors":["驗(yàn)證失敗"]}
使用Token請(qǐng)求用戶信息
curl --header "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.j8-GAiEQ2LIzC8GdbqZ6H5aUA32Mux07uaY9RfOQrx8" http://localhost:3000/users
返回結(jié)果:
{
"username":"json",
"email":"json@gmail.com"
}
通過驗(yàn)證桩蓉。
注銷
JWT 對(duì)應(yīng)注銷已簽發(fā)的token有三種方式:
- payload中的exp過期時(shí)間
- 客戶端丟棄本地緩存的token
- 服務(wù)端維護(hù)一個(gè)token廢棄池
exp
使用JWT規(guī)范定義中payload可以攜帶的過期時(shí)間鍵值對(duì)淋纲,我們可以對(duì)上面的程序做一些修改。
首先在app/models/token.rb 中修改encode方法:
def self.encode(payload)
payload.merge!(exp: (Time.now.to_i + 3600)) # 添加過期時(shí)間為一小時(shí)
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
然后再修改驗(yàn)證過濾器院究,讓它支持捕獲token過期異常
rescue JWT::ExpiredSignature
render_failed ['授權(quán)已過期']
end
最后如果請(qǐng)求發(fā)送的token過期結(jié)果就是:
{"errors":["授權(quán)已過期"]}
廢棄池
在嚴(yán)格要求廢棄指定的token的場(chǎng)景下洽瞬,推薦使用Redis維護(hù)這樣一個(gè)廢棄池,在每次需要驗(yàn)證的請(qǐng)求中业汰,過濾掉已經(jīng)廢棄的token伙窃。
客戶端丟棄
這是成本最低的方式,把任務(wù)分散到各個(gè)客戶端样漆,可以很好的與現(xiàn)在的移動(dòng)端開發(fā)配合为障,每次用戶注銷只要?jiǎng)h除本地存放的token即可。
結(jié)論
JWT作為一種輕量級(jí)的令牌驗(yàn)證方案放祟,是很輕便的鳍怨,使用它,服務(wù)端就可以無需維護(hù)令牌的狀態(tài)跪妥,同時(shí)也解決了多系統(tǒng)的同步登錄問題鞋喇。