JWT实现单点登录
文章目录
- JWT实现单点登录
- JWT 简介
- 存在问题及解决方案
- 登录流程
- 后端程序实现
- 前端保存Token
- store存放信息的缺点及解决
- 校验流程:为gateway增加登录校验拦截器
- 另一种单点登录方法:Token+Redis实现单点登录
JWT实现单点登录
- 登录流程:
校验用户名密码->生成随机JWT Token->返回给前端。之后前端发请求携带该Token就能验证是哪个用户了。 - 校验流程:
从前端请求的header获取JWT Token->根据工具包校验JWT Token->校验成功或失败
JWT 简介
结构
Header 头部信息,主要声明了JWT的签名算法等信息
Payload 载荷信息,主要承载了各种声明并传递明文数据
Signature 签名,拥有该部分的JWT被称为JWS,也就是签了名的JWT,用于校验数据
整体结构是:
header.payload.signature
参考文档:https://doc.hutool.cn/pages/jwt/
存在问题及解决方案
-
- token被解密:如工具包被获取。可通过增加“盐值”来解决。
-
- token被拿到第三方使用:如被包装到第三方使用(ChatGPT工具),可以通过限流来解决。
登录流程
后端程序实现
封装hutool工具类:
public class JwtUtil {
private static final Logger LOG = LoggerFactory.getLogger(JwtUtil.class);
/**
* 盐值很重要,不能泄漏,且每个项目都应该不一样,可以放到配置文件中
*/
private static final String key = "xxx";
public static String createToken(Long id, String mobile) {
LOG.info("开始生成JWT token,id:{},mobile:{}", id, mobile);
GlobalBouncyCastleProvider.setUseBouncyCastle(false);
DateTime now = DateTime.now();
DateTime expTime = now.offsetNew(DateField.HOUR, 24);
// DateTime expTime = now.offsetNew(DateField.SECOND, 10);
Map<String, Object> payload = new HashMap<>();
// 签发时间
payload.put(JWTPayload.ISSUED_AT, now);
// 过期时间
payload.put(JWTPayload.EXPIRES_AT, expTime);
// 生效时间
payload.put(JWTPayload.NOT_BEFORE, now);
// 内容
payload.put("id", id);
payload.put("mobile", mobile);
String token = JWTUtil.createToken(payload, key.getBytes());
LOG.info("生成JWT token:{}", token);
return token;
}
public static boolean validate(String token) {
LOG.info("开始JWT token校验,token:{}", token);
GlobalBouncyCastleProvider.setUseBouncyCastle(false);
JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes());
// validate包含了verify
boolean validate = jwt.validate(0);
LOG.info("JWT token校验结果:{}", validate);
return validate;
}
public static JSONObject getJSONObject(String token) {
GlobalBouncyCastleProvider.setUseBouncyCastle(false);
JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes());
JSONObject payloads = jwt.getPayloads();
payloads.remove(JWTPayload.ISSUED_AT);
payloads.remove(JWTPayload.EXPIRES_AT);
payloads.remove(JWTPayload.NOT_BEFORE);
LOG.info("根据token获取原始内容:{}", payloads);
return payloads;
}
public static void main(String[] args) {
createToken(1L, "123");
String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE3MzY0ODczMDQsIm1vYmlsZSI6IjEyMyIsImlkIjoxLCJleHAiOjE3MzY1NzM3MDQsImlhdCI6MTczNjQ4NzMwNH0.Bui7guCvPEF557eqxRLwmt5tO-W-3oVLnn37H4qOVfA";
validate(token);
getJSONObject(token);
}
}
后端定义登录业务:
public MemberLoginResp login(MemberLoginReq memberLoginReq){
String mobile = memberLoginReq.getMobile();
String code = memberLoginReq.getCode();
Member memberDB = selectByMobile(mobile);
if (ObjectUtil.isEmpty(memberDB)){
throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_NOT_EXIST);
}
if(!code.equals("8888")){
throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR);
}
MemberLoginResp memberLoginResp = new MemberLoginResp();
memberLoginResp.setId(memberDB.getId());
memberLoginResp.setMobile(mobile);
String token = JwtUtil.createToken(memberDB.getId(), memberDB.getMobile());
memberLoginResp.setToken(token);
return memberLoginResp;
}
通过调用封装的JwtUtil生成token并返回前端
成功返回Token结果
前端保存Token
Vuex全局保存Token到store中
import { createStore } from 'vuex'
const MEMBER = "MEMBER";
export default createStore({
state: {
member: {}
},
getters: {
},
mutations: {
setMember (state, _member) {
state.member = _member;
}
},
actions: {
},
modules: {
}
})
const login = () => {
axios.post("/member/member/login", loginForm).then((response) => {
let data = response.data;
if (data.success) {
notification.success({ description: '登录成功!' });
// 登录成功,跳到控台主页
router.push("/welcome");
store.commit("setMember", data.content);
} else {
notification.error({ description: data.message });
}
})
};
store存放信息的缺点及解决
store存放用户信息后,如果刷新页面,那么信息也会消失!
store可以理解为缓存,一旦重新加载,则缓存全都没了。
解决方法:
- step1. 新增session-storage.js,封装会话缓存sessionStorage
// 所有的session key都在这里统一定义,可以避免多个功能使用同一个key
SESSION_ORDER = "SESSION_ORDER";
SESSION_TICKET_PARAMS = "SESSION_TICKET_PARAMS";
SessionStorage = {
get: function (key) {
var v = sessionStorage.getItem(key);
if (v && typeof(v) !== "undefined" && v !== "undefined") {
return JSON.parse(v);
}
},
set: function (key, data) {
sessionStorage.setItem(key, JSON.stringify(data));
},
remove: function (key) {
sessionStorage.removeItem(key);
},
clearAll: function () {
sessionStorage.clear();
}
};
- step2. 在index.html中引入该js
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<!-- 引入js -->
<script src="<%= BASE_URL %>js/session-storage.js"></script>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
- step3. 修改store的index.js
const MEMBER = "MEMBER";
export default createStore({
state: {
member: window.SessionStorage.get(MEMBER) || {} # 读取
},
getters: {
},
mutations: {
setMember (state, _member) {
state.member = _member;
window.SessionStorage.set(MEMBER, _member); # 设置
}
},
不再是把member定义为{},而是首先在缓存中获取,如果没有则设置为{}。同时避免空指针
同时在用户登录后设置MEMBER缓存
校验流程:为gateway增加登录校验拦截器
- 添加依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.10</version>
</dependency>
- 拦截器类
@Component
public class LoginMemberFilter implements Ordered, GlobalFilter {
private static final Logger LOG = LoggerFactory.getLogger(LoginMemberFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 排除不需要拦截的请求
if (path.contains("/admin")
|| path.contains("/redis")
|| path.contains("/test")
|| path.contains("/member/member/login")
|| path.contains("/member/member/send-code")) {
LOG.info("不需要登录验证:{}", path);
return chain.filter(exchange);
} else {
LOG.info("需要登录验证:{}", path);
}
// 获取header的token参数
String token = exchange.getRequest().getHeaders().getFirst("token");
LOG.info("会员登录验证开始,token:{}", token);
if (token == null || token.isEmpty()) {
LOG.info( "token为空,请求被拦截" );
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 校验token是否有效,包括token是否被改过,是否过期
boolean validate = JwtUtil.validate(token);
if (validate) {
LOG.info("token有效,放行该请求");
return chain.filter(exchange);
} else {
LOG.warn( "token无效,请求被拦截" );
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
/**
* 优先级设置 值越小 优先级越高
*
* @return
*/
@Override
public int getOrder() {
return 0;
}
}
- 测试结果:
- 直接调用不需要验证登录的接口
@RestController
public class TestController {
@GetMapping("/test")
public String test(){
return "test";
}
}
- 调用需要登录的接口方法(未登录)
同时服务器端没有打印,表示请求已被拦截
- 调用login登陆后再次执行上述请求
login打印日志:
调用请求打印日志:
可见成功校验token,并读取登录用户信息,通过校验
另一种单点登录方法:Token+Redis实现单点登录
- 登录流程:
校验用户名密码->生成随机Token->将Token存放到Redis,并返回给前端。
之后前端发请求携带该Token就能验证是哪个用户了。 - 校验流程:
从前端请求的header获取Token->根据Token到Redis获取用户数据->若有数据则登录校验通过,否则失败