Files
sundynix-agentix/sundynix-gateway/internal/handler/auth.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

118 lines
4.0 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 handler
import (
"errors"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/auth"
"github.com/sundynix/sundynix-gateway/internal/store"
)
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// userJSON 是对外的用户视图(绝不含密码哈希)。
func userJSON(u *store.User) gin.H {
return gin.H{"id": u.ID, "email": u.Email, "name": u.Name}
}
// Register: POST /api/v1/auth/register {email, password, name} —— 注册并签发 JWT。
func (h *Handler) Register(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := strings.TrimSpace(strings.ToLower(body.Email))
// 密码 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": "需合法邮箱,密码 872 位"})
return
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码处理失败"})
return
}
u, err := h.db.CreateUser(c.Request.Context(), email, strings.TrimSpace(body.Name), hash)
if err != nil {
if errors.Is(err, store.ErrUserExists) {
c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 多租户:每个新用户建一个单人默认租户(owner)。失败不阻断注册(中间件会兜底补建)。
if _, e := h.db.EnsureDefaultTenant(c.Request.Context(), u.ID, firstNonEmpty(strings.TrimSpace(body.Name), email)); e != nil {
log.Printf("[auth] 建默认租户失败 uid=%s: %v", u.ID, e)
}
issueToken(c, u)
}
// Login: POST /api/v1/auth/login {email, password} —— 校验并签发 JWT。
func (h *Handler) Login(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := strings.TrimSpace(strings.ToLower(body.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)
}
// Me: GET /api/v1/auth/me —— 返回当前登录用户(无有效令牌则 401)。
func (h *Handler) Me(c *gin.Context) {
uid := userID(c)
if uid == "" || uid == "anonymous" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
u, err := h.db.GetUserByID(c.Request.Context(), uid)
if err != nil || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
c.JSON(http.StatusOK, gin.H{"user": userJSON(u)})
}
// issueToken 为用户签发 JWT 并返回 {token, user}。
func issueToken(c *gin.Context, u *store.User) {
token, err := auth.Issue(u.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "user": userJSON(u)})
}