介绍
JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。
接口介绍
URI | 描述 |
---|---|
/login | 认证中心登录接口 |
/logout | 认证中心登出接口 |
流程
接入
千行开发框架2.x
后端
在后端application-xxx.yml文件中新增如下配置
ac:
jwt:
# 应用编辑页面->认证中心服务信息->服务URL
acUrlPrefix: http://192.168.59.117:3003/ac
# 认证中心服务地址加上/login
acLoginUrl: ${ac.jwt.acUrlPrefix}/login
# 当前应用服务地址
serverUrl: http://127.0.0.1:8081
# 前端ui地址
uiUrl: http://127.0.0.1:7521/#
# 用于认证中心重定向的地址,即接下来将添加的login接口
service: ${ac.jwt.serverUrl}${server.servlet.context-path}/jwt/login
新增一个配置类关联上述配置文件的参数
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "ac.jwt")
public class JwtConfig {
/**
* 认证中心地址前缀
*/
private String acUrlPrefix;
/**
* 认证中心登录接口地址
*/
private String acLoginUrl;
/**
* 后端服务地址
*/
private String serverUrl;
/**
* 应用前端地址
*/
private String uiUrl;
/**
* 用于认证中心登录成功后重定向的地址
*/
private String service;
}
新增Jwt认证接口,核心思想就是认证中心登录成功后调用该接口,并将生成的Jwt通过ticket参数传递过来,他的入参和CAS协议是一样的,只不过这里的ticket是Jwt字符串而已
import com.gccloud.starter.common.config.GlobalConfig;
import com.gccloud.starter.common.entity.SysUserEntity;
import com.gccloud.starter.core.service.ISysTokenService;
import com.gccloud.starter.core.service.ISysUserService;
import com.gccloud.starter.core.vo.SysTokenVO;
import com.gccloud.starter.sso.cas.cache.TicketCache;
import com.gccloud.starter.sso.cas.config.JwtConfig;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Controller
@RequestMapping("/jwt")
public class JwtController {
@Resource
private ISysUserService userService;
@Resource
private ISysTokenService tokenService;
@Resource
private GlobalConfig globalConfig;
@Resource
private JwtConfig jwtConfig;
@Resource
private IStarterCache starterCache;
@RequestMapping("/login")
public void validateLogin(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 这里获取的ticket即认证中心生成的Jwt
String ticket = request.getParameter("ticket");
log.debug(ticket);
if (StringUtils.isBlank(ticket)) {
log.error("校验ticket失败,ticket 不能为空");
response.sendRedirect(jwtConfig.getUiUrl() + "/403?code=loginError");
return;
}
// 到这里已经获取到jwt,如果jwt符合应用的规范,则可以直接作为token使用,但是目前认证中心生成的token暂时不符合千行框架的规范,故需要根据认证中心的jwt获取用户信息来生成框架的token
// 解析jwt
Claims claims = Jwts.parser()
.setSigningKey(globalConfig.getJwt().getSecret())
.parseClaimsJws(ticket)
.getBody();
String username = claims.get("subject", String.class);
// 获取用户
SysUserEntity user = userService.getByCount(username);
if (user == null) {
log.error("登录失败,本系统不存在该用户,{}", username);
return;
}
// 生成token
SysTokenVO token = tokenService.create(user.getId());
TicketCache ticketCache = new TicketCache();
ticketCache.setTicket(ticket);
ticketCache.setToken(token.getToken());
String tokenKey = globalConfig.getJwt().getTokenKey();
starterCache.put(TicketCache.class, ticketCache.getTicket(), ticketCache);
// 重定向至前端页面,/sys/jwt 路由对应的页面后面会添加
response.sendRedirect(jwtConfig.getUiUrl() + "/sys/jwt?" + tokenKey + "=" + token.getToken());
}
}
新增登出回调接口,认证中心在接收到登出请求后,会进行登出操作并通过post请求的方式通知各应用的登出接口,这里千行应用后端应该采用有状态的Jwt存储,并将其删除,如果有无状态存储,会发现认证中心已退出,当前Jwt应用仍然可以继续访问,这并不是认证中心的问题,而是子应用的问题。
import com.alibaba.fastjson.JSONObject;
import com.gccloud.starter.common.constant.GlobalConst;
import com.gccloud.starter.common.module.login.cache.SysTokenCache;
import com.gccloud.starter.common.utils.JwtUtils;
import com.gccloud.starter.common.utils.XmlUtils;
import com.gccloud.starter.plugins.cache.common.IStarterCache;
import com.gccloud.starter.sso.cas.cache.TicketCache;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@Controller
@RequestMapping("/jwt")
public class JwtController {
@Resource
private IStarterCache starterCache;
@PostMapping("/callBack")
public void postCallBack(HttpServletRequest request){
String logoutRequest = request.getParameter("logoutRequest");
// 获取退出的ticket
String logoutTicket = XmlUtils.getTextForElement(logoutRequest, "SessionIndex");
if (StringUtils.isBlank(logoutTicket)) {
log.error("退出失败,logoutTicket 为空");
return;
}
TicketCache ticketCache = starterCache.get(TicketCache.class, logoutTicket, TicketCache.class);
if (ticketCache == null) {
log.error("退出失败,ticket = {} 未在系统中找到", logoutTicket);
return;
}
String token = ticketCache.getToken();
JSONObject tokenObj = JwtUtils.parseWithOutValidate(token);
if (tokenObj == null) {
log.error("退出失败, token = {} 解析失败", token);
return;
}
String id = tokenObj.getString(GlobalConst.Jwt.ID);
starterCache.invalidate(SysTokenCache.class, id);
log.info("退出ticket={}, 用户名 = {} 的用户", logoutTicket, tokenObj.getString(GlobalConst.Jwt.USER_NAME));
}
}
上诉接口需要匿名访问,所以配置Shiro的匿名接口(各版本的配置可能有差异,以下为千行2.x版本配置说明)
gc:
starter:
shiro:
filter-chain-definition-map:
'[/jwt/**]': anon
前端
在src/views/jwt下新增index.vue,该页面用于处理后端上述/jwt/login接口重定向传来的token
<template>
<div />
</template>
<script>
import * as tokenCacheService from 'gc-starter-ui-plus/packages/service/cache/tokenCacheService'
export default {
name: 'Jwt',
data () {
return {}
},
created () {
// 获取token
const token = this.$route.query[tokenCacheService.getKey()]
if (!token) {
this.$message.error(`不存在参数${tokenCacheService.getKey()},无法进行跳转页面`)
return
}
// 存储token
tokenCacheService.set(token)
// 路由定向至首页
this.$router.push({ path: '/' })
}
}
</script>
修改工程根目录下 src/router/staticRoutes.js 文件,在export default中添加如下
export default [
{
name: 'jwt',
path: '/sys/jwt',
component: () => import('@/views/jwt/index')
}
]
修改工程根目录下 src/permission.js,在 const whiteList = […] 中追加 /sys/jwt,参考如下
...
const whiteList = ['/sys/jwt', '/login', '/todo', '/sys/cas', '/forgotPwd', '/register', '/notice/view']
...
修改工程根目录下 src/permission.js中的登录逻辑,找到如下内容
...(省略部分内容)
router.beforeEach(async (to, from, next) => {
...(省略部分内容)
if (!token) {
// 还没有登录过,如果是白名单路由,那么不登录也可以访问
if (whiteList.indexOf(to.path) !== -1) {
next()
return
}
// 没有登录的话 需要跳转到登录页面,如果是单点登录的话需要调整到认证服务器的登录地址
if (window.SITE_CONFIG.starter.cas.enable) {
window.location.href = window.SITE_CONFIG.starter.cas.loginUrl
return
}
next(`/login?redirect=${to.path}`)
NProgress.done()
return
}
...(省略部分内容)
}
...(省略部分内容)
修改为以下逻辑
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 地址
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"
}