Spring Security结合Redis实现缓存功能

1,870

《从零打造项目》系列文章

工具

ORM框架选型

数据库变更管理

定时任务框架

缓存

安全框架

开发规范

Redis

Redis是一个开源的,基于内存的数据结构存储,可用作于数据库、缓存、消息中间件。

  • Redis基于内存,支持多种数据结构。
  • Redis 提供了多种数据类型来支持不同的业务场景。Redis 还支持事务 、持久化、Lua 脚本、多种集群方案。

Mac安装

brew install redis

查看安装及配置文件位置

  • Homebrew安装的软件会默认在/usr/local/Cellar/路径下
  • redis的配置文件redis.conf存放在/usr/local/etc路径下

启动 redis

//方式一:使用brew帮助我们启动软件
brew services start redis
//方式二
redis-server

5、查看redis服务进程

我们可以通过下面命令查看redis是否正在运行

ps axu | grep redis

6、redis-cli连接redis服务

redis默认端口号6379,默认auth为空,输入以下命令即可连接

redis-cli -h 127.0.0.1 -p 6379

7、启动 redis 客户端,打开终端并输入命令 redis-cli。该命令会连接本地的 redis 服务。

$redis-cli
redis 127.0.0.1:6379>
redis 127.0.0.1:6379> PING
PONG

在以上实例中我们连接到本地的 redis 服务并执行 PING 命令,该命令用于检测 redis 服务是否启动。

8、关闭 redis 服务

  • 正确停止Redis的方式应该是向 Redis 发送 SHUTDOWN 命令
redis-cli shutdown
  • 强行终止 redis
sudo pkill redis-server

9、redis.conf 配置文件详解

redis 默认是前台启动,如果我们想以守护进程的方式运行(后台运行),可以在 redis.conf 中将 daemonize no,修改成 yes即可。

可视化工具Redis Desktop Manager

安装方法

  • 安装brew cask : 在终端中输入下面语句 回车

    ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" < /dev/null 2> /dev/null ; brew install caskroom/cask/brew-cask 2> /dev/null
    

    可能会需要你的mac密码,输入即可

  • 安装Redis Desktop Manager

    安装完cask之后,在终端中输入 回车

    brew install rdm --cask
    brew install another-redis-desktop-manager --cask
    

整合Resis

我们使用 Redis 最主要是将其作为缓存,使用缓存主要是为了提升用户体验以及应对更多的用户,从程序设计而言是为了高性能和高并发。

高性能

假如用户第一次访问数据库中的某些数据的话,这个过程是比较慢,毕竟是从硬盘中读取的。但是,如果说,用户访问的数据属于高频数据并且不会经常改变的话,那么我们就可以很放心地将该用户访问的数据存在缓存中。

这样有什么好处呢? 那就是保证用户下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。

不过,要保持数据库和缓存中的数据的一致性。 如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可!

高并发:

一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4核8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到30w+(就单机redis的情况,redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

所以,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高的系统整体的并发。

不过在本文中,我们仅仅使用 Redis 作为临时缓存,用来存储用户注册时所需的验证码以及用户登录后缓存用户信息。

项目实践

创建一个新项目,名为 springboot-redis.

1、首先引入依赖:

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.6.3</version>
  <relativePath/>
</parent>

<properties>
  <mysql.version>8.0.19</mysql.version>
  <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
  <org.projectlombok.version>1.18.20</org.projectlombok.version>
  <druid.version>1.1.18</druid.version>
</properties>

<dependencies>
  <!-- 以下是>spring boot依赖-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>

  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>

  <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.5.8</version>
  </dependency>
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql.version}</version>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>${druid.version}</version>
  </dependency>

  <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>${org.mapstruct.version}</version>
  </dependency>
  <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>${org.mapstruct.version}</version>
  </dependency>

  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.1</version>
  </dependency>
  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus</artifactId>
    <version>3.5.1</version>
  </dependency>
</dependencies>

2、修改 application.yml

server:
  port: 8085

spring:
  redis:
    host: 127.0.0.1
    database: 0 # Redis数据库索引(默认为0)
    port: 6379 # Redis服务器连接端口
    password: # Redis服务器连接密码(默认为空)
    jedis:
      pool:
        max-active: 8 # 连接池最大连接数(使用负值表示没有限制)
        max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-idle: 8 # 连接池中的最大空闲连接
        min-idle: 0 # 连接池中的最小空闲连接
    timeout: 3000ms # 连接超时时间(毫秒)
  application:
    name: springboot-redis
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/spring_security?serverTimezone=Hongkong&characterEncoding=utf-8&useSSL=false
    username: root
    password: root
  thymeleaf:
    cache: false

redis:
  key:
    prefix:
      authCode: "authCode:"
    expire:
      time: 120  #验证码超期时间,单位s

3、自定义配置 RedisTemplate

Spring 默认为我们注入了 RedisTemplate 和 StringRedisTemplate ,如果我们没有手动注入相同名字的 bean 的话,RedisTemplate 默认的 key,value,hashKey,hashValue 序列化方式都为 JdkSerializationRedisSerializer,即二进制序列化方式,StringRedisTemplate 所有的序列化方式都为 RedisSerializer.string(),即 String。

@Configuration(
  proxyBeanMethods = false
)
@ConditionalOnClass({RedisOperations.class})
@EnableConfigurationProperties({RedisProperties.class})
@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class})
public class RedisAutoConfiguration {
  public RedisAutoConfiguration() {
  }

  @Bean
  @ConditionalOnMissingBean(
    name = {"redisTemplate"}
  )
  @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
  public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<Object, Object> template = new RedisTemplate();
    template.setConnectionFactory(redisConnectionFactory);
    return template;
  }

  @Bean
  @ConditionalOnMissingBean
  @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
  public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
    return new StringRedisTemplate(redisConnectionFactory);
  }
}

Springboot 2.6.3 默认的 Redis 客户端为 Lettuce,默认的连接工厂为 LettuceConnectionFactory:

org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration#redisConnectionFactory

如下图所示:

Redis默认客户端为 Lettuce

对应源码为:

  @Bean
  @ConditionalOnMissingBean({RedisConnectionFactory.class})
  LettuceConnectionFactory redisConnectionFactory(ObjectProvider<LettuceClientConfigurationBuilderCustomizer> builderCustomizers, ClientResources clientResources) {
    LettuceClientConfiguration clientConfig = this.getLettuceClientConfiguration(builderCustomizers, clientResources, this.getProperties().getLettuce().getPool());
    return this.createLettuceConnectionFactory(clientConfig);
  }

另外,Spring Data Redis提供了其他 ConnectionFactory,比如说 JedisConnectionFactory 等,目前 SpringBoot 默认 LettuceConnectionFactory,加之 Lettuce 社区更活跃一些,我们跟着 SpringBoot 的默认值走就好了。

Spring-data-redis 提供的序列化方式

Spring-data-redis 提供的序列化方式

对于字符串,我们希望 key,value 序列化方式都为 String,但是对于 Hash,key 的序列化方式为 String,但是 value 的序列化方式,我们希望为 JSON。所以我们需要自己配置 RedisTemplate 并注入到 Spring 容器中。

自定义配置 RedisTemplate 文件

@Configuration
public class RedisConfig {

  @Bean
  public RedisTemplate<String, Object> redisTemplate(
      RedisConnectionFactory redisConnectionFactory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(redisConnectionFactory);

    // 创建JSON序列化器
    Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(
        Object.class);
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    //必须设置,否则无法将JSON转化为对象,会转化成Map类型
    objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
        ObjectMapper.DefaultTyping.NON_FINAL);
    jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

    StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

    // key采用String的序列化方式
    template.setKeySerializer(stringRedisSerializer);
    // hash的key也采用String的序列化方式
    template.setHashKeySerializer(stringRedisSerializer);
    // value序列化方式采用jackson
    template.setValueSerializer(jackson2JsonRedisSerializer);
    // hash的value序列化方式采用jackson
    template.setHashValueSerializer(jackson2JsonRedisSerializer);
    template.afterPropertiesSet();

    return template;
  }
}

4、添加 RedisService 接口用于定义一些常用 Redis 操作

public interface RedisService {

  /**
   * 保存属性.
   */
  void set(String key, Object value, long time);

  /**
   * 保存属性.
   */
  void set(String key, Object value);

  /**
   * 获取属性.
   */
  Object get(String key);

  /**
   * 删除属性.
   */
  Boolean del(String key);

  /**
   * 批量删除属性.
   */
  Long del(List<String> keys);

  /**
   * 设置过期时间.
   */
  Boolean expire(String key, long time);

  ......
}

因篇幅原因,这里就不贴 RedisServiceImpl 实现类的代码。

5、添加 UserService,定义用户注册、登录以及获取验证码这三个方法,具体实现如下:

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

  private final RedisService redisService;
  private final UserMapper userMapper;
  private final PasswordEncoder passwordEncoder;
  private final UserStruct userStruct;
  private final MyUserDetailsService userDetailsService;

  @Value("${redis.key.prefix.authCode}")
  private String REDIS_KEY_PREFIX_AUTH_CODE;
  @Value("${redis.key.expire.time}")
  private Long AUTH_CODE_EXPIRE_SECONDS;


  @Override
  public String login(String username, String password) {
    String token = IdUtil.simpleUUID();
    try {

      UserDetails userDetails = userDetailsService.loadUserByUsername(username);
      if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        BusinessException.fail("密码不正确");
      }
      if (!userDetails.isEnabled()) {
        BusinessException.fail("帐号已被禁用");
      }
      UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
          userDetails, null, userDetails.getAuthorities());
      SecurityContextHolder.getContext().setAuthentication(authentication);
      redisService.set(token, username, 60 * 60);
    } catch (AuthenticationException e) {
      log.error("登录异常,detail" + e.getMessage());
    }

    return token;
  }


  @Override
  public void register(UserRequest userRequest) {
    if (Objects.isNull(userRequest) || isBlank(userRequest.getUsername()) ||
        isBlank(userRequest.getPassword())) {
      BusinessException.fail("账号或密码为空!");
    }
    boolean flag = verifyAuthCode(userRequest.getUsername(), userRequest.getAuthCode());
    if (flag) {
      User user = userMapper.selectByUserName(userRequest.getUsername());
      if (Objects.nonNull(user)) {
        BusinessException.fail("用户名已存在!");
      }
      String encodePassword = passwordEncoder.encode(userRequest.getPassword());
      User obj = userStruct.toUser(userRequest);
      obj.setPassword(encodePassword);
      userMapper.insert(obj);
    }
  }

  public String generateAuthCode(String username) {
    StringBuilder sb = new StringBuilder();
    Random random = new Random();
    for (int i = 0; i < 6; i++) {
      sb.append(random.nextInt(10));
    }
    String code = sb.toString();
    redisService.set(REDIS_KEY_PREFIX_AUTH_CODE + username, code, AUTH_CODE_EXPIRE_SECONDS);
    return code;
  }

  private boolean verifyAuthCode(String username, String authCode) {
    if (!StringUtils.hasLength(authCode)) {
      BusinessException.fail("请输入验证码!");
    }
    String realAuthCode = (String) redisService.get(REDIS_KEY_PREFIX_AUTH_CODE + username);
    return authCode.equals(realAuthCode);
  }

}

注册用户不允许用户名重复,严格来讲,根据用户名生成验证码时应该校验用户名是否重复,不然同时注册一个用户名,验证码会被覆盖,此处验证码的过期时间为自己配置的时间(这里为120s)。

6、添加 UserController 和 ResourceController

@RestController
@RequiredArgsConstructor
public class UserController {

  private final UserService userService;

  @GetMapping("/verify-code")
  public Result getVerifyCodePng(@RequestParam String username) {
    String authCode = userService.generateAuthCode(username);
    return Result.ok(authCode);
  }

  @PostMapping("/register")
  public Result register(@RequestBody UserRequest request) {
    userService.register(request);
    return Result.ok();
  }

  @PostMapping(value = "/login")
  public Result login(@RequestParam("username") String username,
      @RequestParam("password") String password) {
    String token = userService.login(username, password);
    Map<String, String> tokenMap = new HashMap<>();
    tokenMap.put("token", token);
    tokenMap.put("tokenHead", "Bearer ");
    return Result.ok(tokenMap);
  }
}

@RestController
@RequiredArgsConstructor
public class ResourceController {

  private final RedisService redisService;

  @GetMapping(value = "/home/level1")
  @PreAuthorize("hasAuthority('home')")
  public Result getHomeLevel1(HttpServletRequest request) {
    String token = TokenUtil.getTokenFromAuthorizationHeader(request);
    if (StrUtil.isBlank(token)) {
      return null;
    }
    String username = (String) redisService.get(token);
    return Result.ok(username + " 成功访问Home目录下的Level1页面");
  }

  @GetMapping(value = "/customer/level1")
  @PreAuthorize("hasAuthority('customer')")
  public Result getCustomerLevel1(HttpServletRequest request) {
    String token = TokenUtil.getTokenFromAuthorizationHeader(request);
    if (StrUtil.isBlank(token)) {
      return null;
    }
    String username = (String) redisService.get(token);
    return Result.ok(username + " 成功访问Customer目录下的Level1页面");
  }
}

7、自定义过滤器,继承 OncePerRequestFilter

@Slf4j
public class AuthenticationTokenFilter extends OncePerRequestFilter {

  @Autowired
  private MyUserDetailsService userDetailsService;
  @Autowired
  private RedisService redisService;
  private static final String AUTH_HEADER = "Bearer ";

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    String authHeader = request.getHeader("Authorization");
    if (authHeader != null && authHeader.startsWith(AUTH_HEADER)) {
      String authToken = authHeader.substring(AUTH_HEADER.length());// The part after "Bearer "
      String username = (String) redisService.get(authToken);
      logger.info("checking username:" + username);
      if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities());
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        logger.info("authenticated user:" + username);
        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    }
    filterChain.doFilter(request, response);
  }
}

8、添加 SecurityConfig

@Configuration
public class SecurityConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  public AuthenticationTokenFilter authenticationTokenFilter() {
    return new AuthenticationTokenFilter();
  }

  //安全拦截机制(最重要)
  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf().disable()   //屏蔽CSRF控制,即spring security不再限制CSRF
        .authorizeRequests()
        .antMatchers("/register").permitAll()
        .antMatchers("/verify-code").permitAll()
        .antMatchers("/login").permitAll()
        .antMatchers("/home/level1").hasAuthority("home")
        .antMatchers("/customer/level1").hasAuthority("customer")
        .anyRequest().authenticated()
        .and()
        .addFilterBefore(authenticationTokenFilter(),
            UsernamePasswordAuthenticationFilter.class);// 自定义认证过滤器
    ;
    return http.build();
  }
}

测试

启动项目,我们通过 postman 进行测试。

1、注册用户前,先根据用户名获取验证码

注册用户获取验证码

2、注册用户

注册用户

3、用户登录

用户登录返回token

4、访问资源,我们提前给 zhangsan 用户分配访问 home 路径下的 api 权限

有权访问资源

zhangsan 用户无权访问 customer 路径下的资源

无权访问资源

总结

本文简单介绍了下 Redis 的使用,结合 SpringSecurity 用于用户注册时增加验证码校验逻辑,以及用户登录时将用户名存储到 Redis 中,以供后续使用。验证码的逻辑在实际项目中可以添加,用户信息存储则推荐考虑 JWT,本文案例还未自定义授权处理逻辑,相对来说还是较简单。

参考文献

SpringBoot整合Redis