spark-server/internal/module/auth/logic/jwt.go
2026-09-20 17:49:09 +08:00

333 lines
8.7 KiB
Go

// Package logic is the auth application use-case layer (JWT, login, denylist).
package logic
import (
"context"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
authentity "spark-server/internal/module/auth/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
)
// Service validates an access token. HTTP middleware and TCP AUTH share this.
type Service interface {
Authenticate(ctx context.Context, token string) (*authentity.Principal, error)
}
// Issuer mints, refreshes, and revokes access tokens.
type Issuer interface {
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error
LogoutAll(ctx context.Context, userID int64) error
Revoke(ctx context.Context, token string) error
}
// TokenService is JWT: authenticate, issue, and revoke.
type TokenService interface {
Service
Issuer
}
const (
defaultIssuer = "spark-im"
defaultTTL = 7 * 24 * time.Hour
defaultRefreshTTL = 30 * 24 * time.Hour
)
// Options configure HS256 JWT issuance and validation.
type Options struct {
Secret string
Issuer string
TTL time.Duration
RefreshTTL time.Duration
Denylist Denylist
Pairs PairStore
Clock func() time.Time
}
type claims struct {
DeviceID uint64 `json:"did"`
Use string `json:"use,omitempty"`
jwt.RegisteredClaims
}
// JWT implements TokenService with HS256 and an optional jti denylist.
type JWT struct {
secret []byte
issuer string
ttl time.Duration
refreshTTL time.Duration
denylist Denylist
pairs PairStore
clock func() time.Time
}
func NewJWT(opt *Options) (*JWT, error) {
if opt == nil || strings.TrimSpace(opt.Secret) == "" {
return nil, errs.ErrInner.WithInnerMsg("jwt: secret is required")
}
issuer := opt.Issuer
if issuer == "" {
issuer = defaultIssuer
}
ttl := opt.TTL
if ttl <= 0 {
ttl = defaultTTL
}
refreshTTL := opt.RefreshTTL
if refreshTTL <= 0 {
refreshTTL = defaultRefreshTTL
}
dl := opt.Denylist
if dl == nil {
dl = NewMemoryDenylist()
}
pairs := opt.Pairs
if pairs == nil {
pairs = NewMemoryPairs()
}
clock := opt.Clock
if clock == nil {
clock = time.Now
}
return &JWT{
secret: []byte(opt.Secret),
issuer: issuer,
ttl: ttl,
refreshTTL: refreshTTL,
denylist: dl,
pairs: pairs,
clock: clock,
}, nil
}
func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) {
if err := ctx.Err(); err != nil {
return nil, errs.FromRPCError("jwt", err)
}
if userID <= 0 || deviceID == 0 {
return nil, errs.ErrInvalidArgument
}
access, err := j.sign(userID, deviceID, tokenUseAccess, j.ttl)
if err != nil {
return nil, err
}
refresh, err := j.sign(userID, deviceID, tokenUseRefresh, j.refreshTTL)
if err != nil {
return nil, err
}
if err := j.replacePair(ctx, userID, deviceID, access.jti, refresh.jti); err != nil {
return nil, err
}
return &authentity.IssuedToken{
AccessToken: access.raw,
RefreshToken: refresh.raw,
ExpiresIn: int64(j.ttl / time.Second),
RefreshExpiresIn: int64(j.refreshTTL / time.Second),
UserID: userID,
DeviceID: deviceID,
}, nil
}
type signedToken struct {
raw string
jti string
}
func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration) (signedToken, error) {
now := j.clock()
jti := reqctx.NewID()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
DeviceID: deviceID,
Use: use,
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.FormatInt(userID, 10),
Issuer: j.issuer,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
ID: jti,
},
})
raw, err := tok.SignedString(j.secret)
if err != nil {
return signedToken{}, errs.ErrInner.WithError("jwt", err)
}
return signedToken{raw: raw, jti: jti}, nil
}
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error {
old, err := j.pairs.Get(ctx, userID, deviceID)
if err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
if old.Access != "" {
_ = j.denylist.Add(ctx, old.Access, j.ttl)
}
if old.Refresh != "" {
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
}
if err := j.pairs.Put(ctx, userID, deviceID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
return nil
}
func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) {
c, err := j.parse(refreshToken, false)
if err != nil {
return nil, err
}
if c.Use != tokenUseRefresh {
return nil, errs.ErrUnauthorized
}
denied, err := j.denylist.Has(ctx, c.ID)
if err != nil {
return nil, errs.ErrAvailable.WithError("jwt-denylist", err)
}
if denied {
return nil, errs.ErrUnauthorized
}
uid, err := strconv.ParseInt(c.Subject, 10, 64)
if err != nil || uid <= 0 || c.DeviceID == 0 {
return nil, errs.ErrUnauthorized
}
stored, err := j.pairs.Get(ctx, uid, c.DeviceID)
if err != nil {
return nil, errs.ErrAvailable.WithError("jwt-refresh", err)
}
if stored.Refresh == "" || stored.Refresh != c.ID {
return nil, errs.ErrUnauthorized
}
return j.Issue(ctx, uid, c.DeviceID)
}
func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error {
if accessJTI != "" {
remain := accessExp.Sub(j.clock())
if remain > 0 {
if err := j.denylist.Add(ctx, accessJTI, remain); err != nil {
return errs.ErrAvailable.WithError("jwt-denylist", err)
}
}
}
old, err := j.pairs.Get(ctx, userID, deviceID)
if err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
if old.Access != "" && old.Access != accessJTI {
_ = j.denylist.Add(ctx, old.Access, j.ttl)
}
if old.Refresh != "" {
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
}
if err := j.pairs.Delete(ctx, userID, deviceID); err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
return nil
}
func (j *JWT) LogoutAll(ctx context.Context, userID int64) error {
if userID <= 0 {
return errs.ErrInvalidArgument
}
rows, err := j.pairs.ListByUser(ctx, userID)
if err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
for i := range rows {
row := rows[i]
if row.Pair.Access != "" {
_ = j.denylist.Add(ctx, row.Pair.Access, j.ttl)
}
if row.Pair.Refresh != "" {
_ = j.denylist.Add(ctx, row.Pair.Refresh, j.refreshTTL)
}
if err := j.pairs.Delete(ctx, userID, row.DeviceID); err != nil {
return errs.ErrAvailable.WithError("jwt-refresh", err)
}
}
return nil
}
func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) {
c, err := j.parse(token, false)
if err != nil {
return nil, err
}
denied, err := j.denylist.Has(ctx, c.ID)
if err != nil {
return nil, errs.ErrAvailable.WithError("jwt-denylist", err)
}
if denied {
return nil, errs.ErrUnauthorized
}
if c.Use != "" && c.Use != tokenUseAccess {
return nil, errs.ErrUnauthorized
}
uid, err := strconv.ParseInt(c.Subject, 10, 64)
if err != nil || uid <= 0 || c.DeviceID == 0 {
return nil, errs.ErrUnauthorized
}
exp := time.Time{}
if c.ExpiresAt != nil {
exp = c.ExpiresAt.Time
}
return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, JTI: c.ID, ExpiresAt: exp}, nil
}
func (j *JWT) Revoke(ctx context.Context, token string) error {
c, err := j.parse(token, true)
if err != nil {
return err
}
if c.ExpiresAt == nil {
return nil
}
remain := c.ExpiresAt.Time.Sub(j.clock())
if remain <= 0 {
return nil
}
if err := j.denylist.Add(ctx, c.ID, remain); err != nil {
return errs.ErrAvailable.WithError("jwt-denylist", err)
}
return nil
}
func (j *JWT) parse(token string, allowExpired bool) (*claims, error) {
if token == "" {
return nil, errs.ErrUnauthorized
}
opts := []jwt.ParserOption{
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithTimeFunc(j.clock),
}
if allowExpired {
opts = append(opts, jwt.WithoutClaimsValidation())
} else {
opts = append(opts, jwt.WithIssuer(j.issuer), jwt.WithIssuedAt(), jwt.WithExpirationRequired())
}
parsed, err := jwt.ParseWithClaims(token, &claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, jwt.ErrSignatureInvalid
}
return j.secret, nil
}, opts...)
if err != nil || parsed == nil || !parsed.Valid {
return nil, errs.ErrUnauthorized
}
c, ok := parsed.Claims.(*claims)
if !ok || c.ID == "" {
return nil, errs.ErrUnauthorized
}
if allowExpired && c.Issuer != j.issuer {
return nil, errs.ErrUnauthorized
}
return c, nil
}