feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
type Actor struct {
|
||||
UserID int64
|
||||
TenantID int64
|
||||
Role string
|
||||
}
|
||||
|
||||
type actorKey struct{}
|
||||
|
||||
func WithActor(ctx context.Context, actor Actor) context.Context {
|
||||
return context.WithValue(ctx, actorKey{}, actor)
|
||||
}
|
||||
|
||||
func ActorFromCtx(ctx context.Context) (Actor, bool) {
|
||||
actor, ok := ctx.Value(actorKey{}).(Actor)
|
||||
return actor, ok
|
||||
}
|
||||
|
||||
func MustActor(ctx context.Context) Actor {
|
||||
actor, ok := ActorFromCtx(ctx)
|
||||
if !ok {
|
||||
panic("auth: actor not in context")
|
||||
}
|
||||
return actor
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWithActorAndFromCtx(t *testing.T) {
|
||||
actor := Actor{UserID: 1, TenantID: 10, Role: "editor"}
|
||||
ctx := WithActor(context.Background(), actor)
|
||||
|
||||
got, ok := ActorFromCtx(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, actor, got)
|
||||
}
|
||||
|
||||
func TestActorFromCtxMissing(t *testing.T) {
|
||||
_, ok := ActorFromCtx(context.Background())
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestMustActorPanics(t *testing.T) {
|
||||
assert.Panics(t, func() {
|
||||
MustActor(context.Background())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMustActorReturns(t *testing.T) {
|
||||
actor := Actor{UserID: 5, TenantID: 20, Role: "tenant_admin"}
|
||||
ctx := WithActor(context.Background(), actor)
|
||||
|
||||
got := MustActor(ctx)
|
||||
assert.Equal(t, actor, got)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
func NewManager(secret string, accessTTL, refreshTTL time.Duration) *Manager {
|
||||
return &Manager{
|
||||
secret: []byte(secret),
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessJTI string `json:"-"`
|
||||
RefreshJTI string `json:"-"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (m *Manager) Issue(userID, tenantID int64, role string) (*TokenPair, error) {
|
||||
now := time.Now()
|
||||
accessJTI := uuid.New().String()
|
||||
refreshJTI := uuid.New().String()
|
||||
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: accessJTI,
|
||||
Subject: "access",
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
|
||||
},
|
||||
}
|
||||
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(m.secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
|
||||
refreshClaims := Claims{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: refreshJTI,
|
||||
Subject: "refresh",
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTTL)),
|
||||
},
|
||||
}
|
||||
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(m.secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
AccessJTI: accessJTI,
|
||||
RefreshJTI: refreshJTI,
|
||||
ExpiresAt: now.Add(m.accessTTL).Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (m *Manager) AccessTTL() time.Duration { return m.accessTTL }
|
||||
func (m *Manager) RefreshTTL() time.Duration { return m.refreshTTL }
|
||||
|
||||
func HashToken(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueAndParse(t *testing.T) {
|
||||
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
pair, err := mgr.Issue(42, 10, "tenant_admin")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, pair.AccessToken)
|
||||
assert.NotEmpty(t, pair.RefreshToken)
|
||||
assert.NotEmpty(t, pair.AccessJTI)
|
||||
assert.NotEmpty(t, pair.RefreshJTI)
|
||||
assert.NotZero(t, pair.ExpiresAt)
|
||||
|
||||
claims, err := mgr.Parse(pair.AccessToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(42), claims.UserID)
|
||||
assert.Equal(t, int64(10), claims.TenantID)
|
||||
assert.Equal(t, "tenant_admin", claims.Role)
|
||||
assert.Equal(t, "access", claims.Subject)
|
||||
assert.Equal(t, pair.AccessJTI, claims.ID)
|
||||
}
|
||||
|
||||
func TestParseRefreshToken(t *testing.T) {
|
||||
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
pair, err := mgr.Issue(1, 1, "viewer")
|
||||
require.NoError(t, err)
|
||||
|
||||
claims, err := mgr.Parse(pair.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "refresh", claims.Subject)
|
||||
assert.Equal(t, pair.RefreshJTI, claims.ID)
|
||||
}
|
||||
|
||||
func TestParseExpired(t *testing.T) {
|
||||
mgr := NewManager("test-secret-key", -1*time.Second, -1*time.Second)
|
||||
|
||||
pair, err := mgr.Issue(1, 1, "viewer")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = mgr.Parse(pair.AccessToken)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseWrongSecret(t *testing.T) {
|
||||
mgr1 := NewManager("secret-one", 15*time.Minute, 7*24*time.Hour)
|
||||
mgr2 := NewManager("secret-two", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
pair, err := mgr1.Issue(1, 1, "viewer")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = mgr2.Parse(pair.AccessToken)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseGarbage(t *testing.T) {
|
||||
mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour)
|
||||
|
||||
_, err := mgr.Parse("not-a-real-token")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHashToken(t *testing.T) {
|
||||
h1 := HashToken("some-token")
|
||||
h2 := HashToken("some-token")
|
||||
h3 := HashToken("different-token")
|
||||
|
||||
assert.Equal(t, h1, h2)
|
||||
assert.NotEqual(t, h1, h3)
|
||||
assert.Len(t, h1, 64) // SHA-256 hex
|
||||
}
|
||||
|
||||
func TestAccessAndRefreshTTL(t *testing.T) {
|
||||
mgr := NewManager("s", 15*time.Minute, 24*time.Hour)
|
||||
assert.Equal(t, 15*time.Minute, mgr.AccessTTL())
|
||||
assert.Equal(t, 24*time.Hour, mgr.RefreshTTL())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw, err := bearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtMgr.Parse(raw)
|
||||
if err != nil || claims.Subject != "access" {
|
||||
response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token is invalid or expired"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID)
|
||||
if revoked {
|
||||
response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been logged out"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
actor := Actor{
|
||||
UserID: claims.UserID,
|
||||
TenantID: claims.TenantID,
|
||||
Role: claims.Role,
|
||||
}
|
||||
c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, error) {
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
return "", response.ErrUnauthorized(40101, "missing_bearer_token", "Authorization header is required")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func setupTestMiddleware(t *testing.T) (*Manager, *SessionStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
mgr := NewManager("test-middleware-secret", 15*time.Minute, 7*24*time.Hour)
|
||||
sessions := NewSessionStore(rdb)
|
||||
return mgr, sessions, mr
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||
mgr, sessions, _ := setupTestMiddleware(t)
|
||||
|
||||
pair, err := mgr.Issue(1, 10, "editor")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Middleware(mgr, sessions))
|
||||
|
||||
var gotActor Actor
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
a, ok := ActorFromCtx(c.Request.Context())
|
||||
assert.True(t, ok)
|
||||
gotActor = a
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, int64(1), gotActor.UserID)
|
||||
assert.Equal(t, int64(10), gotActor.TenantID)
|
||||
assert.Equal(t, "editor", gotActor.Role)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_MissingHeader(t *testing.T) {
|
||||
mgr, sessions, _ := setupTestMiddleware(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Middleware(mgr, sessions))
|
||||
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_InvalidToken(t *testing.T) {
|
||||
mgr, sessions, _ := setupTestMiddleware(t)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Middleware(mgr, sessions))
|
||||
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer bad-token-value")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_RefreshTokenRejected(t *testing.T) {
|
||||
mgr, sessions, _ := setupTestMiddleware(t)
|
||||
|
||||
pair, err := mgr.Issue(1, 10, "editor")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Middleware(mgr, sessions))
|
||||
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.RefreshToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_BlacklistedToken(t *testing.T) {
|
||||
mgr, sessions, mr := setupTestMiddleware(t)
|
||||
|
||||
pair, err := mgr.Issue(1, 10, "editor")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Blacklist the access token's JTI
|
||||
require.NoError(t, mr.Set(fmt.Sprintf("blacklist:%s", pair.AccessJTI), "1"))
|
||||
|
||||
r := gin.New()
|
||||
r.Use(Middleware(mgr, sessions))
|
||||
r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
var rolePermissions = map[string][]string{
|
||||
"tenant_admin": {
|
||||
"brand:read", "brand:write", "brand:delete",
|
||||
"article:read", "article:write", "article:delete",
|
||||
"template:read", "template:generate",
|
||||
"workspace:read",
|
||||
"member:read", "member:write",
|
||||
},
|
||||
"editor": {
|
||||
"brand:read", "brand:write",
|
||||
"article:read", "article:write",
|
||||
"template:read", "template:generate",
|
||||
"workspace:read",
|
||||
},
|
||||
"viewer": {
|
||||
"brand:read",
|
||||
"article:read",
|
||||
"template:read",
|
||||
"workspace:read",
|
||||
},
|
||||
}
|
||||
|
||||
func PermissionsForRole(role string) []string {
|
||||
if perms, ok := rolePermissions[role]; ok {
|
||||
return perms
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPermissionsForRole_Admin(t *testing.T) {
|
||||
perms := PermissionsForRole("tenant_admin")
|
||||
assert.Contains(t, perms, "brand:write")
|
||||
assert.Contains(t, perms, "brand:delete")
|
||||
assert.Contains(t, perms, "member:write")
|
||||
}
|
||||
|
||||
func TestPermissionsForRole_Editor(t *testing.T) {
|
||||
perms := PermissionsForRole("editor")
|
||||
assert.Contains(t, perms, "article:write")
|
||||
assert.Contains(t, perms, "template:generate")
|
||||
assert.NotContains(t, perms, "brand:delete")
|
||||
assert.NotContains(t, perms, "member:write")
|
||||
}
|
||||
|
||||
func TestPermissionsForRole_Viewer(t *testing.T) {
|
||||
perms := PermissionsForRole("viewer")
|
||||
assert.Contains(t, perms, "brand:read")
|
||||
assert.Contains(t, perms, "article:read")
|
||||
assert.NotContains(t, perms, "brand:write")
|
||||
assert.NotContains(t, perms, "article:write")
|
||||
}
|
||||
|
||||
func TestPermissionsForRole_Unknown(t *testing.T) {
|
||||
perms := PermissionsForRole("nonexistent")
|
||||
assert.Nil(t, perms)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRefreshSessionNotFound = errors.New("refresh session not found")
|
||||
ErrRefreshTokenMismatch = errors.New("refresh token mismatch")
|
||||
)
|
||||
|
||||
var rotateRefreshScript = redis.NewScript(`
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if not current then
|
||||
return 0
|
||||
end
|
||||
if current ~= ARGV[1] then
|
||||
return -1
|
||||
end
|
||||
redis.call("DEL", KEYS[1])
|
||||
redis.call("PSETEX", KEYS[2], ARGV[3], ARGV[2])
|
||||
return 1
|
||||
`)
|
||||
|
||||
type SessionStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewSessionStore(rdb *redis.Client) *SessionStore {
|
||||
return &SessionStore{rdb: rdb}
|
||||
}
|
||||
|
||||
func (s *SessionStore) SaveRefresh(ctx context.Context, jti string, tokenHash string, ttl time.Duration) error {
|
||||
key := fmt.Sprintf("refresh:%s", jti)
|
||||
return s.rdb.Set(ctx, key, tokenHash, ttl).Err()
|
||||
}
|
||||
|
||||
func (s *SessionStore) GetRefresh(ctx context.Context, jti string) (string, error) {
|
||||
key := fmt.Sprintf("refresh:%s", jti)
|
||||
return s.rdb.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
func (s *SessionStore) DeleteRefresh(ctx context.Context, jti string) error {
|
||||
key := fmt.Sprintf("refresh:%s", jti)
|
||||
return s.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (s *SessionStore) RotateRefresh(ctx context.Context, oldJTI, oldHash, newJTI, newHash string, ttl time.Duration) error {
|
||||
oldKey := fmt.Sprintf("refresh:%s", oldJTI)
|
||||
newKey := fmt.Sprintf("refresh:%s", newJTI)
|
||||
|
||||
result, err := rotateRefreshScript.Run(
|
||||
ctx,
|
||||
s.rdb,
|
||||
[]string{oldKey, newKey},
|
||||
oldHash,
|
||||
newHash,
|
||||
ttl.Milliseconds(),
|
||||
).Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch result {
|
||||
case 1:
|
||||
return nil
|
||||
case 0:
|
||||
return ErrRefreshSessionNotFound
|
||||
case -1:
|
||||
return ErrRefreshTokenMismatch
|
||||
default:
|
||||
return fmt.Errorf("unexpected rotate refresh result: %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SessionStore) Blacklist(ctx context.Context, accessJTI string, ttl time.Duration) error {
|
||||
key := fmt.Sprintf("blacklist:%s", accessJTI)
|
||||
return s.rdb.Set(ctx, key, "1", ttl).Err()
|
||||
}
|
||||
|
||||
func (s *SessionStore) IsBlacklisted(ctx context.Context, accessJTI string) (bool, error) {
|
||||
key := fmt.Sprintf("blacklist:%s", accessJTI)
|
||||
n, err := s.rdb.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSessionStoreRotateRefreshSuccess(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store := NewSessionStore(client)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
|
||||
require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti", "new-hash", 2*time.Minute))
|
||||
|
||||
_, err := store.GetRefresh(ctx, "old-jti")
|
||||
assert.Error(t, err)
|
||||
|
||||
value, err := store.GetRefresh(ctx, "new-jti")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-hash", value)
|
||||
}
|
||||
|
||||
func TestSessionStoreRotateRefreshRejectsMismatch(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store := NewSessionStore(client)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute))
|
||||
|
||||
err := store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti", "new-hash", 2*time.Minute)
|
||||
require.ErrorIs(t, err, ErrRefreshTokenMismatch)
|
||||
|
||||
value, getErr := store.GetRefresh(ctx, "old-jti")
|
||||
require.NoError(t, getErr)
|
||||
assert.Equal(t, "old-hash", value)
|
||||
|
||||
_, getErr = store.GetRefresh(ctx, "new-jti")
|
||||
assert.Error(t, getErr)
|
||||
}
|
||||
Reference in New Issue
Block a user