JWT认证登陆,通过token和refreshToken达到前端无感刷新
“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情”
1 JWT简介
JWT(全称:Json Web Token)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。简而言之,它用在认证机制时,后端依此判断请求是否合法。类似于token。
1.1 JWT数据结构
JWT一般是这样一个xxxxx.yyyyy.zzzzz
字符串,分为三个部分,以"."隔开,按次序分别是:
- Header
- Payload
- Signature
1.1.1 Header
它是一个描述JWT元数据的Json对象,通常由令牌的类型,即JWT,和常用的散列算法,如HMAC、SHA256或RSA组成。例如:
{
"alg": "HS256",
"typ": "JWT"
}
Header部分的JSON会被Base64Url编码。
1.1.2 Payload
它也是一个Json对象,主要包含两部分内容,一是默认的可选择字段,分别是:iss:发行人、exp:到期时间、sub:主题、aud:用户、nbf:在此之前不可用、iat:发布时间、jti:JWT ID用于标识该JWT。另一部分是用户自定义内容,例如用户id和用户名等。
Payload部分也会被Base64Url编码,但没有经过加密,并不安全。因此在存储用户相关信息时不能存储密码等敏感内容。
1.1.3 Signature
它是JWT第一部分和第二部分的签名。通过一个密钥,使用Header指定的算法对Header和Payload进行计算,然后就得出一个签名哈希字符串,也就是Signature。验证时,通过同一密钥,再对Header和Payload进行计算,得出的签名与JWT的第三段Signature相比较看是否相同。相同时则说明认证通过。
Signature可以保证JWT内容的正确性和完整性,避免人为的解析和对前两部分内容的篡改。
1.2 JWT登陆认证优缺点分析
使用JWT Token做认证登陆最主要优点是token中包含用户信息,并存储在客户端,服务端不需要使用session存储用户信息,这也避免了跨域问题。但是在实际中存在诸多问题:
- 用户信息可能存在敏感信息,因为Payload非加密的特性,敏感数据不能放在里面。
- 用户信息过多时,token占用空间大,每次接口访问时,请求头都要携带大量数据,后端解密也费时。
- 客户端token不能及时感应到服务端用户的状态变化。例如这个账户已经被管理员禁止登陆了,但是客户端之前的token假如没有过期,则还可以正常访问。
思来想去,在比较复杂的登陆业务中,服务端session貌似是不可或缺的,其在安全性、方便性和可扩展性都有优势。jwt+session虽然不符合jwt设计理念,但或许是比较好的登陆方案。
2 登陆认证流程
下面纯使用JWT做登陆与认证,不存session,只做代码实现验证和个人小项目使用。
- 浏览器输入用户名和密码登陆。
- 后端校验账户与密码成功后,返回通过JWT生成的token和refreshToken。前端则把两个token都存在cookie中。token有效期半个小时,包含用户的一些简单的账户信息,但不能存账户私密信息,用来进行每次接口调用时的认证。refreshToken有效期三天,只存用户id,当token过期失效时用其来刷新token和refreshToken,避免token固定过期时间失效后的多次登陆。
- 前端访问后端接口时在请求头带上token。
- 若在某次接口访问时token已经过期失效了,则返回特定状态码,前端此时带上参数refreshToken调用刷新token方法去获取新的token和refreshToken。若此时refreshToken也过期失效则返回另一状态码,前端获取后跳转到登陆页面。
- 在步骤4中若获取到了新的token,重新调用之前的接口去获取业务结果值。注意在4和5过程中用户是无感的。
3 后端代码
3.1 JWT工具类
使用gradle引入包
implementation group: 'com.auth0', name: 'java-jwt', version: '3.18.2'
@Slf4j
public class JwtUtil {
private static final String jwtSecret = "dawang123@1,.sWDsLOI_US";
private static final String jwtIssuer = "banXian";
// token持续时间,分钟
private static final long TOKEN_DURING = 30L;
private static final long REFRESH_TOKEN_DURING = 3L * 24 * 60;
private static final Algorithm DEFAULT_ALGORITHM = Algorithm.HMAC256(jwtSecret);
/**
* 对称加密算法,HMAC256创建JWT
*/
public static String createJWT(Object data, Long during) {
String token = "";
try {
token = JWT.create()
.withIssuer(jwtIssuer) //发布者
// .withSubject("subject") //主题
// .withAudience("王大") //观众,相当于接受者
// .withJWTId(UUID.randomUUID().toString()) //编号
.withIssuedAt(new Date()) // 生成签名的时间
.withNotBefore(new Date()) //生效时间
.withExpiresAt(new Date(System.currentTimeMillis() + during * 60 * 1000)) // 生成签名过期时间
.withClaim("data", JacksonUtil.toJson(data)) //自定义数据
.sign(DEFAULT_ALGORITHM);
} catch (JWTCreationException e) {
log.error("createJWT error:", e);
}
return token;
}
public static String createJWT(Object data) {
return createJWT(data, TOKEN_DURING);
}
public static String createRefreshJWT(Object data) {
return createJWT(data, REFRESH_TOKEN_DURING);
}
public static <T> T parseToken(String token, Class<T> clazz) {
Map<String, Claim> claims = getClaims(token);
if (claims == null || claims.isEmpty()) {
return null;
}
return JacksonUtil.parse(claims.get("data").asString(), clazz);
}
private static Map<String, Claim> getClaims(String token) {
DecodedJWT decodedJWT = getDecodedJWT(token);
if (decodedJWT == null) {
return null;
}
return decodedJWT.getClaims();
}
/**
* 获取DecodedJWT
*
* @param token token令牌
* @return DecodedJWT
*/
private static DecodedJWT getDecodedJWT(String token) {
if (token == null || token.isEmpty()) {
return null;
}
DecodedJWT decodedJWT = null;
try {
decodedJWT = JWT.require(DEFAULT_ALGORITHM).build().verify(token);
} catch (AlgorithmMismatchException e) {
log.error("decodedJWT error:{}", "token算法不一致");
} catch (InvalidClaimException e) {
log.error("decodedJWT error:{}", "无效的token声明");
} catch (JWTDecodeException e) {
log.error("decodedJWT error:{}", "token解码异常");
} catch (SignatureVerificationException e) {
log.error("decodedJWT error:{}", "token签名无效");
} catch (TokenExpiredException e) {
log.warn("decodedJWT error:{}", "token已过期");
} catch (Exception e) {
log.error("decodedJWT error:{}", "其他异常");
}
return decodedJWT;
}
3.2 登陆方法与刷新token方法
- refreshToken方法,校验refreshToken失败(可能过期了)或出现别的异常时直接扔出RefreshTokenInvalidException,全局异常处理捕获后返回code为1002,前端获取后直接跳转到登陆页面。
@Override
public JwtToken loginJwt(LoginParam loginParam) {
// 判断登陆密码
Admin admin = validAccountPwd(loginParam);
return createJwtToken(admin);
}
@Override
public JwtToken refreshToken(JwtToken jwtToken) {
try {
UserInfo userInfo = JwtUtil.parseToken(jwtToken.getRefreshToken(), UserInfo.class);
Admin admin = adminService.getById(userInfo.getUserId());
return createJwtToken(admin);
}catch (Exception e){
throw new RefreshTokenInvalidException("RefreshToken已失效或无效,请重新登录");
}
}
private JwtToken createJwtToken(Admin admin) {
UserInfo userInfo = new UserInfo();
userInfo.setUserId(admin.getId());
// refreshToken默认三天
String refreshToken = JwtUtil.createRefreshJWT(userInfo);
// token默认时间30分钟
userInfo.setUserName(admin.getAdminName());
userInfo.setAvatar(admin.getAvatar());
userInfo.setNickname(admin.getNickname());
String token = JwtUtil.createJWT(userInfo);
JwtToken jwtToken = new JwtToken();
jwtToken.setToken(token);
jwtToken.setRefreshToken(refreshToken);
return jwtToken;
}
3.3 登陆拦截器
- 拦截器校验token失败后,直接扔出TokenInvalidException,全局异常处理捕获后返回code为1001,前端获取后调用refreshToken接口去刷新token和refreshToken。
- 若token未失效,则把获取的UserInfo放到ThreadlLocal中,方便获取用户信息,这就不要session了,嘿嘿。
public class LoginInterceptor implements HandlerInterceptor {
public static final Logger log = LoggerFactory.getLogger(LoginInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 可以获取该方法上的自定义注解,然后通过注解来判断该方法是否要被拦截
// @ExcludedPath 自定义注解,不被拦截
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
ExcludedPath excludedPath = method.getAnnotation(ExcludedPath.class);
if (excludedPath != null) {
return true;
}
// token验证
String token = request.getHeader("Authorization").substring("Bearer".length() + 1).trim();
log.info("AuthInterceptor:token is {}", token);
UserInfo userInfo = JwtUtil.parseToken(token, UserInfo.class);
if (userInfo == null) {
throw new TokenInvalidException("token已失效或无效");
}
UserInfoHelper.set(userInfo);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) {
// log.info("执行完方法之后进行(Controller方法调用之后),但是此时还没有进行视图渲染");
UserInfoHelper.clear();
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// log.info("整个请求都处理完毕,DispatcherServlet也渲染了对应的视图,此时可以做一些清理的工作等等");
}
}
4 前端代码
- 难点一是在一个业务接口返回token失效时,需要携带refershToken去调用刷新token接口,在成功的返回新的token后,这个业务接口再携带新的token去获取业务数据,这个过程用户是无感的。
- 难点二是若一个页面多个业务接口同时返回token失效,则只需要第一个接口去刷新token,别的接口等待第一个接口刷新完成后直接使用新的token去获取业务数据。
- 两个难点最后解决参考文章。# axios封装,无感刷新token
- 前端使用Vue,框架模板使用的是GitHub - PanJiaChen/vue-admin-template: a vue2.0 minimal admin template
4.1 axios封装
- 发送每一次请求时,都在请求拦截器中,先获取最新token,然后设置请求头Authorization。
- response拦截器中,code=1001 在刷新token时,旧请求通过await先挂起,返回新token后,使用旧请求的接口配置发起新的请求,并把结果返回给旧的请求。
- response拦截器中,code=1002 则重新登陆。
import axios from 'axios'
import { Message, MessageBox } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
// create an axios instance
const instance = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 20000 // request timeout
})
// request interceptor
instance.interceptors.request.use(
config => {
// do something before request is sent
if (store.getters.token) {
config.headers['Authorization'] = 'Bearer ' + getToken()
}
return config
},
error => {
// do something with request error
console.log(error) // for debug
return Promise.reject(error)
}
)
// response interceptor
instance.interceptors.response.use(
async response => {
const { data } = response
// 0:正常返回 1:系统错误
if (data.code !== 0) {
// token失效尝试通过refreshToken去刷新token
if (data.code === 1001) {
await store.dispatch('user/refreshToken')
return Promise.resolve(instance(response.config))
} else if (data.code === 1002) {
// refreshtoken也失效,去重新登录
MessageBox.confirm('你的登陆状态已失效,请重新登陆', '确认登录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
} else {
Message({ message: data.msg || '系统出错了',
type: 'error',
duration: 5 * 1000
})
}
} else {
return data
}
return Promise.reject(new Error(data.msg || data || 'Error'))
},
error => {
Message({
message: error.msg || error,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)
export default instance
4.2 user.js
- 包含用户登陆、退出、刷新token等的一些方法,重要的是refreshToken方法。
- refreshPromise为真则说明某一请求正在刷新token,别的刷新请求则直接返回refreshPromise。调用完成后,refreshPromise要重新设置为underfine,以免下次刷新token时为真无法去刷新。
- auth.js就是操作token在cookie中放置或移除。
import { login, refreshToken, logout, getInfo } from '@/api/user'
import * as auth from '@/utils/auth'
import { resetRouter } from '@/router'
const getDefaultState = () => {
return {
token: auth.getToken(),
refreshToken: auth.getRefreshToken(),
userName: '',
nickname: '',
avatar: ''
}
}
const state = getDefaultState()
// 是否刷新token标志
let refreshPromise
const mutations = {
RESET_STATE: (state) => {
Object.assign(state, getDefaultState())
},
SET_TOKEN: (state, token) => {
state.token = token
},
SET_REFRESH_TOKEN: (state, refreshToken) => {
state.refreshToken = refreshToken
},
SET_NAME: (state, userName) => {
state.userName = userName
},
SET_NICKNAME: (state, nickname) => {
state.nickname = nickname
},
SET_AVATAR: (state, avatar) => {
state.avatar = avatar || 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif'
}
}
const actions = {
// user login
login({ commit }, userInfo) {
const { username, password } = userInfo
return new Promise((resolve, reject) => {
login({ adminName: username.trim(), password: password }).then(response => {
const { data } = response
commit('SET_TOKEN', data.tokens.token)
commit('SET_REFRESH_TOKEN', data.tokens.refreshToken)
auth.setToken(data.tokens.token)
auth.setRefreshToken(data.tokens.refreshToken)
resolve()
}).catch(error => {
reject(error)
})
})
},
// refresh token
refreshToken({ commit, state }) {
if (!refreshPromise) {
refreshPromise = new Promise((resolve, reject) => {
refreshToken({ refreshToken: state.refreshToken }).then(response => {
const { data } = response
commit('SET_TOKEN', data.tokens.token)
commit('SET_REFRESH_TOKEN', data.tokens.refreshToken)
auth.setToken(data.tokens.token)
auth.setRefreshToken(data.tokens.refreshToken)
resolve()
}).catch(error => {
reject(error)
// eslint-disable-next-line no-return-assign
}).finally(() => refreshPromise = undefined)
})
}
return refreshPromise
},
// get user info
getInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo().then(response => {
const { data } = response
if (!data) {
return reject('Verification failed, please Login again.')
}
const { userName, nickname, avatar } = data.info
commit('SET_NAME', userName)
commit('SET_AVATAR', avatar)
commit('SET_NICKNAME', nickname)
resolve(data)
}).catch(error => {
reject(error)
})
})
},
// user logout
logout({ commit, state }) {
return new Promise((resolve, reject) => {
logout(state.token).then(() => {
auth.removeToken() // must remove token first
auth.removeRefreshToken()
resetRouter()
commit('RESET_STATE')
resolve()
}).catch(error => {
reject(error)
})
})
},
// remove token
resetToken({ commit }) {
return new Promise(resolve => {
auth.removeToken() // must remove token first
auth.removeRefreshToken()
commit('RESET_STATE')
resolve()
})
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
5.总结
JWT登陆个人使用感觉还是挺不错的,免登陆时间长,不占用服务器空间等。在小项目或某些场景下或许有较大的用处。后面再持续探索吧!
技术有限,不足之处望各位大佬多多指教!一起进步!