三、实现JWT单点登录功能
什么是单点登录
一次登录,自由访问
两种单点登录方案:redis+token; jwt
JWT原理:
https://hutool.cn/docs/#ljwt/概述?id=由来
结构:
Header:头部,声明签名算法
Payload:载荷信息,放用户数据
Signature:签名,用于校验数据(私密key来生成签名)
header.payload.signature
JWT存在的问题及解决方案
问题: token被解密
解决∶加盐值(密钥),每个项目的盐值不能─样
i问题:token被拿到第三方使用,自己的产品,被别人包了一个界面,做成他们收费的产品,比较典型的,就是2023年初出现的ChatGPT,很多人把它包成收费小程序
解决:没啥好方法,使用限流
使用hutool生成JWT token 并封装hutool的JWT工具
实现方法:
- 在MemberLoginResp中设置token属性,然后在MemberService中set token,token值伴随MemberLoginResp返回给前端。
- token的生成可以封装成一个JwtUtil工具类,内部实现使用hutool的JWTUtil方法。
- 封装的工具类要把key设置成一个静态常量,不可随意改动。
创建token: JwtUtil.createToken(id, mobile)
检验token:JwtUtil.validate(token)
转换为JSON:JwtUtil.getJSONObject(token)
可以先校验,校验成功才能转换
修改MemberService.java
1 //校验短信验证码 2 if (!"8888".equals(code)) { 3 throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR); 4 } 5 6 MemberLoginResp memberLoginResp = BeanUtil.copyProperties(memberDB, MemberLoginResp.class); 7 Map<String, Object> map = BeanUtil.beanToMap(memberLoginResp); 8 String key = "zihans12306"; 9 String token = JWTUtil.createToken(map, key.getBytes()); 10 memberLoginResp.setToken(token); 11 // memberLoginResp.setId(); 12 // memberLoginResp.setMobile(); 13 return memberLoginResp;
封装Jwt token
1 package com.jiawa.train.common.util; 2 3 import cn.hutool.core.date.DateField; 4 import cn.hutool.core.date.DateTime; 5 import cn.hutool.json.JSONObject; 6 import cn.hutool.jwt.JWT; 7 import cn.hutool.jwt.JWTPayload; 8 import cn.hutool.jwt.JWTUtil; 9 import org.slf4j.Logger; 10 import org.slf4j.LoggerFactory; 11 12 import java.util.HashMap; 13 import java.util.Map; 14 15 public class JwtUtil { 16 private static final Logger LOG = LoggerFactory.getLogger(JwtUtil.class); 17 18 /** 19 * 盐值很重要,不能泄漏,且每个项目都应该不一样,可以放到配置文件中 20 */ 21 private static final String key = "Zihans12306"; 22 23 public static String createToken(Long id, String mobile) { 24 DateTime now = DateTime.now(); 25 DateTime expTime = now.offsetNew(DateField.SECOND, 10); 26 Map<String, Object> payload = new HashMap<>(); 27 // 签发时间 28 payload.put(JWTPayload.ISSUED_AT, now); 29 // 过期时间 30 payload.put(JWTPayload.EXPIRES_AT, expTime); 31 // 生效时间 32 payload.put(JWTPayload.NOT_BEFORE, now); 33 // 内容 34 payload.put("id", id); 35 payload.put("mobile", mobile); 36 String token = JWTUtil.createToken(payload, key.getBytes()); 37 LOG.info("生成JWT token:{}", token); 38 return token; 39 } 40 41 public static boolean validate(String token) { 42 JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes()); 43 // validate包含了verify 44 boolean validate = jwt.validate(0); 45 LOG.info("JWT token校验结果:{}", validate); 46 return validate; 47 } 48 49 public static JSONObject getJSONObject(String token) { 50 JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes()); 51 JSONObject payloads = jwt.getPayloads(); 52 payloads.remove(JWTPayload.ISSUED_AT); 53 payloads.remove(JWTPayload.EXPIRES_AT); 54 payloads.remove(JWTPayload.NOT_BEFORE); 55 LOG.info("根据token获取原始内容:{}", payloads); 56 return payloads; 57 } 58 59 public static void main(String[] args) { 60 createToken(1L, "123"); 61 62 String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE2NzY4OTk4MjcsIm1vYmlsZSI6IjEyMyIsImlkIjoxLCJleHAiOjE2NzY4OTk4MzcsImlhdCI6MTY3Njg5OTgyN30.JbFfdeNHhxKhAeag63kifw9pgYhnNXISJM5bL6hM8eU"; 63 validate(token); 64 65 getJSONObject(token); 66 } 67 }
修改MemberService.java
1 //校验短信验证码 2 if (!"8888".equals(code)) { 3 throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR); 4 } 5 6 MemberLoginResp memberLoginResp = BeanUtil.copyProperties(memberDB, MemberLoginResp.class); 7 String token = JwtUtil.createToken(memberLoginResp.getId(), memberLoginResp.getToken()); 8 memberLoginResp.setToken(token); 9 // memberLoginResp.setId(); 10 // memberLoginResp.setMobile(); 11 return memberLoginResp;
使用vuex(store)来保存登录信息
将后端返回的member保存在store/index.js中(登陆后才能保存)。
其中state,新增一个属性member,store.member进行获取。
mutations对值进行修改
login.vue中store.commit(“setMember”, data.content)将会员信息写入setMember。
the_header中读取并显示member.mobile。
1 import { createStore } from 'vuex' 2 3 export default createStore({ 4 state: { 5 member: {} 6 }, 7 getters: { 8 }, 9 mutations: { 10 setMember (state, _member) { 11 state.member = _member; 12 } 13 }, 14 actions: { 15 }, 16 modules: { 17 } 18 })
1 <template> 2 <a-row class="login"> 3 <a-col :span="8" :offset="8" class="login-main"> 4 <h1 style="text-align: center"><car-two-tone /> 模拟12306售票系统</h1> 5 <a-form 6 :model="loginForm" 7 name="basic" 8 autocomplete="off" 9 > 10 <a-form-item 11 label="" 12 name="mobile" 13 :rules="[{ required: true, message: '请输入手机号!' }]" 14 > 15 <a-input v-model:value="loginForm.mobile" placeholder="手机号"/> 16 </a-form-item> 17 18 <a-form-item 19 label="" 20 name="code" 21 :rules="[{ required: true, message: '请输入验证码!' }]" 22 > 23 <a-input v-model:value="loginForm.code"> 24 <template #addonAfter> 25 <a @click="sendCode">获取验证码</a> 26 </template> 27 </a-input> 28 <!--<a-input v-model:value="loginForm.code" placeholder="验证码"/>--> 29 </a-form-item> 30 31 <a-form-item> 32 <a-button type="primary" block @click="login">登录</a-button> 33 </a-form-item> 34 35 </a-form> 36 </a-col> 37 </a-row> 38 </template> 39 40 <script> 41 import { defineComponent, reactive } from 'vue'; 42 import axios from 'axios'; 43 import { notification } from 'ant-design-vue'; 44 import {useRouter} from 'vue-router' 45 import store from "@/store"; 46 47 export default defineComponent({ 48 name: "login-view", 49 setup() { 50 const router = useRouter(); 51 const loginForm = reactive({ 52 mobile: '13000000000', 53 code: '', 54 }); 55 56 const sendCode = () => { 57 axios.post("/member/member/send-code", { 58 mobile: loginForm.mobile 59 }).then(response => { 60 let data = response.data; 61 if (data.success) { 62 notification.success({ description: '发送验证码成功!' }); 63 loginForm.code = "8888"; 64 } else { 65 notification.error({ description: data.message }); 66 } 67 }); 68 }; 69 70 const login = () => { 71 axios.post("/member/member/login", loginForm).then((response) => { 72 let data = response.data; 73 if (data.success) { 74 notification.success({ description: '登录成功!' }); 75 //登陆成功,跳到控台主页 76 router.push("/"); 77 store.commit("setMember", data.content); 78 } else { 79 notification.error({ description: data.message }); 80 } 81 }) 82 }; 83 84 return { 85 loginForm, 86 sendCode, 87 login 88 }; 89 }, 90 }); 91 </script> 92 93 <style> 94 .login-main h1 { 95 font-size: 25px; 96 font-weight: bold; 97 } 98 .login-main { 99 margin-top: 100px; 100 padding: 30px 30px 20px; 101 border: 2px solid grey; 102 border-radius: 10px; 103 background-color: #fcfcfc; 104 } 105 </style>
1 <template> 2 <a-layout-header class="header"> 3 <div class="logo" /> 4 <div style="float: right; color: white;"> 5 您好:{{member.mobile}} 6 <router-link to="/login"> 7 退出登录 8 </router-link> 9 </div> 10 <a-menu 11 v-model:selectedKeys="selectedKeys1" 12 theme="dark" 13 mode="horizontal" 14 :style="{ lineHeight: '64px' }" 15 > 16 <a-menu-item key="1">nav 11</a-menu-item> 17 <a-menu-item key="2">nav 2</a-menu-item> 18 <a-menu-item key="3">nav 3</a-menu-item> 19 </a-menu> 20 </a-layout-header> 21 </template> 22 23 <script> 24 import {defineComponent, ref} from 'vue'; 25 import store from "@/store"; 26 27 export default defineComponent({ 28 name: "the-header-view", 29 setup() { 30 let member = store.state.member; 31 32 return { 33 selectedKeys1: ref(['2']), 34 member 35 }; 36 }, 37 }); 38 </script> 39 40 <!-- Add "scoped" attribute to limit CSS to this component only --> 41 <style scoped> 42 43 </style>
vuex配合h5的session解决浏览器刷新问题
之前有个致命问题,就是页面不支持刷新,store一刷新里面的内容就没了。
使用js/session-storage.js中将member写入session。
1 SessionStorage = { 2 get: function (key) { 3 var v = sessionStorage.getItem(key); 4 if (v && typeof(v) !== "undefined" && v !== "undefined") { 5 return JSON.parse(v); 6 } 7 }, 8 set: function (key, data) { 9 sessionStorage.setItem(key, JSON.stringify(data)); 10 }, 11 remove: function (key) { 12 sessionStorage.removeItem(key); 13 }, 14 clearAll: function () { 15 sessionStorage.clear(); 16 } 17 };
index.js插入,引入上面的js文件
<script src="<%= BASE_URL %>js/session-storage.js"></script>
the-header.vue修改字体颜色
- <router-link to="/login"> + <router-link to="/login" style="color: white;">
修改index.js
1 import { createStore } from 'vuex' 2 3 const MEMBER = "MEMBER"; 4 export default createStore({ 5 state: { 6 member: window.SessionStorage.get(MEMBER) || {} 7 }, 8 getters: { 9 }, 10 mutations: { 11 setMember (state, _member) { 12 state.member = _member; 13 window.sessionStorage.set(MEMBER, _member); 14 } 15 }, 16 actions: { 17 }, 18 modules: { 19 } 20 })
gateway拦截器的使用
登录校验两个步骤:
前端请求带上token,放在header里
后端校验token有效性,在gateway里统─校验
gateway有多个拦截器时,使用order来确定拦截器的顺序
gateway模块增加依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
gateway模块新增LoginMemberFilter.java
只需要实现GlobalFilter接口,实现拦截器(注意不要忘了@Component注解)。
return chain.filter(exchange)代表请求通过。
另外实现一个接口Ordered,可以实现拦截器的优先级。
1 package com.zihans.train.gateway.config; 2 import com.zihans.train.gateway.util.JwtUtil; 3 import org.slf4j.Logger; 4 import org.slf4j.LoggerFactory; 5 import org.springframework.cloud.gateway.filter.GatewayFilterChain; 6 import org.springframework.cloud.gateway.filter.GlobalFilter; 7 import org.springframework.core.Ordered; 8 import org.springframework.http.HttpStatus; 9 import org.springframework.stereotype.Component; 10 import org.springframework.web.server.ServerWebExchange; 11 import reactor.core.publisher.Mono; 12 13 @Component 14 public class LoginMemberFilter implements Ordered, GlobalFilter { 15 16 private static final Logger LOG = LoggerFactory.getLogger(LoginMemberFilter.class); 17 18 @Override 19 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { 20 String path = exchange.getRequest().getURI().getPath(); 21 22 // 排除不需要拦截的请求 23 if (path.contains("/admin") 24 || path.contains("/hello") 25 || path.contains("/member/member/login") 26 || path.contains("/member/member/send-code")) { 27 LOG.info("不需要登录验证:{}", path); 28 return chain.filter(exchange); 29 } else { 30 LOG.info("需要登录验证:{}", path); 31 } 32 // 获取header的token参数 33 String token = exchange.getRequest().getHeaders().getFirst("token"); 34 LOG.info("会员登录验证开始,token:{}", token); 35 if (token == null || token.isEmpty()) { 36 LOG.info( "token为空,请求被拦截" ); 37 exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); 38 return exchange.getResponse().setComplete(); 39 } 40 41 // 校验token是否有效,包括token是否被改过,是否过期 42 boolean validate = JwtUtil.validate(token); 43 if (validate) { 44 LOG.info("token有效,放行该请求"); 45 return chain.filter(exchange); 46 } else { 47 LOG.warn( "token无效,请求被拦截" ); 48 exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); 49 return exchange.getResponse().setComplete(); 50 } 51 52 } 53 54 /** 55 * 优先级设置 值越小 优先级越高 56 * 57 * @return 58 */ 59 @Override 60 public int getOrder() { 61 return 0; 62 } 63 }
拷贝jwtUtil到gateway。
Login Member Filter进行登录拦截,检验token。
前端在header中添加token参数。
为axios请求增加统一拦截器
在main.js中写,如果返回码是401,就跳转到login页面,并将member置为空。
ref用来声明基本数据类型。
reactive用来声明对象或对象数组。
main.js的请求加上token。
1 import { createApp } from 'vue' 2 import App from './App.vue' 3 import router from './router' 4 import store from './store' 5 import Antd, {notification} from 'ant-design-vue'; 6 import 'ant-design-vue/dist/antd.css'; 7 import * as Icons from '@ant-design/icons-vue'; 8 import axios from 'axios'; 9 10 const app = createApp(App); 11 app.use(Antd).use(store).use(router).mount('#app'); 12 13 // 全局使用图标 14 const icons = Icons; 15 for (const i in icons) { 16 app.component(i, icons[i]); 17 } 18 19 /** 20 * axios拦截器 21 */ 22 axios.interceptors.request.use(function (config) { 23 console.log('请求参数:', config); 24 const _token = store.state.member.token; 25 if (_token) { 26 config.headers.token = _token; 27 console.log("请求headers增加token:", _token); 28 } 29 return config; 30 }, error => { 31 return Promise.reject(error); 32 }); 33 axios.interceptors.response.use(function (response) { 34 console.log('返回结果:', response); 35 return response; 36 }, error => { 37 console.log('返回错误:', error); 38 const response = error.response; 39 const status = response.status; 40 if (status === 401) { 41 // 判断状态码是401 跳转到登录页 42 console.log("未登录或登录超时,跳到登录页"); 43 store.commit("setMember", {}); 44 notification.error({ description: "未登录或登录超时" }); 45 router.push('/login'); 46 } 47 return Promise.reject(error); 48 }); 49 axios.defaults.baseURL = process.env.VUE_APP_SERVER; 50 console.log('环境:', process.env.NODE_ENV); 51 console.log('服务端:', process.env.VUE_APP_SERVER);
1 <template> 2 <a-layout id="components-layout-demo-top-side-2"> 3 <the-header-view></the-header-view> 4 <a-layout> 5 <the-sider-view></the-sider-view> 6 <a-layout style="padding: 0 24px 24px"> 7 <a-breadcrumb style="margin: 16px 0"> 8 <a-breadcrumb-item>Home</a-breadcrumb-item> 9 <a-breadcrumb-item>List</a-breadcrumb-item> 10 <a-breadcrumb-item>App</a-breadcrumb-item> 11 </a-breadcrumb> 12 <a-layout-content 13 :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }" 14 > 15 所有会员总数:{{count}} 16 </a-layout-content> 17 </a-layout> 18 </a-layout> 19 </a-layout> 20 </template> 21 <script> 22 import { defineComponent, ref } from 'vue'; 23 import TheHeaderView from "@/components/the-header"; 24 import TheSiderView from "@/components/the-sider"; 25 import axios from "axios"; 26 import {notification} from "ant-design-vue"; 27 import store from "@/store"; 28 export default defineComponent({ 29 components: { 30 TheSiderView, 31 TheHeaderView, 32 }, 33 setup() { 34 const count = ref(0); 35 axios.get("/member/member/count").then((response) => { 36 let data = response.data; 37 if (data.success) { 38 count.value = data.content; 39 } else { 40 notification.error({ description: data.message }); 41 } 42 }); 43 44 return { 45 count 46 }; 47 }, 48 }); 49 </script> 50 <style> 51 #components-layout-demo-top-side-2 .logo { 52 float: left; 53 width: 120px; 54 height: 31px; 55 margin: 16px 24px 16px 0; 56 background: rgba(255, 255, 255, 0.3); 57 } 58 59 .ant-row-rtl #components-layout-demo-top-side-2 .logo { 60 float: right; 61 margin: 16px 0 16px 24px; 62 } 63 64 .site-layout-background { 65 background: #fff; 66 } 67 </style>
增加路由登录拦截,访问所有的控台页面都需要登录
前面的问题是如果不经过拦截器,就能看到login以外的页面,我们需要已登录才能访问。
可以校验全局变量有没有值,没有才跳转(router.js)。
1 import { createRouter, createWebHistory } from 'vue-router' 2 import store from "@/store"; 3 import {notification} from "ant-design-vue"; 4 5 const routes = [ 6 { 7 path: '/login', 8 component: () => import('../views/login.vue') 9 }, 10 { 11 path: '/', 12 component: () => import('../views/main.vue'), 13 meta: { 14 loginRequire: true 15 }, 16 } 17 ] 18 19 const router = createRouter({ 20 history: createWebHistory(process.env.BASE_URL), 21 routes 22 }) 23 24 // 路由登录拦截 25 router.beforeEach((to, from, next) => { 26 // 要不要对meta.loginRequire属性做监控拦截 27 if (to.matched.some(function (item) { 28 console.log(item, "是否需要登录校验:", item.meta.loginRequire || false); 29 return item.meta.loginRequire 30 })) { 31 const _member = store.state.member; 32 console.log("页面登录校验开始:", _member); 33 if (!_member.token) { 34 console.log("用户未登录或登录超时!"); 35 notification.error({ description: "未登录或登录超时" }); 36 next('/login'); 37 } else { 38 next(); 39 } 40 } else { 41 next(); 42 } 43 }); 44 45 export default router
1 <template> 2 <a-layout id="components-layout-demo-top-side-2"> 3 <the-header-view></the-header-view> 4 <a-layout> 5 <the-sider-view></the-sider-view> 6 <a-layout style="padding: 0 24px 24px"> 7 <a-breadcrumb style="margin: 16px 0"> 8 <a-breadcrumb-item>Home</a-breadcrumb-item> 9 <a-breadcrumb-item>List</a-breadcrumb-item> 10 <a-breadcrumb-item>App</a-breadcrumb-item> 11 </a-breadcrumb> 12 <a-layout-content 13 :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }" 14 > 15 所有会员总数:{{count}} 16 </a-layout-content> 17 </a-layout> 18 </a-layout> 19 </a-layout> 20 </template> 21 <script> 22 import { defineComponent, ref } from 'vue'; 23 import TheHeaderView from "@/components/the-header"; 24 import TheSiderView from "@/components/the-sider"; 25 import axios from "axios"; 26 import {notification} from "ant-design-vue"; 27 import store from "@/store"; 28 export default defineComponent({ 29 components: { 30 TheSiderView, 31 TheHeaderView, 32 }, 33 setup() { 34 const count = ref(0); 35 // axios.get("/member/member/count").then((response) => { 36 // let data = response.data; 37 // if (data.success) { 38 // count.value = data.content; 39 // } else { 40 // notification.error({ description: data.message }); 41 // } 42 // }); 43 44 return { 45 count 46 }; 47 }, 48 }); 49 </script> 50 <style> 51 #components-layout-demo-top-side-2 .logo { 52 float: left; 53 width: 120px; 54 height: 31px; 55 margin: 16px 24px 16px 0; 56 background: rgba(255, 255, 255, 0.3); 57 } 58 59 .ant-row-rtl #components-layout-demo-top-side-2 .logo { 60 float: right; 61 margin: 16px 0 16px 24px; 62 } 63 64 .site-layout-background { 65 background: #fff; 66 } 67 </style>