OAuth2.0与前端无感知token刷新实现
前言
OAuth是一个关于授权(authorization)的开放网络标准,在全世界得到广泛的应用。Facebook、Twitter和Google等各种在线服务都提供了基于OAuth规范的认证机制。
OAuth一般用于面向第三方大范围公开的API中的认证工作。换言之,假设带有用户注册功能的在线服务A(例如腾讯qq)对外公开了API,在线服务B(例如百度网盘)便可使用这些在线服务A的API提供的各种功能。这种情况下,当某个已在qq里注册的用户需要百度网盘的在线服务时,网盘的在线服务就会希望访问qq来使用该用户信息。这时,判断是否允许网盘使用该用户在qq里注册的信息的机制就是OAuth。
OAuth
OAuth的关键是,在使用百度网盘的在线服务时,用户无需再次输入qq的密码。为了实现这一机制,认证过程中会通过qq提供的Web页面,让用户确认是否允许访问向百度网盘的在线服务提供qq账户信息。如果尚未登录qq,则需要用户输入密码,这一过程也是只在qq里完成登录,并不会把密码发送给百度网盘的在线服务。
如果通过OAuth访问成功,网盘就可以从qq中获取一个名为access token的令牌。通过该token,便可访问qq中用户允许访问的信息。
OAuth最主要的优点在于它是一种被广泛认可的认证机制,并且已经实现了标准化。
OAuth2.0的认证流程
Grant Type | 作用 |
---|---|
Authorization Code | 适用于在服务端进行大量处理的web应用 |
Implicit | 适用于智能手机应用及使用JavaScript客户端进行大量处理的应用 |
Resource Owner Password Credential | 适用于不使用服务端的应用 |
Client Credentials | 适用于不以用户为单位来进行认证的应用 |
其中Resource Owner Password Credential模式就是不存在网站B,客户端直接从用户那里得到密码,并从服务器A那里获取access token。这一授权模式就能够应用在公司内部所开发的客户端应用中。
使用Resource Owner Password Credential模式进行认证时,在访问API时需要将参数以application/x-www-form-urlencoded的形式(也就是表单的形式),进行UTF-8字符编码后向服务器发送
键值(key) | 内容 |
---|---|
grant_type | 字符串password。表示使用了Resource Owner Password Credential |
username | 登录的用户名 |
password | 登录的密码 |
scope | 指定允许访问的权限范围 |
最后的scope一栏用来指定允许访问的权限范围。权限范围的名称可以由在线服务独自定义,可以使用除空格、双引号、反斜杠以外的所有ASCII文本字符。通过使用scope,就能在外部服务(在线服务B)获取token的同时对允许访问的范围进行限制,还能向用户显示“该服务会访问以下信息”等提示。虽然scope不是必选项,但还是建议事先定义好。
示例:
POST /v1/oauth2/token HTTP/1.1 Host: api.example.com Authorization: Basic XXXXXXXXXXXXXXXXXXXXXXXXX Content-Type: application/x-www-form-urlencoded grant_type=password&username=zhang&password=zhang&scope=api
示例请求中还附加Authorization首部,称为客户端认证(Client Authorization)。它用来描述需要访问的服务(即在线服务B)是谁。
在应用登录在线服务时,这些服务就会向其发行Client ID和Client Secret视为用户名/密码,并以Basic认证的形式经Base64编码后放入Authorization首部。Client ID和Client Secret可以任意使用,服务器端可以依据这些信息识别出当前访问的服务的应用身份。比如服务端对各个应用访问API的次数进行限制时,或者希望屏蔽一些未经授权的应用时,就可以使用Client ID和Client Secret。
当正确的信息送达服务器后,服务器端便会返回如下JSON格式的响应:
HTTP/1.1 200 OK Content-Type: application/json Cache-Control: no-store Pargma: no-cache { "access_token": 'zskldjflsdjflksjdflkjsd' "token_type": "bearer", "expires_in": 2629743, "refresh_token": 'ajsldkjflskdfjldfg' }
token_type中的bearer是RFC6750中定义的OAuth2.0所用的token类型。access_token是以后访问时所需的access token。在以后访问API时,只需附带发送该token信息即可。这时无需再次发送ClientID和ClientSecret信息了。因为各个不同的客户端都会从服务器端得到特定的access token,即使之后没有ClientID,服务端也同样可以用access token信息来识别应用身份。
根据RFC6750的定义,客户端有3种方法将bearer token信息发送给服务器端:
- 添加到请求信息的首部
- 添加到请求消息体
- 以查询参数的形式添加到URL中
1、将token信息添加到请求消息的首部时,客户端要用到Authorization首部,并按下面的形式指定token的内容:
GET /v1/users/ HTTP/1.1
Host: api.example.com
Authorization: Bearer zskldjflsdjflksjdflkjsd
2、token信息添加到请求消息体中,则需要将请求消息里的Content-Type设定为application/x-www-form-urlencoded,并用access_token来命名消息体里的参数,然后附加上tokan信息
POST /v1/users HTTP/1.1 Host: api.example.com Context-Type: application/x-www-form-urlencoded access_token=zskldjflsdjflksjdflkjsd
3、以查询参数的形式添加token参数时,可以在名为access_token的查询参数后指定token信息。
GET /v1/users?access_token=zskldjflsdjflksjdflkjsd HTTP/1.1 Host: api.example.com
access token的有效期和更新
客户端在获得access token的同时也会在响应信息中得到一个名为expires_in的数据,它表示当前获得的access token会在多少秒以后过期。当超过该指定的秒数后,access token便会过期。当access token过期后,如果客户端依然用它访问服务,服务端就会返回invalid_token的错误或401错误码。
HTTP/1.1 401 Unauthorized Content-Type: application/json Cache-Control: no-store Pragma: no-cache { "error": "invaild_token" }
当发生invalid_token错误时,客户端需要使用refresh token再次向服务端申请access token。这里的refresh token时客户端再次申请access token时需要的另一个令牌信息,它可以和access token一并获得。
在刷新access token的请求里,客户端可以在grent_type参数里指定refresh_token,并和refresh_token一起发送给服务器端。
POST /v1/oauth2/token HTTP/1.1 Host: api.example.com Authorization: Bearer zskldjflsdjflksjdflkjsd Content-Type: application/x-www-form-urlencoded grent_type=refresh_token&refresh_tokne=ajsldkjflskdfjldfg
封装Axios实现无感刷新token
- utils/oauth.js
const TokenKey = 'access_token' const ExpiresKey = 'expires_in' const TokenTypeKey = 'bearer' const RefreshTokenKey = 'refresh_token' export function getToken () { return localStorage.getItem(TokenTypeKey) + ' ' + localStorage.getItem(TokenKey) } export function getRefreshToken () { return localStorage.getItem(RefreshTokenKey) } export function setToken (data) { const ExpiresTime = new Date().getTime() + data.expires_in * 1000 localStorage.setItem(TokenKey, data.access_token) localStorage.setItem(ExpiresKey, ExpiresTime) localStorage.setItem(TokenTypeKey, data.token_type) localStorage.setItem(RefreshTokenKey, data.refresh_token) } export function removeToken () { localStorage.removeItem(TokenKey) localStorage.removeItem(ExpiresKey) localStorage.removeItem(TokenTypeKey) localStorage.removeItem(RefreshTokenKey) }
- request.js
import axios from 'axios' import Vue from 'vue' import { removeToken } from '@/utils/oauth' const server = axios.create({ baseURL: baseUrl, withCredentials: true }) // 用于记录是否正在刷新token,以免同时刷新 window.tokenLock = false function refreshToken () { if (!window.tokenLock) { server.put('/oauth/refresh').then(({data}) => { const ExpiresTime = new Date().getTime() + data.expires_in * 1000 localStorage.setItem('access_token', data.access_token) localStorage.setItem('expires_in', ExpiresTime) localStorage.setItem('token_type', data.token_type) localStorage.setItem('refresh_token', data.refresh_token) }) window.tokenLock = true } } server.interceptors.request.use(req => { req.headers['Authorization'] = `${localStorage.getItem('token_type')} ${localStorage.getItem('access_token')}` return req }, error => { return Promise.reject(error) }) server.interceptors.response.use(rep => { // 如果距离过期时间还有10分钟就使用refresh_token刷新token const expiresTimeStamp = new Date(Number(localStorage.getItem('expires_in'))).getTime() - new Date().getTime() if (expiresTimeStamp < 10 * 60 * 1000 && expiresTimeStamp > 0) { if (rep.config.url.indexOf('current') < 0) { refreshToken() } } return rep }, error => { if (error.response.status === 401) { // 401错误:token失效或登录失败 // 如果是在登录页报错的话直接显示报错信息,否则清除token if (location.href.indexOf('login') > 0) { Vue.prototype.$notify.error({ title: '错误', message: error.response.data.message }) return } removeToken() location.reload() } return Promise.reject(error) }) export default server
作者: zhangwinwin
链接:OAuth2.0与前端无感知token刷新实现
来源:github