介绍

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。

接口介绍

URI 描述
/login 认证中心登录接口
/logout 认证中心登出接口

流程

jwt协议认证流程如下
jwt.svg

接入

千行开发框架2.x

后端

在后端application-xxx.yml文件中新增如下配置

  1. ac:
  2. jwt:
  3. # 应用编辑页面->认证中心服务信息->服务URL
  4. acUrlPrefix: http://192.168.59.117:3003/ac
  5. # 认证中心服务地址加上/login
  6. acLoginUrl: ${ac.jwt.acUrlPrefix}/login
  7. # 当前应用服务地址
  8. serverUrl: http://127.0.0.1:8081
  9. # 前端ui地址
  10. uiUrl: http://127.0.0.1:7521/#
  11. # 用于认证中心重定向的地址,即接下来将添加的login接口
  12. service: ${ac.jwt.serverUrl}${server.servlet.context-path}/jwt/login

新增一个配置类关联上述配置文件的参数

  1. import lombok.Data;
  2. import org.springframework.boot.context.properties.ConfigurationProperties;
  3. import org.springframework.context.annotation.Configuration;
  4. @Data
  5. @Configuration
  6. @ConfigurationProperties(prefix = "ac.jwt")
  7. public class JwtConfig {
  8. /**
  9. * 认证中心地址前缀
  10. */
  11. private String acUrlPrefix;
  12. /**
  13. * 认证中心登录接口地址
  14. */
  15. private String acLoginUrl;
  16. /**
  17. * 后端服务地址
  18. */
  19. private String serverUrl;
  20. /**
  21. * 应用前端地址
  22. */
  23. private String uiUrl;
  24. /**
  25. * 用于认证中心登录成功后重定向的地址
  26. */
  27. private String service;
  28. }

新增Jwt认证接口,核心思想就是认证中心登录成功后调用该接口,并将生成的Jwt通过ticket参数传递过来,他的入参和CAS协议是一样的,只不过这里的ticket是Jwt字符串而已

  1. import com.gccloud.starter.common.config.GlobalConfig;
  2. import com.gccloud.starter.common.entity.SysUserEntity;
  3. import com.gccloud.starter.core.service.ISysTokenService;
  4. import com.gccloud.starter.core.service.ISysUserService;
  5. import com.gccloud.starter.core.vo.SysTokenVO;
  6. import com.gccloud.starter.sso.cas.cache.TicketCache;
  7. import com.gccloud.starter.sso.cas.config.JwtConfig;
  8. import io.jsonwebtoken.Claims;
  9. import io.jsonwebtoken.Jwts;
  10. import lombok.extern.slf4j.Slf4j;
  11. import org.apache.commons.lang3.StringUtils;
  12. import org.springframework.stereotype.Controller;
  13. import org.springframework.web.bind.annotation.RequestMapping;
  14. import javax.annotation.Resource;
  15. import javax.servlet.http.HttpServletRequest;
  16. import javax.servlet.http.HttpServletResponse;
  17. @Slf4j
  18. @Controller
  19. @RequestMapping("/jwt")
  20. public class JwtController {
  21. @Resource
  22. private ISysUserService userService;
  23. @Resource
  24. private ISysTokenService tokenService;
  25. @Resource
  26. private GlobalConfig globalConfig;
  27. @Resource
  28. private JwtConfig jwtConfig;
  29. @Resource
  30. private IStarterCache starterCache;
  31. @RequestMapping("/login")
  32. public void validateLogin(HttpServletRequest request, HttpServletResponse response) throws Exception {
  33. // 这里获取的ticket即认证中心生成的Jwt
  34. String ticket = request.getParameter("ticket");
  35. log.debug(ticket);
  36. if (StringUtils.isBlank(ticket)) {
  37. log.error("校验ticket失败,ticket 不能为空");
  38. response.sendRedirect(jwtConfig.getUiUrl() + "/403?code=loginError");
  39. return;
  40. }
  41. // 到这里已经获取到jwt,如果jwt符合应用的规范,则可以直接作为token使用,但是目前认证中心生成的token暂时不符合千行框架的规范,故需要根据认证中心的jwt获取用户信息来生成框架的token
  42. // 解析jwt
  43. Claims claims = Jwts.parser()
  44. .setSigningKey(globalConfig.getJwt().getSecret())
  45. .parseClaimsJws(ticket)
  46. .getBody();
  47. String username = claims.get("subject", String.class);
  48. // 获取用户
  49. SysUserEntity user = userService.getByCount(username);
  50. if (user == null) {
  51. log.error("登录失败,本系统不存在该用户,{}", username);
  52. return;
  53. }
  54. // 生成token
  55. SysTokenVO token = tokenService.create(user.getId());
  56. TicketCache ticketCache = new TicketCache();
  57. ticketCache.setTicket(ticket);
  58. ticketCache.setToken(token.getToken());
  59. String tokenKey = globalConfig.getJwt().getTokenKey();
  60. starterCache.put(TicketCache.class, ticketCache.getTicket(), ticketCache);
  61. // 重定向至前端页面,/sys/jwt 路由对应的页面后面会添加
  62. response.sendRedirect(jwtConfig.getUiUrl() + "/sys/jwt?" + tokenKey + "=" + token.getToken());
  63. }
  64. }

新增登出回调接口,认证中心在接收到登出请求后,会进行登出操作并通过post请求的方式通知各应用的登出接口,这里千行应用后端应该采用有状态的Jwt存储,并将其删除,如果有无状态存储,会发现认证中心已退出,当前Jwt应用仍然可以继续访问,这并不是认证中心的问题,而是子应用的问题。

  1. import com.alibaba.fastjson.JSONObject;
  2. import com.gccloud.starter.common.constant.GlobalConst;
  3. import com.gccloud.starter.common.module.login.cache.SysTokenCache;
  4. import com.gccloud.starter.common.utils.JwtUtils;
  5. import com.gccloud.starter.common.utils.XmlUtils;
  6. import com.gccloud.starter.plugins.cache.common.IStarterCache;
  7. import com.gccloud.starter.sso.cas.cache.TicketCache;
  8. import lombok.extern.slf4j.Slf4j;
  9. import org.apache.commons.lang3.StringUtils;
  10. import org.springframework.stereotype.Controller;
  11. import org.springframework.web.bind.annotation.PostMapping;
  12. import org.springframework.web.bind.annotation.RequestMapping;
  13. import javax.annotation.Resource;
  14. import javax.servlet.http.HttpServletRequest;
  15. @Slf4j
  16. @Controller
  17. @RequestMapping("/jwt")
  18. public class JwtController {
  19. @Resource
  20. private IStarterCache starterCache;
  21. @PostMapping("/callBack")
  22. public void postCallBack(HttpServletRequest request){
  23. String logoutRequest = request.getParameter("logoutRequest");
  24. // 获取退出的ticket
  25. String logoutTicket = XmlUtils.getTextForElement(logoutRequest, "SessionIndex");
  26. if (StringUtils.isBlank(logoutTicket)) {
  27. log.error("退出失败,logoutTicket 为空");
  28. return;
  29. }
  30. TicketCache ticketCache = starterCache.get(TicketCache.class, logoutTicket, TicketCache.class);
  31. if (ticketCache == null) {
  32. log.error("退出失败,ticket = {} 未在系统中找到", logoutTicket);
  33. return;
  34. }
  35. String token = ticketCache.getToken();
  36. JSONObject tokenObj = JwtUtils.parseWithOutValidate(token);
  37. if (tokenObj == null) {
  38. log.error("退出失败, token = {} 解析失败", token);
  39. return;
  40. }
  41. String id = tokenObj.getString(GlobalConst.Jwt.ID);
  42. starterCache.invalidate(SysTokenCache.class, id);
  43. log.info("退出ticket={}, 用户名 = {} 的用户", logoutTicket, tokenObj.getString(GlobalConst.Jwt.USER_NAME));
  44. }
  45. }

上诉接口需要匿名访问,所以配置Shiro的匿名接口(各版本的配置可能有差异,以下为千行2.x版本配置说明)

  1. gc:
  2. starter:
  3. shiro:
  4. filter-chain-definition-map:
  5. '[/jwt/**]': anon

前端

在src/views/jwt下新增index.vue,该页面用于处理后端上述/jwt/login接口重定向传来的token

  1. <template>
  2. <div />
  3. </template>
  4. <script>
  5. import * as tokenCacheService from 'gc-starter-ui-plus/packages/service/cache/tokenCacheService'
  6. export default {
  7. name: 'Jwt',
  8. data () {
  9. return {}
  10. },
  11. created () {
  12. // 获取token
  13. const token = this.$route.query[tokenCacheService.getKey()]
  14. if (!token) {
  15. this.$message.error(`不存在参数${tokenCacheService.getKey()},无法进行跳转页面`)
  16. return
  17. }
  18. // 存储token
  19. tokenCacheService.set(token)
  20. // 路由定向至首页
  21. this.$router.push({ path: '/' })
  22. }
  23. }
  24. </script>

修改工程根目录下 src/router/staticRoutes.js 文件,在export default中添加如下

  1. export default [
  2. {
  3. name: 'jwt',
  4. path: '/sys/jwt',
  5. component: () => import('@/views/jwt/index')
  6. }
  7. ]

修改工程根目录下 src/permission.js,在 const whiteList = […] 中追加 /sys/jwt,参考如下

  1. ...
  2. const whiteList = ['/sys/jwt', '/login', '/todo', '/sys/cas', '/forgotPwd', '/register', '/notice/view']
  3. ...

修改工程根目录下 src/permission.js中的登录逻辑,找到如下内容

  1. ...(省略部分内容)
  2. router.beforeEach(async (to, from, next) => {
  3. ...(省略部分内容)
  4. if (!token) {
  5. // 还没有登录过,如果是白名单路由,那么不登录也可以访问
  6. if (whiteList.indexOf(to.path) !== -1) {
  7. next()
  8. return
  9. }
  10. // 没有登录的话 需要跳转到登录页面,如果是单点登录的话需要调整到认证服务器的登录地址
  11. if (window.SITE_CONFIG.starter.cas.enable) {
  12. window.location.href = window.SITE_CONFIG.starter.cas.loginUrl
  13. return
  14. }
  15. next(`/login?redirect=${to.path}`)
  16. NProgress.done()
  17. return
  18. }
  19. ...(省略部分内容)
  20. }
  21. ...(省略部分内容)

修改为以下逻辑

router.beforeEach(async (to, from, next) => {
...(省略部分内容)

    if (!token) {
      //  还没有登录过,如果是白名单路由,那么不登录也可以访问
      if (whiteList.indexOf(to.path) !== -1) {
        next()
        return
      }
      // 修改这里
      // 这里直接重定向到认证中心,也可以参考原有的cas类型,加个开关
      window.location.href = 认证中心login地址 + ?service= + 应用后端的/jwt/login接口地址
      return
  }
...(省略部分内容)  
}

修改登出逻辑
修改工程根目录下 src/App.vue 中的 logout方法

    /**
       * 系统登出
       * @returns {Promise<void>}
       */
    async logout () {
      await this.$store.dispatch('user/logout')
      // 直接重定向至认证中心登出接口,也可以参考cas类型,在配置文件中加个开关
      window.location.href = 认证中心logout地址 + ?service= 认证中心login地址 + ?service= + 应用后端的/jwt/login接口地址
    }

改造完成后,客户端需要注册到用户中心中
其中jwt的颁发者、签名算法、签名秘钥以及有效期应与框架的配置保持一致
登出方式选择单点登出,登出地址则填写上述 /jwt/callBack 地址
image.png

JWT案例

jwt报文

eyJhbGciOiJIUzI1NiJ9.eyJpc0Zyb21OZXdMb2dpbiI6ImZhbHNlIiwiYXV0aGVudGljYXRpb25EYXRlIjoiMjAyMi0wMS0yNVQwOToxNzozOS4yODErMDg6MDBbQXNpYS9TaGFuZ2hhaV0iLCJzdWJqZWN0IjoiYWRtaW4iLCJzdWNjZXNzZnVsQXV0aGVudGljYXRpb25IYW5kbGVycyI6IlF1ZXJ5RGF0YWJhc2VBdXRoZW50aWNhdGlvbkhhbmRsZXIiLCJpc3MiOiJnYyIsImp3dElkIjoiU1QtNDMtMHdLRjlyeXVpWE9uWXdXclduWGZpVlVUcE1NdGVzdC1nYy1zdGFydGVyLWFjLTY5YmZjNDQ1YzQtOG1xeGwiLCJzYW1sQXV0aGVudGljYXRpb25TdGF0ZW1lbnRBdXRoTWV0aG9kIjoidXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4wOmFtOnBhc3N3b3JkIiwiY3JlZGVudGlhbFR5cGUiOiJVc2VybmFtZVBhc3N3b3JkQ3JlZGVudGlhbCIsImF1ZCI6IjEiLCJhdXRoZW50aWNhdGlvbk1ldGhvZCI6IlF1ZXJ5RGF0YWJhc2VBdXRoZW50aWNhdGlvbkhhbmRsZXIiLCJzZXJ2aWNlQXVkaWVuY2UiOiJodHRwOi8vMTkyLjE2OC41OS4xMTc6MzAwMzQvand0LWRlbW8vbG9naW4iLCJsb25nVGVybUF1dGhlbnRpY2F0aW9uUmVxdWVzdFRva2VuVXNlZCI6ImZhbHNlIiwiZXhwIjoxNjQzMDg3NTgzLCJpYXQiOjE2NDMwODAzODMsImp0aSI6IlNULTQzLTB3S0Y5cnl1aVhPbll3V3JXblhmaVZVVHBNTXRlc3QtZ2Mtc3RhcnRlci1hYy02OWJmYzQ0NWM0LThtcXhsIn0.hTubn7Fat_mmStKWTDaw64PwdxJ9cfOuByvhr4szEdA

解析结果

{
    "alg": "HS256"
}
{
    "isFromNewLogin": "false",
    "authenticationDate": "2022-01-25T09:17:39.281+08:00[Asia/Shanghai]",
    "subject": "admin",
    "successfulAuthenticationHandlers": "QueryDatabaseAuthenticationHandler",
    "iss": "gc",
    "jwtId": "ST-43-0wKF9ryuiXOnYwWrWnXfiVUTpMMtest-gc-starter-ac-69bfc445c4-8mqxl",
    "samlAuthenticationStatementAuthMethod": "urn:oasis:names:tc:SAML:1.0:am:password",
    "credentialType": "UsernamePasswordCredential",
    "aud": "1",
    "authenticationMethod": "QueryDatabaseAuthenticationHandler",
    "serviceAudience": "http://192.168.59.117:30034/jwt-demo/login",
    "longTermAuthenticationRequestTokenUsed": "false",
    "exp": 1643087583,
    "iat": 1643080383,
    "jti": "ST-43-0wKF9ryuiXOnYwWrWnXfiVUTpMMtest-gc-starter-ac-69bfc445c4-8mqxl"
}