diff --git a/PROGRESS.md b/PROGRESS.md index d19a14b..a2e6e46 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 工具 diff --git a/go.work.sum b/go.work.sum index 4a1da75..a32b6ef 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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= diff --git a/sundynix-gateway/go.mod b/sundynix-gateway/go.mod index a2a8fd6..a55a57e 100644 --- a/sundynix-gateway/go.mod +++ b/sundynix-gateway/go.mod @@ -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 diff --git a/sundynix-gateway/go.sum b/sundynix-gateway/go.sum index 0c96ee6..90533c1 100644 --- a/sundynix-gateway/go.sum +++ b/sundynix-gateway/go.sum @@ -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= diff --git a/sundynix-gateway/internal/auth/auth.go b/sundynix-gateway/internal/auth/auth.go new file mode 100644 index 0000000..17474d1 --- /dev/null +++ b/sundynix-gateway/internal/auth/auth.go @@ -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 为某用户签发 JWT(subject = 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 并返回 userID(subject)。无效/过期/签名不符返回 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 +} diff --git a/sundynix-gateway/internal/auth/auth_test.go b/sundynix-gateway/internal/auth/auth_test.go new file mode 100644 index 0000000..6c3e495 --- /dev/null +++ b/sundynix-gateway/internal/auth/auth_test.go @@ -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("错误密码应校验失败") + } +} diff --git a/sundynix-gateway/internal/handler/auth.go b/sundynix-gateway/internal/handler/auth.go new file mode 100644 index 0000000..6f907c3 --- /dev/null +++ b/sundynix-gateway/internal/handler/auth.go @@ -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)}) +} diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 6c9be41..19d25e8 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -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 } diff --git a/sundynix-gateway/internal/middleware/auth.go b/sundynix-gateway/internal/middleware/auth.go new file mode 100644 index 0000000..02bdc6d --- /dev/null +++ b/sundynix-gateway/internal/middleware/auth.go @@ -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 ,校验通过则把已验证 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() + } +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index d5be11a..926e284 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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 回流执行轨迹事件(运行·观测) diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go index 060fc9a..fbe4ae1 100644 --- a/sundynix-gateway/internal/store/models.go +++ b/sundynix-gateway/internal/store/models.go @@ -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)。 diff --git a/sundynix-gateway/internal/store/user.go b/sundynix-gateway/internal/store/user.go new file mode 100644 index 0000000..ffc8ec6 --- /dev/null +++ b/sundynix-gateway/internal/store/user.go @@ -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 +}