目录
- 前言
- 概念
- 实际演示
- 路由信息
- 初始访问登录界面
- 登录验证
- 验证错误
- vue实现
- 依赖引入
- main.js
- 获取和设置token工具类
- 登录方法
- 实体
- 登录方法
- axios请求
- router配置
- springboot实现
- 依赖引入
- JWT工具类
- 忽视jwt验证注解
- 拦截器逻辑
- 跨域&调用拦截器配置
- 登录接口&验证token接口
- 结语
前言
最近在研究SSO(单点登录)系统,对于内部是如何实现登录验证的产生了兴趣,而现在终于研究出它是如何验证成功的,接下来我将讲解如何通过vue和springboot实现Jwt验证登录
🌺🌹🥀🌺🥀🌹🌺🌹🥀🌺🥀🌹
概念
在正式开始之前,我同样会讲解一下概念单点登录
:
单点登录(Single Sign-On, SSO)是一种
身份认证
和授权机制
,允许用户在多个应用程序或系统
中进行登录,并在登录后可以无需重新输入凭据就能访问其他应用程序。通过SSO,用户只需登录一次,即可获得对多个相关但独立的软件系统或资源的访问权限。
那么这篇文章,只会讲解如何实现身份认证,并不会讲解如何实现SSO
🟠🟡🔴🟠🟣🔵🟡🟠🟣
JWT
:
JWT全称为JSON Web Token,是一种开放标准(RFC 7519),定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。它通常用于
在用户和服务之间传递身份认证信息和声明
。JWT通常被用作实现身份验证和授权的标准方法。JWT的本质就是一个
字符串
,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token,并且这个JWT token带有签名信息,接收后可以校验是否被篡改
🌺🌹🥀🌺🥀🌹🌺🌹🥀🌺🥀🌹
实际演示
路由信息
在我的项目中,我的router页面有这些:
export const constantRoutes = [
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},
{
path: '/main',
component: () => import('@/views/main'),
children: [
{
path: '',
name: 'dashBorad',
component: () => import('@/views/dashBorad')
},
{
path: '/menuPage',
name: 'menuPage',
component: () => import('@/views/menuPage')
},
{
path: '/userDataManage',
name: 'userDataManage',
component: () => import('@/views/user/userDataManage')
}
]
},
{
path: '/404',
component: () => import('@/views/404'),
hidden: true
},
// 404 page must be placed at the end !!!
{path: '*', redirect: '/404', hidden: true}
]
初始访问登录界面
初始我访问界面,会到登录界面
假如我在未登录的情况下想访问其他路由,会禁止:
自动会跳转到登录界面
登录验证
假如输入账号密码登录之后,才能进入到系统内:
这个时候能够切换到不同的界面:
并且能够调用后端接口查询数据:
验证错误
但是一旦jwt验证失败,为演示方便,这边将手动把token修改错误
此时再跳转界面和查数据都会,报错,且跳转到登录页:
调用后台接口:
返回主界面
🧡💚💛🧡💜🧡🧡💚💛🧡💜🧡
🌺🌷🌻🌼🌷🌺🌷🌻🌼🌷🌻🌼~~~~~~~~
🧡💚💛🧡💜🧡🧡💚💛🧡💜🧡
vue实现
🌴🌳🍀🌲🥀🍁
依赖引入
在我的项目中,涉及相关JWT验证的有如下:
// axios
npm i axios@1.5.0
// elementui
npm i element-ui -S
// router
npm i vue-router
// js-cookie
npm i js-cookie
🧡🧡🧡🧡🧡🧡🧡🧡🧡🧡🧡🧡
main.js
这时main.js的代码如下:
import Vue from 'vue'
import App from './App'
import router from './router'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.config.productionTip = false
Vue.use(ElementUI)
new Vue({
el: '#app',
router,
components: { App },
template: '<App/>'
})
💛💛💛💛💛💛💛💛💛💛💛💛
获取和设置token工具类
这里写一个工具类专门获取和设置工具类
建一个token.js
import Cookies from 'js-cookie'
// 获取token的key,需要和后端一致
const TokenKey = 'Authorization'
// 获取token
export function getToken () {
return Cookies.get(TokenKey)
}
// 设置token
export function setToken (token) {
return Cookies.set(TokenKey, token)
}
// 移除token
export function removeToken () {
return Cookies.remove(TokenKey)
}
💙💙💙💙💙💙💙💙💙💙💙💙
登录方法
因为我的登录方法存在别的逻辑,如验证码,记住我等等,因此,这里演示只给出最纯粹的登录逻辑
实体
// 设置用户名和密码登录
loginForm: {
username: 'admin',
password: 'admin',
},
登录方法涉及到引入
// 注意,这里文件的位置请根据自己实际项目文件位置进行修改
import { getToken, setToken } from '@/utils/token'
import {login} from '../../api/login'
🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸🌸
登录方法
点击按钮调用登录方法
submitLogin () {
if (!this.loginForm.username) {
this.$message.error('用户名不能为空!')
return
}
if (!this.loginForm.password) {
this.$message.error('密码不能为空!')
return
}
login(this.loginForm.username, this.loginForm.password).then(res => {
if (res.header.code !== 0) {
this.$message.error(res.header.message)
return
}
// 设置token
setToken(res.value.token)
// 根据自己实际项目跳转主界面
this.$router.push('/main')
})
},
当这里登录之后会在cookie位置新增数据(F12):
💐💐💐💐💐💐💐💐💐💐💐💐💐
axios请求
新增axios的工具类,进行封装,在这里会在调用之前确认是否验证过期request.js
: 新建js代码,代码如下:
import axios from 'axios'
import { Message, MessageBox, Notification } from 'element-ui'
import { getToken } from '@/utils/token'
import errorCode from '@/utils/errorCode'
import router from '../router/index'
import {removeToken} from './token'
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
// 配置后端请求路径,根据自己实际项目修改
baseURL: process.env.VUE_APP_BASE_API,
// 请求超时时间 withCredentials: true,
timeout: 30000
})
// 请求统一拦截处理
service.interceptors.request.use(config => {
// 是否需要设置 token
if (getToken()) {
config.headers['Authorization'] = getToken() // 请求均需携带自定义token
}
return config
},
error => {
// 请求失败
console.log(error) // for debug
// return Promise.reject(error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(res => {
console.log('res.data', res.data)
// 未设置状态码则默认成功状态
const code = res.data.code || 200
// 获取错误信息
const msg = errorCode[code] || res.data.message || errorCode['default']
if (code === 401) {
return new Promise((resolve, reject) => {
MessageBox.confirm('登录状态已过期,请重新登录', '系统提示', {
confirmButtonText: '重新登录',
showCancelButton: false,
type: 'warning'
}).then(() => {
removeToken()
router.push('/login')
resolve() // 手动解决 Promise,避免重复导航
})
})
} else if (code === 500) {
Message({
message: msg,
type: 'error'
})
return Promise.reject(new Error(msg))
} else if (code !== 0 && code !== 200) {
Notification.error({
title: msg
})
// eslint-disable-next-line prefer-promise-reject-errors
return Promise.reject('error')
} else {
return res.data
}
}, error => {
let { message } = error
if (message === 'Network Error') {
message = '服务端连接异常'
} else if (message.includes('timeout')) {
message = '系统接口请求超时'
} else if (message.includes('Request failed with status code')) {
message = '系统接口' + message.substr(message.length - 3) + '异常'
}
Message({
message: message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
}
)
export default service
上述会捕获后端返回的
code
,做不同的事情,是否可调用接口
针对上述的errorCode
,为新建的封装报错代码js,根据自己需要可做可不做errorCode.js
: 代码如下:
export default {
'401': '认证失败,无法访问系统资源',
'403': '当前操作没有权限',
'404': '访问资源不存在',
'default': '系统未知错误,请反馈给管理员'
}
上述登录方法涉及到的axios的api如下
login.js
:
import request from '@/utils/request'
// 登录方法
export function login (account, password) {
const data = {
account,
password
}
return request({
url: '/idle/login',
method: 'post',
data: data
})
}
// 验证token是否有效
export function verify (token) {
let data = {
token
}
return request({
url: '/idle/verify',
method: 'get',
params: data
})
}
router配置
跳转路由,拦截请求是否已经过期
新建router
文件夹,在其中建立index.js
代码如下:
import Vue from 'vue'
import Router from 'vue-router'
// eslint-disable-next-line standard/object-curly-even-spacing
import {getToken, removeToken } from '@/utils/token'
import {verify} from '@/api/login'
import { Message } from 'element-ui'
Vue.use(Router)
export const constantRoutes = [
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},
{
path: '/main',
component: () => import('@/views/main'),
children: [
{
path: '',
name: 'dashBorad',
component: () => import('@/views/dashBorad')
},
{
path: '/menuPage',
name: 'menuPage',
component: () => import('@/views/menuPage')
},
{
path: '/userDataManage',
name: 'userDataManage',
component: () => import('@/views/user/userDataManage')
}
]
},
{
path: '/404',
component: () => import('@/views/404'),
hidden: true
},
// 404 page must be placed at the end !!!
{path: '*', redirect: '/404', hidden: true}
]
const createRouter = () => {
const router = new Router({
mode: 'hash',
scrollBehavior: () => ({y: 0}),
routes: constantRoutes
})
router.beforeEach((to, from, next) => {
let token = getToken()
if (!token) {
// 如果未登录并且不是去往登录页,则跳转到登录页
if (to.path !== '/login') {
next('/login')
} else {
next() // 如果是去往登录页,则直接放行
}
} else {
// 已登录状态
verify(token).then(res => {
let isVerify = res.value
// 判断是否token验证成功,验证成功则跳转要去的路由,否则报错,跳回登录界面
if (isVerify) {
next()
} else {
removeToken()
setTimeout(() => { next('/login') }, 1500)
}
}).catch(() => {
next('/login') // 异步操作失败后再手动重定向
})
}
})
return router
}
const router = createRouter()
export function resetRouter () {
const newRouter = createRouter()
router.matcher = newRouter.matcher // reset router
}
export default router
通过以上
router.js
和request.js
,即可在跳转页面以及访问后端接口的时候进行拦截验证
🌼🌼🌼🌼🌻🌻🌻🌻🌻🌷🌷🌷🌷🌷🌷🌷🌼🌼🌼🌼🌻🌻🌻🌻🌻
springboot实现
依赖引入
同样,为了实现JWT,我们后端也需要做一些引入,注意:本次引入只涉及到JWT相关,其他自己项目相关请额外进行引入
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵🌵
JWT工具类
新建工具类,命名JwtTokenUtil
:
代码如下:
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import java.util.Date;
public class JwtTokenUtil {
//定义token返回头部
public static final String AUTH_HEADER_KEY = "Authorization";
//token前缀
public static final String TOKEN_PREFIX = "Bearer ";
//签名密钥
public static final String KEY = "q3t6w9z$C&F)J@NcQfTjWnZr4u7x";
//有效期默认为 2hour
public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 2;
/**
* 创建TOKEN
*/
public static String createToken(String content) {
return TOKEN_PREFIX + JWT.create()
.withSubject(content)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.sign(Algorithm.HMAC512(KEY));
}
/**
* 验证token
*/
public static String verifyToken(String token) throws Exception {
try {
return JWT.require(Algorithm.HMAC512(KEY))
.build()
.verify(token.replace(TOKEN_PREFIX, ""))
.getSubject();
} catch (TokenExpiredException e) {
throw new Exception("token已失效,请重新登录", e);
} catch (JWTVerificationException e) {
throw new Exception("token验证失败!", e);
}
}
public static Boolean verify(String token) throws Exception {
try {
JWT.require(Algorithm.HMAC512(KEY))
.build()
.verify(token.replace(TOKEN_PREFIX, ""))
.getSubject();
return true;
} catch (Exception e) {
return false;
}
}
}
忽视jwt验证注解
新建一个注解,用于忽视验证,比如登录,注册方法
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtIgnore {
boolean value() default true;
}
🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱
拦截器逻辑
import cn.hutool.json.JSONObject;
import com.pearl.Interface.JwtIgnore;
import com.pearl.utils.JwtTokenUtil;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Slf4j
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
// 从http请求头中取出token
final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);
//如果不是映射到方法,直接通过
if (!(handler instanceof HandlerMethod)) {
return true;
}
//如果方法有JwtIgnore注解,直接通过
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(JwtIgnore.class)) {
JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class);
if (jwtIgnore.value()) {
return true;
}
}
// 执行认证
if (StringUtils.isEmpty(token)) {
JSONObject res = new JSONObject();
res.put("code", 401);
res.put("message", "无token,请重新登录");
res.put("data", false);
PrintWriter out = response.getWriter();
out.append(res.toString());
return false;
}
if (!JwtTokenUtil.verify(token)) {
JSONObject res = new JSONObject();
res.put("code", 401);
res.put("message", "token验证失败,请重新登录");
res.put("data", false);
PrintWriter out = response.getWriter();
out.append(res.toString());
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
}
}
🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳🌳
跨域&调用拦截器配置
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class GlobalWebMvcConfig implements WebMvcConfigurer {
/**
* 重写父类提供的跨域请求处理的接口
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 添加映射路径
registry.addMapping("/**")
.allowedOriginPatterns("*") // 允许所有来源
.allowCredentials(true) // 允许发送身份验证凭据
.allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
.allowedHeaders("*")
.exposedHeaders("Server", "Content-Length", "Authorization", "Access-Token",
"Access-Control-Allow-Origin", "Access-Control-Allow-Credentials");
}
// 添加拦截器,我的项目的基础路径为sso,登录接口路径为/sso/idle/login
// addPathPatterns是拦截所有路径,excludePathPatterns是排除需要拦截的路径
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthenticationInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/sso/idle/login");
}
}
🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴🌴
登录接口&验证token接口
import com.pearl.Interface.JwtIgnore;
import com.pearl.entitys.beans.UserLoginData;
import com.pearl.entitys.dataBaseTable.User;
import com.pearl.responseEntity.Response;
import com.pearl.service.LoginService;
import com.pearl.utils.db.PrimeDB;
import java.sql.Connection;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/idle")
public class LoginController {
// 我的数据库连接类
@Autowired
private PrimeDB primeDB;
// service层,
@Resource
private LoginService loginService;
/**
* 登录
*/
@JwtIgnore
@PostMapping("/login")
public Response<Map<String, Object>> login(@RequestBody UserLoginData userDto,
HttpServletResponse response)
throws Exception {
try (Connection conn = primeDB.create()) {
Map<String, Object> map = loginService.login(conn, userDto, response);
return new Response<>(0, map, "登录成功");
} catch (Exception e) {
return new Response<>(1, e.getMessage());
}
}
@JwtIgnore
@GetMapping("/verify")
public Response<Boolean> verify(@RequestParam("token") String token) {
try {
return new Response<>(0, loginService.verify(token), "验证成功!");
} catch (Exception e) {
return new Response<>(1, false, "验证失败");
}
}
}
🌾🌾🌾🌾🌾🌾🌾🌾🌾🌾🌾🌾🌾🌾🌾
其中,验证接口的逻辑很简单,单纯调用JWT工具类进行判断即可,而登录方法根据不同的项目,可能各有区别,因此登录逻辑给出来只有参考意义.如下是loginService代码:
import com.alibaba.fastjson.JSONObject;
import com.pearl.db.UserDao;
import com.pearl.entitys.beans.UserLoginData;
import com.pearl.entitys.beans.UserToken;
import com.pearl.entitys.dataBaseTable.User;
import com.pearl.utils.AesUtil;
import com.pearl.utils.AssertUtils;
import com.pearl.utils.JwtTokenUtil;
import java.sql.Connection;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
@Service
public class LoginService {
public Map<String, Object> login(Connection conn, UserLoginData userLoginData) throws Exception {
try {
Map<String, Object> map = new HashMap<>();
/**
* 校验账号
* */
UserDao userDao = new UserDao(conn);
AssertUtils.notNull(userLoginData, "请求参数不能为空!");
AssertUtils.isError(StringUtils.isEmpty(userLoginData.getAccount()), "账号不能为空!");
AssertUtils.isError(StringUtils.isEmpty(userLoginData.getPassword()), "密码不能为空!");
User user = userDao.selectbyUserId(userLoginData.getAccount());
AssertUtils.notNull(user, "该账号不存在!");
// 判断账号是否失效
AssertUtils.isError(user.getStatus() != 1, "账号:" + user.getUserId() + "已失效!请联系管理员恢复!");
// 验证账密
Boolean isTruePass = new AuthService()
.checkPassword(userLoginData.getPassword(), user.getPassword(), user.getSalt());
AssertUtils.isError(!isTruePass, "用户名或密码错误!");
//TODO 获取用户权限
UserToken userToken = new UserToken();
BeanUtils.copyProperties(user, userToken);
String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));
map.put("user", user);
map.put("token", token);
return map;
} catch (Exception e) {
throw new Exception(e.getMessage());
}
}
public Boolean verify(String token) throws Exception {
try {
return JwtTokenUtil.verify(token);
} catch (Exception e) {
throw new Exception(e.getMessage());
}
}
}
🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀
以上我有封装响应实体,我的响应实体代码如下:
public class Response<T> {
public Header header;
public T value;
public Response() {
}
public Response(T value) {
this.header = new Header();
this.value = value;
}
public Response(int code, Exception ex) {
if (ex.getMessage() == null) {
this.header = new Header(code, ex.toString());
} else {
this.header = new Header(code, ex.getMessage());
}
this.value = null;
}
public Response(int code, String message) {
this.header = new Header(code, message);
this.value = null;
}
public Response(int code, T value, Exception ex) {
if (ex.getMessage() == null) {
this.header = new Header(code, ex.toString());
} else {
this.header = new Header(code, ex.getMessage());
}
this.value = value;
}
public Response(int code, T value, String message) {
this.header = new Header(code, message);
this.value = value;
}
// 请求头,包含响应码和响应提醒信息
public static class Header {
public int code;
public String message;
public Header() {
this.code = 0;
this.message = "";
}
public Header(int code, String message) {
this.code = code;
this.message = message;
}
}
}
如上我的调用登录数据结构如下:
{
"header": {
"code": 0,
"message": "登录成功"
},
"value": {
"user": {
"userId": "admin",
"avatar": null,
"userName": "超级管理员",
"password": "t3zluLHlyip9A8TcXrR05Q==",
"email": null,
"phone": null,
"sex": null,
"age": 0,
"status": 1,
"createTime": "2024-04-07 08:11:43",
"updateTime": "2024-04-07 08:11:43"
},
"token": "Bearer xxx"
}
}
因此前端可获取token数据,进行赋值设置
🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲🌲
结语
以上,为vue+springboot实现JWT登录验证过程