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
+2 -2
View File
@@ -81,14 +81,14 @@
- [x] DB 规约全库统一:雪花字符串 id + created/updated + 软删(gateway 各表 + mcp-go Profile
- [x] 文件主表,文档间关联用雪花 ID(弃用按名关联)
- [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移)
- [ ] 🟡 owner 隔离靠 `X-User-ID` 头 —— 可用但**可伪造,无真实鉴权**
- [ ] 🟡 真实鉴权(JWT):后端核心已完成 ✅(注册/登录签发 JWT + 校验中间件 + owner 取已验证 uid + 单测/实跑);**前端登录页 + 去掉 header 兜底 + 保护路由**待做(片 2)
- [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试)
---
## 未实现的大块(路线图)
- [ ] **真实登录 / 鉴权 / 会话**替掉裸 `X-User-ID`,最影响"能否交付他人用"
- [ ] 🟡 **真实登录 / 鉴权**JWT 后端核心 ✅;前端登录 + 强制鉴权 = 片 2 待做
- [ ] **代码解释器 + 安全沙箱**mcp-py 核心能力,目前全桩)
- [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成)
- [ ] **长期记忆抽取** + external_api 工具
+7 -3
View File
@@ -14,9 +14,9 @@ github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiG
github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o=
github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -50,8 +50,9 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
@@ -62,10 +63,12 @@ golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
@@ -73,6 +76,7 @@ golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
@@ -83,7 +87,7 @@ golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
nullprogram.com/x/optparse v1.0.0 h1:xGFgVi5ZaWOnYdac2foDT3vg0ZZC9ErXFV57mr4OHrI=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
+6 -5
View File
@@ -27,6 +27,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -58,11 +59,11 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+12
View File
@@ -36,6 +36,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -135,15 +137,25 @@ golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+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 回流执行轨迹事件(运行·观测)
@@ -9,6 +9,8 @@ package store
type User struct {
BaseModel
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
}