登录架构设计

ps:

内含 分库分表 窗口限流 验证码校验 密码加密 jwt加密

等,算是一个合格的架构,我基本都是按照这个方法,生成的。哪怕是单体也是。主要是方便

登录设计

管理员登录

1.怎么实现登录安全的

2.获取短信验证码时间窗口使用了什么限流算法

登录安全

登录前:登录-去查询数据库 如果有反回jwt令牌

登录后

利用getway网关->进行控制请求->JWT验证通过后 可访问其他服务

java 复制代码
CREATE TABLE user (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
    uuid CHAR(36) NOT NULL COMMENT '全局唯一标识,适用于分库分表',
    username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一',
    password CHAR(32) NOT NULL COMMENT 'MD5加密后的密码',
    salt CHAR(8) NOT NULL COMMENT '随机盐值',
    email VARCHAR(100) DEFAULT NULL COMMENT '用户邮箱',
    phone VARCHAR(20) DEFAULT NULL COMMENT '手机号',
    status TINYINT DEFAULT 1 COMMENT '用户状态:1-正常,0-禁用',
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uq_uuid (uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微服务用户表';

开始吧,先看具体服务的逻辑。然后再从大的方向看

知识点

java 复制代码
StringUtils.isNotBlank(dto.getPhone())
表达式 JVM 层面执行 空引用处理 返回值示例 用途
dto == null 直接比较引用 (ifnull/ifnonnull) 安全,dto 为 null 不报错 dto 为 null → true;dto 不为 null → false 判断对象是否存在
dto.equals(null) 调用对象的 equals 方法 (invokevirtual) dto 为 null → 抛 NullPointerException;非空对象返回 false dto 不为 null → false;dto 为 null → NPE 比较对象内容相等性
StringUtils.isNotBlank(dto.getPhone()) null 检查 → length → 遍历字符判断空白 安全,null 返回 false null → false;"" → false;" " → false;"abc" → true 判断字符串是否有效(非空、非全空白)

不要使用dto.equals

加密

java 复制代码
 String pswd = DigestUtils.md5DigestAsHex((password + salt).getBytes());

== 判断 引用是否相同,即是否指向同一个对象。

equals 判断 内容是否相同

对字符串来说,== 可能因为不同对象而返回 false,即使内容相同。

java 复制代码
   if(!pswd.equals(dbUser.getPassword())

jwt加密

java 复制代码
 AppJwtUtil.getToken(dbUser.getId().longValue());

AppJwtUtil 工具类核心分为 5 大功能模块 抽离

老实讲,一直用sqtoken基本忘记了怎么写

  1. Token 生成(核心)
  2. 加密密钥生成
  3. Token 解析(获取 Claims/Header)
  4. Token 有效性校验
  5. 异常处理(过期 / 解析失败)

三部分

复制代码
package io.jsonwebtoken;

1.生成

java 复制代码
    public static String getToken(Long id){
        Map<String, Object> claimMaps = new HashMap<>();
        claimMaps.put("id",id);
        long currentTime = System.currentTimeMillis();
        return Jwts.builder()
                .setId(UUID.randomUUID().toString())
                .setIssuedAt(new Date(currentTime))  //签发时间
                .setSubject("system")  //说明
                .setIssuer("heima") //签发者信息
                .setAudience("app")  //接收用户
                .compressWith(CompressionCodecs.GZIP)  //数据压缩方式
                .signWith(SignatureAlgorithm.HS512, generalKey()) //加密方式
                .setExpiration(new Date(currentTime + TOKEN_TIME_OUT * 1000))  //过期时间戳
                .addClaims(claimMaps) //cla信息
                .compact();
    }
复制代码
// 加密KEY
private static final String TOKEN_ENCRY_KEY = "MDk4ZjZiY2Q0NjIxZDM3M2NhZGU0ZTgzMjYyN2I0ZjY";

package javax.crypto.spec;

public static SecretKey generalKey() {
    byte[] encodedKey = Base64.getEncoder().encode(TOKEN_ENCRY_KEY.getBytes());
    SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
    return key;
}

将字节数组封装成 SecretKey 对象(实现 javax.crypto.SecretKey)。

这里 "AES" 并不是用于 AES 加密,而是 指定密钥类型

实际用于 JWT 的 signWith 时,内部使用 HMAC-SHA512 算法对该字节数组做签名。

对我们的密钥再次加密,后进行哈希签名

所以 JWT 中的 signWith(SignatureAlgorithm.HS512, key) 就是在用密钥对 header + payload 做 HMAC-SHA512 签名,而不是单纯的 SHA512 哈希。

HS512 签名

用密钥对 header + payload 做哈希签名,保证信息未被篡改

Token 解析流程

java 复制代码
    private static Jws<Claims> getJws(String token) {
            return Jwts.parser()
                    .setSigningKey(generalKey())
                    .parseClaimsJws(token);
    }

Jws 返回值来调取东西

java 复制代码
/**
 * 获取payload body信息
 *
 * @param token
 * @return
 */
public static Claims getClaimsBody(String token) {
    try {
        return getJws(token).getBody();
    }catch (ExpiredJwtException e){
        return null;
    }

过期解析

java 复制代码
    /**
     * 是否过期
     *
     * @param claims
     * @return -1:有效,0:有效,1:过期,2:过期
     */
    public static int verifyToken(Claims claims) {
        if(claims==null){
            return 1;
        }
        try {
            // 获取过期时间与当前时间比较
            claims.getExpiration()
                    .before(new Date());
            // 需要自动刷新TOKEN      如果 Token 距离过期时间大于 REFRESH_TIME 秒,则无需刷新
            if((claims.getExpiration().getTime()-System.currentTimeMillis())>REFRESH_TIME*1000){
                // Token 快过期,需要自动刷新
                return -1;
            }else {
                return 0;
            }
        } catch (ExpiredJwtException ex) {
            // 捕获 JWT 库抛出的过期异常
            return 1;
        }catch (Exception e){
            // 捕获其他异常(例如解码错误等)
            return 2;
        }
    }

先这样

APP登录

接下来研究APP端的登录涉及

层级 技术 作用
应用层 Java 业务编排
缓存层 Redis 限流 + 验证码存储
算法 滑动窗口 / 固定窗口 限流策略
数据结构 Hash / String / ZSet 计数与时间

肯定可以接入对应拉框校验,这种,完成之后给一个校验,持久化,下次发送一起发来校验是否可以发送。

层级 技术 作用
应用层 Java 业务编排
缓存层 Redis 限流 + 验证码存储
算法 滑动窗口 / 固定窗口 限流策略
数据结构 Hash / String / ZSet 计数与时间
java 复制代码
APP登录
 ├─ sms:code:{phone}           -> 风控对接放爬虫等一系列机制
 ├─ sms:code:{phone}           -> 验证码对象
 ├─ sms:send:sliding:{phone}   -> 发送限流
 ├─ sms:verify:error:{phone}   -> 校验错误次数
 ├─ login:ip:{ip}              -> 接口防刷

### 实名存储

ZSET滑动窗口

java 复制代码
| ZSet 特性        | 在限流中的含义        |
| -------------- | -------------- |
| score 有序       | 用时间戳作为事件发生时间   |
| 支持按 score 范围删除 | 快速删除窗口外请求      |
| 支持 `ZCARD`     | O(1) 得到窗口内请求数量 |
    • 60 秒内最多发送 1 次
    • 10 分钟内最多发送 5 次

下面用滑动窗口实现「60 秒 1 次」,10 分钟规则是同一个模型换参数。

sms:send:sliding:{phone}

维度:手机号 一个手机号 = 一个滑动窗口

ZSet 内容

score=1700000000123 value=550e8400-e29b 时间锉和唯一ID

限流窗口定义

windowSize = 60_000 ms

maxCount = 1

任意连续 60 秒内,只允许 1 次发送行为

1️⃣ 限流组件

java 复制代码
@Component
public class SmsSlidingWindowLimiter {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 短信发送限流
     *
     * @param phone      手机号
     * @param maxCount   窗口内最大次数
     * @param windowSize 窗口大小(毫秒)
     */
    public boolean canSend(String phone, int maxCount, long windowSize) {

        String key = "sms:send:sliding:" + phone;
        long now = System.currentTimeMillis();
        long windowStart = now - windowSize;

        ZSetOperations<String, String> zSetOps = stringRedisTemplate.opsForZSet();

        // 1. 删除窗口外的数据
        zSetOps.removeRangeByScore(key, 0, windowStart);

        // 2. 统计窗口内请求数
        Long count = zSetOps.zCard(key);
        if (count != null && count >= maxCount) {
            return false;
        }

        // 3. 记录本次发送行为
        zSetOps.add(key, UUID.randomUUID().toString(), now);

        // 4. 设置过期时间(窗口 + 冗余)
        stringRedisTemplate.expire(key, Duration.ofMillis(windowSize + 1000));

        return true;
    }
}

短信验证码发送 Service(业务层)

java 复制代码
@Service
public class SmsService {

    @Resource
    private SmsSlidingWindowLimiter limiter;

    public void sendLoginCode(String phone) {

        // 60 秒内最多 1 次
        boolean allow = limiter.canSend(phone, 1, 60_000);
        // 10 分钟最多 5 次
  boolean allow10Min = limiter.canSend(phone, 5, 600_000);
if (!allow10Min) {
    throw new RuntimeException("发送次数过多,请稍后再试");
}


        if (!allow) {
            throw new RuntimeException("短信发送过于频繁,请稍后再试");
        }

        // 生成验证码
        String code = String.valueOf((int)((Math.random() * 9 + 1) * 100000));

        // TODO 调用第三方短信平台发送
        System.out.println("向手机号 " + phone + " 发送验证码:" + code);

        // TODO 存储验证码(如 Redis,设置 5 分钟过期)
    }
}

英文验证码(图形/字母校验)

既然弹了,就说说。要么是对接其他家的,要么是调用库

我都是调用库,真要爬,我也没办法-详情见easypan

java 复制代码
1. APP 请求获取英文校验码
2. 后端生成英文验证码(如 4 位字母)
3. 返回:
   - 校验码图片(Base64)
   - captchaKey(唯一标识)

4. 用户输入英文验证码
5. APP 请求发送短信:
   - phone
   - captchaKey
   - captchaValue(用户输入)

6. 后端校验英文验证码
7. 校验通过 → 执行短信限流 → 发送短信

captcha:img:{captchaKey}

code -> Ab3F

验证码生成工具

java 复制代码
public class CaptchaUtil {

    private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";

    public static String randomCode(int length) {
        StringBuilder sb = new StringBuilder(length);
        Random random = new Random();
        for (int i = 0; i < length; i++) {
            sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
        }
        return sb.toString();
    }
}

图片验证码生成(Java2D)

省,这块蛮多的。不多说

获取英文验证码接口

java 复制代码
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
    @Resource
    private StringRedisTemplate redisTemplate;
    @GetMapping("/image")
    public Map<String, String> getCaptcha() throws IOException {
        String captchaKey = UUID.randomUUID().toString();
        String code = CaptchaUtil.randomCode(4);
        // 存 Redis(60 秒)
        redisTemplate.opsForValue()
                .set("captcha:img:" + captchaKey, code, Duration.ofSeconds(60));
        BufferedImage image = CaptchaImageUtil.createImage(code);
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        ImageIO.write(image, "png", os);
        String base64 = Base64.getEncoder().encodeToString(os.toByteArray());
        Map<String, String> result = new HashMap<>();
        result.put("captchaKey", captchaKey);
        result.put("imageBase64", "data:image/png;base64," + base64);
        return result;
    }
}
java 复制代码
        // 3. 生成图片
        BufferedImage image = CaptchaImageUtil.createImage(code);

        // 4. 设置响应头
        response.setContentType("image/png");
        response.setHeader("Captcha-Key", captchaKey);//设置key 或者持久化,记得删除就好
        response.setHeader("Cache-Control", "no-store, no-cache");

        // 5. 写入输出流
        ServletOutputStream os = response.getOutputStream();
        ImageIO.write(image, "png", os);
        os.flush();

短信发送处理

注意,可以根据返回值来看看删不删验证码。容易被刷库。

java 复制代码
  public void sendLoginCode(String phone, String captchaKey, String captchaValue) {

        String redisKey = "captcha:img:" + captchaKey;
        String realCode = redisTemplate.opsForValue().get(redisKey);

        // 1. 校验英文验证码
        if (realCode == null || !realCode.equalsIgnoreCase(captchaValue)) {
            //如果要删除记得处理
            throw new RuntimeException("英文验证码错误或已过期");
        }

        // 2. 验证通过后立即删除(一次性)
      ///记得删除别流空
        redisTemplate.delete(redisKey);

        // 3. 短信发送限流
        boolean allow = limiter.canSend(phone, 1, 60_000);
        if (!allow) {
            throw new RuntimeException("短信发送过于频繁");
        }

        // 4. 生成并发送短信验证码
        String smsCode = String.valueOf((int)((Math.random() * 9 + 1) * 100000));
        System.out.println("发送短信验证码:" + smsCode);

        // TODO 存储短信验证码
    }
}

请求 /captcha/image

展示 Base64 图片

提交:

java 复制代码
{
  "phone": "138xxxx",
  "captchaKey": "uuid",
  "captchaValue": "Ab3F"
}
相关推荐
月明长歌6 小时前
【码道初阶】【牛客BM30】二叉搜索树与双向链表:java中以引用代指针操作的艺术与陷阱
java·数据结构·算法·leetcode·二叉树·笔试·字节跳动
南棱笑笑生6 小时前
20251215给飞凌OK3588-C开发板适配Rockchip原厂的Buildroot【linux-6.1】系统时统计eth1的插拔次数
linux·c语言·开发语言·rockchip
小坏讲微服务6 小时前
Spring Boot4.0整合RabbitMQ死信队列详解
java·spring boot·后端·rabbitmq·java-rabbitmq
yuuki2332336 小时前
【C++】内存管理
java·c++·算法
消失的旧时光-19436 小时前
Java 线程池(第四篇):ScheduledThreadPoolExecutor 原理与定时任务执行机制全解析
java·开发语言
刃神太酷啦6 小时前
Linux 进程核心原理精讲:从体系结构到实战操作(含 fork / 状态 / 优先级)----《Hello Linux!》(6)
java·linux·运维·c语言·c++·算法·leetcode
利刃大大6 小时前
【JavaSE】十五、线程同步wait | notify && 单例模式 && 阻塞队列 && 线程池 && 定时器
java·单例模式·线程池·定时器·阻塞队列
dudke6 小时前
js的reduce详解
开发语言·javascript·ecmascript
kevin_水滴石穿6 小时前
docker-compose.yml案例
java·服务器·开发语言