JTW token 登录认证实现

场景:

最近应业务要求,把一个子系统嵌入到一个主平台中,这个子系统使用的是 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 进行相关操作?
  • 未完待续。。。

欢迎提出建议及对错误的地方指出纠正。

  • 15
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值