前言
本文将介绍基于 SpringBoot
和 Vue
的前后端分离项目集成 JWT
的一种思路,此外还包括在包括记住密码
功能情况下 Token
的刷新策略,本文假设你对以下知识有一定的了解,如果未接触过,建议先看一下推荐链接的内容:
JWT
的基本知识:JWT入门教程- 跨域问题:解决前后端分离项目跨域问题
SpringBoot
集成redis
:通过源码了解redis的自动配置
下面这张图是本文实现 Token
验证以及刷新 Token
的基本思路,本文展示了实现最终效果的核心代码,完整代码(包括前端和后端代码)已同步到GitHub:
效果
下面是最终的效果展示,只展示了前端的界面部分:
实现
由于完整代码已上传到GitHub,本文将只展示了核心相关代码的实现思路,首先是后端部分:
后端实现
这里先简要介绍一下 Token
的刷新策略,为了实现在记住密码时 Token
失效时能得到刷新,这里将用户的最近一次登陆时间
存储在 redis
中,当然也可以存在数据库中,如果满足刷新条件,更新 redis
中的最近一次登陆时间
以及 Token
有限期限并刷新 Token
。
下面再展示具体的配置,首先展示配置文件的设置:
spring:
# 取消 banner 图标
main:
banner-mode: off
# mysql 数据库连接设置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/blog?serverTimezone=GMT%2B8&charset=utf8mb4&useSSL=false
username: root
password: root
# jpa 相关设置
jpa:
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
open-in-view: false
# redis 相关配置
redis:
database: 0
host: localhost
port: 6379
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 10
min-idle: 5
shutdown-timeout: 100ms
# 设置端口
server:
port: 8888
# 设置 jwt, 涉及时间单位均为秒
jwt:
# 设置 Token 过期时间
expiration: 10
# 设置 JWT 的密钥
secret: zjw1221
# 设置记住密码的时间
remember-time: 1296000
# 设置小于某个时间, 自动更新 Token
validate-time: 300
首先是 WebMvc
的配置:
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
private final HttpInterceptor httpInterceptor;
@Autowired
public WebConfig(HttpInterceptor httpInterceptor) {
this.httpInterceptor = httpInterceptor;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// 跨域相关配置, 并让 authorization 可在响应头中出现
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("POST", "GET", "PUT", "PATCH", "OPTIONS", "DELETE")
.exposedHeaders("authorization")
.allowedHeaders("*")
.maxAge(3600);
}
@Override
protected void addInterceptors(InterceptorRegistry registry) {
// 设置自定义的拦截器, 拦截所有界面
// 排除 /login 请求, 未防止 /login 失效, 将 /error 也加入
registry.addInterceptor(httpInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/login", "/error");
super.addInterceptors(registry);
}
}
然后是拦截器的设置:
@Configuration
public class HttpInterceptor implements HandlerInterceptor {
private final Gson gson;
private final UserSecurityUtil userSecurityUtil;
@Autowired
public HttpInterceptor(Gson gson, UserSecurityUtil userSecurityUtil) {
this.gson = gson;
this.userSecurityUtil = userSecurityUtil;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 为了处理跨域请求, 如果发送的是 OPTIONS 直接正常返回
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return true;
}
// 设置请求头和响应头的编码格式
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
// 调用校验方法, 校验请求头的 Token 是否合法
boolean isOk = userSecurityUtil.verifyWebToken(request, response);
// 如果不合法, 返回 false, 否则为 true
if (!isOk) {
ResultEntity<String> resultEntity = new ResultEntity<>();
resultEntity.setErrMsg("请重新登录");
resultEntity.setStatus(false);
response.getWriter().write(gson.toJson(resultEntity));
return false;
}
return true;
}
}
之后是校验方法的设置:
@Component
public class UserSecurityUtil {
private final RedisUtil redis;
@Value("${jwt.validate-time}")
private long validateTime;
@Autowired
public UserSecurityUtil(RedisUtil redis) {
this.redis = redis;
}
public boolean verifyWebToken(HttpServletRequest req, HttpServletResponse resp) {
// 获取请求头中的 authorization 信息
String token = req.getHeader("authorization");
// 如果为空直接返回 false
if (token == null) {
return false;
}
// 解码 Token 信息, 如果为空直接返回 false
DecodedJWT jwtToken = JwtUtil.decode(token);
if (jwtToken == null) {
return false;
}
// 获取 Token 信息中的用户 id 信息
// 到 redis 中判断, 如果不存在相关信息直接返回 false
long uid = Long.parseLong(jwtToken.getSubject());
if (redis.getExpire(uid) == -2) {
return false;
}
// 根据 uid 到 redis 中获取 JwtEntity 实体信息
JwtEntity jwtEntity = (JwtEntity) redis.get(uid);
try {
// 继续校验
JwtUtil.verifyToken(token);
} catch (SignatureVerificationException e) {
// 出现签名校验异常直接返回 false
return false;
} catch (TokenExpiredException e) {
// 如果过期, 判断是否符合获得刷新 Token 的条件
// 如果返回为空, 说明 Token 过期, 删除 redis 中的信息, 并返回 false
String newToken = JwtUtil.getRefreshToken(jwtToken, jwtEntity);
if (newToken == null) {
redis.del(uid);
return false;
}
// 否则说明符合 token 刷新条件, 设置返回头部的 authorization, 并返回 true
resp.setHeader("authorization", newToken);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
// 校验成功, 判断是否为不记住密码, 且 Token剩余有效时间小于某个特定值
// 若小于某个特定时间, 则刷新 Token
if (!jwtEntity.getIsRemember()) {
Instant exp = jwtEntity.getLastLoginTime().atZone(ZoneId.systemDefault()).toInstant();
Instant now = Instant.now();
if (now.getEpochSecond() - exp.getEpochSecond() <= validateTime) {
token = JwtUtil.getRefreshToken(jwtToken);
}
}
// 设置返回头中的 token
resp.setHeader("authorization", token);
return true;
}
}
然后是 JwtEntity
类设置:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JwtEntity {
private String token;
// 防止 LocalDateTime 在序列化中出错
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonSerialize(using = LocalDateTimeSerializer.class)
private LocalDateTime lastLoginTime;
private Boolean isRemember;
}
然后是 JwtUtil
的设置:
@Component
public class JwtUtil {
private static String secret;
private static long expiration;
private static long rememberTime;
private static RedisUtil redis;
@Autowired
public JwtUtil(RedisUtil redis) {
JwtUtil.redis = redis;
}
@Value("${jwt.secret}")
public void setSecret(String secret) {
JwtUtil.secret = secret;
}
@Value("${jwt.expiration}")
public void setExpiration(long expiration) {
JwtUtil.expiration = expiration;
}
@Value("${jwt.remember-time}")
public void setRememberTime(long rememberTime) {
JwtUtil.rememberTime = rememberTime;
}
public static String createToken(Long uid, Instant issueAt) {
// 生成 Token
Instant exp = issueAt.plusSeconds(expiration);
return createToken(uid.toString(), issueAt, exp);
}
public static DecodedJWT decode(String token){
try {
// 返回 Token 的解码信息
return JWT.decode(token);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public static void verifyToken(String token) {
// 校验 Token
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(JwtUtil.secret)).build();
verifier.verify(token);
}
public static String getRefreshToken(DecodedJWT jwtToken, JwtEntity jwtEntity) {
// 这个刷新 Token 类用于处理 Token 失效的情况
Instant exp = jwtEntity.getLastLoginTime().atZone(ZoneId.systemDefault()).toInstant();
Instant now = Instant.now();
// 如果超过记住密码的期限或者是设置未记住密码, 直接返回 null
if (!jwtEntity.getIsRemember() || (now.getEpochSecond() - exp.getEpochSecond()) > rememberTime) {
return null;
}
// 否则生成刷新的 Token, 并重新设置 redis 中存储的信息
Instant newExp = exp.plusSeconds(expiration);
String token = createToken(jwtToken.getSubject(), now, newExp);
LocalDateTime lastLoginTime = getLastLoginTime(newExp);
redis.set(jwtToken.getSubject(), new JwtEntity(token, lastLoginTime, true));
return token;
}
public static String getRefreshToken(DecodedJWT jwtToken) {
// 这个刷新 Token 类用于处理 Token 有效时间小于某个特定的值的情况
// 生成刷新的 Token, 并重新设置 redis 中存储的信息
Instant now = Instant.now();
Instant newExp = now.plusSeconds(expiration);
String token = createToken(jwtToken.getSubject(), now, newExp);
redis.set(jwtToken.getSubject(), new JwtEntity(token, getLastLoginTime(now), false));
return token;
}
private static String createToken(String sub, Instant iat, Instant exp) {
// 生成 Token, 包括用户 uid, 生效和失效日期
return JWT.create()
.withClaim("sub", sub)
.withClaim("iat", Date.from(iat))
.withClaim("exp", Date.from(exp))
.sign(Algorithm.HMAC256(JwtUtil.secret));
}
private static LocalDateTime getLastLoginTime(Instant newExp) {
// 获取当前时间的 LocalDateTime 格式
return LocalDateTime.ofInstant(newExp, ZoneId.systemDefault());
}
}
然后就是登录控制器的设置:
@RestController
public class LoginInController {
private final UserService userService;
@Autowired
public LoginInController(UserService userService) {
this.userService = userService;
}
@PostMapping("/login")
public ResultEntity<Long> login(@RequestBody LoginEntity loginEntity, HttpServletResponse response) {
ResultEntity<Long> resultEntity = new ResultEntity<>();
// 对登录信息中的用户名和密码进行校验
User user = userService.findByUsername(loginEntity.getUsername());
boolean isOk = user != null && user.getPassword().equals(loginEntity.getPassword());
// 如果不满足, 直接返回
if (!isOk) {
resultEntity.setErrMsg("用户名或密码错误");
resultEntity.setStatus(false);
return resultEntity;
}
// 否则生成 Token, 并设置头部的 authorization 信息
String token = userService.createWebToken(user.getId(), loginEntity.getIsRemember());
response.setHeader("authorization", token);
resultEntity.setData(user.getId());
return resultEntity;
}
}
登录实体类如下:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginEntity {
private String username;
private String password;
private Boolean isRemember;
}
UserService
内容如下:
@Service
public class UserService {
private final UserDao dao;
private final RedisUtil redis;
@Autowired
public UserService(UserDao dao, RedisUtil redis) {
this.dao = dao;
this.redis = redis;
}
public User findById(Long uid) {
return dao.findById(uid).orElse(null);
}
public User findByUsername(String username) {
return dao.findByUsername(username);
}
@Transactional
public String createWebToken(Long uid, Boolean isRemember) {
// 调用 JwtUtil 工具类的生成 Token 方法
// 并在 redis 中存储对应用户的 token, 上次登录时间, 是否记住密码等时间
Instant now = Instant.now();
String token = JwtUtil.createToken(uid, now);
LocalDateTime lastLoginTime = LocalDateTime.ofInstant(now, ZoneId.systemDefault());
redis.set(uid, new JwtEntity(token, lastLoginTime, isRemember != null && isRemember));
return token;
}
@Transactional
public void deleteWebToken(Long uid) {
// 从 redis 中删除对应用户的 Jwt 信息
redis.del(uid);
}
}
注销的控制器设置如下:
@RestController
public class LoginOutController {
private final UserService userService;
@Autowired
public LoginOutController(UserService userService) {
this.userService = userService;
}
@GetMapping("/exit")
public String login(Long uid) {
// 用户注销时, 根据用户 id 删除 redis 中存储的信息
// 由于前台 uid 存在 localStorage, 因此这里存在一些安全问题
// 不过本文属于 Jwt 的入门使用, 不做进一步处理
userService.deleteWebToken(uid);
return "注销成功";
}
}
前端实现
首先是登录界面:
<template>
<el-row type="flex" class="login">
<el-col :span="6">
<h1 class="title">JWT 测试</h1>
<el-form :model="loginForm" :rules="rules" status-icon ref="ruleForm" class="demo-ruleForm">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
autocomplete="off"
placeholder="用户名"
prefix-icon="el-icon-user-solid"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
type="password"
v-model="loginForm.password"
autocomplete="off"
placeholder="请输入密码"
prefix-icon="el-icon-lock"
></el-input>
</el-form-item>
<el-form-item prop="isRemember">
<el-checkbox v-model="loginForm.isRemember" class="remember">记住密码</el-checkbox>
<el-button type="primary" @click="submitForm" class="login-btn">登录</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</template>
<script>
import {
Row,
Col,
Form,
Input,
Button,
Message,
Checkbox,
FormItem,
Loading
} from 'element-ui'
import {request} from '../network/request'
export default {
name: 'Login',
components: {
'el-row': Row,
'el-col': Col,
'el-form': Form,
'el-checkbox': Checkbox,
'el-input': Input,
'el-button': Button,
'el-form-item': FormItem
},
data() {
return {
loginForm: {
username: '',
password: '',
isRemember: false
},
rules: {
username: [{
required: true,
message: '请输入用户名',
trigger: 'blur'
}],
password: [{
required: true,
message: '请输入密码',
trigger: 'blur'
}]
}
}
},
methods: {
submitForm() {
const loading = Loading.service({ fullscreen: true })
request({
method: 'post',
url: '/login',
data: {
'username': this.loginForm.username,
'password': this.loginForm.password,
'isRemember': this.loginForm.isRemember
}
}).then(res => {
loading.close()
// 登录成功将用户 uid 存入 localStorage
localStorage.setItem('uid', res.data)
// 并跳转到 home 界面
this.$router.push('/home')
Message('登录成功')
}).catch(err => {
console.log(err)
})
}
}
}
</script>
然后是 Home
界面:
<template>
<el-row type="flex" justify="center" align="middle" class="main">
<el-col :span="12">
<el-button @click="exit">注销</el-button>
<el-button @click="test">测试</el-button>
</el-col>
</el-row>
</template>
<script>
import {
Row,
Col,
Button,
Message
} from 'element-ui'
import {request} from '../network/request'
export default {
name: 'Home',
components: {
'el-row': Row,
'el-col': Col,
'el-button': Button
},
methods: {
exit() {
request({
method: 'get',
url: '/exit',
params: {
uid: localStorage.getItem('uid')
}
}).then(() => {
// 用户注销后清除本地 Token
localStorage.removeItem('authorization')
// 并跳转到登录界面
this.$router.push('/login')
Message('注销成功')
}).catch(err => {
console.log(err)
})
},
test() {
request({
method: 'get',
url: '/hello',
}).then(res => {
if (res.data) {
// Token 未过期则会正常返回 'Hello, world!' 信息
Message(res.data)
} else {
// 否则提示用户登录
Message('请重新登录')
}
}).catch(err => {
console.log(err)
})
}
}
}
</script>
路由配置如下:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
path: '/',
component() {
return import('@/views/Home')
}
},
{
path: '/home',
name: 'Home',
component() {
return import('@/views/Home')
}
},
{
path:'/login',
name:'Login',
component() {
return import('@/views/Login')
}
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
// 设置全局的前置导航守卫
router.beforeEach((to, from, next) => {
// 如果跳转的目的路径是 login 界面, 不做操作
if (to.path === '/login') {
next()
} else {
/**
* 如果是其他界面, 判断本地是否存在 Token
* 如果存在, 则正常跳转
* 否则重定向到 login 界面
*/
let token = localStorage.getItem('authorization')
if (!token) {
next('/login')
} else {
next()
}
}
})
export default router
最后是网络请求相关的设置:
import axios from 'axios'
import router from '../router'
export function request(config) {
const req = axios.create({
baseURL: 'http://localhost:8888',
timeout: 5000
})
// 将所有的网络请求头都带上本地存储的 Token
req.interceptors.request.use(config => {
const token = localStorage.getItem('authorization')
token && (config.headers.authorization = token)
return config
})
/**
* 对所有请求得到的响应结果获取其中的 Token
* 如果返回不为空, 则说明本地 Token 未失效, 重新设置本地 Token
* 否则清除本地 Token, 并跳转到 login 界面
*/
req.interceptors.response.use(response => {
const token = response.headers.authorization
if (token) {
localStorage.setItem('authorization', token)
} else {
localStorage.removeItem('authorization')
router.push('/login')
}
return response.data
})
return req(config)
}
总结
本文简单介绍了前后端分离项目集成 JWT
以及 Token
刷新的机制,由于经验不足,可能思路有很多错误之处,欢迎留言指正。
参考资料
本文实现的思路参考了以下博客:
- https://www.cnblogs.com/zxcoder/p/11964433.html
- https://blog.csdn.net/qq_38345296/article/details/99598148
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/5376.html