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("错误密码应校验失败")
}
}