什么是单点登录
一次登录,自由访问
两种单点登录方案: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 }
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 /> 模拟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}}
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