Files
sundynix-agentix/sundynix-gateway/internal/store/redis.go
T
Blizzard ebbeed90e9 feat(auth): 登录账户级失败锁定 + 密码强度 8-72 位(B4)
补上 A5 只做了 IP 限流、没做账户锁定的缺口(审计 B4)。

- 账户级失败锁定:连续登录失败达 5 次即临时锁定 15min(Redis 计数+TTL),锁定期直接拒绝
  不再校验密码,成功登录即清零。与 A5 的 IP 限流互补:IP 限流挡'一个 IP 猛打',账户锁定
  挡'分布式慢速猜一个号'。权衡:per-account 可被人为锁死受害者(lockout DoS),故窗口设短
  自动解锁+叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
- 密码强度:注册从'至少 6 位'提到 8-72 位。上限 72 字节——bcrypt 超 72 字节静默截断,
  不拦会让超长密码实际只用前 72 字节。桌面端注册提示同步改 8-72 位。

存量用户不受影响(只校验注册/改密时的新密码)。build+vet+test 绿;desktop tsc 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:56:29 +08:00

258 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"log"
"time"
"github.com/redis/go-redis/v9"
)
// Redis 持有 CacheDB 连接(Session / Rate Limit)。
// rdb 为 nil 表示降级模式(连接失败时放行,不限流)。
type Redis struct {
rdb *redis.Client
}
// OpenRedis 连接 CacheDB。连接失败不 fatal:返回降级实例(限流放行)。
func OpenRedis(addr string) *Redis {
rdb := redis.NewClient(&redis.Options{Addr: addr})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
log.Printf("[store] redis 不可用,降级运行(不限流): %v", err)
_ = rdb.Close()
return &Redis{}
}
log.Println("[store] redis connected")
return &Redis{rdb: rdb}
}
// Enabled 报告是否处于真实限流模式。
func (r *Redis) Enabled() bool { return r.rdb != nil }
// Ping 活性探测:发一次 PING,验证 Redis 此刻真的可达(非仅启动时连过)。
func (r *Redis) Ping(ctx context.Context) bool {
if r.rdb == nil {
return false
}
return r.rdb.Ping(ctx).Err() == nil
}
// Allow 滑动窗口计数限流:在 window 内对 key 累加,超过 limit 即拒绝。
// 降级模式(rdb==nil)始终放行。
func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.Duration) (bool, error) {
if r.rdb == nil {
return true, nil
}
rk := "sundynix:ratelimit:" + key
n, err := r.rdb.Incr(ctx, rk).Result()
if err != nil {
return true, err // 限流后端故障时放行,不阻断业务
}
if n == 1 {
_ = r.rdb.Expire(ctx, rk, window).Err() // 首次计数设置窗口过期
}
return n <= limit, nil
}
// ---- 登录失败锁定(账户级,防撞库:连续失败 N 次锁 window,成功即清)----
// 与 A5 的 IP 限流是两道互补闸:IP 限流挡"一个 IP 猛打",账户锁定挡"分布式慢速猜一个号"。
// 权衡:per-account 锁定可被人为锁死受害者账户(lockout DoS),故窗口设短(15min)自动解锁 +
// 叠加 IP 限流;Redis 降级则不锁定(尽力而为,IP 限流仍在)。
const (
loginFailWindow = 15 * time.Minute
loginFailThreshold = 5
)
func loginFailKey(email string) string { return "sundynix:loginfail:" + email }
// LoginLocked 报告账户是否因连续登录失败被临时锁定。降级(rdb==nil)返回 false。
func (r *Redis) LoginLocked(ctx context.Context, email string) bool {
if r.rdb == nil || email == "" {
return false
}
n, err := r.rdb.Get(ctx, loginFailKey(email)).Int()
if err != nil {
return false
}
return n >= loginFailThreshold
}
// NoteLoginFail 记一次登录失败,返回失败后是否已达锁定阈值。首次失败设窗口 TTL
// (之后锁定期内因提前拒绝不再自增 → TTL 自最后一次失败起倒计时,到点自动解锁)。
func (r *Redis) NoteLoginFail(ctx context.Context, email string) bool {
if r.rdb == nil || email == "" {
return false
}
n, err := r.rdb.Incr(ctx, loginFailKey(email)).Result()
if err != nil {
return false
}
if n == 1 {
_ = r.rdb.Expire(ctx, loginFailKey(email), loginFailWindow).Err()
}
return n >= loginFailThreshold
}
// ClearLoginFails 登录成功后清零失败计数。
func (r *Redis) ClearLoginFails(ctx context.Context, email string) {
if r.rdb == nil || email == "" {
return
}
_ = r.rdb.Del(ctx, loginFailKey(email)).Err()
}
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token----
// ---- Token 用量日预算(成本护栏:按用户按天累计 token,供提交前门控)----
// usageDailyKey 是某用户某天的累计 token keyday 形如 20260626,便于到期自然滚动)。
func usageDailyKey(userID, day string) string { return "sundynix:usage:" + userID + ":" + day }
// AddUsage 给某用户当天累计 token,并返回累计后总量(首次设 48h 过期自动清理)。降级返回 (0,nil)。
func (r *Redis) AddUsage(ctx context.Context, userID, day string, tokens int) (int64, error) {
if r.rdb == nil || userID == "" {
return 0, nil
}
rk := usageDailyKey(userID, day)
n, err := r.rdb.IncrBy(ctx, rk, int64(tokens)).Result()
if err != nil {
return 0, err
}
if n == int64(tokens) { // 首次累计:设过期
_ = r.rdb.Expire(ctx, rk, 48*time.Hour).Err()
}
return n, nil
}
// GetUsage 取某用户当天已用 token(不存在/降级返回 0)。
func (r *Redis) GetUsage(ctx context.Context, userID, day string) int64 {
if r.rdb == nil || userID == "" {
return 0
}
n, err := r.rdb.Get(ctx, usageDailyKey(userID, day)).Int64()
if err != nil {
return 0
}
return n
}
// ---- 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢事件)----
// 按 channel 分流:token 走 "stream",执行轨迹走 "exec"——同一套 XADD/XREAD/TTL 逻辑复用。
const streamTTL = 10 * time.Minute
// Channel 是回放流的种类(决定 Redis key 命名空间,互不串扰)。
const (
ChannelToken = "stream" // Token 流(与历史 key 兼容)
ChannelExec = "exec" // 执行轨迹流
)
func streamKey(channel, taskID string) string { return "sundynix:" + channel + ":" + taskID }
// StreamEntry 是回放流里的一条记录(ID 用于 SSE 的 Last-Event-ID 断点续传)。
type StreamEntry struct {
ID string
Kind string // token / exec / done
Data string
}
// StreamAppend 把一条记录追加到任务某 channel 的 Redis Stream(带 TTL 自动清理)。
func (r *Redis) StreamAppend(ctx context.Context, channel, taskID, kind, data string) error {
if r.rdb == nil {
return nil
}
k := streamKey(channel, taskID)
if err := r.rdb.XAdd(ctx, &redis.XAddArgs{Stream: k, Values: map[string]any{"kind": kind, "data": data}}).Err(); err != nil {
return err
}
return r.rdb.Expire(ctx, k, streamTTL).Err()
}
// StreamRead 从 lastID 之后阻塞读取某 channel 的新条目(XREAD BLOCK)。lastID="0" 表示从头回放。
// 阻塞超时无新数据时返回空切片 + 原 lastID(调用方据此继续轮询)。
func (r *Redis) StreamRead(ctx context.Context, channel, taskID, lastID string, block time.Duration) ([]StreamEntry, string, error) {
if r.rdb == nil {
return nil, lastID, nil
}
res, err := r.rdb.XRead(ctx, &redis.XReadArgs{
Streams: []string{streamKey(channel, taskID), lastID}, Block: block, Count: 256,
}).Result()
if err == redis.Nil {
return nil, lastID, nil // 阻塞超时、无新条目
}
if err != nil {
return nil, lastID, err
}
var out []StreamEntry
nl := lastID
for _, st := range res {
for _, m := range st.Messages {
out = append(out, StreamEntry{ID: m.ID, Kind: asString(m.Values["kind"]), Data: asString(m.Values["data"])})
nl = m.ID
}
}
return out, nl, nil
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
// ---- 微信登录 ticket ----
// ticket 是一次性、短命、高频轮询的临时态,用 Redis 存(不落 PG)。
// Redis 降级时回退进程内内存 map —— 本地开发无 Redis 也能登录;生产多实例下内存回退
// 会因 PC 轮询与微信回调可能落在不同实例而失效,所以生产**必须**有 Redis(服务状态页会亮)。
// WxTicketSet 写入 ticket 状态(JSON 值),带 TTL。
func (r *Redis) WxTicketSet(ctx context.Context, ticket, value string, ttl time.Duration) error {
if r.rdb == nil {
memTicketSet(ticket, value, ttl)
return nil
}
return r.rdb.Set(ctx, "wxlogin:"+ticket, value, ttl).Err()
}
// WxTicketGet 读取 ticket 状态;不存在或过期返回空串。
func (r *Redis) WxTicketGet(ctx context.Context, ticket string) string {
if r.rdb == nil {
return memTicketGet(ticket)
}
v, err := r.rdb.Get(ctx, "wxlogin:"+ticket).Result()
if err != nil {
return ""
}
return v
}
// WxTokenGet/Set 缓存微信 access_token(跨实例共享,避免重复拉取互相失效)。
// 无 Redis 时返回空 → 调用方每次现拉(单实例开发可接受)。
func (r *Redis) WxTokenGet(ctx context.Context, appID string) string {
if r.rdb == nil {
return ""
}
v, err := r.rdb.Get(ctx, "wxtoken:"+appID).Result()
if err != nil {
return ""
}
return v
}
func (r *Redis) WxTokenSet(ctx context.Context, appID, token string, ttl time.Duration) {
if r.rdb == nil {
return
}
_ = r.rdb.Set(ctx, "wxtoken:"+appID, token, ttl).Err()
}
// Close 释放底层连接。
func (r *Redis) Close() {
if r.rdb != nil {
_ = r.rdb.Close()
}
}