mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-25 19:56:46 +00:00
Prevent JWT login token reuse
This commit is contained in:
61
management/server/auth/session.go
Normal file
61
management/server/auth/session.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/cache"
|
||||
"github.com/eko/gocache/lib/v4/store"
|
||||
)
|
||||
|
||||
const (
|
||||
usedTokenKeyPrefix = "jwt-used:"
|
||||
usedTokenMarker = "1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenAlreadyUsed = errors.New("JWT already used")
|
||||
ErrTokenExpired = errors.New("JWT expired")
|
||||
)
|
||||
|
||||
type SessionStore struct {
|
||||
cache *cache.Cache[string]
|
||||
}
|
||||
|
||||
func NewSessionStore(cacheStore store.StoreInterface) *SessionStore {
|
||||
return &SessionStore{cache: cache.New[string](cacheStore)}
|
||||
}
|
||||
|
||||
// RegisterToken records a JWT until its exp time and rejects reuse.
|
||||
func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresAt time.Time) error {
|
||||
ttl := time.Until(expiresAt)
|
||||
if ttl <= 0 {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
key := usedTokenKeyPrefix + hashToken(token)
|
||||
_, err := s.cache.Get(ctx, key)
|
||||
if err == nil {
|
||||
return ErrTokenAlreadyUsed
|
||||
}
|
||||
|
||||
var notFound *store.NotFound
|
||||
if !errors.As(err, ¬Found) {
|
||||
return fmt.Errorf("failed to lookup used token entry: %w", err)
|
||||
}
|
||||
|
||||
if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil {
|
||||
return fmt.Errorf("failed to store used token entry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
82
management/server/auth/session_test.go
Normal file
82
management/server/auth/session_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
)
|
||||
|
||||
func newTestSessionStore(t *testing.T) *SessionStore {
|
||||
t.Helper()
|
||||
cacheStore, err := nbcache.NewStore(context.Background(), time.Hour, time.Hour, 100)
|
||||
require.NoError(t, err)
|
||||
return NewSessionStore(cacheStore)
|
||||
}
|
||||
|
||||
func TestSessionStore_FirstRegisterSucceeds(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.RegisterToken(ctx, "token", time.Now().Add(time.Hour)))
|
||||
}
|
||||
|
||||
func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
token := "token"
|
||||
exp := time.Now().Add(time.Hour)
|
||||
|
||||
require.NoError(t, s.RegisterToken(ctx, token, exp))
|
||||
|
||||
err := s.RegisterToken(ctx, token, exp)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
}
|
||||
|
||||
func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
exp := time.Now().Add(time.Hour)
|
||||
|
||||
require.NoError(t, s.RegisterToken(ctx, "tokenA", exp))
|
||||
require.NoError(t, s.RegisterToken(ctx, "tokenB", exp))
|
||||
}
|
||||
|
||||
func TestSessionStore_RegisterWithPastExpiryIsRejected(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
token := "token"
|
||||
|
||||
err := s.RegisterToken(ctx, token, time.Now().Add(-time.Second))
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrTokenExpired)
|
||||
}
|
||||
|
||||
func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) {
|
||||
s := newTestSessionStore(t)
|
||||
ctx := context.Background()
|
||||
token := "token"
|
||||
|
||||
require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(50*time.Millisecond)))
|
||||
|
||||
err := s.RegisterToken(ctx, token, time.Now().Add(50*time.Millisecond))
|
||||
assert.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
|
||||
require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour)))
|
||||
}
|
||||
|
||||
func TestHashToken_StableAndDoesNotLeak(t *testing.T) {
|
||||
a := hashToken("tokenA")
|
||||
b := hashToken("tokenB")
|
||||
assert.Equal(t, a, hashToken("tokenA"), "hash must be deterministic")
|
||||
assert.NotEqual(t, a, b, "different tokens must hash differently")
|
||||
assert.Len(t, a, 64, "sha256 hex must be 64 chars")
|
||||
assert.NotContains(t, a, "tokenA", "raw token must not appear in hash")
|
||||
}
|
||||
Reference in New Issue
Block a user