Spring项目使用Redis限制用户登录失败的次数以及暂时锁定用户登录权限

文章目录

背景

前两天被面试到这个问题,最初回答的不是很合理,登录次数这方面记录还一直往数据库上面想,后来感觉在数据库中加一个登录日志表,来查询一段时间内用户登录的次数,现在看来,自己还是太年轻了,做个登录限制肯定是为了防止数据库高并发,加一个表来记录登录日志,这就把请求都打到数据库了,后来看了前辈们的解决方案,可以用Redis来实现,包括登录次数和账号锁定,我最开始在回答这个问题的时候只回答到了账号锁定用Redis做管理,前者登录次数回答的比较差劲,后来我也及时复盘了这次面试,就标题的内容做出基本实现

环境

Java8、MySQL8、Redis、IDEA,环境支持的同学可以参考这份代码实现以下

代码实现

0. 项目结构图(供参考)

1. 数据库中的表(供参考)

sql 复制代码
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for login_demo_user
-- ----------------------------
DROP TABLE IF EXISTS `login_demo_user`;
CREATE TABLE `login_demo_user`  (
  `id` bigint NOT NULL COMMENT '主键',
  `username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密码',
  PRIMARY KEY (`id`, `username`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of login_demo_user
-- ----------------------------
INSERT INTO `login_demo_user` VALUES (1, 'hh', 'hh');

SET FOREIGN_KEY_CHECKS = 1;

2. 依赖(pom.xml)

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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/>
    </parent>
    
    <groupId>com.openallzzz</groupId>
    <artifactId>logindemo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.0</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.1</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

3. 配置文件(application.yml)

yml 复制代码
server:
  port: 8080

spring:
  profiles:
    active: dev
  main:
    allow-circular-references: true
  datasource:
    druid:
      driver-class-name: ${datasource.driver-class-name}
      url: jdbc:mysql://${datasource.host}:${datasource.port}/${datasource.database}?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
      username: ${datasource.username}
      password: ${datasource.password}
  redis:
    host: ${redis.host}
    port: ${redis.port}
    database: ${redis.database}

mybatis:
  #mapper配置文件
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.openallzzz.logindemo.entity
  configuration:
    #开启驼峰命名
    map-underscore-to-camel-case: true

logging:
  level:
    com:
      openallzzz:
        mapper: debug
        service: info
        controller: info

4. 配置文件(application-dev.yml)

yml 复制代码
datasource:
  driver-class-name: com.mysql.cj.jdbc.Driver
  host: localhost
  port: 3306
  database: testdb
  username: root
  password: root
redis:
  host: localhost
  port: 6379
  database: 1

5. UserLoginDTO

java 复制代码
package com.openallzzz.logindemo.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserLoginDTO {

    private String username;
    private String password;

}

6. DemoConstant

java 复制代码
package com.openallzzz.logindemo.constant;

public class DemoConstant {

    // 每分钟限制登录的最大次数
    public static final int MAX_LOGIN_TIMRS_PER_MINUTE = 5;
}

7. User(后来才用的lombok,没有统一写法)

java 复制代码
package com.openallzzz.logindemo.entity;

public class User {

    private Long id;
    private String username;
    private String password;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

8. UserMapper

java 复制代码
package com.openallzzz.logindemo.mapper;

import com.openallzzz.logindemo.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface UserMapper {

    @Select("select id, username, password from login_demo_user where username = #{username}")
    User selectByUsername(String username);

}

9. UserService(供参考)

java 复制代码
package com.openallzzz.logindemo.service;

import com.openallzzz.logindemo.constant.DemoConstant;
import com.openallzzz.logindemo.dto.UserLoginDTO;
import com.openallzzz.logindemo.entity.User;
import com.openallzzz.logindemo.mapper.UserMapper;
import com.openallzzz.logindemo.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    public Result<String> login(UserLoginDTO userLoginDTO) {
        if (userLoginDTO == null || userLoginDTO.getUsername() == null || userLoginDTO.getPassword() == null) {
            return Result.error("用户信息不完整!");
        }

        String username = userLoginDTO.getUsername();
        String password = userLoginDTO.getPassword();

        boolean lockStatus = getLockStatus(username);
        if (lockStatus) {
            return Result.error("失败次数过多,请稍后重试");
        }

        User user = userMapper.selectByUsername(username);

        if (user.getPassword().equals(password)) {
            clearLastCount(username);
            clearLockStatus(username);
            return Result.success();
        } else {
            int lastCount = getLastCount(username);
            if (lastCount + 1 == DemoConstant.MAX_LOGIN_TIMRS_PER_MINUTE) {
                setLockStatus(username);
                return Result.error("失败次数过多,请稍后重试");
            } else {
                setLastCount(username);
                return Result.error("登录失败,请检查用户名或密码,再重试");
            }
        }
    }

    private void clearLockStatus(String username) {
        log.info("清除用户锁定状态:{}", username);
        String key = "Lock" + ":" + username;
        redisTemplate.delete(key);
    }

    private void clearLastCount(String username) {
        log.info("将用户登录次数还原:{}", username);
        String key = "Count" + ":" + username;
        redisTemplate.delete(key);
    }

    private int getLastCount(String username) {
        log.info("获取用户一分钟内已经失败登录了多少次:{}", username);
        String key = "Count" + ":" + username;
        Integer count = (Integer) redisTemplate.opsForValue().get(key);
        return count == null ? 0 : count;
    }

    private void setLastCount(String username) {
        log.info("设置用户一分钟内已经失败登录了多少次:{}", username);
        String key = "Count" + ":" + username;
        redisTemplate.opsForValue().set(key, getLastCount(username) + 1, 1, TimeUnit.MINUTES);
    }

    private void setLockStatus(String username) {
        log.info("锁定用户,限制其登录:{}", username);
        String key = "Lock" + ":" + username;
        redisTemplate.opsForValue().set(key, "lock", 2, TimeUnit.HOURS);
    }

    private boolean getLockStatus(String username) {
        log.info("获取用户是否被限制其登录:{}", username);
        String key = "Lock" + ":" + username;
        String o = (String) redisTemplate.opsForValue().get(key);
        if ("lock".equals(o)) return true;
        return false;
    }

}

10. UserController

java 复制代码
package com.openallzzz.logindemo.controller;

import com.openallzzz.logindemo.dto.UserLoginDTO;
import com.openallzzz.logindemo.result.Result;
import com.openallzzz.logindemo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public Result<String> login(@RequestBody UserLoginDTO userLoginDTO) {
        log.info("用户登录:{}", userLoginDTO);
        return userService.login(userLoginDTO);
    }

}

11. RedisConfig

java 复制代码
package com.openallzzz.logindemo.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.StringRedisSerializer;

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean(name = "redisTemplate")
    public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory){
        RedisTemplate<String,Object> template = new RedisTemplate<>();
        //配置连接工厂
        template.setConnectionFactory(factory);
        //使用jackson序列化和反序列value的值,
        Jackson2JsonRedisSerializer jacksonSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper mapper = new ObjectMapper();
        //指定需要序列化的范围,All表示field、get和set,以及修饰符范围,ANY表示所有范围,包括private
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        jacksonSerializer.setObjectMapper(mapper);
        //设置template中value使用Jackson2JsonRedisSerializer序列化
        template.setValueSerializer(jacksonSerializer);
        //设置template中key使用StringRedisSerializer序列化
        template.setKeySerializer(new StringRedisSerializer());
        //这是hash中key和value的序列化方式,key采用StringRedisSerializer,value采用Jackson2JsonRedisSerializer
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(jacksonSerializer);
        //使template属性生效
        template.afterPropertiesSet();
        return template;
    }
}

12. 统一返回结果

java 复制代码
package com.openallzzz.logindemo.result;

import lombok.Data;

import java.io.Serializable;

/**
 * 后端统一返回结果
 * @param <T>
 */
@Data
public class Result<T> implements Serializable {

    private Integer code; //编码:1成功,0和其它数字为失败
    private String msg; //错误信息
    private T data; //数据

    public static <T> Result<T> success() {
        Result<T> result = new Result<T>();
        result.code = 1;
        return result;
    }

    public static <T> Result<T> success(T object) {
        Result<T> result = new Result<T>();
        result.data = object;
        result.code = 1;
        return result;
    }

    public static <T> Result<T> error(String msg) {
        Result result = new Result();
        result.msg = msg;
        result.code = 0;
        return result;
    }

}

13. 启动类LoginDemoApplication

java 复制代码
package com.openallzzz.logindemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LoginDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(LoginDemoApplication.class, args);
    }

}

测试登录接口(一分钟内五次密码全错,触发账号锁定登录权限)

第一次测试(300ms)

第二次测试(32ms)

第三次测试(22ms)

第四次测试(12ms)

第五次测试(8ms)

总结

登录业务预先判断了该账号是否被锁定,如果短期内有大量登录请求(用户不断试错、被恶意攻击),压力只会给到Redis,从而避免DB被大量请求打中。

相关推荐
Rsun045517 小时前
Redis中实现访问量计数
数据库·redis·缓存
Hui Baby8 小时前
spring优雅释放资源
java·spring
pip install USART10 小时前
解决@Autowired注解失败导致空指针bug
java·spring·bug
摇滚侠10 小时前
限流的方法,Redis 计算器限流算法、滑动时间窗口限流算法、漏漏桶限流算法、令牌桶限流算法,Java 开发
java·数据库·redis
wuqingshun31415910 小时前
说一下spring的bean的作用域
java·后端·spring
fy1216311 小时前
Redis 下载与安装 教程 windows版
数据库·windows·redis
华科易迅12 小时前
Spring JDBC
java·后端·spring
云烟成雨TD12 小时前
Spring AI 1.x 系列【17】函数型工具开发与使用
java·人工智能·spring
云烟成雨TD12 小时前
Spring AI 1.x 系列【15】AI Agent 基石:Tool Calling 标准与 Spring AI 集成
java·人工智能·spring