diff --git a/sundynix-desktop/frontend/src/views/Login.tsx b/sundynix-desktop/frontend/src/views/Login.tsx
index c360382..bb77b5d 100644
--- a/sundynix-desktop/frontend/src/views/Login.tsx
+++ b/sundynix-desktop/frontend/src/views/Login.tsx
@@ -50,7 +50,7 @@ export function Login({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!isRegister} onKeyDown={(e) => e.key === "Enter" && submit()} />
- setPassword(e.target.value)} placeholder={isRegister ? "至少 6 位" : "••••••••"} onKeyDown={(e) => e.key === "Enter" && submit()} />
+ setPassword(e.target.value)} placeholder={isRegister ? "8–72 位" : "••••••••"} onKeyDown={(e) => e.key === "Enter" && submit()} />
diff --git a/sundynix-gateway/internal/handler/auth.go b/sundynix-gateway/internal/handler/auth.go
index a398ffa..74f3ef5 100644
--- a/sundynix-gateway/internal/handler/auth.go
+++ b/sundynix-gateway/internal/handler/auth.go
@@ -36,8 +36,10 @@ func (h *Handler) Register(c *gin.Context) {
return
}
email := strings.TrimSpace(strings.ToLower(body.Email))
- if !strings.Contains(email, "@") || len(body.Password) < 6 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱且密码至少 6 位"})
+ // 密码 8–72 位:8 起步(此前 6 太弱);上限 72 字节——bcrypt 超 72 字节静默截断,
+ // 不拦会让"超长密码"实际只用前 72 字节,用户以为更安全其实不然。
+ if !strings.Contains(email, "@") || len(body.Password) < 8 || len(body.Password) > 72 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱,密码 8–72 位"})
return
}
hash, err := auth.HashPassword(body.Password)
@@ -72,12 +74,20 @@ func (h *Handler) Login(c *gin.Context) {
return
}
email := strings.TrimSpace(strings.ToLower(body.Email))
- u, err := h.db.GetUserByEmail(c.Request.Context(), email)
+ ctx := c.Request.Context()
+ // 账户级锁定:连续失败达阈值即临时拒绝,不再校验密码(挡分布式慢速撞库;与 A5 IP 限流互补)。
+ if h.cache.LoginLocked(ctx, email) {
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "登录失败次数过多,账户已临时锁定,请稍后再试"})
+ return
+ }
+ u, err := h.db.GetUserByEmail(ctx, email)
// 用户不存在与密码错误返回同一文案,避免邮箱枚举。
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, body.Password) {
+ h.cache.NoteLoginFail(ctx, email) // 记一次失败,累计到阈值即锁定
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
+ h.cache.ClearLoginFails(ctx, email) // 成功即清零
issueToken(c, u)
}
diff --git a/sundynix-gateway/internal/store/redis.go b/sundynix-gateway/internal/store/redis.go
index 5f804f6..ec830f1 100644
--- a/sundynix-gateway/internal/store/redis.go
+++ b/sundynix-gateway/internal/store/redis.go
@@ -56,6 +56,53 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.
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,供提交前门控)----
diff --git a/sundynix-gateway/internal/store/redis_test.go b/sundynix-gateway/internal/store/redis_test.go
index 3f1bf71..3c68227 100644
--- a/sundynix-gateway/internal/store/redis_test.go
+++ b/sundynix-gateway/internal/store/redis_test.go
@@ -16,3 +16,16 @@ func TestStreamKey_ChannelIsolation(t *testing.T) {
t.Errorf("exec key=%q", ex)
}
}
+
+// 登录失败计数 key 独立命名空间,且阈值/窗口是合理防撞库值。
+func TestLoginFailKey(t *testing.T) {
+ if k := loginFailKey("a@b.com"); k != "sundynix:loginfail:a@b.com" {
+ t.Fatalf("key=%q", k)
+ }
+ if loginFailThreshold < 3 || loginFailThreshold > 10 {
+ t.Fatalf("阈值应在 3–10 合理区间,得 %d", loginFailThreshold)
+ }
+ if loginFailWindow < 5*60*1e9 {
+ t.Fatal("锁定窗口不该太短")
+ }
+}