用户登录认证是 Web 应用中非常常见的一个业务,一般的流程是这样的:

  • 客户端向服务器端发送用户名和密码
  • 服务器端验证通过后,在当前会话(session)中保存相关数据,比如说登录时间、登录 IP 等。
  • 服务器端向客户端返回一个 session_id,客户端将其保存在 Cookie 中。
  • 客户端再向服务器端发起请求时,将 session_id 传回给服务器端。
  • 服务器端拿到 session_id 后,对用户的身份进行鉴定。

单机情况下,这种模式是没有任何问题的,但对于前后端分离的 Web 应用来说,就非常痛苦了。于是就有了另外一种解决方案,服务器端不再保存 session 数据,而是将其保存在客户端,客户端每次发起请求时再把这个数据发送给服务器端进行验证。JWT(JSON Web Token)就是这种方案的典型代表。

一、关于 JWT

JWT,是目前最流行的一个跨域认证解决方案:客户端发起用户登录请求,服务器端接收并认证成功后,生成一个 JSON 对象(如下所示),然后将其返回给客户端。

  1. {
  2. "sub": "wanger",
  3. "created": 1645700436900,
  4. "exp": 1646305236
  5. }

客户端再次与服务器端通信的时候,把这个 JSON 对象捎带上,作为前后端互相信任的一个凭证。服务器端接收到请求后,通过 JSON 对象对用户身份进行鉴定,这样就不再需要保存任何 session 数据了。

假如我现在使用用户名 wanger 和密码 123456 进行访问编程喵(Codingmore)的 login 接口,那么实际的 JWT 是一串看起来像是加过密的字符串。

为了让大家看的更清楚一点,我将其复制到了 jwt 的官网

左侧 Encoded 部分就是 JWT 密文,中间用「.」分割成了三部分(右侧 Decoded 部分):

  • Header(头部),描述 JWT 的元数据,其中 alg 属性表示签名的算法(当前为 HS512);
  • Payload(负载),用来存放实际需要传递的数据,其中 sub 属性表示主题(实际值为用户名),created 属性表示 JWT 产生的时间,exp 属性表示过期时间
  • Signature(签名),对前两部分的签名,防止数据篡改;这里需要服务器端指定一个密钥(只有服务器端才知道),不能泄露给客户端,然后使用 Header 中指定的签名算法,按照下面的公式产生签名:
  1. HMACSHA512(
  2. base64UrlEncode(header) + "." +
  3. base64UrlEncode(payload),
  4. your-256-bit-secret
  5. )

算出签名后,再把 Header、Payload、Signature 拼接成一个字符串,中间用「.」分割,就可以返回给客户端了。

客户端拿到 JWT 后,可以放在 localStorage,也可以放在 Cookie 里面。

  1. const TokenKey = '1D596CD8-8A20-4CEC-98DD-CDC12282D65C' // createUuid()
  2. export function getToken () {
  3. return Cookies.get(TokenKey)
  4. }
  5. export function setToken (token) {
  6. return Cookies.set(TokenKey, token)
  7. }

以后客户端再与服务器端通信的时候,就带上这个 JWT,一般放在 HTTP 的请求的头信息 Authorization 字段里。

  1. Authorization: Bearer <token>

服务器端接收到请求后,再对 JWT 进行验证,如果验证通过就返回相应的资源。

二、实战 JWT

第一步,在 pom.xml 文件中添加 JWT 的依赖。

  1. <dependency>
  2. <groupId>io.jsonwebtoken</groupId>
  3. <artifactId>jjwt</artifactId>
  4. <version>0.9.0</version>
  5. </dependency>

第二步,在 application.yml 中添加 JWT 的配置项。

  1. jwt:
  2. tokenHeader: Authorization #JWT存储的请求头
  3. secret: codingmore-admin-secret #JWT加解密使用的密钥
  4. expiration: 604800 #JWT的超期限时间(60*60*24*7)
  5. tokenHead: 'Bearer ' #JWT负载中拿到开头

第三步,新建 JwtTokenUtil.java 工具类,主要有三个方法:

  • generateToken(UserDetails userDetails):根据登录用户生成 token
  • getUserNameFromToken(String token):从 token 中获取登录用户
  • validateToken(String token, UserDetails userDetails):判断 token 是否仍然有效
  1. public class JwtTokenUtil {
  2. @Value("${jwt.secret}")
  3. private String secret;
  4. @Value("${jwt.expiration}")
  5. private Long expiration;
  6. @Value("${jwt.tokenHead}")
  7. private String tokenHead;
  8. /**
  9. * 根据用户信息生成token
  10. */
  11. public String generateToken(UserDetails userDetails) {
  12. Map<String, Object> claims = new HashMap<>();
  13. claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
  14. claims.put(CLAIM_KEY_CREATED, new Date());
  15. return generateToken(claims);
  16. }
  17. /**
  18. * 根据用户名、创建时间生成JWT的token
  19. */
  20. private String generateToken(Map<String, Object> claims) {
  21. return Jwts.builder()
  22. .setClaims(claims)
  23. .setExpiration(generateExpirationDate())
  24. .signWith(SignatureAlgorithm.HS512, secret)
  25. .compact();
  26. }
  27. /**
  28. * 从token中获取登录用户名
  29. */
  30. public String getUserNameFromToken(String token) {
  31. String username = null;
  32. Claims claims = getClaimsFromToken(token);
  33. if (claims != null) {
  34. username = claims.getSubject();
  35. }
  36. return username;
  37. }
  38. /**
  39. * 从token中获取JWT中的负载
  40. */
  41. private Claims getClaimsFromToken(String token) {
  42. Claims claims = null;
  43. try {
  44. claims = Jwts.parser()
  45. .setSigningKey(secret)
  46. .parseClaimsJws(token)
  47. .getBody();
  48. } catch (Exception e) {
  49. LOGGER.info("JWT格式验证失败:{}", token);
  50. }
  51. return claims;
  52. }
  53. /**
  54. * 验证token是否还有效
  55. *
  56. * @param token 客户端传入的token
  57. * @param userDetails 从数据库中查询出来的用户信息
  58. */
  59. public boolean validateToken(String token, UserDetails userDetails) {
  60. String username = getUserNameFromToken(token);
  61. return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
  62. }
  63. /**
  64. * 判断token是否已经失效
  65. */
  66. private boolean isTokenExpired(String token) {
  67. Date expiredDate = getExpiredDateFromToken(token);
  68. return expiredDate.before(new Date());
  69. }
  70. /**
  71. * 从token中获取过期时间
  72. */
  73. private Date getExpiredDateFromToken(String token) {
  74. Claims claims = getClaimsFromToken(token);
  75. return claims.getExpiration();
  76. }
  77. }

第四步, 在 UsersController.java 中新增 login 登录接口,接收用户名和密码,并将 JWT 返回给客户端。

  1. @Controller
  2. @Api(tags="用户")
  3. @RequestMapping("/users")
  4. public class UsersController {
  5. @Autowired
  6. private IUsersService usersService;
  7. @Value("${jwt.tokenHeader}")
  8. private String tokenHeader;
  9. @Value("${jwt.tokenHead}")
  10. private String tokenHead;
  11. @ApiOperation(value = "登录以后返回token")
  12. @RequestMapping(value = "/login", method = RequestMethod.POST)
  13. @ResponseBody
  14. public ResultObject login(@Validated UsersLoginParam users, BindingResult result) {
  15. String token = usersService.login(users.getUserLogin(), users.getUserPass());
  16. if (token == null) {
  17. return ResultObject.validateFailed("用户名或密码错误");
  18. }
  19. // 将 JWT 传递回客户端
  20. Map<String, String> tokenMap = new HashMap<>();
  21. tokenMap.put("token", token);
  22. tokenMap.put("tokenHead", tokenHead);
  23. return ResultObject.success(tokenMap);
  24. }
  25. }

第五步,在 UsersServiceImpl.java 中新增 login 方法,根据用户名从数据库中查询用户,密码验证通过后生成 JWT。

  1. @Service
  2. public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUsersService {
  3. @Autowired
  4. private PasswordEncoder passwordEncoder;
  5. @Autowired
  6. private JwtTokenUtil jwtTokenUtil;
  7. public String login(String username, String password) {
  8. String token = null;
  9. //密码需要客户端加密后传递
  10. try {
  11. // 查询用户+用户资源
  12. UserDetails userDetails = loadUserByUsername(username);
  13. // 验证密码
  14. if (!passwordEncoder.matches(password, userDetails.getPassword())) {
  15. Asserts.fail("密码不正确");
  16. }
  17. // 返回 JWT
  18. token = jwtTokenUtil.generateToken(userDetails);
  19. } catch (AuthenticationException e) {
  20. LOGGER.warn("登录异常:{}", e.getMessage());
  21. }
  22. return token;
  23. }
  24. }

第六步,新增 JwtAuthenticationTokenFilter.java,每次客户端发起请求时对 JWT 进行验证。

  1. public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
  2. private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
  3. @Autowired
  4. private UserDetailsService userDetailsService;
  5. @Autowired
  6. private JwtTokenUtil jwtTokenUtil;
  7. @Value("${jwt.tokenHeader}")
  8. private String tokenHeader;
  9. @Value("${jwt.tokenHead}")
  10. private String tokenHead;
  11. @Override
  12. protected void doFilterInternal(HttpServletRequest request,
  13. HttpServletResponse response,
  14. FilterChain chain) throws ServletException, IOException {
  15. // 从客户端请求中获取 JWT
  16. String authHeader = request.getHeader(this.tokenHeader);
  17. // 该 JWT 是我们规定的格式,以 tokenHead 开头
  18. if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
  19. // The part after "Bearer "
  20. String authToken = authHeader.substring(this.tokenHead.length());
  21. // 从 JWT 中获取用户名
  22. String username = jwtTokenUtil.getUserNameFromToken(authToken);
  23. LOGGER.info("checking username:{}", username);
  24. // SecurityContextHolder 是 SpringSecurity 的一个工具类
  25. // 保存应用程序中当前使用人的安全上下文
  26. if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
  27. // 根据用户名获取登录用户信息
  28. UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
  29. // 验证 token 是否过期
  30. if (jwtTokenUtil.validateToken(authToken, userDetails)) {
  31. // 将登录用户保存到安全上下文中
  32. UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,
  33. null, userDetails.getAuthorities());
  34. authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
  35. SecurityContextHolder.getContext().setAuthentication(authentication);
  36. LOGGER.info("authenticated user:{}", username);
  37. }
  38. }
  39. }
  40. chain.doFilter(request, response);
  41. }
  42. }

JwtAuthenticationTokenFilter 继承了 OncePerRequestFilter,该过滤器能确保一次请求只通过一次 filter,而不需要重复执行。也就是说,客户端每发起一次请求,该过滤器就会执行一次。

这个过滤器非常关键啊,基本上每行代码我都添加了注释,当然了,为了确保大家都能搞清楚这个类到底做了什么,我再来画一幅流程图,这样就一清二楚了。

SpringSecurity 是一个安全管理框架,可以和 Spring Boot 应用无缝衔接,SecurityContextHolder 是其中非常关键的一个工具类,持有安全上下文信息,里面保存有当前操作的用户是谁,用户是否已经被认证,用户拥有的权限等关键信息。

SecurityContextHolder 默认使用了 ThreadLocal 策略来存储认证信息,ThreadLocal 的特点是存在它里边的数据,哪个线程存的,哪个线程才能访问到。这就意味着不同的请求进入到服务器端后,会由不同的 Thread 去处理,例如线程 A 将请求 1 的用户信息存入了 ThreadLocal,线程 B 在处理请求 2 的时候是无法获取到用户信息的。

所以说 JwtAuthenticationTokenFilter 过滤器会在每次请求过来的时候进行一遍 JWT 的验证,确保客户端过来的请求是安全的。然后 SpringSecurity 才会对接下来的请求接口放行。这也是 JWT 和 Session 的根本区别:

  • JWT 需要每次请求的时候验证一次,并且只要 JWT 没有过期,哪怕服务器端重启了,认证仍然有效。
  • Session 在没有过期的情况下是不需要重新对用户信息进行验证的,当服务器端重启后,用户需要重新登录获取新的 Session。

也就是说,在 JWT 的方案下,服务器端保存的密钥(secret)一定不能泄露,否则客户端就可以根据签名算法伪造用户的认证信息了

三、Swagger 中添加 JWT 验证

对于后端开发人员来说,如何在 Swagger(整合了 Knife4j 进行美化) 中添加 JWT 验证呢?

第一步,访问 login 接口,输入用户名和密码进行登录,获取服务器端返回的 JWT。

第二步,收集服务器端返回的 tokenHead 和 token,将其填入 Authorize(注意 tokenHead 和 token 之间有一个空格)完成登录认证。

第三步,再次请求其他接口时,Swagger 会自动将 Authorization 作为请求头信息发送到服务器端。

第四步,服务器端接收到该请求后,会通过 JwtAuthenticationTokenFilter 过滤器对 JWT 进行校验。

到此为止,整个流程全部打通了,完美!

四、总结

综上来看,用 JWT 来解决前后端分离项目中的跨域认证还是非常丝滑的,这主要得益于 JSON 的通用性,可以跨语言,JavaScript 和 Java 都支持;另外,JWT 的组成非常简单,非常便于传输;还有 JWT 不需要在服务器端保存会话信息(Session),非常易于扩展。

当然了,为了保证 JWT 的安全性,不要在 JWT 中保存敏感信息,因为一旦私钥泄露,JWT 是很容易在客户端被解密的;如果可以,请使用 HTTPS 协议。

参考链接:

阮一峰:https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

春夏秋冬过:https://segmentfault.com/a/1190000012557493

江南一点雨:https://cloud.tencent.com/developer/article/1612175

Dearmadman:https://www.jianshu.com/p/576dbf44b2ae

mcarozheng:http://www.macrozheng.com/

源码路径:

https://github.com/itwanger/coding-more


本篇已收录至 GitHub 上星标 1.6k+ star 的开源专栏《Java 程序员进阶之路》,据说每一个优秀的 Java 程序员都喜欢她,风趣幽默、通俗易懂。内容包括 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发、Java 面试等核心知识点。学 Java,就认准 Java 程序员进阶之路

https://github.com/itwanger/toBeBetterJavaer

star 了这个仓库就等于你拥有了成为了一名优秀 Java 工程师的潜力。也可以戳下面的链接跳转到《Java 程序员进阶之路》的官网网址,开始愉快的学习之旅吧。

https://tobebetterjavaer.com/

没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟

干掉Session?这个跨域认证解决方案真的优雅!的更多相关文章

  1. JSON Web Token(缩写 JWT) 目前最流行的跨域认证解决方案

    一.跨域认证的问题 互联网服务离不开用户认证.一般流程是下面这样. 1.用户向服务器发送用户名和密码. 2.服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色.登录时间等等. ...

  2. JWT token 跨域认证

    JSON Web Token(缩写 JWT),是目前最流行的跨域认证解决方案. session登录认证方案:用户从客户端传递用户名.密码等信息,服务端认证后将信息存储在session中,将sessio ...

  3. Cookie、Session、Token与JWT(跨域认证)

    之前看到群里有人问JWT相关的内容,只记得是token的一种,去补习了一下,和很久之前发的认证方式总结的笔记放在一起发出来吧. Cookie.Session.Token与JWT(跨域认证) 什么是Co ...

  4. angularjs跨域post解决方案

    转自:http://www.thinksaas.cn/topics/0/34/34536.html 前端同学李雷和后台同学韩梅梅分别在自己电脑上进行开发,后台接口写好的时候,李雷改动完就把前端代码上传 ...

  5. C#进阶系列——WebApi 跨域问题解决方案:CORS

    前言:上篇总结了下WebApi的接口测试工具的使用,这篇接着来看看WebAPI的另一个常见问题:跨域问题.本篇主要从实例的角度分享下CORS解决跨域问题一些细节. WebApi系列文章 C#进阶系列— ...

  6. thinkphp,javascript跨域请求解决方案

    javascript跨域请求解决方案 前言 对于很多前端或者做混合开发的同学,我们难免会遇到跨域发起请求业务,比如A站点向B站点请求数据等等.由于最近要做一个站点集群的项目,所以具体业务要求很多个站点 ...

  7. C#进阶系列——WebApi 跨域问题解决方案:CORS(转载)

    C#进阶系列——WebApi 跨域问题解决方案:CORS   阅读目录 一.跨域问题的由来 二.跨域问题解决原理 三.跨域问题解决细节 1.场景描述 2.场景测试 四.总结 正文 前言:上篇总结了下W ...

  8. 跨域学习笔记2--WebApi 跨域问题解决方案:CORS

    自己并不懂,在此先记录下来,留待以后学习... 正文 前言:上篇总结了下WebApi的接口测试工具的使用,这篇接着来看看WebAPI的另一个常见问题:跨域问题.本篇主要从实例的角度分享下CORS解决跨 ...

  9. jquery跨域访问解决方案(转)

    客户端“跨域访问”一直是一个头疼的问题,好在有jQuery帮忙,从jQuery-1.2以后跨域问题便迎刃而解.由于自己在项目中遇到跨域问题,借此机会对跨域问题来刨根问底,查阅了相关资料和自己的实践,算 ...

随机推荐

  1. VUE3 之 全局组件与局部组件

    1. 概述 老话说的好:忍耐是一种策略,同时也是一种性格磨炼. 言归正传,今天我们来聊聊 VUE 的全局组件与局部组件. 2. 全局组件 2.1 不使用组件的写法  <body> < ...

  2. Python SQL execute加参数的原理

    在Python中,当用pymysql库,或者MySQLdb库进行数据库查询时,为了防止sql注入,可以在execute的时候,把参数单独带进去,例如: def execute_v1(): config ...

  3. linux设置系统用户密码

    目录 一:系统用户密码 1.设置用户密码 一:系统用户密码 1.设置用户密码 1.交互式方法 passwd [用户名] 2.免交互式 echo [设置密码] | passwd --stdin [用户名 ...

  4. 近期Android学习II

    一晃眼又过了5天,这几天的学习有些杂乱,半年在家没运动,返校了准备慢慢恢复运动,身体才是革命的本钱~ 四天跑了三回步,每次都死亡喘息= = 这几天的学习重点总归还是放在Android上了,前面31天连 ...

  5. 安装Windows11操作系统(不需要绕过TPM检测脚本等) - 初学者系列 - 学习者系列文章

    Windows11操作系统是去年微软公司的最新力作.对于该操作系统的安装,网上有很多的教程了.这次主要写的是不需要绕过TPM检测操作安装Windows11操作系统. 1.        制作启动U盘: ...

  6. JVM专题1: 类和类加载机制

    合集目录 JVM专题1: 类和类加载机制 Java对象的结构 在HotSpot虚拟机中, 对象在内存中存储的布局可以分为3块区域 对象头Header 实例数据Instance Data 对齐填充Pad ...

  7. hadoop面试

    hadoop.apache.orgspark.apache.orgflink.apache.orghadoop :HDFS/YARN/MAPREDUCE HDFS读写流程 NameNode DataN ...

  8. QA(测试) 工作准则建议

    身为一个专业的 QA 当然需要有自己的测试原则,这些测试原则不仅可以帮助我们提高产品质量,对外还能体现出我们的专业性,从而让合作方后续还有意愿和我们合作. 1 测试前 1.1 需求评审 必须参与,有问 ...

  9. Element Plus 正式版发布啦!🎉🎉

    今天,我们非常高兴地宣布 Element Plus 稳定版正式发布.自第一个 commit 起,经过 1 年零 7 个月的持续迭代开发,总计 2635 commits,经过 256 位贡献者所提交的 ...

  10. 集合remove()方法相关问题

    学习集合的过程中,了解到一个有关于remove()方法的有关特性,特此记录 首先remove方法的格式: collection.remove(Object o); 这是指对集合collection内的 ...