server: Go 服务器(REST+WSS 网关+JWT+Argon2+配对+素材直链+任务状态机);修复并发下线 send-on-closed-channel、下发查询 SQL 优先级、配对码原子占用、上传体积上限、JWT 默认密钥告警
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func jwtRegisteredExpired() jwt.RegisteredClaims {
|
||||
return jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
|
||||
Issuer: "everypublish",
|
||||
}
|
||||
}
|
||||
|
||||
func signExpired(secret string, claims AccessClaims) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashVerifyPassword(t *testing.T) {
|
||||
hash, err := HashPassword("everypublish-2026")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Fatalf("bad hash format: %s", hash)
|
||||
}
|
||||
ok, err := VerifyPassword("everypublish-2026", hash)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("verify should pass: ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok, err = VerifyPassword("wrong-pass", hash)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("verify should fail: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAccessRoundTrip(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
token, err := IssueAccess(secret, 42, 7, "user", "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
claims, err := ParseAccess(secret, token)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if claims.UID != 42 || claims.WorkspaceID != 7 || claims.MemberRole != "owner" {
|
||||
t.Fatalf("claims mismatch: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAccessExpired(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
claims := AccessClaims{UID: 1, RegisteredClaims: jwtRegisteredExpired()}
|
||||
token, err := signExpired(secret, claims)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if _, err = ParseAccess(secret, token); err == nil {
|
||||
t.Fatal("expired token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRefreshRoundTrip(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
token, err := IssueRefresh(secret, 9, "jti-1")
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
claims, err := ParseRefresh(secret, token)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if claims.UID != 9 || claims.JTI != "jti-1" {
|
||||
t.Fatalf("claims mismatch: %+v", claims)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// 过期策略:Access 15 分钟 / Refresh 7 天(旋转)/ Invite 24 小时
|
||||
const (
|
||||
AccessTTL = 15 * time.Minute
|
||||
RefreshTTL = 7 * 24 * time.Hour
|
||||
InviteTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// AccessClaims 访问令牌声明
|
||||
type AccessClaims struct {
|
||||
UID uint64 `json:"uid"`
|
||||
WorkspaceID uint64 `json:"wsid"`
|
||||
Role string `json:"role"`
|
||||
MemberRole string `json:"mrole"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// RefreshClaims 刷新令牌声明
|
||||
type RefreshClaims struct {
|
||||
UID uint64 `json:"uid"`
|
||||
JTI string `json:"jti"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// InviteClaims 邀请令牌声明
|
||||
type InviteClaims struct {
|
||||
WorkspaceID uint64 `json:"wsid"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// IssueAccess 签发访问令牌
|
||||
func IssueAccess(secret string, uid, wsid uint64, role, memberRole string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := AccessClaims{
|
||||
UID: uid, WorkspaceID: wsid, Role: role, MemberRole: memberRole,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTTL)),
|
||||
Issuer: "everypublish",
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// IssueRefresh 签发刷新令牌
|
||||
func IssueRefresh(secret string, uid uint64, jti string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := RefreshClaims{
|
||||
UID: uid, JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(RefreshTTL)),
|
||||
Issuer: "everypublish",
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// IssueInvite 签发邀请令牌
|
||||
func IssueInvite(secret string, wsid uint64, email, role string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := InviteClaims{
|
||||
WorkspaceID: wsid, Email: email, Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(InviteTTL)),
|
||||
Issuer: "everypublish",
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ParseAccess 解析访问令牌
|
||||
func ParseAccess(secret, token string) (*AccessClaims, error) {
|
||||
claims := &AccessClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ParseRefresh 解析刷新令牌
|
||||
func ParseRefresh(secret, token string) (*RefreshClaims, error) {
|
||||
claims := &RefreshClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ParseInvite 解析邀请令牌
|
||||
func ParseInvite(secret, token string) (*InviteClaims, error) {
|
||||
claims := &InviteClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id 参数(OWASP 推荐基线)
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// HashPassword 生成 $argon2id$v=19$m=65536,t=1,p=4$salt$hash
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 校验密码(常数时间比较)
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 {
|
||||
return false, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
var memory uint32
|
||||
var timeCost uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||
return false, err
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
Reference in New Issue
Block a user