实现JWT单点登录功能
单点登录:直观意义就是对于一个系统,只需登录一次就可以处处访问
两种单点登录的设计方案
方案一:redis+token
登录阶段
登录开始
->校验用户名和密码
->生成随机的token,每次都不一样
->将token放入redis
->结束
校验阶段
从header中获取token
->根据token到redis获取数据
->是否有数据
->有数据登录校验成功,反之校验失败
方案二:JWT
登录阶段
登陆开始
->校验用户名和密码
->生成JWT Token,每次都不一样
->结束
校验阶段
校验开始
->从header获取token
->使用工具包校验token
->校验是否成功
JWT原理
JWT原理及其用法
JWT存在的问题
问题一:token被解密破解
给密钥加盐值,每个项目盐值不一样,减小被破解风险
问题二:token被第三方使用
背景:自己的产品,被第三方包装成一个界面,做成他们自己的收费产品
此类问题无好的解决办法,可以通过限流进行一定程度缓解,如果某个相同的token有大量请求,则可能被第三方利用
生成JWT单点登录token
本项目中,我们利用Hutool工具包提供的JWT模块进行单点登录的开发
将JWT token生成和校验功能封装为一个工具类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| package com.bang.train.common.util;
import cn.hutool.core.date.DateField; import cn.hutool.core.date.DateTime; import cn.hutool.crypto.GlobalBouncyCastleProvider; import cn.hutool.json.JSONObject; import cn.hutool.jwt.JWT; import cn.hutool.jwt.JWTPayload; import cn.hutool.jwt.JWTUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory;
import java.util.HashMap; import java.util.Map;
public class JwtUtil { private static final Logger LOG = LoggerFactory.getLogger(JwtUtil.class);
private static final String key = "train-12306";
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); 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()); 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) { String token = createToken(1L, "123");
validate(token);
getJSONObject(token); } }
|
修改登录功能service层代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Override public MemberLoginResp login(MemberLoginReq req) { String mobile = req.getMobile(); String code = req.getCode(); Member member = getMembersByMobile(mobile); if(ObjectUtil.isNull(member)){ throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_NOT_EXIST); } if(!"8888".equals(code)){ throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR); } MemberLoginResp memberLoginResp = BeanUtil.copyProperties(member,MemberLoginResp.class); String token = JwtUtil.createToken(memberLoginResp.getId(),memberLoginResp.getMobile()); memberLoginResp.setToken(token); return memberLoginResp; }
|
使用vuex保存登录信息
在store index.js中定义登录信息对应的全局变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { createStore } from 'vuex'
export default createStore({ state: { member:{} }, getters: { }, mutations: { setMember(state,_member){ state.member = _member; } }, actions: { }, modules: { } })
|
修改login.vue
登录成功之后,将后端数据存储至全部变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const login = () => { axios.post("/member/login", loginForm).then((response) => { let data = response.data; if (data.success) { notification.success({ description: '登录成功!' }); // 登录成功,跳到控台主页 router.push("/"); //使用vuex保存会员登录信息,里面包含JWT TOKEN store.commit("setMember", data.content); } else { notification.error({ description: data.message }); } }) };
|
vuex配置后的session解决浏览器刷新问题
gateway拦截器的简单使用使用
- 自定义过滤器
- 继承接口
GlobalFilter
,实现filter
方法
- 多个过滤器存在时,如何确定执行的先后顺序
- 继承
Ordered
接口,实现getOrder
方法,按照返回数值由小到大执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| package com.bang.train.gateway.filter;
import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono;
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { private static final Logger LOG = LoggerFactory.getLogger(AuthGlobalFilter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { LOG.info("网关登录校验拦截器:{}","AuthGlobalFilter"); return chain.filter(exchange); }
@Override public int getOrder() { return 0; } }
|
编写会员登录校验拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| package com.bang.train.gateway.filter;
import com.bang.train.gateway.util.JwtUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono;
@Component public class LoginMemberFilter implements GlobalFilter, Ordered { 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("/hello") || path.contains("/member/login") || path.contains("/member/send-code")) { LOG.info("不需要登录验证:{}", path); return chain.filter(exchange); } else { LOG.info("需要登录验证:{}", path); } 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(); }
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(); } }
@Override public int getOrder() { return 0; } }
|
为axios请求增加统一拦截器
在main.js
中修改拦截器代码,为所有的axios请求头加上token参数
1 2 3 4 5 6 7 8 9 10 11 12 13
| axios.interceptors.request.use(function (config) { console.log('请求参数:', config); const token = store.state.member.token; if(token){ config.headers.token = token; console.log("为请求header增加token:",token); } return config; }, error => { return Promise.reject(error); });
|
为了增加用户请求,在token失效之后(后端返货401)时,应该让页面自动跳转到登录页面,并提示用户登录超时
修改main.js
中响应拦截器代码,对401做出特定动作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| axios.interceptors.response.use(function (response) { console.log('返回结果:', response); return response; }, error => { console.log('返回错误:', error); const response = error.response; const status = response.status; if(status === 401){ console.log("未登录或者登录超时,跳转至登录页面"); store.commit("setMember",{}); notification.error({description: "未登录或登录超时"}); router.push("/login"); } return Promise.reject(error); });
|
为路由页面添加拦截器
背景
通过axios发送请求,可以通过后端jwt校验验证用户权限,未登录或者登录超时会直接跳转至登录页面
但是系统中可能存在一些其他静态页面,比如帮助文档等页面,这类页面不用于后端进行交互,无法根据后端响应进行拦截,所以需要增加路由跳转拦截器
在router/index.js
中增加拦截器代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| import { createRouter, createWebHistory } from 'vue-router' import store from "@/store"; import {notification} from "ant-design-vue";
const routes = [ { path: '/login', name: 'login', component: () => import('../views/LoginView.vue') }, { path: '/', component: () => import('../views/main.vue'), meta: { loginRequire: true }, } ];
const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes })
router.beforeEach((to, from, next) => { if (to.matched.some(function (item) { console.log(item, "是否需要登录校验:", item.meta.loginRequire || false); return item.meta.loginRequire; })) { const _member = store.state.member; console.log("页面登录校验开始:", _member); if (!_member.token) { console.log("用户未登录或登录超时!"); notification.error({ description: "未登录或登录超时" }); next('/login'); } else { next(); } } else { next(); } }); export default router
|