实现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());
// 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) {
String token = createToken(1L, "123");

// String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE2NzY4OTk4MjcsIm1vYmlsZSI6IjEyMyIsImlkIjoxLCJleHAiOjE2NzY4OTk4MzcsImlhdCI6MTY3Njg5OTgyN30.JbFfdeNHhxKhAeag63kifw9pgYhnNXISJM5bL6hM8eU";
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();
//1.判断当前手机号是否已被注册
Member member = getMembersByMobile(mobile);
if(ObjectUtil.isNull(member)){//2.手机号为空,提示用户需要先获取验证码
throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_NOT_EXIST);
}
//3.验证校验,实际应查询验证码短信记录数据库,这里为了测试简便,直接写死
//实际可能包含验证码正确性、时效性、业务类型匹配等多种校验
if(!"8888".equals(code)){ //验证码错误
throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR);
}
//4.校验通过,返回用户对象
//对于系统而言,用户只需登录一次,登录对象中可能包含用户昵称、头像等数据信息,所以应该直接返回整个用户对象
//但是又不能直接将后台数据完整返回前端,所以需要创建一个响应实体类,并将后台数据转换为响应类
MemberLoginResp memberLoginResp = BeanUtil.copyProperties(member,MemberLoginResp.class);
//5.生成对应的JWT token,并返回给前端
//JWT Token包含信息: header,payload,signature
//生成token
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
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);
}

//当存在多个过滤器时,按照getOrder的顺序从小到大去执行
@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;

//GateWay登录校验
@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) {
//1.获取登录路径
String path = exchange.getRequest().getURI().getPath();
//2.排除不需要拦截的请求
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);
}
// 3.获取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();
}

// 4.校验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();
}
}

@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);
//为所有的axios请求加上token
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) => {
// 要不要对meta.loginRequire属性做监控拦截
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