03旭锋集团运营管理平台--认证模块

旭锋集团运营管理平台--认证模块

1 模块说明

1.1 模块功能

  • 主要功能:用于用户登录信息认证。
    • 认证成功,登录平台,依据权限访问对应页面并完成相关操作。
    • 认证失败,显示认证失败原因信息,提醒用户重新登录。

1.2 页面初始化

  1. 初始化代码:
<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. 页面效果

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HYjjIbX4-1668756136465)(https://secure2.wostatic.cn/static/2cj9XcYBwfukce581nucM/image.png?auth_key=1668754912-ekXAt9Yfc5MYKKnhqivymy-0-7e76306ea932760432f0723c6184c00a)]

1.3 服务器端初始化

1.3.1 创建微服务模块xfsy-auth-center

  • 项目结构如图:
  • ![在这里插入图片描述](https://img-blog.csdnimg.cn/6b749704325c412fbe1f93b77695d466.png

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 业务逻辑

  1. 校验路由跳转路径,
    • 当跳转路径是登录页面,则清空 sessonStorage 内部保存所有数据,保存访问登录页面后,用户处于未登录状态。
  2. 当跳转路径不是登录页面时,则:校验 sessionStorage 是否保存了token数据,
    • 如果未保存token数据,则说明用户尚未登录,页面强制跳转到登录页面。
    • 如果保存有token数据,则跳转到对应页面。

2.2.3 具体实现

修改router/index.ts ,添加全局路由前置导航守卫。

  1. 代码实现
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 业务逻辑

  1. 页面加载完成后,构建获取验证码的key,并保存,携带该key发起请求,请求后端对应接口。
  2. 后端接口接收到请求后,校验是否存在验证码对应的key。
  3. 如果存在:
    • 产生验证码。
    • 响应产生的验证码内容和产生时间。
    • 多线程以验证码对应的key为key,验证码内容全小写为value保存到redis中,并设置过期时间为180s。
    • 加密响应结果。
  4. 如果不存在对应key,则返回失败响应。
  5. 当客户端接收到响应后,解密响应结果,并检验响应业务状态码。
    • 业务成功:
      • 通过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次,则锁定当前用户,并删除密码错误次数,并响应客户端用户账号已锁定。
  • 账号信息校验成功,则多线程获取当前登录用户的用户账号信息并保护用户密码,校验验证码,构建token体。
  • 校验验证码检验结果
    • 校验失败,响应对应失败信息。
  • 验证码结果校验成功,响应客户端成功响应。包括当前用户信息与access_token、token_type、refresh_token
  • 客户端接收响应后,校验响应类型。
    • 失败的响应则页面渲染失败信息。
    • 成功响应,则保存响应信息到sessionStorage中,提示用户登录成功,页面跳转直主页面。

4.2 客户端业务实现

4.2.1 提交用户信息方法
  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;
    }
  });
}
  1. 为登录表单 回车键弹起事件与 登录按钮点击事件绑定该方法,具体实现如下:
 <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异常进行转换
    • 生成密码解密器
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);
})
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值