旭锋集团运营管理平台--认证模块
- 1 模块说明
- 2 强制访问登录页
- 3 生成验证码
- 4 认证
- 4.1 业务逻辑
- 4.2 客户端业务实现
- 4.3 服务端实现
- 4.3.1 定义Oauth2配置,实现生成JWTtoken,SSO
- 4.3.1.1 定义生成并保存JWTtoken配置
- 4.3.1.2 定义生成token服务配置
- 4.3.1.3 在数据库中创建相关表
- 4.3.1.4 定义封装类的公共父类
- 4.3.1.5 定义SysUserPojo封装类
- 4.3.1.6 定义SysMenuPojo封装类
- 4.3.1.7 定义Mapper层及映射文件
- 4.3.1.8 定义UserDetailsService实现类
- 4.3.1.9 修改认证中心SpringSecurity配置
- 4.3.1.10定义XfAuthenticationEntryPoint
- 4.3.1.11 定义AuthClientCredentialsTokenEndpointFilter
- 4.3.1.12 完成认证中心Oauth2配置
- 4.3.2 定义认证的Service层
- 4.3.3 定义服务端接收请求的Controller
- 4.3.5 定义认证期间全局异常处理
- 5 认证成功后的token处理
1 模块说明
1.1 模块功能
- 主要功能:用于用户登录信息认证。
- 认证成功,登录平台,依据权限访问对应页面并完成相关操作。
- 认证失败,显示认证失败原因信息,提醒用户重新登录。
1.2 页面初始化
- 初始化代码:
<template>
<div class="login-container">
<!--登录-->
<div class="login-box">
<!--左侧修饰图片-->
<img class="box-left" alt="图片" src="../assets/image/loginshow.png"/>
<!--用户登录-->
<div class="box-right">
<!--第一层:系统标题-->
<a-row type="flex" justify="center" align="middle">
<a-col :span="4">
<img class="group-logo" src="../assets/logo.png" alt="集团图标">
</a-col>
<a-col :span="20">
<div ref="cpTitle" class="group-title"></div>
</a-col>
</a-row>
<!--第二层:登录失败信息提示框-->
<a-row type="flex" justify="center">
<a-col :span="24">
<div class="failure-msg-box" v-show="showErrMsg">
{{ failureMsg }}
</div>
</a-col>
</a-row>
<!--第三层:登录表单-->
<a-form class="vertical-space-2" ref="loginForm" :model="loginData" :rules="loginRules">
<a-row type="flex" justify="center">
<a-col :span="24">
<a-form-item has-feedback name="username">
<a-input v-model:value.trim ="loginData.username" placeholder="请输入用户名..." size="large" allow-clear autocomplete="off">
<template #prefix>
<i class="fa-solid fa-user icon-hor-space"></i>
</template>
</a-input>
</a-form-item>
</a-col>
</a-row>
<a-row type="flex" justify="center">
<a-col :span="24">
<a-form-item has-feedback name="password">
<a-input-password v-model:value.trim="loginData.password" placeholder="请输入密码..." size="large" allow-clear autocomplete="off">
<template #prefix>
<i class="fa-solid fa-unlock-keyhole icon-hor-space"></i>
</template>
</a-input-password>
</a-form-item>
</a-col>
</a-row>
<a-row type="flex" justify="space-between">
<a-col :span="16">
<a-form-item has-feedback name="captcha">
<a-input v-model:value.trim="loginData.captcha" size="large" placeholder="请输入验证码..." allow-clear autocomplete="off">
<template #prefix>
<i class="fa-solid fa-shield-halved icon-hor-space"></i>
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="7">
<a-tooltip title="看不清,可点击刷新" placement="top" color="green">
<canvas v-if="refreshCaptcha" ref="captchaContainer" class="captcha-container" ></canvas>
</a-tooltip>
</a-col>
</a-row>
<a-form-item>
<a-row type="flex" justify="space-between" align="center">
<a-col :span="9">
<a-checkbox v-model="loginData.remember_me">RememberMe</a-checkbox>
</a-col>
<a-col :span="15">
<span class="captcha-expired-container">{{ captchaExpiredMsg }}</span>
</a-col>
</a-row>
</a-form-item>
<a-row type="flex" justify="center">
<a-col :span="20">
<a-form-item>
<a-button type="primary" class="ui-all-width">登录</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/*=====================================文件导入====================================*/
import {onBeforeUnmount, onMounted, ref} from "vue";
import {destroyGroupTilte, initGroupTitle} from "@/plugins/echarts";
/*=====================================变量声明====================================*/
// 登录页面系统标题echarts容器
const cpTitle = ref(null);
// 验证码容器
const captcha = ref(null);
// 登录失败信息内容
let failureMsg = ref("登录失败信息!");
// 登录失败框显示控制
let showErrMsg = ref(false);
// 登录表单绑定变量
let loginData = ref({
username: "",
password: "",
captcha: "",
captchaKey: "",
remember_me: false,
})
// 登录表单静态校验规则
const loginRules = ref({})
// 验证码过期信息
let captchaExpiredMsg = ref("当前验证码已过期,请点击刷新!");
/*=====================================页面UI初始化方法============================*/
/*=====================================业务逻辑方法================================*/
/*=====================================业务逻辑方法================================*/
/*=====================================其它生命周期方法============================*/
onMounted(() => {
initGroupTitle(cpTitle.value);
})
onBeforeUnmount(() => {
destroyGroupTilte();
})
</script>
<style scoped>
/*login页面版心样式*/
.login-container {
background-image: url("../assets/image/login-bg.jpg");
background-size: cover;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
/*登录UI部分*/
.login-container .login-box {
width: 40vw;
/*如果使用min-height: 左侧div会失高*/
min-height: 40vh;
background-color: rgba(255, 255, 255, 0.95);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 0.25vh;
box-shadow: 0.5vh -0.5vh 3vh 0.1vh #091E53;
display: flex;
justify-content: space-between;
}
/*登录UI左侧修饰图*/
.login-container .login-box .box-left {
width: 50%;
display: block;
}
/*登录UI右侧登录部分*/
.login-container .login-box .box-right {
width: 50%;
padding: 1vh 1.5vw;
}
/*系统标题*/
.login-container .login-box .box-right .group-title {
font-size: 1.4vw;
width: 100%;
height: 5vh;
line-height: 5vh;
}
/*系统logo*/
.login-container .login-box .box-right .group-logo {
display: block;
width: 2.6vw;
}
/*失败消息提示框*/
.login-container .login-box .box-right .failure-msg-box {
border: red 1px solid;
background-color: rgba(255, 0, 0, 0.8);
color: #ffffff;
height: 4.5vh;
line-height: 4.5vh;
text-align: center;
border-radius: 0.2vh;
}
.captcha-container {
background-color: #fff;
width: 100%;
height: 39px;
border-radius: 0.2vh;
transition: all 0.25s 0s linear;
}
.captcha-expired-container {
color: red;
text-align: right;
font-size: 13px;
display: block;
}
.captcha-container:hover{
cursor: pointer;
height: 40px;
width: 101%;
box-shadow: 0 0 0 0.15vh #7DC3FB;
}
</style>
-
页面效果
1.3 服务器端初始化
1.3.1 创建微服务模块xfsy-auth-center
- 项目结构如图:
1.3.2 pom.xml添加依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>xfsy-server</artifactId>
<groupId>org.wjk</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>xfsy-auth-center</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!--xfsy-common模块依赖-->
<dependency>
<groupId>org.wjk</groupId>
<artifactId>xfsy-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.wjk</groupId>
<artifactId>crypto-spring-boot-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
1.3.3 定义bootstrap.yml
server:
port: 9005
spring:
application:
name: xfsy-auth-center
cloud:
nacos:
config:
server-addr: ###########:8848
namespace: 2934dee2-2954-4cb4-93db-0c0c8643f4b9
group: dev
file-extension: yml
discovery:
group: dev
namespace: 2934dee2-2954-4cb4-93db-0c0c8643f4b9
server-addr: ###########:8848
main:
banner-mode: off
logging:
level:
org.wjk: debug
1.3.4 nacos配置中心定义xfsy-auth-center.yml
crypto:
algo-name: AES
algorithm: AES/ECB/PKCS5Padding
secret-key: xfsy_project_v_2
jedis:
max-idle: 33
max-total: 33
min-idle: 33
host: ##########
timeout: 3000
password: ########
thread:
core-size: 33
max-size: 33
keep-alive: 60
queue-capacity: 256
name-prefix: xfsy_auth_
1.3.5 定义启动类
- 启动线程池
- 启动redis
- 具体实现
package org.wjk;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.wjk.annotation.EnableJedis;
import org.wjk.annotation.EnableThreadPool;
@SpringBootApplication
@EnableJedis
@EnableThreadPool
@EnableAsync
public class XfsyAuthCenterApp
{
public static void main(String[] args)
{
SpringApplication.run(XfsyAuthCenterApp.class, args);
}
}
1.3.6 定义响应加密类
- 具体实现
package org.wjk.advice;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import org.wjk.annotation.Encrypt;
import org.wjk.entity.vo.ResponseResult;
import org.wjk.utils.CryptUtils;
@ControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class EncryptResponseBody implements ResponseBodyAdvice<ResponseResult>
{
private final CryptUtils utils;
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType)
{
return returnType.hasMethodAnnotation(Encrypt.class);
}
@Override
public ResponseResult beforeBodyWrite(ResponseResult body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response)
{
assert body != null;
if(body.getResData() != null)
{
try
{
body.setResData(utils.encrypt(new ObjectMapper().writeValueAsBytes(body.getResData())));
}
catch (Exception e)
{
log.debug("执行加密操作时抛出异常,具体信息为:{}", e.getMessage());
}
}
return body;
}
1.3.7 定义请求参数解密
- 具体实现
package org.wjk.advice;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
import org.wjk.annotation.Decrypt;
import org.wjk.utils.CryptUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.util.Base64;
@ControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class DecryptRequestBody extends RequestBodyAdviceAdapter
{
private final CryptUtils utils;
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType)
{
return methodParameter.hasParameterAnnotation(Decrypt.class);
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException
{
byte[] body = new byte[inputMessage.getBody().available()];
int read = inputMessage.getBody().read(body);
if(read <= 0)
return super.beforeBodyRead(inputMessage, parameter, targetType, converterType);
try
{
byte[] res = utils.decrypt(body);
ByteArrayInputStream bais = new ByteArrayInputStream(res);
return new HttpInputMessage()
{
@Override
public InputStream getBody() throws IOException
{
return bais;
}
@Override
public HttpHeaders getHeaders()
{
return inputMessage.getHeaders();
}
};
}
catch (Exception e)
{
log.debug("解密时抛出异常,具体信息为:{}", e.getMessage());
return super.beforeBodyRead(inputMessage, parameter, targetType, converterType);
}
}
}
2 强制访问登录页
2.1 功能描述
用户在未登录的情况下,只能访问登录页面,访问其它页面时,页面强制跳转回登录页面。
2.2 功能实现
2.2.1 实现要点
利用vue-router的全局前置路由导航守卫。
2.2.2 业务逻辑
- 校验路由跳转路径,
- 当跳转路径是登录页面,则清空
sessonStorage
内部保存所有数据,保存访问登录页面后,用户处于未登录状态。
- 当跳转路径是登录页面,则清空
- 当跳转路径不是登录页面时,则:校验
sessionStorage
是否保存了token数据,- 如果未保存token数据,则说明用户尚未登录,页面强制跳转到登录页面。
- 如果保存有token数据,则跳转到对应页面。
2.2.3 具体实现
修改
router/index.ts
,添加全局路由前置导航守卫。
- 代码实现
router.beforeEach((to, from, next) => {
/*用户访问登录页面*/
if(to.path === "/login")
{
/**
* 用户登录成功后:
* 使用sessionStorage保存服务端返回的access_token与refresh_token。
* 当用户显示退出登录,则要清空sessionStorage;
* */
sessionStorage.clear();
return next();
}
/**
* 用户访问其它页面:
* 首先检查当前用户是否登录,
* 如果登录,则sessionStorage中保存有token数据,
* 如果未登录,则没有。
* */
// 当sessionStorage中没有token数据,则用户未登录,页面强制跳转到登录页面。
if(!sessionStorage.getItem("tokenData"))
return next("/login");
// 当sessionStorage中保存有token数据,则用户已登录,页面正常跳转到要访问的页面。
next();
})
3 生成验证码
3.1 业务逻辑
- 页面加载完成后,构建获取验证码的key,并保存,携带该key发起请求,请求后端对应接口。
- 后端接口接收到请求后,校验是否存在验证码对应的key。
- 如果存在:
- 产生验证码。
- 响应产生的验证码内容和产生时间。
- 多线程以验证码对应的key为key,验证码内容全小写为value保存到redis中,并设置过期时间为180s。
- 加密响应结果。
- 如果不存在对应key,则返回失败响应。
- 当客户端接收到响应后,解密响应结果,并检验响应业务状态码。
- 业务成功:
- 通过canvas在页面上画出验证码。
- 并构建显示验证距过期的剩余时间。
- 业务成功:
3.2 客户端实现
3.2.1 构建获取验证码的key
- 具体实现:
// 构建Captcha的key
function captchaKeyBuilder()
{
let key = Math.floor(Math.random()* 100000).toString();
loginData.value.captchaKey = key;
return key
}
3.2.2 控制canvas元素重新创建
当多次通过canvas绘画元素时,canvas不会自动清除之前绘画,也没有提供清除之前绘画的API;要实现多次绘画,只显示当前绘画内容,可以通过控制canvas元素本身虚拟Dom元素的删除与创建完成清理之前绘画的效果。
- 给canvas元素添加v-if指令并定义控制变量。具体实现如下:
// template中实现
<canvas v-if="refreshCaptcha" ref="captchaContainer" class="captcha-container"></canvas>
// script中实现
// 验证码刷新控制
const refreshCaptcha = ref(true);
// canvas从新创建虚拟DOM
async function captchaRefresh() {
refreshCaptcha.value = false;
await nextTick(() => {
refreshCaptcha.value = true
});
}
3.2.3 请求并绘制验证码
3.2.3.1 安装加解密插件crypto.js
3.2.3.2 定义加解密工具方法
- 创建plugin/crypto.ts
- 具体实现:
import crypto from "crypto-js";
import {systemInfo} from "@/plugins/api";
const keyHex = crypto.enc.Utf8.parse(systemInfo.sysSecretKey);
const cryptoUtils = {
onEncrypt(source: any)
{
if(typeof source === "object")
source = JSON.stringify(source);
return crypto.AES.encrypt(source, keyHex, {
mode: crypto.mode.ECB,
padding: crypto.pad.Pkcs7
// @ts-ignore
}).toString(crypto.enc.Utf8);
},
onDecrypt(source: string)
{
return crypto.AES.decrypt(source, keyHex, {
mode: crypto.mode.ECB,
padding: crypto.pad.Pkcs7
}).toString(crypto.enc.Utf8);
}
}
export default cryptoUtils;
3.2.3.3 定义canvas 绘图方法
- 创建plugins/api.ts定义方法:
export function captchaBuilder(el:any, content:string, existLine:boolean=true, lineCounts:number = 4)
{
let ctx = el.getContext("2d");
let gradient = ctx.createLinearGradient(0,0,170,0);
gradient.addColorStop("0","magenta");
gradient.addColorStop("0.5","blue");
gradient.addColorStop("1.0","red");
ctx.fillStyle = gradient;
ctx.font = "3vw Arial";
ctx.fillText(content,33,100);
if(existLine)
{
ctx.strokeStyle = gradient;
ctx.lineWidth = 3;
for(let i = 0; i < lineCounts; i++)
{
ctx.beginPath();
ctx.moveTo(Math.floor(Math.random()*300), Math.floor(Math.random()*150));
ctx.quadraticCurveTo(Math.floor(Math.random()*300), Math.floor(Math.random()*150),Math.floor(Math.random()*300), Math.floor(Math.random()*150));
ctx.stroke();
}
}
}
3.2.3.4 定义请求方法
- 在Login.vue的script标签定义
// 从服务器获取captcha内容
async function initCaptcha() {
await captchaRefresh();
const key = captchaKeyBuilder();
const {data: {resCode, resData:{captcha, created}}} = await $axios.get(`/captcha/${key}`, {
baseURL: "http://localhost:9000/auth",
transformResponse: [function (data){
data = JSON.parse(data);
data.resData = JSON.parse(cryptoUtils.onDecrypt(data.resData));
console.log(data.resData);
return data;
}]
});
let expirdTime = Math.ceil((new Date().getTime() - new Date(created).getTime())/1000);
console.log(resCode, captcha, expirdTime);
captchaBuilder(captchaContainer.value, captcha);
}
3.2.3.5 在mounted周期中调用该方法
- 具体实现
onMounted(() => {
initGroupTitle(cpTitle.value);
initCaptcha();
})
3.2.3.6 实现点击刷新验证码
- 给canvas元素绑定点击事件处理函数
initCaptcha()
,具体实现如下:
<canvas v-if="refreshCaptcha" ref="captchaContainer" class="captcha-container" @click="initCaptcha"></canvas>
3.3 服务端实现
3.3.1 定义验证码工具类
- 功能:定义验证码所包含的所有字符
- 具体实现:
package org.wjk.utils;
public class CaptchaContentUnit
{
public static final String UNITS = "abcdefghgkmnprstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789";
}
3.3.2 定义处理验证码controller
- 具体实现
package org.wjk.controller;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.wjk.annotation.Encrypt;
import org.wjk.entity.vo.ResponseResult;
import org.wjk.utils.CaptchaContentUnit;
import org.wjk.utils.method.JedisTemplate;
import javax.validation.constraints.NotNull;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/captcha")
@RequiredArgsConstructor
@Validated
public class CaptchaCtrllr
{
private final ThreadPoolTaskExecutor executor;
private final JedisTemplate template;
@GetMapping("/{captchaKey}")
@Encrypt
public ResponseResult<Map<String, Object>> getCaptchaCtrllr(@PathVariable @NotNull Integer captchaKey)
{
String captchaContent = RandomStringUtils.random(6, CaptchaContentUnit.UNITS);
Map<String, Object> res = new HashMap<>();
res.put("captcha", captchaContent);
res.put("created", new Date());
executor.execute(() ->{
template.setEx(captchaKey.toString(), captchaContent.toLowerCase(), 180);
});
return ResponseResult.success(null, res);
}
}
3.3.3 定义SpringSecurity配置类
- 功能:对/captcha/**所有路径不做认证
package org.wjk.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
public class AuthCenterSecurityConfig extends WebSecurityConfigurerAdapter
{
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
super.configure(auth);
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
@Override
public void configure(WebSecurity web) throws Exception
{
web.ignoring().mvcMatchers("/captcha/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
super.configure(http);
}
}
3.4 显示验证过期时间
- 在Login.vue中定义:
// 构建显示距验证码过期剩余时间
function showExpiredMsg(expiredTime: number)
{
if(timerCounts)
{
for (let i = 0; i <= timerCounts; i++)
clearInterval(timerCounts);
}
let initTime = 180 - expiredTime;
timerCounts = setInterval(()=>{
initTime --;
if(initTime > 0)
{
captchaExpiredMsg.value = `距离验证码过期,还剩${initTime}秒`;
console.log(initTime);
}
else
{
captchaExpiredMsg.value = "当前验证码已过期,请点击刷新!";
clearInterval(timerCounts);
}
}, 1000);
}
- 在
initCaptcha()
中调用该函数,具体实现:
// 从服务器获取captcha内容
async function initCaptcha() {
await captchaRefresh();
const key = captchaKeyBuilder();
const {data: {resCode, resData:{captcha, created}}} = await $axios.get(`/captcha/${key}`, {
baseURL: "http://localhost:9000/auth",
transformResponse: [function (data){
data = JSON.parse(data);
data.resData = JSON.parse(cryptoUtils.onDecrypt(data.resData));
return data;
}]
});
let expirdTime = Math.ceil((new Date().getTime() - new Date(created).getTime())/1000);
captchaBuilder(captchaContainer.value, captcha);
showExpiredMsg(expirdTime);
}
4 认证
4.1 业务逻辑
- 用户提交用户名密码及验证码到服务器,服务器对客户端信息及用户名密码非空校验:
- 校验失败,响应客户端对应的失败信息。
- 非空校验成功,获取数据库中用户账号信息并缓存,完成数据库用户账号信息与用户提交登录信息的比对,
- 校验失败,如果非用户密码错误,则响应客户端对应的失败信息。
- 如果是用户密码错误,则校验用户密码错误对数是否大于等于3次。
- 如果小于3次,则用户密码错误次数加1,并响应客户端用户名密码错误,
- 如果大于等于3次,则锁定当前用户,并删除密码错误次数,并响应客户端用户账号已锁定。
- 如果是用户密码错误,则校验用户密码错误对数是否大于等于3次。
- 校验失败,如果非用户密码错误,则响应客户端对应的失败信息。
- 账号信息校验成功,则多线程获取当前登录用户的用户账号信息并保护用户密码,校验验证码,构建token体。
- 校验验证码检验结果
- 校验失败,响应对应失败信息。
- 验证码结果校验成功,响应客户端成功响应。包括当前用户信息与access_token、token_type、refresh_token
- 客户端接收响应后,校验响应类型。
- 失败的响应则页面渲染失败信息。
- 成功响应,则保存响应信息到sessionStorage中,提示用户登录成功,页面跳转直主页面。
4.2 客户端业务实现
4.2.1 提交用户信息方法
- 具体实现
//登录认证方法
function loginBtnClkEvent()
{
loginForm.value?.validate().then(async () => {
Object.assign(loginData.value, loginExtend);
let loginDataDto = {... loginData.value};
loginDataDto.username = cryptoUtils.onEncrypt(loginDataDto.username);
loginDataDto.password = cryptoUtils.onEncrypt(loginDataDto.password);
loginDataDto.captcha = loginDataDto.captcha.toLowerCase();
const {data: {resCode, resMsg, resData}} = await $axios.post("/oauth/token", Qs.stringify(loginDataDto), {
baseURL: "http://localhost:9000/auth",
transformResponse: [cryptoUtils.onDecryptResponse]
});
if(resCode === 2000)
{
notifyBox(resMsg);
const{login_user, token_data: {access_token, refresh_token, token_type}} = resData;
sessionStorage.setItem("login_user", JSON.stringify(login_user));
sessionStorage.setItem("access_token", access_token);
sessionStorage.setItem("refresh_token", refresh_token);
sessionStorage.setItem("token_type", token_type);
if(showErrMsg.value)
{
showErrMsg.value = false;
}
return;
}
if(resCode === 5002)
{
showErrMsg.value = true;
failureMsg.value = resMsg;
}
});
}
- 为登录表单
回车键
弹起事件与登录按钮
点击事件绑定该方法,具体实现如下:
<a-form class="vertical-space-2" ref="loginForm" :model="loginData" :rules="loginRules" @keyup.enter="loginBtnClkEvent">
<a-button type="primary" class="ui-all-width" @click="loginBtnClkEvent">登录</a-button>
4.3 服务端实现
4.3.1 定义Oauth2配置,实现生成JWTtoken,SSO
4.3.1.1 定义生成并保存JWTtoken配置
以下配置类需要定义在在
xfsy-common
模块中。
4.3.1.1.1 定义TokenBuilderDefaultProperties常量类
- 主要功能:
- 定义生成配置生成Token相关默认属性的属性值
package org.wjk.utils.constant;
public class TokenBuilderDefaultProperties
{
public static final String DEFAULT_SIGNER_KEY = "SYSTEM_SECRET";
public static final String DEFAULT_RESOURCE_ID = "";
public static final String DEFAULT_TOKEN_TYPE = "bearer";
}
4.3.1.1.2 定义TokenBuilderProperties类
- 主要功能:
- 用户接收用户application.yml配置的相关属性
package org.wjk.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.wjk.utils.constant.TokenBuilderDefaultProperties;
@Configuration
@ConfigurationProperties(prefix = "system.token")
@Data
public class TokenBuilderProperties
{
private String signerKey = TokenBuilderDefaultProperties.DEFAULT_SIGNER_KEY;
private String resourceId = TokenBuilderDefaultProperties.DEFAULT_RESOURCE_ID;
private String tokenType = TokenBuilderDefaultProperties.DEFAULT_TOKEN_TYPE;
}
4.3.1.1.3 定义TokenStore配置类
- 主要功能:
- 创建JWT token序列化与反列化功能类:JwtAccessTokenConverter 实例,并将给IOC容器管理。
- 创建保存token的类的TokenStore接口实例,并交给IOC容器管理。
package org.wjk.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.wjk.properties.TokenBuilderProperties;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class TokenStoreConfig
{
private final TokenBuilderProperties properties;
//private final JwtAccessTokenConverter converter;
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter()
{
log.debug("当前TokenBuilderProperties为{}", properties);
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(properties.getSignerKey());
return converter;
}
@Bean
public TokenStore tokenStore()
{
return new JwtTokenStore(/*converter*/ jwtAccessTokenConverter());
}
}
4.3.1.2 定义生成token服务配置
以下配置定义于
xfsy-auth-center
模块中。
4.3.1.2.1 定义DefaultAuthProps常量类
- 主要功能:
- 用于定义生成token服务配置类的默认属性。
package org.wjk.utils.constant;
public class DefaultAuthProps
{
public static final String DEFAULT_CLIENT_ID = "SYSTEM_CLIENT";
public static final String DEFAULT_CLIENT_SECRET = "SYSTEM";
public static final String DEFAULT_GRANT_TYPES = "password";
public static final String DEFAULT_SCOPES = "ALL";
public static final Integer DEFAULT_ACCESS_TOKEN_EXPIRED = 7200;
public static final Integer DEFAULT_REFRESH_TOKEN_EXPIRED = 28800;
}
4.3.1.2.2 定义AuthProperties配置类
- 主要功能:
- 用于接收用户application.yml中相关配置信息。
package org.wjk.props;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.wjk.utils.constant.DefaultAuthProps;
@Configuration
@ConfigurationProperties("system.auth")
@Data
@Accessors(chain = true)
public class AuthProperties
{
private String clientId = DefaultAuthProps.DEFAULT_CLIENT_ID;
private String clientSecret = DefaultAuthProps.DEFAULT_CLIENT_SECRET;
private String grantTypes = DefaultAuthProps.DEFAULT_GRANT_TYPES;
private String scopes = DefaultAuthProps.DEFAULT_SCOPES;
private Integer accessExpired = DefaultAuthProps.DEFAULT_ACCESS_TOKEN_EXPIRED;
private Integer refreshExpired = DefaultAuthProps.DEFAULT_REFRESH_TOKEN_EXPIRED;
}
4.3.1.2.3 定义TokenServiceConfig配置类
- 主要功能:
- 用于token序列化与反序列化及其它相关属性配置
package org.wjk.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.wjk.props.AuthProperties;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class TokenServiceConfig
{
private final AuthProperties properties;
private final JwtAccessTokenConverter converter;
private final TokenStore tokenStore;
@Bean
@Primary
public AuthorizationServerTokenServices tokenService()
{
log.debug("this object created with AuthProperties is {}", properties);
DefaultTokenServices services = new DefaultTokenServices();
services.setTokenStore(tokenStore);
services.setTokenEnhancer(converter);
services.setAccessTokenValiditySeconds(properties.getAccessExpired());
services.setRefreshTokenValiditySeconds(properties.getRefreshExpired());
services.setSupportRefreshToken(true);
return services;
}
}
4.3.1.3 在数据库中创建相关表
/*
Navicat Premium Data Transfer
Source Server : txCloudM1
Source Server Type : MariaDB
Source Server Version : 100611 (10.6.11-MariaDB-1:10.6.11+maria~ubu2004-log)
Source Host : 49.233.38.67:3306
Source Schema : xfsy_v2
Target Server Type : MariaDB
Target Server Version : 100611 (10.6.11-MariaDB-1:10.6.11+maria~ubu2004-log)
File Encoding : 65001
Date: 23/11/2022 18:32:32
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for adm_org
-- ----------------------------
DROP TABLE IF EXISTS `adm_org`;
CREATE TABLE `adm_org` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '机构id',
`type` tinyint(1) NOT NULL COMMENT '机构类型;1:总部;2:分司;3:部门',
`parent_id` int(11) NULL DEFAULT NULL COMMENT '上级机构id',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '机构名称',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of adm_org
-- ----------------------------
INSERT INTO `adm_org` VALUES (1, 1, 0, '旭锋集团(北京总部)');
-- ----------------------------
-- Table structure for adm_pstn
-- ----------------------------
DROP TABLE IF EXISTS `adm_pstn`;
CREATE TABLE `adm_pstn` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '岗位id',
`org_id` int(11) NULL DEFAULT NULL COMMENT '所属机构id',
`name` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '岗位名称',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of adm_pstn
-- ----------------------------
INSERT INTO `adm_pstn` VALUES (1, 1, '超级管理员');
-- ----------------------------
-- Table structure for hum_emp
-- ----------------------------
DROP TABLE IF EXISTS `hum_emp`;
CREATE TABLE `hum_emp` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '员工id',
`user_id` int(11) UNSIGNED NULL DEFAULT NULL COMMENT '系统使用用户账号id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of hum_emp
-- ----------------------------
INSERT INTO `hum_emp` VALUES (1, 1);
-- ----------------------------
-- Table structure for hum_emp_pstn
-- ----------------------------
DROP TABLE IF EXISTS `hum_emp_pstn`;
CREATE TABLE `hum_emp_pstn` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
`emp_id` int(11) NOT NULL COMMENT '员工id',
`pstn_id` int(11) NOT NULL COMMENT '岗位id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of hum_emp_pstn
-- ----------------------------
INSERT INTO `hum_emp_pstn` VALUES (1, 1, 1);
-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '菜单名称',
`alias` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单别名',
`type` tinyint(4) NOT NULL COMMENT '菜单类型, 1:菜单,2:按钮,3:其它',
`icon_class` varchar(62) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '代表图标',
`functional` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '菜单功能',
`apply_sys` tinyint(4) NOT NULL COMMENT '支持系统',
`parent_id` int(11) NULL DEFAULT NULL COMMENT '上级菜单',
`enabled` tinyint(4) NOT NULL COMMENT '是否启用,0:禁用,1:启用',
`sort` int(11) NOT NULL COMMENT '序列排序号',
`permission` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '权限标识',
`path` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '菜单对应路由路径',
`com_dir` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '路径对应组件所在目录',
`com_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '对应组件文件名',
`com_title` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组件展示页面标题',
`recode_user` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '录入用户',
`recode_date` date NOT NULL COMMENT '录入时间',
`modified_user` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '最近一次修改用户',
`modified_date` date NULL DEFAULT NULL COMMENT '最近一次修改时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `name`(`name`) USING BTREE,
INDEX `sort`(`sort`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES (1, '工作台', 'workbench', 1, 'fa-solid fa-desktop', '工作所需工具集', 0, NULL, 1, 1, NULL, NULL, NULL, NULL, NULL, 'admin', '2022-11-23', NULL, NULL);
INSERT INTO `sys_menu` VALUES (2, '系统管理', 'sysconfig', 1, 'fa-solid fa-gear', '管理系统管理菜单组', 0, NULL, 1, 2, NULL, NULL, NULL, NULL, NULL, 'admin', '2022-11-23', NULL, NULL);
INSERT INTO `sys_menu` VALUES (3, '菜单管理', 'menumngr', 1, 'fa-solid fa-bars', '依据权限访问菜单管理页面,并依据权限完成对应操作', 0, 2, 1, 1, 'sys:menu:mngr', '/menu', '/menu', 'xf_menu_mngr', '菜单管理', 'admin', '2022-11-23', NULL, NULL);
INSERT INTO `sys_menu` VALUES (4, '新增', 'm_add', 2, 'fa-solid fa-plus', '依据权限完成菜单新增页面,并完成相关操作', 0, 3, 1, 1, 'sys:menu:add', '/menu/add', '/menu', 'xf_menu_add_or_update', '菜单新增', 'admin', '2022-11-23', NULL, NULL);
-- ----------------------------
-- Table structure for sys_menu_pstn
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu_pstn`;
CREATE TABLE `sys_menu_pstn` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
`menu_id` int(11) NOT NULL COMMENT '授权菜单id',
`pstn_id` int(11) NOT NULL COMMENT '授权岗位id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_menu_pstn
-- ----------------------------
INSERT INTO `sys_menu_pstn` VALUES (1, 1, 1);
INSERT INTO `sys_menu_pstn` VALUES (2, 2, 1);
INSERT INTO `sys_menu_pstn` VALUES (3, 3, 1);
INSERT INTO `sys_menu_pstn` VALUES (4, 4, 1);
-- ----------------------------
-- Table structure for sys_menu_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_menu_role`;
CREATE TABLE `sys_menu_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
`menu_id` int(11) NOT NULL COMMENT '授权菜单id',
`role_id` int(11) NOT NULL COMMENT '授权角色id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_menu_role
-- ----------------------------
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '角色id',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色名称',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `name`(`name`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES (1, '员工');
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'user 的id',
`username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户名',
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '密码',
`user_face` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户头像',
`role_id` int(11) NOT NULL COMMENT '用户角色id',
`group_id` int(11) NULL DEFAULT NULL COMMENT '用户组id:用于区别数据所属租户。在运营管理系统中,以所属分司id为用户组id;在销售系统中,以用户id为用户组id',
`is_not_locked` tinyint(1) NOT NULL COMMENT '当前用户凭证是否锁定。true:未锁定,false:锁定',
`is_enable` tinyint(1) NOT NULL COMMENT '当前用户账号是否启动。true:启用,false:未启用',
`recode_date` date NOT NULL COMMENT '用户创建时间',
`recode_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '用户信息录入用户',
`modified_date` date NULL DEFAULT NULL COMMENT '用户信息最近一次修改日期',
`modified_user` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户信息最近一次修改者',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `username`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'admin', '$2a$10$uTyDs8a7rPVbcrNB2CYJ0eKl9IBVhS8I0LO4EJz.qfOAF.wBmWzr.', NULL, 1, 1, 1, 1, '2022-11-19', 'admin', NULL, NULL);
SET FOREIGN_KEY_CHECKS = 1;
4.3.1.4 定义封装类的公共父类
在
xfsy-common
模块中定义CommonBase类
package org.wjk.entity.pojo;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
@Data
@Accessors(chain = true)
public class CommonBase implements Serializable
{
private static final long serialVersionUID = -6540574174847510083L;
@TableField(fill = FieldFill.INSERT)
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date recodeDate;
@TableField(fill = FieldFill.UPDATE)
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date modifiedDate;
}
4.3.1.5 定义SysUserPojo封装类
在
xfsy-common
模块中定义UserDetails接口实现类SysUser用于封装数据库中sys_user表中保存的用户账号信息
package org.wjk.entity.pojo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@TableName("sys_user")
public class SysUserPojo extends CommonBase implements UserDetails
{
private static final long serialVersionUID = -1908776780513384876L;
@TableId(type = IdType.AUTO)
private Integer id;
private String username;
private String password;
private String userFace;
private Integer roleId;
private Integer groupId;
private Boolean isNotLocked;
private Boolean isEnable;
private String recodeUser;
private String modifiedUser;
@TableField(exist = false)
private List<String> permissions;
@Override
public Collection<? extends GrantedAuthority> getAuthorities()
{
if(permissions == null)
return null;
return AuthorityUtils.createAuthorityList(permissions.toArray(new String[0]));
}
@Override
public boolean isAccountNonExpired()
{
return true;
}
@Override
public boolean isAccountNonLocked()
{
return this.isNotLocked;
}
@Override
public boolean isCredentialsNonExpired()
{
return true;
}
@Override
public boolean isEnabled()
{
return this.isEnable;
}
}
4.3.1.6 定义SysMenuPojo封装类
在
xfsy-common
模块中定义该封装类,用于封装sys_menu表中保存的菜单信息
package org.wjk.entity.pojo;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_menu")
public class SysMenuPojo extends CommonBase
{
private static final long serialVersionUID = 209271746363193275L;
@TableId(type = IdType.AUTO)
private Integer id;
private String name;
private String alias;
private Integer type;
private String iconClass;
private String functional;
private Integer applySys;
private Integer parentId;
@TableField(exist = false)
private String parentName;
private Boolean enabled;
private Integer sort;
private String permission;
private String path;
private String comDir;
private String comName;
private String comTitle;
private String recodeUser;
private String modifiedUser;
}
4.3.1.7 定义Mapper层及映射文件
在
xfsy-auth-center
模块中,定义访问数据库sys_user表与sys_menu表对应的mapper层
package org.wjk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.wjk.entity.pojo.SysMenuPojo;
import java.util.List;
@Mapper
public interface AuthMenuMapper extends BaseMapper<SysMenuPojo>
{
List<String> getCurrentLoginUserPermissions(@Param("userId") Integer userId, @Param("sysType") Integer sysType);
}
//--------------------------------------------------------------------------------------------------------------------------------------
package org.wjk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.wjk.entity.pojo.SysUserPojo;
@Mapper
public interface AuthUserMapper extends BaseMapper<SysUserPojo>
{
// 依据用户名锁定对应用户账号信息
@Update("update sys_user set is_not_locked=false where username=#{username}")
void lockCurrentUser(@Param("username") String username);
}
定义对应的映射文件
<!--sys_menu.xml-->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.wjk.mapper.AuthMenuMapper">
<!--查询语句的书写顺序:[on Expr][where Expr][group by][having][order by][limit]-->
<select id="getCurrentLoginUserPermissions" resultType="String">
select sm.permission from sys_menu sm left join sys_menu_pstn smp on sm.id=smp.menu_id left join hum_emp_pstn hep on smp.pstn_id=hep.pstn_id
left join hum_emp he on he.id=hep.emp_id left join sys_user su on he.user_id=su.id
where su.id=#{userId} and sm.apply_sys=#{sysType} and sm.enabled=1 and trim(sm.permission)!=""
</select>
</mapper>
4.3.1.8 定义UserDetailsService实现类
在
xfsy-auth-center
模块中定义该实现类,主要完成从数据库中获取登录用户的账号信息。
package org.wjk.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.wjk.annotation.CacheRequire;
import org.wjk.entity.pojo.SysUserPojo;
import org.wjk.exception.ExceptionSpec;
import org.wjk.exception.XfNonDbOperationException;
import org.wjk.mapper.AuthMenuMapper;
import org.wjk.mapper.AuthUserMapper;
import org.wjk.utils.constant.JedisStoreKey;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
@Service
@RequiredArgsConstructor
@Slf4j
public class UserDetailsSvsImpl implements UserDetailsService
{
private final ThreadPoolTaskExecutor executor;
private final AuthMenuMapper menuMapper;
private final AuthUserMapper userMapper;
@Override
@CacheRequire(JedisStoreKey.USER_DETAILS_SERVICE)
public SysUserPojo loadUserByUsername(String username) throws UsernameNotFoundException
{
//log.debug("current login user's username is {}", username);
FutureTask<SysUserPojo> getSysUser = new FutureTask<>(()-> {
LambdaQueryWrapper<SysUserPojo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysUserPojo::getUsername, username);
return userMapper.selectOne(wrapper);
});
executor.submit(getSysUser);
Integer sysType = Integer.parseInt(((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes()).getRequest().getHeader("system_type"));
try
{
SysUserPojo sysUser = Optional.ofNullable(getSysUser.get())
.orElseThrow(() -> new UsernameNotFoundException("用户名或密码错误,请查证后再重试!"));
List<String> permissions = menuMapper.getCurrentLoginUserPermissions(sysUser.getId(), sysType);
sysUser.setPermissions(permissions);
return sysUser;
}
catch (InterruptedException | ExecutionException e)
{
log.debug("UserDetailsService::loadUserByUsername() multi thread processing task throw exception, the excMsg is {}", e.getMessage());
throw new XfNonDbOperationException(ExceptionSpec.SYSTEM_ERROR);
}
}
}
4.3.1.9 修改认证中心SpringSecurity配置
在
xfsy-auth-center
模块中定义
- 主要功能:
- 配置Oauth2不对
UsernameNotFoundException
异常进行转换 - 生成密码解密器
- 配置Oauth2不对
package org.wjk.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@RequiredArgsConstructor
public class AuthCenterSecurityConfig extends WebSecurityConfigurerAdapter
{
private final UserDetailsService service;
@Bean
public PasswordEncoder passwordEncoder()
{
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationProvider authenticationProvider()
{
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false);
provider.setUserDetailsService(service);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
//super.configure(auth);
auth.authenticationProvider(authenticationProvider());
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
@Override
public void configure(WebSecurity web) throws Exception
{
web.ignoring().mvcMatchers("/captcha/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
super.configure(http);
}
}
4.3.1.10定义XfAuthenticationEntryPoint
在
xfsy-auth-center
模块中定义AuthenticationEntryPoint
接口的实现类XfAuthenticationEntryPoint
,该实现类完成自定义client_id、client_secret错误与access_token过期的响应。
package org.wjk.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.wjk.entity.vo.ResponseResult;
import org.wjk.entity.vo.XfReturnResponse;
import org.wjk.exception.ExceptionSpec;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
@Slf4j
public class XfAuthenticationEntryPoint implements AuthenticationEntryPoint
{
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException
{
log.debug("认证过程中抛出 {} 异常,具体信息为:{}", authException.getClass().getName(), authException.getMessage());
if(authException instanceof InsufficientAuthenticationException)
{
XfReturnResponse.resJson(response, ResponseResult.failure(ExceptionSpec.ILLEGAL_ACCESS_TOKEN));
return;
}
if(authException instanceof BadCredentialsException)
{
XfReturnResponse.resJson(response, ResponseResult.failure(ExceptionSpec.ILLEGAL_ACCESS));
return;
}
XfReturnResponse.resJson(response, ResponseResult.failure(ExceptionSpec.SYSTEM_ERROR));
}
}
4.3.1.11 定义AuthClientCredentialsTokenEndpointFilter
在
xfsy-auth-center
模块中定义ClientCredentialsTokenEndpointFilter
接口的实现类AuthClientCredentialsTokenEndpointFilter
,该类用于将XfAuthenticationEntryPoint
配置到Oauth2
package org.wjk.handler;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.client.ClientCredentialsTokenEndpointFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
public class AuthClientCredentialsTokenEndpointFilter extends ClientCredentialsTokenEndpointFilter
{
private AuthenticationEntryPoint entryPoint;
private final AuthorizationServerSecurityConfigurer securityConfigurer;
public AuthClientCredentialsTokenEndpointFilter (AuthorizationServerSecurityConfigurer configurer)
{
this.securityConfigurer = configurer;
}
@Override
public void setAuthenticationEntryPoint(AuthenticationEntryPoint authenticationEntryPoint)
{
this.entryPoint = authenticationEntryPoint;
}
@Override
public void afterPropertiesSet()
{
this.setAuthenticationFailureHandler((request, response, exception) -> {
entryPoint.commence(request, response, exception);
});
/*如果不配置该配置AuthenticationSuccessHandler,则会导致在client_id与client_secret都正确后,不能往下执行*/
this.setAuthenticationSuccessHandler((request, response, credentials)->{});
}
@Override
protected AuthenticationManager getAuthenticationManager()
{
return securityConfigurer.and().getSharedObject(AuthenticationManager.class);
}
}
4.3.1.12 完成认证中心Oauth2配置
package org.wjk.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.wjk.handler.AuthClientCredentialsTokenEndpointFilter;
import org.wjk.handler.XfAuthenticationEntryPoint;
import org.wjk.props.AuthProperties;
import java.util.Arrays;
@Configuration
/*注解@EnableAuthorizationServer
* 该注解能够决定TokenEndPoint类的实例是否被创建
* TokenEndPoint的实例主要完成:
* 1. 依次校验client_id,client_secret, grand_type是否匹配,
* 不匹配,默认返回对应的不匹配信息。匹配则继续完成下述工作
* 2. 依据指定的grand_type触发登录认证
* 3. 认证成功,依据指定的TokenStore生成对应的token
* 4. 认证失败,则返回对应的失败信息
* */
@EnableAuthorizationServer
@RequiredArgsConstructor
@Slf4j
public class Oauth2Config extends AuthorizationServerConfigurerAdapter
{
private final AuthProperties properties;
private final UserDetailsService service;
private final PasswordEncoder encoder;
private final AuthenticationManager manager;
private final AuthorizationServerTokenServices tokenServices;
private final XfAuthenticationEntryPoint entryPoint;
/*security
* Oauth2协议中的Security特定配置。
* 可以指定允许匿名访问的URL
* 配置特定的认证入口
* 配置特定的过滤器
* */
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception
{
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()");
/*主要用于捕获client_id、client_secret不正确的异常*/
AuthClientCredentialsTokenEndpointFilter endpointFilter = new AuthClientCredentialsTokenEndpointFilter(security);
endpointFilter.setAuthenticationEntryPoint(entryPoint);
endpointFilter.afterPropertiesSet();
security.addTokenEndpointAuthenticationFilter(endpointFilter);
}
/*clients
* Oauth2协议中接收认证信息中需要指定的信息及认证器的类型,主要包括:
* 认证器类型:通过inMemory()或jdbc()方法设置
* client_id: 通过withClient()方法设置
* client_secret: 通过secret()方法设置,参数必须经过加密
* grant_type: 通过authorizedGrantTypes()方法设置,指定了认证方式
* scopes: 通过scopes()设置,指定token的作用域,默认为all。
* */
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception
{
String [] grantTypes = properties.getGrantTypes().split(",");
log.debug("auth grand types are {}", Arrays.toString(grantTypes));
clients.inMemory()
.withClient(properties.getClientId())
.secret(encoder.encode(properties.getClientSecret()))
.authorizedGrantTypes(grantTypes)
.scopes(properties.getScopes());
}
/*endpoints
* Oauth2协议中认证终端配置,主要指定:
* userDetailsService: 完成从数据库中获取用户账户信息
* authenticationManger:认证管理器,完成认证。
* tokenServices:指定生产token的类型,过期时间等。
* */
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception
{
endpoints.userDetailsService(service)
.authenticationManager(manager)
.tokenServices(tokenServices);
}
}
4.3.2 定义认证的Service层
4.3.2.1 定义Service接口
package org.wjk.service;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import java.security.Principal;
import java.util.Map;
public interface LoginSvs
{
Map<String, Object> doLogin(Principal principal, Map<String, String> parameters)throws HttpRequestMethodNotSupportedException;
}
4.3.2.2 定义Service接口实现类
package org.wjk.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.wjk.entity.pojo.SysUserPojo;
import org.wjk.exception.ExceptionSpec;
import org.wjk.exception.XfNonDbOperationException;
import org.wjk.service.LoginSvs;
import org.wjk.utils.constant.JedisStoreKey;
import org.wjk.utils.method.JedisTemplate;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
@Service
@RequiredArgsConstructor
@Slf4j
public class LoginSvsImpl implements LoginSvs
{
private final TokenEndpoint endpoint;
private final ThreadPoolTaskExecutor executor;
private final JedisTemplate jedisTemplate;
@Override
public Map<String, Object> doLogin(Principal principal, Map<String, String> parameters) throws HttpRequestMethodNotSupportedException
{
OAuth2AccessToken token = endpoint.postAccessToken(principal, parameters).getBody();
/*构建token响应*/
FutureTask<Map<String, String>> getToken = new FutureTask<>(()->{
Map<String, String> tokenContent = new HashMap<>();
Assert.notNull(token, ExceptionSpec.SYSTEM_ERROR.getResMsg());
tokenContent.put("access_token", token.getValue());
tokenContent.put("refresh_token", token.getRefreshToken().getValue());
tokenContent.put("token_type", token.getTokenType());
return tokenContent;
});
executor.submit(getToken);
FutureTask<SysUserPojo> getCurrentUser = null;
FutureTask<Boolean> getCaptchaCheckout = null;
if("password".equals(parameters.get("grant_type")))
{
String username = parameters.get("username");
/*获取当前用户信息:*/
getCurrentUser = new FutureTask<>(() -> (SysUserPojo) jedisTemplate.get(JedisStoreKey.USER_DETAILS_SERVICE + "::[" + username +"]"));
executor.submit(getCurrentUser);
/*校验验证码*/
getCaptchaCheckout = new FutureTask<>(()->{
boolean checkoutRes = true;
if("password".equals(parameters.get("grant_type")))
{
String captchaRedis = jedisTemplate.getStringRes(parameters.get("captchaKey"));
if(!parameters.get("captcha").equals(captchaRedis))
checkoutRes = false;
}
if(checkoutRes)
jedisTemplate.deleteFailure(username);
return checkoutRes;
});
executor.submit(getCaptchaCheckout);
}
try
{
Map<String, Object> loginData = new HashMap<>();
if("password".equals(parameters.get("grant_type")))
{
assert getCaptchaCheckout != null;
Boolean checkRes = getCaptchaCheckout.get();
if(!checkRes)
throw new XfNonDbOperationException(ExceptionSpec.CAPTCHA_EXPIRED);
SysUserPojo sysUserPojo = getCurrentUser.get();
sysUserPojo.setPassword("[PROTECTED]");
log.debug("当前登录用户信息为:{}", sysUserPojo);
loginData.put("login_user", sysUserPojo);
}
loginData.put("token_data", getToken.get());
return loginData;
} catch (InterruptedException | ExecutionException e)
{
log.debug("认证过程中执行多线程任务后抛出异常,具体异常信息为{}", e.getMessage());
throw new XfNonDbOperationException(ExceptionSpec.SYSTEM_ERROR);
}
}
}
4.3.3 定义服务端接收请求的Controller
在
xfsy-auth-center
模块中定义请求认证求的Controller,接收认证请求的URL必须是/oauth/token
- 主要功能:
- 完成入参非空校验
- 解密用户名与密码
- 响应客户端成功响应
- 接收请求的URL:/oauth/token
- 具体实现:
package org.wjk.controller;
import lombok.RequiredArgsConstructor;
import org.bouncycastle.util.Arrays;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.wjk.annotation.Encrypt;
import org.wjk.entity.vo.ResponseResult;
import org.wjk.exception.ExceptionSpec;
import org.wjk.exception.XfNonDbOperationException;
import org.wjk.service.LoginSvs;
import org.wjk.utils.CryptUtils;
import org.wjk.utils.constant.SystemInfo;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Map;
import java.util.Optional;
@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth")
public class LoginCtrllr
{
private final LoginSvs loginSvs;
private final CryptUtils utils;
@PostMapping("/token")
@Encrypt
public ResponseResult<Map<String, Object>> login(Principal principal, @RequestParam Map<String, String> parameters) throws Exception
{
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
int system_type = Integer.parseInt(Optional.ofNullable(attributes.getRequest().getHeader("system_type"))
.orElseThrow(()->new XfNonDbOperationException(ExceptionSpec.ILLEGAL_ACCESS))) ;
/*校验客户端是否为官方客户端*/
if(!Arrays.contains(SystemInfo.SYSTEM_TYPE, system_type))
throw new XfNonDbOperationException(ExceptionSpec.ILLEGAL_ACCESS);
/*client_id与client_secret 在此处不用校验
* 当请求参数不包含client_id与client_secret时,在ClientCredentialsTokenEndpointFilter过滤器中即为返回错误响应,401
* 当请求参数的client_id或client_secret为null时,则Oauth2抛出异常InvalidGrantException。
* */
/*开始校验grand_type
* grand_type不能为空,也不能为""*/
String grantType = parameters.get("grant_type");
if(grantType == null || "".equals(grantType))
throw new XfNonDbOperationException(ExceptionSpec.ILLEGAL_ACCESS);
if("password".equals(grantType))
{
// 校验username不能为空,并解密username
String username = parameters.get("username");
Assert.notNull(username, "");
parameters.put("username", new String(utils.decrypt(username.getBytes(StandardCharsets.UTF_8))));
// System.out.println(parameters.get(username));
// 校验不能为空,并解密password
String password = parameters.get("password");
Assert.notNull(password, "");
parameters.put("password", new String(utils.decrypt(password.getBytes(StandardCharsets.UTF_8))));
}
if("refresh_token".equals(grantType))
{
// 校验不能为空,并解密refresh_token
String refresh_token = parameters.get("refresh_token");
Assert.notNull(grantType, "");
parameters.put("refresh_token",new String(utils.decrypt(refresh_token.getBytes(StandardCharsets.UTF_8))));
}
return ResponseResult.success("登录成功,祝您使用愉快!", loginSvs.doLogin(principal, parameters));
}
}
4.3.5 定义认证期间全局异常处理
4.3.5.1 修改异常信息enum
修改
xfsy-common
模块中定义的异常信息enum
package org.wjk.exception;
public enum ExceptionSpec
{
SYSTEM_ERROR(5000, "系统内部错误,请稍后重试!"),
ILLEGAL_ARGUMENTS(5001, "您提供的信息无效!请修改后再重试!"),
ILLEGAL_ACCESS(5002, "您使用的客户端无效,请使用官方客户端!"),
USERNAME_NOT_FOUND(5002, "用户名或密码错误,请查证后重新登录!"),
PASSWORD_ERROR(5002, "用户名或密码错误,请查证后重新登录!"),
USER_IS_LOCK(5002, "当前用户已被锁定,请联系系统管理员!"),
USER_IS_DISABLE(5002, "当前用户尚未启用,请联系系统管理员!"),
CAPTCHA_EXPIRED(5002, "验证码错误或已过期,请点击刷新或重新输入!"),
ILLEGAL_ACCESS_TOKEN(5003, "你的登录无效,请使用官方客户端重新登录!"),
ILLEGAL_REFRESH_TOKEN(5004, "您的登录已过期,请重新登录!"),
NOT_HAS_PERMISSION(5005, "您无访问该资源的权限,请联系系统管理员!")
;
private final Integer resCode;
private final String resMsg;
ExceptionSpec(Integer resCode, String resMsg)
{
this.resCode = resCode;
this.resMsg = resMsg;
}
public Integer getResCode()
{
return resCode;
}
public String getResMsg()
{
return resMsg;
}
}
4.3.5.2 认证期间全局异常处理
package org.wjk.handler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.common.exceptions.UnsupportedGrantTypeException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.wjk.entity.vo.ResponseResult;
import org.wjk.exception.ExceptionSpec;
import org.wjk.mapper.AuthUserMapper;
import org.wjk.utils.CryptUtils;
import org.wjk.utils.constant.JedisStoreKey;
import org.wjk.utils.method.JedisTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class AuthExceptionHandler
{
private final JedisTemplate template;
private final CryptUtils utils;
private final ThreadPoolTaskExecutor executor;
private final AuthUserMapper userMapper;
/*grant_type不正确*/
@ExceptionHandler(UnsupportedGrantTypeException.class)
public ResponseResult<Object> unsupportedGrantTypeExceptionHandler(UnsupportedGrantTypeException exception)
{
log.debug("登录认证时抛出UnsupportedGrantTypeException异常,异常信息为 {},异常信息码为 {}", exception.getMessage(), exception.getOAuth2ErrorCode());
return ResponseResult.failure(ExceptionSpec.ILLEGAL_ACCESS);
}
/*refresh_token过期*/
@ExceptionHandler(InvalidTokenException.class)
public ResponseResult<Object> invalidTokenExceptionHandler(InvalidTokenException exception)
{
log.debug("登录认证时抛出InvalidTokenException异常,异常信息为 {},异常信息码为 {}", exception.getMessage(), exception.getOAuth2ErrorCode());
return ResponseResult.failure(ExceptionSpec.ILLEGAL_REFRESH_TOKEN);
}
/*用户名不存在*/
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseResult<Object> usernameNotFoundExceptionHandler(UsernameNotFoundException exception)
{
log.debug("登录认证时抛出UsernameNotFoundException异常,异常信息为 {}", exception.getMessage());
return ResponseResult.failure(ExceptionSpec.USERNAME_NOT_FOUND);
}
/*密码错误, 用户账号被锁定, 用户未启用*/
@ExceptionHandler(InvalidGrantException.class)
public ResponseResult<Object> invalidGrantExceptionHandler(InvalidGrantException exception) throws Exception
{
log.debug("登录认证时抛出InvalidGrantException异常,异常信息为{}, 异常信息码为 {}", exception.getMessage(), exception.getOAuth2ErrorCode());
/*用户密码错误*/
if(exception.getMessage().equals("Bad credentials"))
{
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
String username = new String(utils.decrypt(attributes.getRequest().getParameter("username").getBytes(StandardCharsets.UTF_8)));
String failureCountsKey = JedisStoreKey.FAILURE_COUNTS+ "::" + username;
// 1.增加因密码错误登录失败次数
template.increaseFailureCount(failureCountsKey);
// 2.校验登录失败的次数是否<3
// 3.如果为真,响应密码错误
if(3 > Integer.parseInt(template.getStringRes(failureCountsKey)))
return ResponseResult.failure(ExceptionSpec.PASSWORD_ERROR);
// 4.如果为假,锁定该用户,响应用户账号已锁定
executor.execute(()->{
// 锁定用户
userMapper.lockCurrentUser(username);
// 删除失败次数
template.deleteFailure(username);
// 删除当前用户相关缓存
template.deleteCache(JedisStoreKey.USER_OPTION + "::[" + username +"]");
});
}
/*用户账号未启用*/
if(exception.getMessage().equals("User is disabled"))
{
return ResponseResult.failure(ExceptionSpec.USER_IS_DISABLE);
}
/*用户账号被锁定*/
return ResponseResult.failure(ExceptionSpec.USER_IS_LOCK);
}
}
5 认证成功后的token处理
5.1 access_token使用
5.1.1 使用需求
当access_token存在时,每次请求都需要将access_token设置到请求头的
Authorization
属性中。以保证以当前用户以认证状态访问后端对应资源。
5.1.2 具体实现
在
src/plugins/axios.ts
中定义axios请求拦截器具体实现如下:
$axios.interceptors.request.use((config: AxiosRequestConfig) =>{
loadingInstance = ElLoading.service({
fullscreen: true,
text: "正在加载数据,请稍候...",
lock: true,
background: 'rgba(0, 0, 0, 0.7)'
})
/*校验是否存在token
* 如果存在token则携带token
* 如果不存在则不携带*/
if(sessionStorage.getItem("access_token"))
{
// @ts-ignore
config.headers.Authorization = sessionStorage.getItem("token_type") + sessionStorage.getItem("access_token");
}
return config;
})
5.2 refresh_token使用
5.2.1 使用需求
为保证服务器端安全,access_token有效时间一般设置较短,当access_token过期后,需要通过refresh_token再次访问认证中心获取access_token以保证用户能够继续以认证状态访问服务器。
5.2.2 实现方式
实现无感刷新access_token主要有两种方式:计算过期时间方式与捕获过期响应方式
5.2.2.1 计算过期时间方式
- 实现逻辑:
- 每次发送请求之前,首先判断access_token是否过期,
- 如果未达到过期时间,则继续发送当前请求。
- 如果达到或超过过期时间,则首先通过refresh_token获取新access_token,再次发送原请求。
- 优势:
- 能够减少http请求次数。
- 缺点:
- 计算过期时间麻烦,当前使用access_token一般为jwt token,获取过期时间,要么对jwttoken进行解析,要么自行维护定时器。因此实现复杂。
- 当服务器时间与本地时间不一致时,则会造成错误access_token刷新失败。
5.2.2.2 捕获过期响应方式
- 实现逻辑:
- 获取当前请求响应后,校验业务响应码
- 如果业务响应码为access_token过期时,则通过refresh_token获取新access_token,获取后重新发送因access_token过期而业务失败的请求。
- 优点:
- 无需计算access_token过期时间。能够保证用户不因access_token过期而重新登录。
- 缺点:
- 多一次http请求。
5.2.3 捕获过期响应实现
在
src/plugins/axios.ts
中定义axios响应拦截器,主要作用:1. 实现通过refresh_token获取access_token,2.实现业务失败响应统一处理。具体实现如下 :
$axios.interceptors.response.use(async (res: AxiosResponse) => {
const {data: {resCode, resMsg, resData}} = res;
// 通过refresh_token获取access_token并重新发送当前请求。
if(5003 === resCode)
{
/* 0.27.2版本的axios可以直接通过$axios(res.config)重新发送请求
* 但1.1.3版本的axios,通过$axios(res.config)重新发送请求,将报axios无法设置请求头错误。
* 因此需要自行设置请求配置。具体实现如下:
*/
let config:any = {};
config.method = res.config.method;
config.baseURL = res.config.baseURL;
config.url = res.config.url;
config.params = res.config.params;
config.data = res.config.data;
config.transformResponse = res.config.transformResponse;
config.transformRequest = res.config.transformRequest;
const refreshToken = sessionStorage.getItem("refresh_token");
if(!refreshToken)
{
notifyBox("系统内部错误,请重新登录!", "error");
await router.push("/login");
// @ts-ignore
loadingInstance?.close();
return res;
}
let parameters = JSON.parse(JSON.stringify(loginExtend));
parameters.grant_type = "refresh_token";
parameters.refresh_token = cryptoUtils.onEncrypt(refreshToken);
const {data} = await $axios.post("/oauth/token", Qs.stringify(parameters), {
baseURL: authBaseUrl,
transformResponse: [cryptoUtils.onDecryptResponse]
});
if(2000 === data.resCode)
{
const{token_data: {access_token}} = data.resData;
sessionStorage.setItem("access_token", access_token);
// @ts-ignore
loadingInstance?.close();
return await $axios(config);
}
else
{
notifyBox("系统内部错误,请重新登录!", "error");
await router.push("/login");
// @ts-ignore
loadingInstance?.close();
return res;
}
}
if(5004 === resCode)
{
notifyBox(resMsg);
await router.push("/login");
// @ts-ignore
loadingInstance?.close();
return res;
}
if(!resCodes.includes(resCode))
notifyBox(resMsg, "error");
// @ts-ignore
loadingInstance?.close();
return res;
}, error => {
notifyBox("系统内部错误,请稍后重试!", "error");
// @ts-ignore
loadingInstance?.close();
return Promise.reject(error);
})