refactor(auth): migrate JWT handling to v5
Use RegisteredClaims and ParseWithClaims, restrict accepted tokens to HS256, and keep existing claim names and signatures wire-compatible. Add regression coverage for generated and legacy tokens, expiry validation, and algorithm rejection.
This commit is contained in:
4 files changed
+89
-19
No files matched your search
@@ -20,7 +20,7 @@ require (
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/gofiber/fiber/v2 v2.52.14
|
||||
github.com/gofiber/swagger v1.1.1
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/iancoleman/strcase v0.3.0
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||
github.com/jeremywohl/flatten v1.0.1
|
||||
@@ -84,7 +84,6 @@ require (
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
|
||||
@@ -184,8 +184,6 @@ github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA
|
||||
github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
|
||||
+18
-15
@@ -1,7 +1,6 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -9,23 +8,23 @@ import (
|
||||
"github.com/artalkjs/artalk/v2/internal/core"
|
||||
"github.com/artalkjs/artalk/v2/internal/entity"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// jwtCustomClaims are custom claims extending default ones.
|
||||
// See https://github.com/golang-jwt/jwt for more examples
|
||||
type jwtCustomClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
jwt.StandardClaims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func LoginGetUserToken(user entity.User, key string, ttl int) (string, error) {
|
||||
// Set custom claims
|
||||
claims := &jwtCustomClaims{
|
||||
UserID: user.ID,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
IssuedAt: time.Now().Unix(), // 签发时间
|
||||
ExpiresAt: time.Now().Add(time.Second * time.Duration(ttl)).Unix(), // 过期时间
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()), // 签发时间
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(ttl))), // 过期时间
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,22 +61,22 @@ func GetJwtDataByReq(app *core.App, c *fiber.Ctx) (jwtCustomClaims, error) {
|
||||
if token == "" {
|
||||
return jwtCustomClaims{}, ErrTokenNotProvided
|
||||
}
|
||||
return parseJWTToken(token, app.Conf().AppKey)
|
||||
}
|
||||
|
||||
jwt, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method.Alg() != "HS256" {
|
||||
func parseJWTToken(token, key string) (jwtCustomClaims, error) {
|
||||
claims := jwtCustomClaims{}
|
||||
_, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||
return nil, fmt.Errorf("unexpected jwt signing method=%v", t.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(app.Conf().AppKey), nil // 密钥
|
||||
})
|
||||
return []byte(key), nil // 密钥
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
if err != nil {
|
||||
return jwtCustomClaims{}, err
|
||||
}
|
||||
|
||||
claims := jwtCustomClaims{}
|
||||
tmp, _ := json.Marshal(jwt.Claims)
|
||||
_ = json.Unmarshal(tmp, &claims)
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
@@ -93,7 +92,11 @@ func GetUserByReq(app *core.App, c *fiber.Ctx) (entity.User, error) {
|
||||
}
|
||||
|
||||
// check tokenValidFrom
|
||||
if user.TokenValidFrom.Valid && claims.IssuedAt < user.TokenValidFrom.Time.Unix() {
|
||||
issuedAt := int64(0)
|
||||
if claims.IssuedAt != nil {
|
||||
issuedAt = claims.IssuedAt.Unix()
|
||||
}
|
||||
if user.TokenValidFrom.Valid && issuedAt < user.TokenValidFrom.Time.Unix() {
|
||||
return entity.User{}, ErrTokenInvalidFromDate
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/artalkjs/artalk/v2/internal/entity"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoginGetUserToken(t *testing.T) {
|
||||
const key = "test-secret"
|
||||
user := entity.User{}
|
||||
user.ID = 42
|
||||
token, err := LoginGetUserToken(user, key, 60)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := parseJWTToken(token, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint(42), claims.UserID)
|
||||
assert.WithinDuration(t, time.Now(), claims.IssuedAt.Time, time.Second)
|
||||
assert.WithinDuration(t, time.Now().Add(time.Minute), claims.ExpiresAt.Time, time.Second)
|
||||
}
|
||||
|
||||
func TestParseJWTTokenAcceptsLegacyClaims(t *testing.T) {
|
||||
const key = "test-secret"
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"user_id":42,"exp":4102444800,"iat":1600000000}`))
|
||||
unsigned := header + "." + payload
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
_, _ = mac.Write([]byte(unsigned))
|
||||
token := unsigned + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
claims, err := parseJWTToken(token, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint(42), claims.UserID)
|
||||
assert.Equal(t, int64(1600000000), claims.IssuedAt.Unix())
|
||||
}
|
||||
|
||||
func TestParseJWTTokenRejectsUnexpectedAlgorithm(t *testing.T) {
|
||||
const key = "test-secret"
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS384, jwt.MapClaims{
|
||||
"user_id": 42,
|
||||
"exp": time.Now().Add(time.Minute).Unix(),
|
||||
}).SignedString([]byte(key))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = parseJWTToken(token, key)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), fmt.Sprintf("signing method %s is invalid", jwt.SigningMethodHS384.Alg()))
|
||||
}
|
||||
|
||||
func TestParseJWTTokenRejectsExpiredToken(t *testing.T) {
|
||||
const key = "test-secret"
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": 42,
|
||||
"exp": time.Now().Add(-time.Minute).Unix(),
|
||||
}).SignedString([]byte(key))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = parseJWTToken(token, key)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, jwt.ErrTokenExpired)
|
||||
}
|
||||
Reference in new issue
Block a user