场景:
最近应业务要求,把一个子系统嵌入到一个主平台中,这个子系统使用的是 session 认证的方式,而主平台使用的是 jwt token 的认证方式,子系统嵌入到主平台中,需要兼容主平台的认证方式,借此机会把子系统的认证方式改成了 jwt token 认证。
什么是 JWT?
JWT(JSON WebToken)是一种用于在网络应用间安全传递信息的开放标准。JWT的核心机制包括数字签名和JSON格式的数据传输。它由三部分组成,分别是头部(Header)、载荷(Payload)和签名(Signature),其中头部和载荷包含JSON格式的数据,而签名则是对这些数据进行的哈希或加密处理,以确保数据的完整性和发送者的身份。载荷部分包含了需要传递的信息,如用户身份、权限等,而头部则描述了JWT的类型以及所使用的签名算法。
JWT广泛应用于身份验证和授权,特别是在前后端分离的系统和跨平台环境中。当用户登录后,服务器会生成一个包含用户信息和授权数据的JWT,并将其返回给客户端。客户端(如浏览器)可以在后续的请求中将这个JWT发送给服务器,以证明用户的身份或获取权限。服务器通过验证JWT的签名来确认其完整性和来源,从而无需维护用户的会话状态,简化了服务器的负担。
JWT 的格式?
JWT 由header (头部)、payload (载荷)、signature (签证信息 )三部分组成,具体如下:
真实的 jwt 字符串:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjb2RlIjoienQyMjc2OCIsIm5hbWUiOiLlvKDli4ciLCJpZCI6MjU4Nn0.js7ptHWYVuPT54IwPd_XhhxJVsgILsvMECqBT_uiGM8
我们可以把 jwt 字符串进行如下拆分
#header 头部 申明类型及加密算法 加密算法一般是 HMAC SHA256 然后将头部进行base64加密就得到了如下字符串
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
.
#payload 载荷 其实就是存放有效信息的地方 其实就是我们放进去的用户信息 将其进行base64加密 就得到了下面的字符串
eyJjb2RlIjoienQyMjc2OCIsIm5hbWUiOiLlvKDli4ciLCJpZCI6MjU4Nn0
.
#signature 签证信息 这个部分使用 base64 加密后的 header 和 base64 加密后的 payload 使用 . 连接组成的字符串,然后通过header中声明的加密方式进行加盐 secret 组合加密,就得到了如下字符串
js7ptHWYVuPT54IwPd_XhhxJVsgILsvMECqBT_uiGM8
session 认证流程:
- 用户输入其登录信息,服务器验证登录信息的准确性,然后生成一个带有用户信息的 session 保存在内存中。
- 服务端生成一个 sessionId,将sessionId 设置到浏览器的 cookie 中。
- 后续的用户请求,浏览器携带 sessionId,服务端通过 sessionId 获取 session 信息,验证有消息及获取用户信息。
JWTtoken 认证流程:
- 用户输入其登录信息,服务器验证登录信息的准确性,然后生成一个带有用户信息的 token 保存在数据库中,一般保存在 Redis 中。
- 前端获取到 token 后,存储到 cookie 中或者 local storgae 中,后续的请求都将携带这个 token。
- 服务端校验 token 的有效性,并获取用户信息。
session 和 JWTtoken 的比较:
- 用户状态保存位置:session 存储在服务端的,jwt token 存储在客户端。
- 扩展性:session 用户状态存储在服务端,意味着上次用户访问的是哪台服务器,下一次还需要访问这台服务器,不合适分布式环境,jwt token 就不存在这个问题。
- 内存占用问题:session 一般是存放在内存中,随着用户的增多,内存的开销会比较大,jwt token 就不存在这个问题。
- 安全性:session 报错在服务端相对安全,jwt token 的 payload 使用的是 base64编码的,因此在JWT中不能存储敏感数据。
- 性能:session 一般是一个很小的字符串,而 jwt token 会是一个比较长的字符串,携带 session 发送 HTTP 请求的性能会更好一些。
JWTtoken 实现登录功能
JWT所需依赖如下:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.2</version>
</dependency>
JWT 工具类如下:
package com.my.study.main.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.my.study.constants.CommConstant;
import com.my.study.vo.newReport.testAnalysis.UserInfoVO;
import org.apache.commons.lang3.StringUtils;
public final class JwtUtil {
public static String[] chars = new String[] { "a", "b", "c", "d", "e", "f",
"g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
"t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5",
"6", "7", "8", "9", "A", "B", "C", "D", "E", "F", "G", "H", "I",
"J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V",
"W", "X", "Y", "Z", "@", "#", "$", "!", "&","*", ",", ".", "/" };
/**
* @Description: 创建 jwt token
* @Date: 2024/4/16 17:35
*/
public static String crreateJwtToken(UserInfoVO userInfoVO) {
return JWT.create().withClaim(CommConstant.JWT_CLAIM_NAME, userInfoVO.getName())
.withClaim(CommConstant.JWT_CLAIM_ID, userInfoVO.getId())
.withClaim(CommConstant.JWT_CLAIM_CODE, userInfoVO.getCode())
//生成的是固定的 jwt token
.sign(Algorithm.HMAC256(CommConstant.JWT_SECRET));
//生成随机的 jwt token
//.sign(Algorithm.HMAC256(generateRandomString()));
}
/**
* @Description: jwt 解析用户信息
* @Date: 2024/4/2 15:06
*/
public static UserInfoVO parseUserInfo(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) {
return null;
}
UserInfoVO userInfo = new UserInfoVO();
DecodedJWT decode = JWT.decode(jwtToken);
userInfo.setName(decode.getClaim(CommConstant.JWT_CLAIM_NAME).asString());
userInfo.setId(decode.getClaim(CommConstant.JWT_CLAIM_ID).asLong());
userInfo.setCode(decode.getClaim(CommConstant.JWT_CLAIM_CODE).asString());
userInfo.setCasTicket(decode.getClaim(CommConstant.JWT_CLAIM_CAS_TICKET).asString());
return userInfo;
}
/**
* @Description: 获取用户工号 jwt 解析工号
* @Date: 2024/4/2 15:05
*/
public static String getCode(String jwtToken) {
if (StringUtils.isEmpty(jwtToken)) {
return null;
}
DecodedJWT decode = JWT.decode(jwtToken);
return decode.getClaim(CommConstant.JWT_CLAIM_CODE).asString();
}
/**
* @Description: 生产随机字符串
* @Author: zhangyong
* @Date: 2024/4/20 11:02
*/
public static String generateRandomString() {
StringBuilder stringBuilder = new StringBuilder();
String uuid = UUID.randomUUID().toString().replace("-", "");
for (int i = 0; i < 8; i++) {
String str = uuid.substring(i * 4, i * 4 + 4);
int x = Integer.parseInt(str, 16);
stringBuilder.append(chars[x % 0x3E]);
}
return stringBuilder.toString();
}
}
登录业务代码部分:
@RequestMapping("/login")
@ApiOperation(httpMethod = "GET", value = "登录", notes = "登录")
public void redirectLogin(@RequestParam String ticket, HttpServletRequest request, HttpServletResponse response) {
log.info("登录 ticket:{}", ticket);
String requestUrl = request.getRequestURL().toString();
LoginVO loginVO = iAuthService.createJwt(ticket, requestUrl);
indexUrl = "http://199.199.199.199:8888";
try {
response.sendRedirect(indexUrl + "/index" + "?ticket=" + loginVO.getJwtToken() + "&empNo=" + loginVO.getUserCode());
} catch (IOException e) {
log.error("登录重定向异常,异常信息:", e);
e.printStackTrace();
}
}
@Override
public LoginVO createJwt(String ticket, String url) {
log.info("当前登录 ticket:{},转发地址target:{}", ticket, url);
//ticket cas 下发的 一次有效
//url CAS 校验时候似乎没啥用
String code = validateCasTicket(url, ticket);
if (StringUtils.isBlank(code)) {
throw new AuthorizationValidationException("CAS 认证失败");
}
LoginVO loginVO = new LoginVO();
loginVO.setUserCode(code);
//根据用户工号查询用户信息
UserInfoVO userInfoVO = departmentStaffMapper.queryUserInfoByCode(code);
if (ObjectUtil.isNull(userInfoVO)) {
throw new AuthorizationValidationException("当前用户不存在,请核实后重试");
}
//生成 JWT token
String jwtToken = JwtUtil.crreateJwtToken(userInfoVO);
//设置缓存 key 是 工号 接口token 验证时候根据 jwt token 解密的工号去 redis 查询有就表示登录了
this.cacheLoginToken("login", code, jwtToken);
loginVO.setJwtToken(jwtToken);
return loginVO;
}
/**
* @Description: CAS ticket 有效性验证 (ticket 一次有效 验证后即失效)
* @Date: 2024/4/2 14:38
*/
private String validateCasTicket(String url, String ticket) {
TicketValidator ticketValidator = new Cas10TicketValidator(casServerUrl);
// 验证Ticket的有效性
String userCode = null;
try {
Assertion assertion = ticketValidator.validate(ticket, url);
if (null != assertion && null != assertion.getPrincipal()
&& StringUtil.isNotBlank(assertion.getPrincipal().getName())) {
userCode = assertion.getPrincipal().getName();
return userCode;
}
log.info("ticket 验证得到的工号:{}", userCode);
} catch (TicketValidationException e) {
log.error("CAS ticket 认证失败", e);
throw new BusinessException("CAS ticket 认证失败,请稍后重试");
}
return null;
}
登录业务代码解析:
贴出来的代码是非生产代码,项目中使用了公司统一的 CAS 登录,子系统登录的时候只需要再次校验 ticket 即可,无需进行账号密码验证(一般来说是需要进行账号密码准确性验证的),ticket 验证通过,根据用户非敏感信息生成 jwt token,生成的 token 中不带有效期,jwt token 存入 Redis 中,有效期交给 Redis 来管理,然后把 jwt token 返回给前端,登录结束。
登出业务代码部分:
@RequestMapping("/logout")
@ApiOperation(httpMethod = "GET", value = "注销登录", notes = "注销登录")
public void toLogout() {
//退出登录
iAuthService.toLogout();
}
@Override
public void toLogout() {
UserInfoVO user = UserContextHolder.getUser();
if (ObjectUtil.isNull(user)) {
log.error("登出操作 token 认证失败");
return;
}
String code = user.getCode();
//删除 jwt token
redisUtils.del(code);
}
登出业务代码分析:
登出功能十分简单,删除登录用户的 jwt token 即可。
拦截器实现:
拦截器中主要做两件事:
- 判断用户的 token 是否存在,不存在则token过期。
- 若 token 存在,则对 token 进行续期,其实这里可能还需要比对 token 是否一致,保证同一时间只有一个用户操作(本案例没做)。
正常来说是需要使用 Gateway 来做权限认证的,因为老项目的原因没有使用 Gateway,这里就使用拦截器来进行权限验证。
拦截器代码如下:
@Slf4j
public class AuthorizationInterceptor implements HandlerInterceptor {
@Resource
private RedisUtils redisUtils;
@Override
public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws MalformedURLException {
//校验认证信息
UserInfoVO userInfoVO = validateAuthorization(request);
if (ObjectUtil.isNull(userInfoVO)) {
//校验认证信息 失败 可能解析 token 异常 可能没有解析到正确的工号
throw new AuthorizationValidationException(ResultCode.CAS_AUTHORIZATION);
}
//设置用户信息
UserContextHolder.setUser(userInfoVO);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
//将ThreadLocal数据清空
UserContextHolder.remove();
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
}
/**
* @Description: 校验 Authorization
* @Date: 2024/4/3 10:25
*/
public UserInfoVO validateAuthorization(HttpServletRequest request) {
//获取 Authorization
String authorization = request.getHeader(CommConstant.AUTHORIZATION);
if (StringUtils.isBlank(authorization)) {
StringBuffer requestUrl = request.getRequestURL();
log.info("Authorization 为空的请求url:{}", requestUrl);
//Authorization 为空 没有登录
return null;
}
//检验 authorization
return validateToken(authorization);
}
/**
* @Description: 校验 Authorization
* @Date: 2024/4/3 10:25
*/
public UserInfoVO validateToken(String token) {
//根据token 用户信息
UserInfoVO userInfoVO = JwtUtil.parseUserInfo(token);
if (ObjectUtil.isNull(userInfoVO)) {
//token 伪造的
return null;
}
//根据用户code 获取缓存的token
Object obj = redisUtils.get(MessageFormat.format(RedisKeyConstant.LOGIN_KEY, userInfoVO.getCode()));
if (ObjectUtil.isNull(obj)) {
//缓存中没有 token 过期了 或者是伪造的
return null;
}
return userInfoVO;
}
}
拦截器配置如下:
@Configuration
public class WebConfig implements WebMvcConfigurer {
//不拦截的uri
@Value("${authorization.interceptor.excludes.uri}")
private String excludesUri;
@Bean
public AuthorizationInterceptor authorizationInterceptor() {
return new AuthorizationInterceptor();
}
@Bean
public PermissionInterceptor permissionInterceptor() {
return new PermissionInterceptor();
}
@Bean
public ApiVerifyInterceptor apiVerifyInterceptor() {
return new ApiVerifyInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authorizationInterceptor())
.addPathPatterns("/**").
excludePathPatterns(Arrays.asList(excludesUri.split("\\|")));
}
}
总结:
本篇只是简单的实现 jwt token 登录,但这里面还有很多优化的余地,也有很多值得我们思考的地方,比如:
- token 认证的时候是否需要比对 token 是否一致,保证同一时间只有一个用户操作?
- token 续期的时候是改变无脑续期,改为判断 token 过期时间来确定是否要进行 token 续期,以此来减少 Redis?
- 用户进行密码更新的时候,后端更新 token 的同事是否需要同步更新 token 到前端,避免用户重新登陆,提升用户的体验?
- 后端管理员在删除某些用户的时候,是否需要对 token 进行相关操作?
- 未完待续。。。
欢迎提出建议及对错误的地方指出纠正。