384 lines
10 KiB
Go
384 lines
10 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, appID uint32) (*authentity.IssuedToken, error)
|
|
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
|
|
Logout(ctx context.Context, userID int64, deviceID uint64, appID uint32, 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
|
|
PairKeyMode PairKeyMode
|
|
Clock func() time.Time
|
|
}
|
|
|
|
type claims struct {
|
|
DeviceID uint64 `json:"did"`
|
|
AppID uint32 `json:"aid"`
|
|
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
|
|
pairMode PairKeyMode
|
|
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,
|
|
pairMode: normalizePairKeyMode(opt.PairKeyMode),
|
|
clock: clock,
|
|
}, nil
|
|
}
|
|
|
|
func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64, appID uint32) (*authentity.IssuedToken, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, errs.FromRPCError("jwt", err)
|
|
}
|
|
if userID <= 0 || deviceID == 0 || appID == 0 {
|
|
return nil, errs.ErrInvalidArgument
|
|
}
|
|
access, err := j.sign(userID, deviceID, appID, tokenUseAccess, j.ttl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
refresh, err := j.sign(userID, deviceID, appID, tokenUseRefresh, j.refreshTTL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := j.replacePair(ctx, userID, deviceID, appID, 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,
|
|
AppID: appID,
|
|
}, nil
|
|
}
|
|
|
|
type signedToken struct {
|
|
raw string
|
|
jti string
|
|
}
|
|
|
|
func (j *JWT) sign(userID int64, deviceID uint64, appID uint32, use string, ttl time.Duration) (signedToken, error) {
|
|
now := j.clock()
|
|
jti := reqctx.NewID()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
|
|
DeviceID: deviceID,
|
|
AppID: appID,
|
|
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, appID uint32, accessJTI, refreshJTI string) error {
|
|
var old TokenPair
|
|
var err error
|
|
if j.pairMode == PairKeyPair {
|
|
old, err = j.pairs.Get(ctx, userID, deviceID, 0)
|
|
} else {
|
|
old, err = j.pairs.Get(ctx, userID, deviceID, appID)
|
|
}
|
|
if err != nil {
|
|
return errs.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
if err := j.revokePairJTIs(ctx, old); err != nil {
|
|
return err
|
|
}
|
|
if j.pairMode == PairKeyPair {
|
|
if err := j.pairs.DeleteAllOnDevice(ctx, userID, deviceID); err != nil {
|
|
return errs.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
}
|
|
if err := j.pairs.Put(ctx, userID, deviceID, appID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil {
|
|
return errs.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *JWT) revokePairJTIs(ctx context.Context, old TokenPair) error {
|
|
if old.Access != "" {
|
|
_ = j.denylist.Add(ctx, old.Access, j.ttl)
|
|
}
|
|
if old.Refresh != "" {
|
|
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
|
|
}
|
|
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.FromDependencyError("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 || c.AppID == 0 {
|
|
return nil, errs.ErrUnauthorized
|
|
}
|
|
stored, err := j.pairs.Get(ctx, uid, c.DeviceID, j.storeAppID(c.AppID))
|
|
if err != nil {
|
|
return nil, errs.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
if stored.Refresh == "" || stored.Refresh != c.ID {
|
|
return nil, errs.ErrUnauthorized
|
|
}
|
|
return j.Issue(ctx, uid, c.DeviceID, c.AppID)
|
|
}
|
|
|
|
func (j *JWT) storeAppID(appID uint32) uint32 {
|
|
if j.pairMode == PairKeyPair {
|
|
return 0
|
|
}
|
|
return appID
|
|
}
|
|
|
|
func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, appID uint32, 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.FromDependencyError("jwt-denylist", err)
|
|
}
|
|
}
|
|
}
|
|
var old TokenPair
|
|
var err error
|
|
if j.pairMode == PairKeyPair {
|
|
old, err = j.pairs.Get(ctx, userID, deviceID, 0)
|
|
} else {
|
|
old, err = j.pairs.Get(ctx, userID, deviceID, appID)
|
|
}
|
|
if err != nil {
|
|
return errs.FromDependencyError("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 j.pairMode == PairKeyPair {
|
|
if err := j.pairs.DeleteAllOnDevice(ctx, userID, deviceID); err != nil {
|
|
return errs.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err := j.pairs.Delete(ctx, userID, deviceID, appID); err != nil {
|
|
return errs.FromDependencyError("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.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
seen := make(map[uint64]struct{})
|
|
for i := range rows {
|
|
row := rows[i]
|
|
if err := j.revokePairJTIs(ctx, row.Pair); err != nil {
|
|
return err
|
|
}
|
|
if j.pairMode == PairKeyPair {
|
|
if _, ok := seen[row.DeviceID]; ok {
|
|
continue
|
|
}
|
|
seen[row.DeviceID] = struct{}{}
|
|
if err := j.pairs.DeleteAllOnDevice(ctx, userID, row.DeviceID); err != nil {
|
|
return errs.FromDependencyError("jwt-refresh", err)
|
|
}
|
|
continue
|
|
}
|
|
if err := j.pairs.Delete(ctx, userID, row.DeviceID, row.AppID); err != nil {
|
|
return errs.FromDependencyError("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.FromDependencyError("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 || c.AppID == 0 {
|
|
return nil, errs.ErrUnauthorized
|
|
}
|
|
exp := time.Time{}
|
|
if c.ExpiresAt != nil {
|
|
exp = c.ExpiresAt.Time
|
|
}
|
|
return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, AppID: c.AppID, 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.FromDependencyError("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
|
|
}
|