嘘~ 正在从服务器偷取页面 . . .

FreshCup 开发


1. JWT

1.1 跨域问题

  1. 用户向服务器发送用户名和密码;

  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等;

  3. 服务器向用户返回一个 session_id,写入用户的 Cookie;

  4. 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器;

  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

以上是使用 session 进行的一般认证方式,但是对于集群服务器的跨域问题难以解决,这需要几个服务器共享 session。

解决方案:

  1. 配置 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败;
  2. 服务器将 session 保存在客户端,每次请求时发回服务器。比如 JWT。

1.2 结构样式

对于 JWT,拥有三个组成部分:

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

1.3.1 Header

{
  "alg": "HS256",
  "typ": "JWT"
}
//alg 表示签名的算法(algorithm)
//typ 表示这个令牌的类型,JWT 令牌统一写为 JWT

1.3.2 Payload

JWT 规定了7个官方字段:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

当然,可以在 Payload 中添加私有字段,包括 name、uid、role 等。

1.3.3 Signature

Signature 部分是对前两部分的签名,防止数据篡改。

需要指定一个密钥(secret),然后使用 Header 里指定的签名算法进行签名。

1.3 SpringBoot 结合 JWT

导入对应的 maven 依赖:

<!--导入 maven 依赖--> 
<dependency>
     <groupId>com.auth0</groupId>
     <artifactId>java-jwt</artifactId>
     <version>3.18.3</version>
</dependency>
        
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

SpringBoot 配置文件:

jwt:
  #jwt需要的密钥
  secret: 239FJAS993JASLVKCLS02JGFS
  #跟前端固定请求头
  prefix: Bearer_
  #jwt设置过期时间
  expiration: 864000

整合提取工具类:

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.sast.jwt.common.enums.CustomError;
import com.sast.jwt.entity.Account;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.mapper.AccountMapper;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.Map;

//jwt 实现工具类
@Component
@Data
public class JWTUtil {

    private final AccountMapper accountMapper;

    private final RedisUtil redisUtil;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    @Value("${jwt.prefix}")
    private String prefix;

    public static final String USER_LOGIN_TOKEN = "TOKEN";

    public JWTUtil(RedisUtil redisUtil, AccountMapper accountMapper) {
        this.redisUtil = redisUtil;
        this.accountMapper = accountMapper;
    }

    /**
     * 给每个账户生成一个 token
     *
     * @param account 用户信息
     * @return token
     */
    public String generateToken(Account account) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }

    //获取 token 具体信息
    public Map<String, Claim> getClaims(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
            DecodedJWT decodedJWT = verifier.verify(token);
            return decodedJWT.getClaims();
        } catch (IllegalArgumentException | JWTVerificationException e) {
            throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
        }
    }

    public Account getAccount(String token) {
        Map<String, Claim> map = getClaims(token);
        return new Account(
                map.get("id").asLong(),
                map.get("username").asString(),
                map.get("role").asInt());
    }

    //判断 token 是否过期
    /*public boolean isTokenExpired(String token) {
        Date expiresAt = getClaims(token).get("exp").asDate();
        return expiresAt.getTime() - System.currentTimeMillis() < 0;
    }*/

    //存储在 redis 中的 token 是否过期
    public boolean isTokenExpired(String token) {
        Account account = getAccount(token);
        long expiration = redisUtil.ddl(account.getUsername());
        return expiration <= 0;
    }

    //判断 token 是否需要刷新
    private boolean isTokenNeedRefresh(String key) {
        long expiration = redisUtil.ddl(key);
        return expiration < (this.expiration * 1000 >> 1);
    }

    //刷新 token 过期时间
    /*public String refreshToken(String token) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        Account account = getAccount(token);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }*/

    //刷新 token 存在时间
    public void refreshExpiration(String key) {
        if (isTokenNeedRefresh(key)) {
            redisUtil.expire(key, expiration);
        }
    }

}

1.4 SSO

Single Sign On,单点登录,简称 SSO。在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。

比如在学校的智慧校园登录之后,就可以不需要认证访问图书馆等其他系统。

普通的登录认证机制,都是通过浏览器中的 Cookie 携带对应的 SessionId,通过 session 来判断用户是否登录。

1.4.1 实现

常规的登录机制有两个问题需要解决:

  • Cookie 是不能跨域的,domain 属性设置之后不能作用于其他域;
  • 不同应用 Session 不共享。

Cookie 可以通过设置顶域解决问题,Session 也有很多共享的方案。同域下的单点登录就实现了,但这还不是真正的单点登录。

SSO 的主要实现方式有:

  1. 共享 cookies:基于共享同域的 cookie 是 Web 刚开始阶段时使用的一种方式,它利用浏览同域名之间自动传递 cookies 机制,实现两个域名之间系统令牌 传递问题;另外,关于跨域问题,虽然 cookies本身不跨域,但可以利用它实现跨域的 SSO 。如:代理、暴露 SSO 令牌值等;
  2. Broker-based(基于经纪人):这种技术的特点就是,有一个集中的认证和用户帐号管理的服务器。经纪人给被用于进一步请求的电子身份存取。中央数据库的使用减少了管理的代价,并为认证提供一个公共和独立的 “第三方 “ 。例如 Kerberos 、 Sesame、IBM KryptoKnight(凭证库思想)等;
  3. Agent-based(基于代理人):在这种解决方案中,有一个自动地为不同的应用程序认证用户身份的代理程序。这个代理程序需要设计有不同的功能。比如,它可以使用口令表或加密密钥来自动地将 认证的负担从用户移开。代理人被放在服务器上面,在服务器的认证系统和客户端认证方法之间充当一个 “ 翻译 “。例如 SSH 等;
  4. Token-based:例如 SecureID、WebID,现在被广泛使用的口令认证,比如 FTP 、邮件服务器的登录认证,这是一种简单易用的方式,实现一个口令在多种应用当中使用;
  5. 基于网关;
  6. 基于 SAML:SAML(Security Assertion Markup Language,安全断言标记语言)的出现大大简化了 SSO ,并被 OASIS 批准为 SSO 的执行标准 。开源组织 OpenSAML 实现了 SAML 规范。

1.4.2 角色

一般 SSO 体系主要角色有三种:

  • User(多个);
  • Web 应用(多个);
  • SSO 认证中心(1个)。

SSO 实现模式一般包括以下三个原则:

  • 所有的认证登录都在 SSO 认证中心进行;
  • SSO 认证中心通过一些方法来告诉 Web 应用当前访问用户究竟是不是已通过认证的用户;
  • SSO 认证中心和所有的 Web 应用建立一种信任关系,也就是说 web 应用必须信任认证中心(单点信任)。

1.4.3 CAS

CAS(Central Authentication Service),中央认证服务,给 SSO 提供了一种解决方法。

CAS 基本认证过程

访问服务:SSO 客户端发送请求访问应用系统提供的服务资源;

定向认证:SSO 客户端会重定向用户请求到 SSO 服务器;

用户认证:用户身份认证;

发放票据:SSO 服务器会产生一个随机的 Service Ticket;

验证票据:SSO 服务器验证票据 Service Ticket 的合法性,验证通过后,允许客户端访问服务;

传输用户信息:SSO 服务器验证票据通过后,传输用户认证结果信息给客户端。

实现 SSO

当用户访问另一个应用的服务再次被重定向到 CAS Server 的时候,CAS Server 会主动获到这个 TGC cookie,然后判断:

  • 如果 User 持有 TGC 且其还没失效,那么就走基础协议图的 Step4,达到了 SSO 的效果;

  • 如果 TGC 失效,那么用户还是要重新认证(走基础协议图的 Step3)。

CAS 请求认证时序图

CAS 的 SSO 实现方式可简化理解为: 1个 Cookie 和 N个 Session。CAS Server 创建 cookie,在所有应用认证时使用,各应用通过创建各自的 Session 来标识用户是否已登录。

CAS 代理模式

该模式形式为用户访问 App1,App1 又依赖于 App2 来获取一些信息,如:User –>App1 –>App2。

CAS 引入了一种 Proxy 认证机制,即 CAS Client 可以代理用户去访问其它 Web 应用。代理的前提是需要 CAS Client 拥有用户的身份信息(类似凭据)。之前我们提到的 TGC 是用户持有对自己身份信息的一种凭据,这里的 PGT 就是 CAS Client 端持有的对用户身份信息的一种凭据。凭借 TGC , User 可以免去输入密码以获取访问其它服务的 Service Ticket ,所以,这里凭借 PGT,Web 应用可以代理用户去实现后端的认证,而无需前端用户的参与 。

解释

  • Ticket-granting cookie(TGC):存放用户身份认证凭证的 cookie ,在浏览器和 CAS Server 间通讯时使用,并且只能基于安全通道传输(Https)(截取 TGC 难度非常大,从而确保 CAS 的安全性),是 CAS Server 用来明确用户身份的凭证;
  • Service ticket(ST):服务票据,服务的惟一标识码,由 CAS Server 发出(Http 传送),通过客户端浏览器到达业务服务器端;一个特定的服务只能有一个惟一的 ST ;
  • Proxy-Granting ticket(PGT):由 CAS Server 颁发给拥有 ST 凭证的服务,PGT 绑定一个用户的特定服务,使其拥有向 CAS Server 申请,获得 PT 的能力;
  • Proxy-Granting Ticket I Owe You(PGTIOU): 作用是将通过凭证校验时的应答信息由 CAS Server 返回给 CAS Client ,同时,与该 PGTIOU 对应的 PGT 将通过回调链接传给 Web 应用。 Web 应用负责维护 PGTIOU 与 PGT 之间映射关系的内容表;
  • Proxy Ticket(PT):是应用程序代理用户身份对目标程序进行访问的凭证;
  • Ticket Granting ticket(TGT) :票据授权票据,由 KDC 的 AS 发放。即获取这样一张票据后,以后申请各种其他服务票据(ST)便不必再向 KDC 提交身份认证信息(Credentials)(TGT 的存活周期默认为 120分钟);
  • Authentication service(AS):认证用服务,索取 Credentials ,发放 TGT ;
  • Ticket-granting service(TGS):票据授权服务,索取 TGT ,发放 ST ;
  • KDC(Key Distribution Center):密钥发放中心。

2. Redis

2.1 什么是 Redis

我们使用 Redis 来实现 NoSQL (not only sql)。

主要是作为一个中间件,在真实的服务中,有时像后端发送的信息量会很大,如果这期间还是使用数据库的话,读取速度会很慢。Redis 提供了一个缓存,这样写入和读取数据都要快上不少。

1.方便扩展(数据之间没有关系,很好扩展);

2.大数据高性能(Redis一秒写八万次,读取11万,NoSQL 缓存记录级);

3.数据类型是多样的;

4.传统 RDBMS 和 NoSQL。

Redis 是单线程的,但是运行速度十分的快,一秒钟读取接近十万条读取数据。

CPU>内存>硬盘。

核心:Redis 将所有数据全部放在内存中,所以读写速度非常快。

在之后的八股文整合中,会更加详细的描述 MySQL 以及 Redis 的内容。

2.2 启动 Redis

Windows 的 Redis 少有人去维护更新,现在的版本停留在5版本,所以还是建议 Linux Docker 部署 redis 服务。

Redis 的容器启动时,如果没有指定配置文件,会默认无配置文件启动(当然这本身是被允许的,Redis 本身就有很多被注释掉的配置)。

在 Redis 的配置文件中,在注释中有详细的解释。

本身的配置有着多达一千多行的完整说明

# 首先创建好一个本地的 Redis 配置文件
mkdir conf
cd conf
touch redis.conf
vim redis.conf

# 创建 data 目录,用于存储持久化缓存的 redis 数据
mkdir data

# 配置文件内容:
protected-mode no
requirepass sast_forever
# 我们只需要关闭保护模式和配置密码就可以直接使用了
# 具体配置信息可以参考 Windows 端的 redis 的配置文件说明

# 指定本地的 redis.conf 文件和容器的配置文件绑定
docker run -p 7000:6379 --name redis -v $PWD/conf/redis.conf:/etc/redis/redis.conf -v $PWD/data:/redis/data -d redis redis-server /etc/redis/redis.conf

# 可以直接在启动的时候直接使用参数配置连接密码(不推荐)
--requirepass "sast_forever"
redis-server /etc/redis/redis.conf:使用指定的配置文件启动redis

可以使用 Redis DeskTop Manager 对 Redis 的连接管理(可以像 navicat 那样进行桥接连接)。

2.3 SpringBoot 整合 redis

jedis 使用 Java 来操作 Redis。(在 springboot2.x 之后已经被改成 lettuce)

jedis:采用的直连,多个线程操作话是不安全的,如果想要避免;

lettuce:采用netty(高性能网络结构,异步传值),实例可以在多个线程中进行共享,不存在线程不安全的情况,可以减少线程数据。

原子性(atomicity):

  • 一个事务是一个不可分割的最小工作单位,事务中包括的诸操作要么都做,要么都不做;

  • Redis 所有单个命令的执行都是原子性的,这与它的单线程机制有关;

  • Redis 命令的原子性使得我们不用考虑并发问题,可以方便的利用原子性自增操作INCR实现简单计数器功能。

package com.sast.atsast;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisTemplate;

/**
 * @program: atSast
 * @summary: 对 jedis 进行测试
 * @author: cxy621
 * @create: 2021-07-23 13:45
 **/
@SpringBootTest
public class JedisTest {

    @Autowired
    private RedisTemplate redisTemplate;
	//opsforValue String
    //opsforList List
    //opsforSet Set
    //opsforHash Hash
    //opsforZSet Zset
    @Test
    public void lettuceTest() {
//        RedisConnection redisConnection = redisTemplate.getConnectionFactory().getConnection();
//        redisConnection.flushAll();
//        redisConnection.flushDb();

        redisTemplate.opsForValue().set("name", "cxy");
        System.out.println(redisTemplate.opsForValue().get("name"));

    }

}

所有的对象需要序列化如果没有序列化就会报错。对于没有序列化的值,Java 中会有自带的 jdk 自带的序列化,但是如果不自己配置redis,会自动出现转义字符。可以自己自己去设置 redis 工具类,重写 redisUtil,就不需要调用原生麻烦的包了。

配置文件中的配置:

spring:
  redis:
    # Redis 数据库索引(默认为0)
    database: 0
    host: localhost
    port: 6379	
    timeout: 180000
    jedis:
      pool:
        # 连接池最大连接数(使用负值表示没有限制)
        max-active: 8
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池中的最小空闲连接
        min-idle: 0

通过源码可以看出,SpringBoot 自动帮我们在容器中生成了一个 RedisTemplate 和一个 StringRedisTemplate。但是,这个RedisTemplate 的泛型是 <Object,Object>,写代码不方便,需要写好多类型转换的代码;我们需要一个泛型为 <String,Object> 形式的 RedisTemplate。并且,这个RedisTemplate 没有设置数据存在 Redis 时,key 及 value 的序列化方式。

编写 redis 配置类,内容如下,在该类中完成 Jedis 池、Redis 连接和 RedisTemplate 序列化三个配置完成 SpringBoot 整合 redis 的进一步配置。其中 RedisTemplate 对 key 和 value 的序列化类,各人结合自己项目情况进行选择即可。

配置 redis config 文件,防止出现转义字符的问题:

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        // 解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置序列化(解决乱码的问题),过期时间600秒
        RedisCacheConfiguration config = RedisCacheConfiguration
                .defaultCacheConfig().entryTtl(Duration.ofSeconds(600))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(redisSerializer)).serializeValuesWith(RedisSerializationContext
                        .SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
                .disableCachingNullValues();
        RedisCacheManager cacheManager = RedisCacheManager.builder(factory).cacheDefaults(config).build();
        return cacheManager;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setConnectionFactory(factory);
        // key序列化方式
        template.setKeySerializer(redisSerializer);
        // value序列化
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // value hashmap序列化
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }
    
}

除此之外,直接这样使用 RedisTemplate 还是会比较麻烦,我们可以自己写一个工具类:

import com.sast.jwt.common.enums.CustomError;
import com.sast.jwt.exception.LocalRuntimeException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisUtil {

    private final StringRedisTemplate redisTemplate;

    public RedisUtil(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }

    public void set(String key, String value, long time) {
        redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);//TimeUnit.SECONDS 设置时间单位为秒
    }
    
    public void set(String key, String value, long time, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, time, unit);
    }

    // 设置过期时间
    public void expire(String key, long time) {
        redisTemplate.expire(key, time, TimeUnit.SECONDS);
    }
    
    public void expire(String key, long time, TimeUnit unit) {
        redisTemplate.expire(key, time, unit);
    }

    // 获取过期截止时间
    public long ddl(String key) {
        if (hasKey(key)) {
            return redisTemplate.getExpire(key);
        }
        throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
    }

    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public boolean hasKey(String key) {
        String token = get(key);
        return token != null;
    }
    
    /**
     * 删除缓存
     *
     * @param key 可以传一个值或多个
     * 使用 ... 表示不确定
     */
    @SuppressWarnings("unchecked")
    // 忽略编译器中的警告错误,比如 unused
    public void del(String... key) {
        if (key != null && key.length > 0) {
            for (String s : key) {
                redisTemplate.delete(s);
            }
        }
    }
    
}

2.4 整合 JWT 和 Redis

yaml 自定义配置 jwt 参数:

jwt:
  # jwt需要的密钥
  secret: 239FJAS993JASLVKCLS02JGFS
  # 跟前端固定请求头
  header: Token
  # jwt设置过期时间
  expiration: 864000

配置 jwt 工具类:

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.sast.jwt.common.enums.CustomError;
import com.sast.jwt.entity.Account;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.mapper.AccountMapper;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.Map;

// jwt 实现工具类
@Component
@Data
public class JwtUtil {

    private final AccountMapper accountMapper;

    private final RedisUtil redisUtil;

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    @Value("${jwt.prefix}")
    private String prefix;

    public static final String USER_LOGIN_TOKEN = "TOKEN";

    public JwtUtil(RedisUtil redisUtil, AccountMapper accountMapper) {
        this.redisUtil = redisUtil;
        this.accountMapper = accountMapper;
    }

    // 给每个账户生成一个 token
    public String generateToken(Account account) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }

    // 获取 token 具体信息
    public Map<String, Claim> getClaims(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret)).build();
            DecodedJWT decodedJWT = verifier.verify(token);
            return decodedJWT.getClaims();
        } catch (IllegalArgumentException | JWTVerificationException e) {
            throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
        }
    }

    public Account getAccount(String token) {
        Map<String, Claim> map = getClaims(token);
        return new Account(
                map.get("id").asLong(),
                map.get("username").asString(),
                map.get("role").asInt());
    }

    // 判断 token 是否过期
    /*public boolean isTokenExpired(String token) {
        Date expiresAt = getClaims(token).get("exp").asDate();
        return expiresAt.getTime() - System.currentTimeMillis() < 0;
    }*/

    // 存储在 redis 中的 token 是否过期
    public boolean isTokenExpired(String token) {
        Account account = getAccount(token);
        long expiration = redisUtil.ddl(account.getUsername());
        return expiration <= 0;
    }

    // 判断 token 是否需要刷新
    private boolean isTokenNeedRefresh(String key) {
        long expiration = redisUtil.ddl(key);
        return expiration < (this.expiration * 1000 >> 1);
    }

    // 刷新 token 过期时间
    /*public String refreshToken(String token) {
        Date nowDate = new Date();
        Date expiredDate = new Date(nowDate.getTime() + expiration * 1000);
        Account account = getAccount(token);
        JWTCreator.Builder builder = JWT.create();
        builder.withClaim("id", account.getId());
        builder.withClaim("username", account.getUsername());
        builder.withClaim("role", account.getRole());
        builder.withExpiresAt(expiredDate);
        return prefix + builder.sign(Algorithm.HMAC256(secret));
    }*/

    // 刷新 token 存在时间
    public void refreshExpiration(String key) {
        if (isTokenNeedRefresh(key)) {
            redisUtil.expire(key, expiration);
        }
    }

}

上面其实是一定程度有悖 JWT 的设计初衷,因为 JWT 的本意是将 token 存放在客户端,但其实我们将 token 存放在服务端。但是这也是为了安全性的考虑,因为我们需要验证是否是用户本人。

并且,由于 token 不像 cookie,不可以进行手动过期,对于过期 token 必须重新生成一个新的 token。在 FC 中,将 token 存入 redis,采取 redis 的过期时间来对 token 进行反复刷新操作。

在和一些已经工作的学长的交流中,对于登录的验证来说,不建议使用 token。在权限管理中,可能更加具有普适性。并且 token 和 session 并非是完全的替代关系,对于一些不规范的程序开发来说,

为了维护 JWT 的安全性,需要有一系列的考虑,之后可能会说。

3. AOP

3.1 定义

面向对象编程(OOP)的好处是显而易见的,缺点也同样明显。当需要为多个不具有继承关系的对象添加一个公共的方法的时候,例如日志记录、性能监控等,如果采用面向对象编程的方法,需要在每个对象里面都添加相同的方法,这样就产生了较大的重复工作量和大量的重复代码,不利于维护。面向切面编程(AOP)是面向对象编程的补充,简单来说就是统一处理某一“切面”的问题的编程思想。

AOP 结构图片

与面向对象的顺序流程不同,AOP采用的是横向切面的方式,注入与主业务流程无关的功能,例如事务管理和日志管理。如果使用AOP的方式进行日志的记录和处理,所有的日志代码都集中于一处,不需要再每个方法里面都去添加,极大减少了重复代码。

3.2 AOP 专业术语

通知(Advice)包含了需要用于多个应用对象的横切行为,完全听不懂,没关系,通俗一点说就是定义了“什么时候”和“做什么”;

连接点(Join Point)是程序执行过程中能够应用通知的所有点;

切点(Poincut)是定义了在“什么地方”进行切入,哪些连接点会得到通知。显然,切点一定是连接点;

切面(Aspect)是通知和切点的结合。通知和切点共同定义了切面的全部内容——是什么,何时,何地完成功能;

引入(Introduction)允许我们向现有的类中添加新方法或者属性;

织入(Weaving)是把切面应用到目标对象并创建新的代理对象的过程,分为编译期织入、类加载期织入和运行期织入。

示例图片

3.3 Spring-Boot 中使用 AOP

3.3.1 定义切面和切点

Spring 采用@Aspect注解对 POJO 进行标注,该注解表明该类不仅仅是一个 POJO,还是一个切面。切面是切点和通知的结合,那么定义一个切面就需要编写切点和通知。在代码中,只需要添加 @Aspect 注解即可。

切点是通过@Pointcut注解和切点表达式定义的。

@Pointcut注解可以在一个切面内定义可重用的切点。

由于Spring切面粒度最小是达到方法级别,而execution表达式可以用于明确指定方法返回类型,类名,方法名和参数名等与方法相关的部件,并且实际中,大部分需要使用AOP的业务场景也只需要达到方法级别即可,因而execution表达式的使用是最为广泛的。如图是execution表达式的语法:

execution表示在方法执行的时候触发。以*开头,表明方法返回值类型为任意类型。然后是全限定的类名和方法名,* 可以表示任意类和任意方法。对于方法参数列表,可以使用“..”表示参数为任意类型。如果需要多个表达式,可以使用“&&”、“||”和“!”完成与、或、非的操作。

execution( 方法修饰符 返回类型 方法所属的包.类名.方法名称(方法参数) )  
  "*"表示不限     ".."表示参数不限
  方法修饰符不写表示不限,不用"*" 

对指定路径的方法操作

3.3.2 使用通知

通知有五种类型,分别是:

前置通知(@Before):在目标方法调用之前调用通知;

后置通知(@After):在目标方法完成之后调用通知;

环绕通知(@Around):在被通知的方法调用之前和调用之后执行自定义的方法;

返回通知(@AfterReturning):在目标方法成功执行之后调用通知;

异常通知(@AfterThrowing):在目标方法抛出异常之后调用通知。

3.3.3 简单例子

首先是简单写几个控制器,方便我们之后随机进行测试。

写了一个带传递的参数是为了方面之后的切面中的演示,下面对日志功能进行简单演示:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @GetMapping("/hello")
    public String sayHello() {
        System.out.println("Hello, World");
        return "Hello";
    }

    @GetMapping("/hello/{name}")
    public String sayLove(@PathVariable("name") String name) {
        System.out.println("Activate Successfully");
        return "i love you " + name;
    }
}

首先定义切入点,切入点的位置定好之后之后便可使用注释将对应的通知插入。

这里使用 @Slf4j 的注释,使用日志(这里使用夏佬的例子进行简单的说明)。

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Component
@Slf4j
@Aspect
public class AopAdvice {
    //表示实体类中所有的方法
    @Pointcut("execution(* com.example.demo.controller.*.*(..))")
    public void MyPointCut() {
    }

    @Before("MyPointCut()")
    public void BeforeAdvice() {
        log.info("this is before");
    }

    @After("MyPointCut()")
    public void AfterAdvice() {
        log.info("this is after");
    }

    @Around("MyPointCut()")
    public Object AroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        String methodName = proceedingJoinPoint.getSignature().getName();
        String className = proceedingJoinPoint.getTarget().getClass().toString();
        ObjectMapper objectMapper = new ObjectMapper();
        Object[] array = proceedingJoinPoint.getArgs();//获取其中的参数
        log.info("调用前:" + className + ":" + methodName + " args=" + objectMapper.writeValueAsString(array));
        Object object = proceedingJoinPoint.proceed();//对应事件开始处理,这里需要抛出异常
        log.info("调用后:" + className + ":" + methodName + " args=" + objectMapper.writeValueAsString(array));
        return object;
    }
}

调用 /hello

调用 /hello/cxy

3.4 AOP 结合 JWT

整体思路就是通过拦截器获取对应的 token 存放到 ThreadLocal 中,之后与 AOP 中定义的权限进行比对。

3.4.1 @annotation

创建自定义注释:

import com.sast.jwt.enums.AuthEnum;

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
// 创建一个自定义注释,里面的内容规定为需要的权限
public @interface AuthHandler {
    AuthEnum value();
}

创建一个 AOP 的切面,绑定到注释:

import com.sast.jwt.annotation.AuthHandler;
import com.sast.jwt.entity.Account;
import com.sast.jwt.enums.AuthEnum;
import com.sast.jwt.enums.CustomError;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.interceptor.LoginInterceptor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Component
@Aspect
public class AuthAspect {
	
    // 将切点设置为注释,只要触发注释便会触发对应的方法
    // 这样的方法其实不能作用在类
    @Pointcut("@annotation(com.sast.jwt.annotation.AuthHandler)")
    public void start() {

    }

    // 绑定切入点和切入点形参
    @Before("start()&&@annotation(authHandler)")
    public Object authJudge(JoinPoint joinPoint, AuthHandler authHandler) {
        AuthEnum authEnum = authHandler.value();
        Account account = LoginInterceptor.accountThreadLocal.get();
        if (!AuthEnum.checkAuth(account, authEnum)) {
            throw new LocalRuntimeException(CustomError.AUTHENTICATION_ERROR);
        }
        return joinPoint;
    }
}

配置拦截器,对发送的 Token 进行判断:

import com.sast.jwt.dao.AccountDao;
import com.sast.jwt.entity.Account;
import com.sast.jwt.enums.CustomError;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

    //在多个线程中传递 token
    public static ThreadLocal<Account> accountThreadLocal = new ThreadLocal<>();

    private final AccountDao accountDao;

    private final JwtUtil jwtUtil;

    public LoginInterceptor(JwtUtil jwtUtil, AccountDao accountDao) {
        this.jwtUtil = jwtUtil;
        this.accountDao = accountDao;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler)
            throws Exception {
        //规定发送的头中带有 Token 字段
        String header = request.getHeader("Token");
        //规定 Token 中的开头为 Bearer_,以便以后的功能扩展
        if (!StringUtils.hasLength(header) || !header.startsWith("Bearer_")) {
            throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
        }
        String token = header.substring(7);
        log.info(token);
        Account account = jwtUtil.getAccount(token);
        log.info(String.valueOf(account));
        if (account != null) {
            if (jwtUtil.isTokenExpired(token)) {
                throw new LocalRuntimeException(CustomError.TOKEN_OUT_TIME);
            }
            if (accountDao.selectById(account.getId()) != null) {
                jwtUtil.refreshExpiration(account.getUsername());
                accountThreadLocal.set(account);
                return true;
            }
        }
        throw new LocalRuntimeException(CustomError.TOKEN_ERROR);
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex)
            throws Exception {
        accountThreadLocal.remove();
    }
    
}

配置拦截器拦截的 url:

import com.sast.jwt.converter.CustomJsonHttpMessageConverter;
import com.sast.jwt.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

import java.util.List;

@Configuration
@Component
public class WebMvcConfig extends WebMvcConfigurationSupport {

    private final CustomJsonHttpMessageConverter converter;

    private final LoginInterceptor loginInterceptor;

    public WebMvcConfig(CustomJsonHttpMessageConverter converter,
                        LoginInterceptor loginInterceptor) {
        this.converter = converter;
        this.loginInterceptor = loginInterceptor;
    }

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        //这里需要使用自动装配的拦截器,需要公用 ThreadLocal
        //所以这里不能每次都创建新的拦截器,而是需要使用 IOC 帮我们feng'z
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/user/login", "/user/register");
    }
    
}

3.4.2 execution

直接在 AOP 中使用 @annotation 是没有办法直接作用类,只能定义在对应方法。如果需要直接在类中使用 AOP 仍然需要 execution。通过其他方法间接获取注解中的值。

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import reactor.util.annotation.Nullable;
import sast.freshcup.annotation.AuthHandle;
import sast.freshcup.common.enums.AuthEnum;
import sast.freshcup.common.enums.ErrorEnum;
import sast.freshcup.entity.Account;
import sast.freshcup.exception.LocalRunTimeException;
import sast.freshcup.interceptor.AccountInterceptor;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;

@Slf4j
@Aspect
@Component
public class AuthAspect {

    @Pointcut("execution(* sast.freshcup.controller.*.*(..))" +
            "&&" +
            "!execution(* sast.freshcup.controller.LoginController.*(..))")
    // 对除 LoginController 之外的其他 Controller 执行
    public void start() {
    }

    @Before("start()")
    public Object auth(JoinPoint joinPoint) {
        final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        final AuthHandle auth = getAnnotation(signature.getMethod(), AuthHandle.class);
        AuthEnum authEnum = auth.value();
        Account account = AccountInterceptor.accountHolder.get();
        if (!AuthEnum.checkAuth(account, authEnum)) {
            throw new LocalRunTimeException(ErrorEnum.AUTHORITY_ERROR);
        }
        return joinPoint;
    }

    /**
     * 获得方法或类上的注解
     * @param method 方法
     * @param annotationClass 注解
     */
    @Nullable
    public static <T extends Annotation> T getAnnotation(Method method,
                                                         Class<T> annotationClass) {
        if (method == null) {
            return null;
        }
        // 判断 method 上是否由 annotation 注解
        if (method.isAnnotationPresent(annotationClass)) {
            return AnnotationUtils.getAnnotation(method, annotationClass);
        } else {
            // 没有就表示注释在类上,需要获取对应 Class
            return AnnotationUtils.getAnnotation(method.getDeclaringClass(), annotationClass);
        }
    }
}

Java 中一个方法由方法名称和参数类型组成(重载也是通过这两个决定),他们的组合就是 Method Signature(方法签名)。

public double calculateAnswer(double wingSpan, int numberOfEngines,
                              double length, double grossTons) {
    //do the calculation here
}
// calculateAnswer(double, int, double, double) 这是上述方法的方法签名

aspectj 中通过 MethodSignature 可以获得 joinPoint 的方法的 Signature。

joinPoint.getSignature()

3.5 ThreadLocal 使用

对于 Java 中的多线程中,我们需要传递同一个参数,这个时候就可以使用 ThreadLocal,对每个一线程共享这个对象。使用 static 修饰避免重复创建 TSO(Thread Specific Object)所导致的浪费。

ThreadLocal 实例本身不存储值,它只是提供了一个在当前线程中找到副本值得 key。ThreadLocal 包含在 Thread 中,而不是 Thread 包含在 ThreadLocal 中每个线程有一个自己的 ThreadLocalMap。每个线程在往 ThreadLocal 里放值的时候,都会往自己的 ThreadLocalMap 里存,读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离。

static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
//一般写法

实际上,可以把 ThreadLocal 看成一个全局 Map<Thread, Object>:每个线程获取 ThreadLocal 变量时,总是使用 Thread 自身作为 key。因此我们就没必要在每个线程都创建一个 ThreadLocal(虽然 Spring 的 Bean 都是单例模式)。

ThreadLocal 结构

Object threadLocalValue = threadLocalMap.get(Thread.currentThread());

不过要在最后注意使用 threadLocalUser.remove(); 将线程清除或者将 threadLocalUser = null

3.6 AOP 和 Controller

在 Controller 中,我们一般都将方法修饰为 public。但是有没有思考过,如果换为 private 和 protected 会有什么变化?

一般是没有问题,在“笼统”中文互联网回答中,一般会告诉你因为 CGLIB 动态代理的问题导致 private 的方法无法注入,所以 Service 的 field 值会为 null。

但是在一般前后端分离中,一般都会加上 @RestController 表示我们返回的是一个 body。并没有对应的基类,也就是说 Spring 并不会因此生成代理。所以,也就不会出现使用 private 时,Service 层 field NPE 的情况。

然而,如果我们使用对应的 AOP 就不可以了。使用了 AOP,也就是使用动态代理,底层默认调用的是 CGLIB 作为动态代理,其本质是:调用某个类的方法时,实际上是先为该类生成一个子类,然后再在子类中通过反射等,达到方法拦截的目的,对于子类,其父类中,private 修饰的方法,子类如果与父类不在同一包下,是没有访问的权限的,此场景下,CGLIB 生成的子类,不会和父类在同一包下,也就是 private 修饰的方法,不能进行动态代理,所以会报空指针异常。

4. 验证码

需要添加对应依赖包:

<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

设计生成图片验证码的接口,将生成的验证码存放在 redis 中。

通过 UUID 给二维码生成独一无二的 uid。

UUID由以下几部分的组合:

  1. 当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同;

  2. 时钟序列;

  3. 全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。

配置图片验证码:

import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

@Configuration
public class KaptchaConfig {

    @Bean
    public DefaultKaptcha getDefaultKaptcha() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        // 图片边框
        properties.setProperty("kaptcha.border", "no");
        // 边框颜色
        properties.setProperty("kaptcha.border.color", "black");
        //边框厚度
        properties.setProperty("kaptcha.border.thickness", "1");
        // 图片宽
        properties.setProperty("kaptcha.image.width", "200");
        // 图片高
        properties.setProperty("kaptcha.image.height", "50");
        //图片实现类
        properties.setProperty("kaptcha.producer.impl", "com.google.code.kaptcha.impl.DefaultKaptcha");
        //文本实现类
        properties.setProperty("kaptcha.textproducer.impl", "com.google.code.kaptcha.text.impl.DefaultTextCreator");
        //文本集合,验证码值从此集合中获取
        properties.setProperty("kaptcha.textproducer.char.string", "01234567890");
        //验证码长度
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        //字体
        properties.setProperty("kaptcha.textproducer.font.names", "宋体");
        //字体颜色
        properties.setProperty("kaptcha.textproducer.font.color", "black");
        //文字间隔
        properties.setProperty("kaptcha.textproducer.char.space", "5");
        //干扰实现类
        properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise");
        //干扰颜色
        properties.setProperty("kaptcha.noise.color", "blue");
        //干扰图片样式
        properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple");
        //背景实现类
        properties.setProperty("kaptcha.background.impl", "com.google.code.kaptcha.impl.DefaultBackground");
        //背景颜色渐变,结束颜色
        properties.setProperty("kaptcha.background.clear.to", "white");
        //文字渲染器
        properties.setProperty("kaptcha.word.impl", "com.google.code.kaptcha.text.impl.DefaultWordRenderer");
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }

}

5. 完整登录逻辑

设计的过程是用户在登录之后获得对应 token 并存放到 Redis 中(token 无法重设过期时间),之后的操作统一需要读取 token 获得对应权限。

创建工具类,获取存储在 redis 中的 token:

import com.sast.jwt.entity.Account;

public class RedisKeyFetch {

    public static String getTokenKey(Account account){
        return "TOKEN:" + account.getUsername();
    }

}

使用 LoginController:

import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.sast.jwt.common.contents.RedisKeyFetch;
import com.sast.jwt.entity.Account;
import com.sast.jwt.exception.LocalRuntimeException;
import com.sast.jwt.mapper.AccountMapper;
import com.sast.jwt.utils.JWTUtil;
import com.sast.jwt.utils.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Slf4j
@RestController
public class LoginController {

    public static final String LOGIN_VALIDATE_CODE = "VAL_CODE:";

    private final DefaultKaptcha kaptchaProducer;

    private final RedisUtil redisUtil;

    private final AccountMapper accountMapper;

    private final JWTUtil jwtUtil;

    public LoginController(DefaultKaptcha kaptchaProducer, RedisUtil redisUtil, AccountMapper accountMapper, JWTUtil jwtUtil) {
        this.kaptchaProducer = kaptchaProducer;
        this.redisUtil = redisUtil;
        this.accountMapper = accountMapper;
        this.jwtUtil = jwtUtil;
    }

    /**
     * 返回验证码图片,并将验证码存入Redis
     *
     * @param response 设置响应头参数信息
     */
    @GetMapping("/getValidateCode")
    public void getImgValidateCode(HttpServletResponse response) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");

        response.setDateHeader("Expires", 0);
        response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
        response.addHeader("Cache-Control", "post-check=0, pre-check=0");
        response.setHeader("Pragma", "no-cache");
        //为每个请求生成一个唯一的验证码
        response.addHeader("CAPTCHA", uuid);
        response.setContentType("image/jpeg");

        String capText = kaptchaProducer.createText();
        BufferedImage bi = kaptchaProducer.createImage(capText);

        try {
            ServletOutputStream out = response.getOutputStream();
            ImageIO.write(bi, "jpg", out);
            out.flush();
        } catch (Exception e) {
            e.printStackTrace();
        }

        redisUtil.set(LOGIN_VALIDATE_CODE + uuid, capText, 60 * 5);
    }


    @PostMapping("/login")
    public Map<String, String> login(@RequestBody Account account,
                                     @RequestHeader("User-Agent") String agent,
                                     @RequestParam("validateCode") String validateCode,
                                     @RequestHeader("CAPTCHA") String uuid) {
        //验证验证码
        String currentCode = redisUtil.get(LOGIN_VALIDATE_CODE + uuid);
        if (currentCode == null) {
            throw new LocalRuntimeException("验证码失效");
        } else if (!currentCode.equals(validateCode)) {
            throw new LocalRuntimeException("验证码错误");
        }
        redisUtil.del(LOGIN_VALIDATE_CODE + uuid);

        //登录处理
        QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", account.getUsername());
        Account accountFromDB = accountMapper.selectOne(queryWrapper);
        if (accountFromDB == null) {
            throw new LocalRuntimeException("账号不存在");
        } else if (!SecureUtil.md5(account.getPassword()).equals(accountFromDB.getPassword())) {
            throw new LocalRuntimeException("密码错误");
        }
        String token = jwtUtil.generateToken(accountFromDB);
        Map<String, String> map = new HashMap<>();
        map.put("role", accountFromDB.getRole().toString());
        map.put("token", token);

        log.info("===============================================");
        log.info("用户登录:{},role:{}", accountFromDB.getUsername(), accountFromDB.getRole());
        log.info("登录Agent:{}", agent);
        log.info("===============================================");

        //用 Redis 中的过期时间代替 JWT 的过期时间,每次经过拦截器时更新过期时间
        //先设置为30天
        redisUtil.set(RedisKeyFetch.getTokenKey(account), token, 30, TimeUnit.DAYS);
        return map;
    }

}

6. open api

通过接口内容自动生成接口文档,比手动更新更具有时效性。

6.1 swagger

配置 swagger 配置文件:

import io.swagger.annotations.ApiOperation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.oas.annotations.EnableOpenApi;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

@Configuration
@EnableOpenApi//开启 Swagger 功能
public class SwaggerConfig {

    @Bean
    public Docket docket() {
        return new Docket(DocumentationType.OAS_30)
                .apiInfo(apiInfo()).enable(true)
                .select()
                //apis: 添加swagger接口提取范围
                .apis(RequestHandlerSelectors.basePackage("com.sast.jwt.controller"))
                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("toy_Backend 项目接口文档")
                .description("toy_Backend 项目描述")
                .contact(new Contact("cxy621", "https://hexo.cxy621.top", "1580779474@qq.com"))
                .version("1.0")
                .build();
    }

}

但在 SpringBoot 更新到新版本之后,springfox 已经不再更新了,所以之后 swagger 的使用频率一定会减少,甚至停用。

在 SpringBoot 2.6.X 版本及以上使用 swagger 时,会出现Failed to start bean 'documentationPluginsBootstrapper';的错误。

因为 Springfox 使用的路径匹配是基于 AntPathMatche r的,而 SpringBoot 2.6.X 使用的是 PathPatternMatcher。

我们需要修改配置文件:

spring.mvc.pathmatch.matching-strategy: ANT_PATH_MATCHER

注意:对于 swagger 来说,返回的页面会被我们的全局异常给捕获,所以无法正常显示。

好消息是,我们有一个新的 open api 生成工具。

6.2 springdoc

依赖导入:

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-ui</artifactId>
    <version>1.6.7</version>
</dependency>

springdoc 相比于单纯的 swagger,不需要配置 config 文件就能够直接使用。

默认 json 数据访问链接🔗为:/v3/api-docs/

默认 swagger 页面访问链接🔗为:swagger-ui.html

可以在对应的 yaml 文件中配置制定自定义路径:

springdoc:
  swagger-ui:
    path: /swagger-ui-custom.html
  api-docs:
    path: /api-docs

之后打开对应链接,就可以打开 open-api 界面。

之后,需要在对应 controller 中加入 swagger 注释,让生成 api 具有一定的可读性。

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
//使用 @Tag 对 controller 进行确认
@Tag(name = "测试接口", description = "第一次使用 swagger 形式进行书写")
public class TestController {
    @GetMapping("/hello")
    @Operation(summary = "输出 hello world")
    public String index() {
        return "Hello World!";
    }

    @PostMapping("")
    public String test() {
        return "Hello!";
    }

    @GetMapping("/call/{name}")
    @Operation(summary = "对特定的用户问好", description = "需要传入对应的用户名",
               //对需要的参数进行声明,会通过注释来判断参数的类型
            parameters = {
                    @Parameter(name = "name", description = "用户名", required = true)
            })
    public String call(@PathVariable String name) {
        return "Hello " + name + "!";
    }
}

7. poi

依赖包导入:

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>3.17</version>
</dependency>

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>3.15</version>
</dependency>

FreshCup 过程中的示例:

public Workbook importAdmin(Long contestId, MultipartFile file)
    throws IOException {
    if (contestMapper.selectOne(new LambdaQueryWrapper<Contest>()
                                .eq(Contest::getId, contestId)) == null) {
        throw new LocalRunTimeException(ErrorEnum.NO_CONTEST);
    }
	
    AtomicInteger success = new AtomicInteger(0);
    AtomicInteger failure = new AtomicInteger(0);

    // 创建一个新的工作簿
    Workbook output = new XSSFWorkbook();
    // 创建一个“结果” sheet
    Sheet sheetOutput = output.createSheet("结果");
    Row rowOutput = sheetOutput.createRow(0);
    rowOutput.createCell(0).setCellValue("账号");
    rowOutput.createCell(1).setCellValue("密码");

    //通过文件流,读取文件的文件流
    Workbook workbook = new XSSFWorkbook(file.getInputStream());
    Sheet sheet = workbook.getSheetAt(0);
    int rowNum = sheet.getLastRowNum();
    for (int i = 1; i <= rowNum; i++) {
        Row row = sheet.getRow(i);
        Cell cell = row.getCell(0);
        String username = cell.getStringCellValue();
        Account account = accountMapper.selectOne(new LambdaQueryWrapper<Account>()
                                                  .eq(Account::getUsername, username));
        if (account != null) {
            if (!account.getRole().equals(1)) {
                throw new LocalRunTimeException(ErrorEnum.ROLE_ERROR);
            }
            log.info(username + " 管理员已导入,无需再次导入");
            failure.getAndIncrement();
        } else {
            log.info(username + " 管理员导入成功");
            success.getAndIncrement();

            rowOutput = sheetOutput.createRow(success.get());
            rowOutput.createCell(0).setCellValue(username);
            String password = createAdmin(username);
            rowOutput.createCell(1).setCellValue(password);
        }
        addContestUser(contestId, username, accountMapper, accountContestManagerMapper);
    }
    //判断是否有创建成功的学生账号
    if (sheetOutput.getLastRowNum() == 0) {
        sheetOutput.createRow(1);
    }
    sheetOutput.getRow(0).createCell(3).setCellValue("成功导入" + success.get() + "个");
    sheetOutput.getRow(1).createCell(3).setCellValue("失败" + failure.get() + "个");
    return output;
}

参考文章

  1. JSON Web Token 入门教程
  2. 单点登录(SSO)
  3. 前后端鉴权二三事
  4. CAS 实现单点登录(SSO)原理
  5. AOP 获取方法上的注解,并修改注解内容
  6. Java 中什么是方法签名?
  7. SpringBoot 整合 Redis 以及工具类撰写
  8. 使用 ThreadLocal
  9. 关于 ThreadLocal 你一定要知道的
  10. ThreadLocal 为什么要设计成 private static
  11. SpringBoot 中 Controller 层中的方法为什么只能是 public?
  12. 在 Controller 层 private 修饰的方法导致 Service 注入为空
  13. SpringBoot 整合 Captcha 验证码
  14. Java 生成 UUID
  15. 升级 SpringBoot 2.6.x 版本后,Swagger 没法用了
  16. SpringBoot 集成 swagger3
  17. SpringDoc 生成 OpenAPI3.0
  18. SpringBoot2 集成 springdoc-openapi-ui
  19. SpringBoot 中 poi 操作合集
  20. Redis 数据结构快速链表
  21. Redis 学习记录

文章作者: 陈鑫扬
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 陈鑫扬 !
评论
  目录