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>
This commit is contained in:
Blizzard
2026-06-17 16:14:21 +08:00
parent 3ae009db38
commit 149c35c21b
12 changed files with 345 additions and 13 deletions
+69
View File
@@ -0,0 +1,69 @@
// Package auth 提供无状态鉴权能力:JWT 签发/校验 + 密码哈希。
package auth
import (
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// TokenTTL 是访问令牌有效期。
const TokenTTL = 24 * time.Hour
// secret 是 JWT 签名密钥。生产经环境变量 JWT_SECRET 注入;缺省仅供开发(务必覆盖)。
var secret = []byte(envOr("JWT_SECRET", "sundynix-dev-secret-change-me"))
// ErrInvalidToken 表示令牌无效/过期/签名不符。
var ErrInvalidToken = errors.New("invalid token")
// Issue 为某用户签发 JWTsubject = userID)。
func Issue(userID string) (string, error) { return issue(userID, TokenTTL) }
func issue(userID string, ttl time.Duration) (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
// Parse 校验 JWT 并返回 userIDsubject)。无效/过期/签名不符返回 ErrInvalidToken。
func Parse(token string) (string, error) {
t, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { // 仅接受 HMAC,防 alg 混淆攻击
return nil, ErrInvalidToken
}
return secret, nil
})
if err != nil || !t.Valid {
return "", ErrInvalidToken
}
c, ok := t.Claims.(*jwt.RegisteredClaims)
if !ok || c.Subject == "" {
return "", ErrInvalidToken
}
return c.Subject, nil
}
// HashPassword 用 bcrypt 哈希明文密码。
func HashPassword(pw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(b), err
}
// CheckPassword 校验明文与 bcrypt 哈希是否匹配。
func CheckPassword(hash, pw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
@@ -0,0 +1,56 @@
package auth
import (
"testing"
"time"
)
func TestJWT_RoundTrip(t *testing.T) {
tok, err := Issue("user-123")
if err != nil {
t.Fatal(err)
}
uid, err := Parse(tok)
if err != nil {
t.Fatalf("应能解析自签令牌: %v", err)
}
if uid != "user-123" {
t.Errorf("subject = %q, want user-123", uid)
}
}
func TestJWT_Expired(t *testing.T) {
tok, _ := issue("u", -time.Hour) // 已过期
if _, err := Parse(tok); err == nil {
t.Error("过期令牌应拒绝")
}
}
func TestJWT_Tampered(t *testing.T) {
tok, _ := Issue("u")
if _, err := Parse(tok + "x"); err == nil {
t.Error("被篡改令牌应拒绝")
}
if _, err := Parse("not.a.jwt"); err == nil {
t.Error("非法令牌应拒绝")
}
if _, err := Parse(""); err == nil {
t.Error("空令牌应拒绝")
}
}
func TestPassword_HashAndCheck(t *testing.T) {
hash, err := HashPassword("s3cret-pw")
if err != nil {
t.Fatal(err)
}
if hash == "s3cret-pw" || hash == "" {
t.Error("应为 bcrypt 哈希,非明文")
}
if !CheckPassword(hash, "s3cret-pw") {
t.Error("正确密码应校验通过")
}
if CheckPassword(hash, "wrong-pw") {
t.Error("错误密码应校验失败")
}
}
+95
View File
@@ -0,0 +1,95 @@
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)})
}
@@ -184,8 +184,14 @@ func (h *Handler) SetMemory(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content})
}
// userID 从请求取已登录用户标识(真实场景应由鉴权中间件注入)。
// userID 取当前用户标识:优先 JWT 鉴权中间件注入的已验证 uid;
// 兜底 X-User-ID 头(开发期 / 前端尚未接登录),都没有则匿名。
func userID(c *gin.Context) string {
if v, ok := c.Get("uid"); ok {
if s, _ := v.(string); s != "" {
return s
}
}
if u := c.GetHeader("X-User-ID"); u != "" {
return u
}
@@ -0,0 +1,27 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/auth"
)
// CtxUserID 是鉴权后写入 gin.Context 的已验证用户 ID 键。
const CtxUserID = "uid"
// Auth 解析 Authorization: Bearer <JWT>,校验通过则把已验证 userID 写入上下文。
// 非阻断:无 token / 无效 token 时不报错,由各 handler(经 userID 兜底 header)或
// 后续 RequireAuth 决定是否放行。
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
if strings.HasPrefix(h, "Bearer ") {
if uid, err := auth.Parse(strings.TrimSpace(h[len("Bearer "):])); err == nil {
c.Set(CtxUserID, uid)
}
}
c.Next()
}
}
+6 -1
View File
@@ -16,11 +16,16 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
r := gin.Default()
r.Use(cors()) // 桌面端/浏览器跨源访问(开发期放开)
r.Use(middleware.RateLimit(cache))
r.Use(middleware.Guardrail()) // Harness: Input/Output Guardrail
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)
r.Use(middleware.Guardrail()) // Harness: Input Guardrail
h := handler.New(db, cache, bus, blobStore)
api := r.Group("/api/v1")
{
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
api.GET("/auth/me", h.Me) // 当前登录用户
api.POST("/tasks", h.SubmitTask) // 1. 解析 DSL 并 Publish 到 NATS
api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream
api.GET("/tasks/:id/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测)
+3 -1
View File
@@ -8,7 +8,9 @@ package store
// User 是平台用户(Users)。
type User struct {
BaseModel
Email string `gorm:"uniqueIndex;size:255"`
Email string `gorm:"uniqueIndex;size:255"`
Name string `gorm:"size:64"`
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
}
// Task 是一次提交的 Agent 编排任务(DSL)。
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"context"
"errors"
"gorm.io/gorm"
)
// ErrUserExists 表示邮箱已注册(唯一约束冲突)。
var ErrUserExists = errors.New("email already registered")
// CreateUser 新建用户(密码已在调用方 bcrypt 哈希)。邮箱重复返回 ErrUserExists。
func (p *Postgres) CreateUser(ctx context.Context, email, name, passwordHash string) (*User, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var existed int64
p.db.WithContext(ctx).Model(&User{}).Where("email = ?", email).Count(&existed)
if existed > 0 {
return nil, ErrUserExists
}
u := &User{Email: email, Name: name, PasswordHash: passwordHash}
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
return nil, err
}
return u, nil
}
// GetUserByEmail 按邮箱取用户(登录用)。
func (p *Postgres) GetUserByEmail(ctx context.Context, email string) (*User, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var u User
if err := p.db.WithContext(ctx).Where("email = ?", email).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// GetUserByID 按主键取用户(鉴权后取当前用户)。
func (p *Postgres) GetUserByID(ctx context.Context, id string) (*User, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var u User
if err := p.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &u, nil
}