Files
sundynix-agentix/sundynix-gateway/internal/handler/auth.go
T
Blizzard 149c35c21b feat(gateway): 真实鉴权片1 —— JWT 注册/登录 + 校验中间件(后端核心)
替掉"裸 X-User-ID 头当身份"的临时方案,落地无状态 JWT 鉴权后端:

- internal/auth:JWT 签发/校验(HS256,密钥 env JWT_SECRET,仅接受 HMAC 防 alg 混淆)
  + bcrypt 密码哈希/校验。纯包,含单测。
- User 模型加 Name + PasswordHash(json:"-" 不外泄);store 加 CreateUser/GetUserByEmail/
  GetUserByID(邮箱唯一冲突 → ErrUserExists)。
- handler/auth:POST /auth/register(建用户+签发)· POST /auth/login(校验+签发,
  用户不存在与密码错同一文案防枚举)· GET /auth/me。
- middleware/auth:解析 Bearer JWT,校验通过把已验证 userID 注入上下文(非阻断)。
- userID(c) 改为优先取 JWT 注入的 uid,兜底 X-User-ID 头(前端尚未接登录,保持可用)。

验证:
- 单测:JWT 签发/解析往返、过期拒绝、篡改/非法拒绝、bcrypt 哈希校验。
- 实跑(nats+pg+gateway):注册→token+user(无密码)、重复注册 409、错密码 401、
  /auth/me 带 token 200 / 无 token 401;owner 隔离改用已验证 uid —— 带 token 建的库
  匿名/伪造 header 都看不到(JWT 用户数据归于雪花 id,header 无法臆测)。

片 2 待做:前端登录页 + 存令牌带 Bearer + 处理 401 + 去掉 header 兜底 + 保护路由。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:14:21 +08:00

96 lines
2.9 KiB
Go

package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/auth"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// 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))
if !strings.Contains(email, "@") || len(body.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "需合法邮箱且密码至少 6 位"})
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
}
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))
u, err := h.db.GetUserByEmail(c.Request.Context(), email)
// 用户不存在与密码错误返回同一文案,避免邮箱枚举。
if err != nil || u == nil || !auth.CheckPassword(u.PasswordHash, body.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
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)})
}