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

主要功能

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

生成图形验证码

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

   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.代码(邮箱发验证码为例)

   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脚本进行一步执行

   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);
   }

过时数据的积累

定时任务清理过时数据

@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。

示例:

{
  "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):自定义的声明,用于双方协商使用,不在公共注册中。

示例:

{
  "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

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

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的解析

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();
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值