三、实现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工具

实现方法:

  1. 在MemberLoginResp中设置token属性,然后在MemberService中set token,token值伴随MemberLoginResp返回给前端。
  2. token的生成可以封装成一个JwtUtil工具类,内部实现使用hutool的JWTUtil方法。
  3. 封装的工具类要把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 }
JwtUtil.java

修改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 })
web/src/store/index.js
  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 />&nbsp;模拟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>
web/src/views/login.vue
 1 <template>
 2   <a-layout-header class="header">
 3     <div class="logo" />
 4     <div style="float: right; color: white;">
 5       您好:{{member.mobile}} &nbsp;&nbsp;
 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>
web/src/components/the-header.vue

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 };
web/public/js/session-storage.js

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 })
web/src/store/index.js

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 }
LoginMemberFilter.java

拷贝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);
main.js
 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>
main.vue

增加路由登录拦截,访问所有的控台页面都需要登录

前面的问题是如果不经过拦截器,就能看到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
index.js
 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>
main.vue

 

posted on 2023-04-17 14:15  夏雪冬蝉  阅读(812)  评论(0编辑  收藏  举报