ebbeed90e9
补上 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>
118 lines
4.0 KiB
Go
118 lines
4.0 KiB
Go
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": "需合法邮箱,密码 8–72 位"})
|
||
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)})
|
||
}
|