在前后端分离的开发过程中,前端发起请求,调用后端接口,后端在接收请求时,首先需要对收到的请求鉴权,在这种情况先我们可以采用JWT机制来鉴权。
JWT有两种机制,单令牌机制和双令牌机制。
单令牌机制服务端只生成一个 token,一般过期时间比较长,因此安全性稍差。
双令牌机制服务端生成两个token,一个access_token用来鉴权,过期时间一般较短(5分钟,15分钟等),另一个 refresh_token,只用来获取新的 access_token,过期时间较长(可设置为24小时或者更长)
单令牌机制一般步骤:
1.前端发起登录请求
2.服务端验证用户名密码,验证通过则下发token返回给前端。
3.前端收到返回的token进行保存。
4.前端后续请求头将携带 token 供服务端验证。
5.服务端收到请求首先验证 token,验证通过则正常提供服务,不通过则返回相应提示信息。
6.token 过期后重新登录。
双令牌机制与单令牌机制不同的是服务端生成两个token,一个access_token用来鉴权,一个 refresh_token 用来刷新 access_token
双令牌机制一般步骤:
1.前端发起登录请求
2.服务端验证用户名密码,验证通过则下发access_token和refresh_token 返回给前端。
3.前端收到返回的两个 token进行保存。
4.前端后续请求头将携带 access_token供服务端验证。
5.服务端收到请求首先验证 token,验证通过则正常提供服务,不通过则返回相应提示信息。
6.access_token过期后,前端请求头 将 access_token替换为 refresh_token,调用刷新 access_token的接口。获取到新的 access_token并保存,重新发起请求。
1.pom.xml添加 java-jwt
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.1</version>
</dependency>
2.编写JwtUtil.java
import java.util.Calendar;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
public class JwtUtil {
private final static String SIGNATURE = "sdf46fsdf"; //密钥
private final static String ACCESS_TYPE = "access";
private final static String REFRESH_TYPE = "refresh";
private final static String SCOPE = "cms";
private static int accessExpire = 15*60; //15分钟
private static int refreshExpire = 60*60*24; //24小时
/**
* 生成 访问 token
* @return 返回token
*/
public static String getAccessToken( String identity){
JWTCreator.Builder builder = JWT.create();
builder.withClaim("type", ACCESS_TYPE);
builder.withClaim("identity", identity);
builder.withClaim("scope", SCOPE);
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND, accessExpire);
builder.withExpiresAt(instance.getTime());
return builder.sign(Algorithm.HMAC256(SIGNATURE)).toString();
}
/**
* 生成 刷新 token
* @return 返回token
*/
public static String getRefreshToken(String identity){
JWTCreator.Builder builder = JWT.create();
builder.withClaim("type", REFRESH_TYPE);
builder.withClaim("identity", identity);
builder.withClaim("scope", SCOPE);
Calendar instance = Calendar.getInstance();
instance.add(Calendar.SECOND, refreshExpire);
builder.withExpiresAt(instance.getTime());
return builder.sign(Algorithm.HMAC256(SIGNATURE)).toString();
}
/**
* 验证token
* @param token
*/
public static void verify(String token){
JWT.require(Algorithm.HMAC256(SIGNATURE)).build().verify(token);
}
/**
* 获取token中payload
* @param token
* @return
*/
public static DecodedJWT getToken(String token){
return JWT.require(Algorithm.HMAC256(SIGNATURE)).build().verify(token);
}
}
3.过滤器校验token
import org.hibernate.annotations.common.util.StringHelper;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.InvalidClaimException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qyp.hpa.util.JwtUtil;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.*;
@Component
public class FilterConfig implements HandlerInterceptor{
public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3)
throws Exception {
}
public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2)
throws Exception {
}
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object arg2) throws Exception {
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "Authorization,Origin, X-Requested-With, Content-Type, Accept,Access-Token,Content-length,Content-Range,Keep-Alive,Accept-Ranges,Connection");//Origin, X-Requested-With, Content-Type, Accept,Access-Token
String requestURL = request.getRequestURL().toString();
if(requestURL.indexOf("login")!=-1 || requestURL.indexOf("refresh")!=-1){
return true;
}
Map<String,Object> map = new HashMap<>();
//令牌建议是放在请求头中,获取请求头中令牌Authorization
String token = request.getHeader("Authorization");
try{
if(StringHelper.isEmpty(token)){
map.put("msg","token不能为空");
return false;
}
JwtUtil.verify(token);//验证令牌
return true;//放行请求
} catch (SignatureVerificationException e) {
e.printStackTrace();
map.put("msg","无效签名");
} catch (TokenExpiredException e) {
e.printStackTrace();
map.put("code","10051");
map.put("msg","token过期");
} catch (AlgorithmMismatchException | JWTDecodeException | InvalidClaimException e) {
e.printStackTrace();
map.put("code","10041");
map.put("msg","token算法不一致");
} catch (Exception e) {
e.printStackTrace();
map.put("msg","token失效");
}
map.put("state",false);//设置状态
//将map转化成json,response使用的是Jackson
String json = new ObjectMapper().writeValueAsString(map);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print(json);
return true;
}
}
4.登录接口、刷新令牌接口
服务端登录接口
@RequestMapping(value="sys/login", method = RequestMethod.POST)
public Map<String,Object> logIn(@RequestBody Map<String,Object> param) {
Map<String,Object> map = new HashMap<String,Object>();
String username = param.get("username")==null?"":param.get("username").toString();
String password = param.get("password")==null?"":param.get("password").toString();
try{
//为了方便演示,直接写了固定的用户名和密码
if("admin".equals(username) && "123456".equals(password)){
String userId = "sadfsdfsdfsdfs";
//生成JWT令牌
String token = JwtUtil.getAccessToken(userId);
String refresh_token = JwtUtil.getRefreshToken(userId);
map.put(NS.MSG, "登录成功");
map.put("access_token", token);
map.put("refresh_token", refresh_token);
map.put(NS.SUCC, NS.OK);
}else {
map.put(NS.MSG, NS.NO_USER);
map.put(NS.SUCC, NS.ERROR);
}
} catch(Exception e){
map.put(NS.SUCC, NS.ERROR);
e.printStackTrace();
}
return map;
}
服务端刷新令牌接口
/**
* 刷新令牌
*/
@GetMapping("sys/refresh")
public Map<String,Object> getRefreshToken(HttpServletRequest request) {
Map<String,Object> map = new HashMap<String,Object>();
String tokenStr = request.getHeader("Authorization");
DecodedJWT token = JwtUtil.getToken(tokenStr);
String id = token.getClaim("identity").asString();
//生成JWT令牌
String access_token = JwtUtil.getAccessToken(id);
map.put("access_token", access_token);
return map;
}
5. Vue前端调用
前端这里使用的是 vue3,以下是调用服务端登录接口,登录成功保存 access_token和 refresh_token
import {
saveTokens
} from '@/util/token'
import { login} from '@/util/user'
const _login= async () =>{
const param = {
username: 'admin',
password: '123456',
}
const res:any = await login(param);
console.log('login res', res);
if(res.succ == '1'){
saveTokens(res.access_token,res.refresh_token)
}
}
axios.js 封装处理 access_token过期,无感刷新 token
axios.js 封装处理当前端调用刷新 token的接口时,将请求头 headers.Authorization 字段替换为 refresh_token
T
util / token.js 工具类
/**
* 存储tokens
* @param {string} accessToken
* @param {string} refreshToken
*/
export function saveTokens(accessToken, refreshToken) {
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refreshToken)
}
/**
* 存储access_token
* @param {string} accessToken
*/
export function saveAccessToken(accessToken) {
localStorage.setItem('access_token', accessToken)
}
/**
* 获得某个token
* @param {string} tokenKey
*/
export function getToken(tokenKey) {
return localStorage.getItem(tokenKey)
}
/**
* 移除token
*/
export function removeToken() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
util / user.js 配置请求服务端登录接口
import {post,get} from '@/util/axios'
export async function login(data) {
return await post('sys/login', data)
}
axios.js 完整代码
/**
* 封装 axios
*/
import axios from 'axios'
import {Message} from 'view-ui-plus'
import { getToken, saveAccessToken } from '@/util/token'
const baseUrl = "http://localhost:8089/"
const ErrorCode = {
777: '前端错误码未定义',
999: '服务器未知错误',
10000: '未携带令牌',
10020: '资源不存在',
10030: '参数错误',
10041: 'assessToken损坏',
10042: 'refreshToken损坏',
10051: 'assessToken过期',
10052: 'refreshToken过期',
10060: '字段重复',
10070: '不可操作',
}
const config = {
baseURL: baseUrl || '',
timeout: 5 * 1000, // 请求超时时间设置
crossDomain: true,
// withCredentials: true, // Check cross-site Access-Control
// 定义可获得的http响应状态码
// return true、设置为null或者undefined,promise将resolved,否则将rejected
validateStatus(status) {
return status >= 200 && status < 510
},
}
/**
* 错误码是否是refresh相关
* @param { number } code 错误码
*/
function refreshTokenException(code) {
const codes = [10000, 10042, 10050, 10052, 10012]
return codes.includes(code)
}
// 创建请求实例
const _axios = axios.create(config)
_axios.interceptors.request.use(
originConfig => {
// 有 API 请求重新计时
///autoJump(router)
const reqConfig = { ...originConfig }
// step1: 容错处理
if (!reqConfig.url) {
console.error('request need url')
}
reqConfig.method = reqConfig.method.toLowerCase() // 大小写容错
// 参数容错
if (reqConfig.method === 'get') {
if (!reqConfig.params) {
reqConfig.params = reqConfig.data || {}
}
} else if (reqConfig.method === 'post') {
if (!reqConfig.data) {
reqConfig.data = reqConfig.params || {}
}
// 检测是否包含文件类型, 若包含则进行 formData 封装
let hasFile = false
Object.keys(reqConfig.data).forEach(key => {
if (typeof reqConfig.data[key] === 'object') {
const item = reqConfig.data[key]
if (item instanceof FileList || item instanceof File || item instanceof Blob) {
hasFile = true
}
}
})
// 检测到存在文件使用 FormData 提交数据
if (hasFile) {
const formData = new FormData()
Object.keys(reqConfig.data).forEach(key => {
formData.append(key, reqConfig.data[key])
})
reqConfig.data = formData
}
}
// step2: permission 处理
if (reqConfig.url === 'sys/refresh') {
const refreshToken = getToken('refresh_token')
if (refreshToken) {
reqConfig.headers.Authorization = refreshToken
}
} else {
const accessToken = getToken('access_token')
if (accessToken) {
reqConfig.headers.Authorization = accessToken
}
}
return reqConfig
},
error => Promise.reject(error),
)
// Add a response interceptor
_axios.interceptors.response.use(
async res => {
if (res.status.toString().charAt(0) === '2') {
return res.data
}
const { code, msg } = res.data
return new Promise(async (resolve, reject) => {
let tipMessage = ''
const { url } = res.config
// refresh_token 异常,直接登出
if (refreshTokenException(code)) {
/**
setTimeout(() => {
store.dispatch('loginOut')
const { origin } = window.location
window.location.href = origin
}, 1500)
return resolve(null)
*/
}
// assessToken相关,刷新令牌
console.log('msg',msg,'code',code)
if (code === "10041" || code === "10051") {
console.log('--msg',msg,'code',code)
const cache = {}
if (cache.url !== url) {
cache.url = url
const refreshResult = await _axios('sys/refresh')
saveAccessToken(refreshResult.access_token)
// 将上次失败请求重发
const result = await _axios(res.config)
return resolve(result)
}
}
// 弹出信息提示的第一种情况:直接提示后端返回的异常信息(框架默认为此配置);
// 特殊情况:如果本次请求添加了 handleError: true,用户自行通过 try catch 处理,框架不做额外处理
if (res.config.handleError) {
return reject(res)
}
if (typeof msg === 'string') {
tipMessage = msg
}
if (Object.prototype.toString.call(msg) === '[object Object]') {
;[tipMessage] = Object.values(msg).flat()
}
if (Object.prototype.toString.call(msg) === '[object Array]') {
;[tipMessage] = msg
}
//ElMessage.error(tipMessage)
Message.error(tipMessage)
reject(res)
})
},
error => {
if (!error.response) {
//ElMessage.error('请检查 API 是否异常')
console.log('error', error)
}
// 判断请求超时
if (error.code === 'ECONNABORTED' && error.message.indexOf('timeout') !== -1) {
//ElMessage.warning('请求超时')
}
return Promise.reject(error)
},
)
// 导出常用函数
/**
* @param {string} url
* @param {object} data
* @param {object} params
*/
export function post(url, data = {}, params = {}) {
return _axios({
method: 'post',
url,
data,
params,
})
}
/**
* @param {string} url
* @param {object} params
*/
export function get(url, params = {}) {
return _axios({
method: 'get',
url,
params,
})
}
/**
* @param {string} url
* @param {object} data
* @param {object} params
*/
export function put(url, data = {}, params = {}) {
return _axios({
method: 'put',
url,
params,
data,
})
}
/**
* @param {string} url
* @param {object} params
*/
export function _delete(url, params = {}) {
return _axios({
method: 'delete',
url,
params,
})
}
export default _axios
6.效果展示
调用登录接口
请求
响应
调用 list 接口,请求时将 token 放在 headers的 Authorization字段即可
在 access_token过期而 refresh_token未过期时,调用任意接口会刷新 access_token
由上图可以看到,第一次调用 list 接口时,报 token 已过期,此时会自动调用 刷新 access_token的 refresh接口,成功刷新 access_token后,再次自动调用 list 接口,成功返回,达到了无感刷新token的效果。