ps:
内含 分库分表 窗口限流 验证码校验 密码加密 jwt加密
等,算是一个合格的架构,我基本都是按照这个方法,生成的。哪怕是单体也是。主要是方便
登录设计
管理员登录
1.怎么实现登录安全的
2.获取短信验证码时间窗口使用了什么限流算法
登录安全
登录前:登录-去查询数据库 如果有反回jwt令牌
登录后
利用getway网关->进行控制请求->JWT验证通过后 可访问其他服务
CREATETABLEuser(id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT'自增主键',uuidCHAR(36)NOT NULL COMMENT'全局唯一标识,适用于分库分表',usernameVARCHAR(50)NOT NULL UNIQUE COMMENT'用户名,唯一',passwordCHAR(32)NOT NULL COMMENT'MD5加密后的密码',saltCHAR(8)NOT NULL COMMENT'随机盐值',emailVARCHAR(100)DEFAULT NULL COMMENT'用户邮箱',phoneVARCHAR(20)DEFAULT NULL COMMENT'手机号',status TINYINT DEFAULT1COMMENT'用户状态:1-正常,0-禁用',create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT'创建时间',update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT'更新时间',PRIMARYKEY(id),UNIQUEKEYuq_uuid(uuid))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='微服务用户表';开始吧,先看具体服务的逻辑。然后再从大的方向看
知识点
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
加密
Stringpswd=DigestUtils.md5DigestAsHex((password+salt).getBytes());==判断引用是否相同,即是否指向同一个对象。
equals判断内容是否相同。
对字符串来说,==可能因为不同对象而返回false,即使内容相同。
if(!pswd.equals(dbUser.getPassword())jwt加密
AppJwtUtil.getToken(dbUser.getId().longValue());AppJwtUtil 工具类核心分为 5 大功能模块 抽离
老实讲,一直用sqtoken基本忘记了怎么写
- Token 生成(核心)
- 加密密钥生成
- Token 解析(获取 Claims/Header)
- Token 有效性校验
- 异常处理(过期 / 解析失败)
三部分
package io.jsonwebtoken;1.生成
publicstaticStringgetToken(Longid){Map<String,Object>claimMaps=newHashMap<>();claimMaps.put("id",id);longcurrentTime=System.currentTimeMillis();returnJwts.builder().setId(UUID.randomUUID().toString()).setIssuedAt(newDate(currentTime))//签发时间.setSubject("system")//说明.setIssuer("heima")//签发者信息.setAudience("app")//接收用户.compressWith(CompressionCodecs.GZIP)//数据压缩方式.signWith(SignatureAlgorithm.HS512,generalKey())//加密方式.setExpiration(newDate(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 解析流程
privatestaticJws<Claims>getJws(Stringtoken){returnJwts.parser().setSigningKey(generalKey()).parseClaimsJws(token);}Jws 返回值来调取东西
/** * 获取payload body信息 * * @param token * @return */publicstaticClaimsgetClaimsBody(Stringtoken){try{returngetJws(token).getBody();}catch(ExpiredJwtExceptione){returnnull;}过期解析
/** * 是否过期 * * @param claims * @return -1:有效,0:有效,1:过期,2:过期 */publicstaticintverifyToken(Claimsclaims){if(claims==null){return1;}try{// 获取过期时间与当前时间比较claims.getExpiration().before(newDate());// 需要自动刷新TOKEN 如果 Token 距离过期时间大于 REFRESH_TIME 秒,则无需刷新if((claims.getExpiration().getTime()-System.currentTimeMillis())>REFRESH_TIME*1000){// Token 快过期,需要自动刷新return-1;}else{return0;}}catch(ExpiredJwtExceptionex){// 捕获 JWT 库抛出的过期异常return1;}catch(Exceptione){// 捕获其他异常(例如解码错误等)return2;}}先这样
APP登录
接下来研究APP端的登录涉及
| 层级 | 技术 | 作用 |
|---|---|---|
| 应用层 | Java | 业务编排 |
| 缓存层 | Redis | 限流 + 验证码存储 |
| 算法 | 滑动窗口 / 固定窗口 | 限流策略 |
| 数据结构 | Hash / String / ZSet | 计数与时间 |
肯定可以接入对应拉框校验,这种,完成之后给一个校验,持久化,下次发送一起发来校验是否可以发送。
| 层级 | 技术 | 作用 |
|---|---|---|
| 应用层 | Java | 业务编排 |
| 缓存层 | Redis | 限流 + 验证码存储 |
| 算法 | 滑动窗口 / 固定窗口 | 限流策略 |
| 数据结构 | Hash / String / ZSet | 计数与时间 |
APP登录 ├─ sms:code:{phone}->风控对接放爬虫等一系列机制 ├─ sms:code:{phone}->验证码对象 ├─ sms:send:sliding:{phone}->发送限流 ├─ sms:verify:error:{phone}->校验错误次数 ├─ login:ip:{ip}->接口防刷 ### 实名存储ZSET滑动窗口
|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️⃣ 限流组件
@ComponentpublicclassSmsSlidingWindowLimiter{@ResourceprivateStringRedisTemplatestringRedisTemplate;/** * 短信发送限流 * * @param phone 手机号 * @param maxCount 窗口内最大次数 * @param windowSize 窗口大小(毫秒) */publicbooleancanSend(Stringphone,intmaxCount,longwindowSize){Stringkey="sms:send:sliding:"+phone;longnow=System.currentTimeMillis();longwindowStart=now-windowSize;ZSetOperations<String,String>zSetOps=stringRedisTemplate.opsForZSet();// 1. 删除窗口外的数据zSetOps.removeRangeByScore(key,0,windowStart);// 2. 统计窗口内请求数Longcount=zSetOps.zCard(key);if(count!=null&&count>=maxCount){returnfalse;}// 3. 记录本次发送行为zSetOps.add(key,UUID.randomUUID().toString(),now);// 4. 设置过期时间(窗口 + 冗余)stringRedisTemplate.expire(key,Duration.ofMillis(windowSize+1000));returntrue;}}短信验证码发送 Service(业务层)
@ServicepublicclassSmsService{@ResourceprivateSmsSlidingWindowLimiterlimiter;publicvoidsendLoginCode(Stringphone){// 60 秒内最多 1 次booleanallow=limiter.canSend(phone,1,60_000);// 10 分钟最多 5 次booleanallow10Min=limiter.canSend(phone,5,600_000);if(!allow10Min){thrownewRuntimeException("发送次数过多,请稍后再试");}if(!allow){thrownewRuntimeException("短信发送过于频繁,请稍后再试");}// 生成验证码Stringcode=String.valueOf((int)((Math.random()*9+1)*100000));// TODO 调用第三方短信平台发送System.out.println("向手机号 "+phone+" 发送验证码:"+code);// TODO 存储验证码(如 Redis,设置 5 分钟过期)}}英文验证码(图形/字母校验)
既然弹了,就说说。要么是对接其他家的,要么是调用库
我都是调用库,真要爬,我也没办法-详情见easypan
1.APP 请求获取英文校验码2.后端生成英文验证码(如4位字母)3.返回:-校验码图片(Base64)-captchaKey(唯一标识)4.用户输入英文验证码5.APP 请求发送短信:-phone-captchaKey-captchaValue(用户输入)6.后端校验英文验证码7.校验通过 → 执行短信限流 → 发送短信captcha:img:{captchaKey}
code -> Ab3F
验证码生成工具
publicclassCaptchaUtil{privatestaticfinalStringCHARS="ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz";publicstaticStringrandomCode(intlength){StringBuildersb=newStringBuilder(length);Randomrandom=newRandom();for(inti=0;i<length;i++){sb.append(CHARS.charAt(random.nextInt(CHARS.length())));}returnsb.toString();}}图片验证码生成(Java2D)
省,这块蛮多的。不多说
获取英文验证码接口
@RestController@RequestMapping("/captcha")publicclassCaptchaController{@ResourceprivateStringRedisTemplateredisTemplate;@GetMapping("/image")publicMap<String,String>getCaptcha()throwsIOException{StringcaptchaKey=UUID.randomUUID().toString();Stringcode=CaptchaUtil.randomCode(4);// 存 Redis(60 秒)redisTemplate.opsForValue().set("captcha:img:"+captchaKey,code,Duration.ofSeconds(60));BufferedImageimage=CaptchaImageUtil.createImage(code);ByteArrayOutputStreamos=newByteArrayOutputStream();ImageIO.write(image,"png",os);Stringbase64=Base64.getEncoder().encodeToString(os.toByteArray());Map<String,String>result=newHashMap<>();result.put("captchaKey",captchaKey);result.put("imageBase64","data:image/png;base64,"+base64);returnresult;}}// 3. 生成图片BufferedImageimage=CaptchaImageUtil.createImage(code);// 4. 设置响应头response.setContentType("image/png");response.setHeader("Captcha-Key",captchaKey);//设置key 或者持久化,记得删除就好response.setHeader("Cache-Control","no-store, no-cache");// 5. 写入输出流ServletOutputStreamos=response.getOutputStream();ImageIO.write(image,"png",os);os.flush();短信发送处理
注意,可以根据返回值来看看删不删验证码。容易被刷库。
publicvoidsendLoginCode(Stringphone,StringcaptchaKey,StringcaptchaValue){StringredisKey="captcha:img:"+captchaKey;StringrealCode=redisTemplate.opsForValue().get(redisKey);// 1. 校验英文验证码if(realCode==null||!realCode.equalsIgnoreCase(captchaValue)){//如果要删除记得处理thrownewRuntimeException("英文验证码错误或已过期");}// 2. 验证通过后立即删除(一次性)///记得删除别流空redisTemplate.delete(redisKey);// 3. 短信发送限流booleanallow=limiter.canSend(phone,1,60_000);if(!allow){thrownewRuntimeException("短信发送过于频繁");}// 4. 生成并发送短信验证码StringsmsCode=String.valueOf((int)((Math.random()*9+1)*100000));System.out.println("发送短信验证码:"+smsCode);// TODO 存储短信验证码}}请求/captcha/image
展示 Base64 图片
提交:
{"phone":"138xxxx","captchaKey":"uuid","captchaValue":"Ab3F"}