ByteCinema(1):用户的登录注册

文章目录

主要功能

  1. 生成图形验证码
  2. redis滑动窗口操作限流
  3. 续约token实现长期登录的效果

生成图形验证码

hutoool提供了工具类,直接用就行

java 复制代码
   public Captcha createCaptcha(){
       // 定义图形验证码的长和宽
       LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(200, 100);
       //将验证码储存到redis中,加上TTL
       String uuid = UUID.randomUUID().toString();
       stringRedisTemplate.opsForValue().set(LOGIN_CAPTCHA + uuid, 
               lineCaptcha.getCode(), LOGIN_CAPTCHA_TTL, TimeUnit.MINUTES);
       return new Captcha(uuid, lineCaptcha.getImageBase64());
   }

redis滑动窗口操作限流

0.限流设计的必要性

用户可能有很多行为,是无意义,或者非法的。比如:频繁发送短信、频繁修改个人信息、频繁的点赞、评论等等行为,这些做法不仅是意义不大的操作,而且还会对我们的服务器带来压力,所以需要设计限流操作。

1.原理

Redis 中的有序集合(ZSet,或称为 Sorted Set)是按照成员的分数(score)从小到大排序的。因此我们将当前的时间戳作为分数的话,这样我们就得到了一个"时间轴"

Key的格式设计为【场景:行为:用户唯一标识】,score分数值是时间戳,value值是什么都可以,不重要,一般会放时间戳、用户唯一标识和次数等等。

具体流程:

  1. 当用户每次发生限流行为,都会记录这个行为,以Redis zset的方式进行记录
  2. 在业务处理流程中,使用java api进行查询判断,其实本质就是调用redis的zcount命令,这个命令可以传入起始分值和结束分值。我就把当前时间戳作为结束分值,然后当前时间戳减去限流时间,比如说5分钟的毫秒值,求出来5分钟前的时间戳。于是根据这两个时间戳作为分值,范围查询zset中出现的次数,就得到用户在5分钟内,这个行为一共触发了几次。
  3. 后续的业务,就是不同场景中,根据不同的需求,进行校验就行了。

2.代码(邮箱发验证码为例)

java 复制代码
   public void getCode(String email) {
       //0.合法性检验,虽然前端会检验邮箱合法性,但后端最好还是也做一些保底的检验
       //TODO:更多的校验步骤
       int in = email.indexOf('@');
       if(in == -1){
           throw new RuntimeException("邮箱地址不合法");
       }
       //1.获取当前的时间窗口
       long currentTimeMillis = System.currentTimeMillis();
       long start = currentTimeMillis - LOGIN_EMAIL_WINDOW;
       //2.执行限流操作前,检查用户是否达到了限制条件
       Long count = stringRedisTemplate.opsForZSet().count(
               LOGIN_EMAIL + email, 
               start, currentTimeMillis);//时间窗口里面的操作次数
       if(count != null && count > 2){
           //3.达到限流条件,进行限制(deny user)
           throw new RuntimeException("操作过于频繁,请稍后再试");
       }
       //4.未达到,执行操作
       String code = RandomGenerator.generateRandom(6);
       MailUtil.send(email, "注册验证码", code, false);
       stringRedisTemplate.opsForValue().set(LOGIN_CODE + email, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
       //5.记录此次操作
       stringRedisTemplate.opsForZSet().add(LOGIN_EMAIL + email, email, currentTimeMillis);
   }

3. 问题与解决

高并发环境下redis操作的原子性

使用lua脚本进行一步执行

java 复制代码
   public void getCode(String email) {
       // 检验邮箱合法性
       int in = email.indexOf('@');
       if (in == -1) {
           throw new RuntimeException("邮箱地址不合法");
       }
       
       // Lua 脚本
       String luaScript =
               "local count = redis.call('zcount', KEYS[1], ARGV[1], ARGV[2])\n" +
                       "if count > tonumber(ARGV[3]) then\n" +
                       "    return false\n" +
                       "else\n" +
                       "    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])\n" +
                       "    redis.call('setex', KEYS[2], ARGV[5], ARGV[6])\n" +
                       "    return true\n" +
                       "end";
       
       long currentTimeMillis = System.currentTimeMillis();
       long start = currentTimeMillis - LOGIN_EMAIL_WINDOW;
       String code = RandomGenerator.generateRandom(6);
       
       // 参数设置
       List<String> keys = Arrays.asList(LOGIN_EMAIL + email, LOGIN_CODE + email);
       List<String> args = Arrays.asList(
               String.valueOf(start),
               String.valueOf(currentTimeMillis),
               "2", // 请求次数上限
               email,
               String.valueOf(LOGIN_CODE_TTL * 60), // 过期时间(秒)
               code
       );
       
       // 执行Lua脚本
       Boolean result = stringRedisTemplate.execute(
               new DefaultRedisScript<Boolean>(luaScript, Boolean.class),
               keys,
               args.toArray(new String[0])
       );
       
       // 判断结果
       if (Boolean.FALSE.equals(result)) {
           throw new RuntimeException("操作过于频繁,请稍后再试");
       }
       
       // 发送邮件
       MailUtil.send(email, "注册验证码", code, false);
   }

过时数据的积累

定时任务清理过时数据

java 复制代码
@Component
public class RedisDataCleaner {
    private final StringRedisTemplate stringRedisTemplate;

    public RedisDataCleaner(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Scheduled(fixedRate = 300000)  // 每5分钟执行一次
    public void cleanOldEntries() {
        long currentTimeMillis = System.currentTimeMillis();
        long threshold = currentTimeMillis - (5 * 60 * 1000);  // 5分钟前
        stringRedisTemplate.opsForZSet().removeRangeByScore("yourZSetKey", 0, threshold);
    }
}

续约token实现长期登录

0.设计的出发点

减少用户登录次数,提高用户体验

1.前置知识:JWT

什么是 JWT?

JWT(JSON Web Token)是一种基于 JSON 的开放标准(RFC 7519),用于在各方之间安全地传输信息。它可以被用来进行身份验证和信息交换。由于 JWT 是经过数字签名的,因此信息是可信任的。

JWT 的结构

一个 JWT 由三个部分组成,每部分之间用点(.)分隔:

  1. Header(头部)
  2. Payload(负载)
  3. Signature(签名)

组合后的格式如下:

xxxxx.yyyyy.zzzzz
1. Header(头部)

Header 通常包含两部分信息:

  • 类型:即令牌类型,JWT。
  • 算法:用于生成签名的哈希算法,例如 HMAC SHA256 或 RSA。

示例:

json 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

然后,将 JSON 格式的 Header 使用 Base64URL 编码,得到 JWT 的第一部分。

2. Payload(负载)

Payload 部分包含声明(Claims),即需要传递的数据。这些声明分为三类:

  • 注册声明(Registered Claims) :一组预定义的声明,推荐但不强制使用,例如 iss(发行人)、exp(过期时间)、sub(主题)、aud(受众)等。
  • 公共声明(Public Claims):可自定义的声明,但为了避免冲突,最好在 IANA JSON Web Token Registry 中注册或使用 URI 形式。
  • 私有声明(Private Claims):自定义的声明,用于双方协商使用,不在公共注册中。

示例:

json 复制代码
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

同样,将 Payload 使用 Base64URL 编码,得到 JWT 的第二部分。

3. Signature(签名)

签名部分是对前两部分的签名,确保令牌的完整性和真实性。签名的生成方式如下:

signature = HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

其中,secret 是服务器端的密钥,不能泄露。

JWT 的工作原理

  1. 认证阶段:用户通过提供凭证(如用户名和密码)向服务器请求认证。

  2. 生成 JWT:服务器验证用户身份后,生成 JWT,包含用户信息和其他声明,并使用密钥进行签名。

  3. 返回 JWT:服务器将生成的 JWT 返回给客户端。

  4. 存储 JWT:客户端通常将 JWT 存储在本地存储(LocalStorage)或 Cookie 中。

  5. 请求带上 JWT :客户端在后续请求中,将 JWT 放在 HTTP 请求的 Authorization 头部中:

    Authorization: Bearer <token>
    
  6. 服务器验证 JWT:服务器接收到请求后,验证 JWT 的签名和有效性,确认后处理请求。

2.思路

首先明确,无论用户用什么方式登录(包括第三方认证)的,全是返回token作为认证

正常登录的流程

后端在返回Token的时候,是生成两个Token:

一个是AccessToken,我管他叫访问令牌,我处于安全考虑,比如防止令牌被恶意使用,设置他的有效期为3个小时,每次请求资源时携带这个令牌;

另一个是RefreshToken,我管他叫刷新令牌,这个令牌不能用来访问资源,只能用来刷新访问令牌,就是每当访问令牌过期,前端携带这个RefreshToken获取新的AccessToken,这个刷新Token的有效期我设置为7天,当然这个可以改,这是写在配置文件中的。

当Token返回给前端后,浏览器端用的是localStorage保存的,App端的话有他们自己的本地保存方式,将这两个Token保存下来。

访问受限资源

我设置了一个拦截器对受限资源进行拦截,这个拦截器会检验请求中是否携带accessToken,携带了正常的accessToken,放行就行,在这里没有讨论的必要,这里讲解一下其它几种情况:

  1. 未携带,直接拒绝
  2. 携带了过期的accessToken,返回accessToken过期的标识,提醒客户端使用refresh刷新accessToken;

前端判断拒绝的状态码为AccessToken无效后,会重新发起一次请求,携带RefreshToken重新请求续约接口,这个续约接口是不需要网关拦截的,然后续约接口针对RefreshToken进行解密后,校验签名没有问题,没有被篡改,于是重新颁发新的AccessToken,返回给前端。

前端重新携带AccessToken发起请求就行了。

code

登录返回双重token

java 复制代码
public LoginVO login(LoginDTO loginDTO) {
   String email = loginDTO.getEmail().toLowerCase();
   LambdaQueryWrapper<User> queryWrapper = Wrappers.lambdaQuery(User.class)
           .eq(User::getEmail, email)
           .eq(User::getPassword, loginDTO.getPassword())
           .eq(User::getDelFlag, 0);
   User user = userService.getOne(queryWrapper);
   if(BeanUtil.isEmpty(user)){
       throw new RuntimeException("用户账号或密码错误");
   }
   // 生成令牌
   String accessToken = jwtTokenUtil.generateAccessToken(user);
   String refreshToken = jwtTokenUtil.generateRefreshToken(user);
   return new LoginVO(accessToken, refreshToken);
}

刷新接口,提供使用refreshToken刷新accessToken

java 复制代码
public LoginVO refresh(String refreshToken) {
    boolean f = jwtTokenUtil.validateToken(refreshToken);
    if(!f){
        throw new RuntimeException("刷新令牌异常,请重新登录");
    }
    LambdaQueryWrapper<User> queryWrapper = Wrappers.lambdaQuery(User.class)
            .eq(User::getEmail, jwtTokenUtil.getEmailFromToken(refreshToken))
            .eq(User::getDelFlag, 0);
    User user = userService.getOne(queryWrapper);
    if(BeanUtil.isEmpty(user)){
        throw new RuntimeException("刷新令牌异常,请重新登录");
    }
    String accessToken = jwtTokenUtil.generateAccessToken(user);
    String newRefreshToken = jwtTokenUtil.generateRefreshToken(user);
    return new LoginVO(accessToken, newRefreshToken);
}

拦截器,实现对accessToken的解析

java 复制代码
public class JwtAuthenticationIntercept implements HandlerInterceptor {
    private JwtTokenUtil jwtTokenUtil;

    public JwtAuthenticationIntercept(JwtTokenUtil jwtTokenUtil) {
        this.jwtTokenUtil = jwtTokenUtil;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String header = request.getHeader("Authorization");
        if (StrUtil.isNotBlank(header) && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            if (jwtTokenUtil.validateToken(token)) {
                String email = jwtTokenUtil.getEmailFromToken(token);
                UserHolder.saveUser(new UserDTO(email));
                return true;
            }
            response.setHeader("accessToken", "outdated");
            response.getWriter().write("访问token过期");
        }
        return false;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserHolder.removeUser();
    }
}
相关推荐
程序员大金5 分钟前
基于SpringBoot+Vue+MySQL的校园一卡通系统
java·javascript·vue.js·spring boot·后端·mysql·tomcat
基础不牢,地动山摇...11 分钟前
jbcTemplate和namedParameterJdbcTemplate详解
java·开发语言·数据库
逸狼12 分钟前
【JavaEE初阶】文件IO(上)
java·java-ee
科研小白_d.s12 分钟前
数据结构的基础知识
java·开发语言·数据结构
布说在见20 分钟前
Spring Boot管理用户数据
java·spring boot·后端
小七蒙恩27 分钟前
java的排序算法,代码详细说明
java·算法·排序算法
coder what39 分钟前
基于springboot的图书管理系统
java·spring boot·后端·图书管理系统
码农小旋风39 分钟前
一文详解大语言模型Transformer结构
后端
熙客1 小时前
Spring MVC的应用
java·spring·mvc
赤橙红的黄1 小时前
策略模式+模版模式+工厂模式
java·开发语言·策略模式