Files
template-vitrine/backend/internal/modules/auth/repository.go
T
2026-09-14 20:50:19 +02:00

119 lines
3.7 KiB
Go

package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"backend/internal/platform/security"
)
// RefreshStore tracks live refresh tokens in Redis, scoped per audience
// (admin/customer) so a session in one space is never visible to the other.
type RefreshStore interface {
Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error)
Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (newToken string, userID uuid.UUID, err error)
Revoke(ctx context.Context, aud security.Audience, token string) error
RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error
}
type redisRefreshStore struct {
client *redis.Client
}
func NewRefreshStore(client *redis.Client) RefreshStore {
return &redisRefreshStore{client: client}
}
func sessionKey(aud security.Audience, token string) string {
return fmt.Sprintf("session:%s:%s", aud, token)
}
func userSetKey(aud security.Audience, userID uuid.UUID) string {
return fmt.Sprintf("session:%s:user:%s", aud, userID)
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func (s *redisRefreshStore) Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error) {
token, err := randomToken()
if err != nil {
return "", fmt.Errorf("generate refresh token: %w", err)
}
if err := s.client.Set(ctx, sessionKey(aud, token), userID.String(), ttl).Err(); err != nil {
return "", fmt.Errorf("store refresh token: %w", err)
}
// Best-effort secondary index for RevokeAllForUser; a token still present
// here after its primary key has naturally expired is harmless (RevokeAllForUser
// simply issues a DEL for an already-gone key).
s.client.SAdd(ctx, userSetKey(aud, userID), token)
return token, nil
}
func (s *redisRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) {
val, err := s.client.GetDel(ctx, sessionKey(aud, oldToken)).Result()
if errors.Is(err, redis.Nil) {
return "", uuid.UUID{}, ErrInvalidRefreshToken
}
if err != nil {
return "", uuid.UUID{}, fmt.Errorf("lookup refresh token: %w", err)
}
userID, err := uuid.Parse(val)
if err != nil {
return "", uuid.UUID{}, fmt.Errorf("corrupt session value: %w", err)
}
s.client.SRem(ctx, userSetKey(aud, userID), oldToken)
newToken, err := s.Issue(ctx, aud, userID, ttl)
if err != nil {
return "", uuid.UUID{}, err
}
return newToken, userID, nil
}
func (s *redisRefreshStore) Revoke(ctx context.Context, aud security.Audience, token string) error {
val, err := s.client.GetDel(ctx, sessionKey(aud, token)).Result()
if errors.Is(err, redis.Nil) {
return nil // already revoked/expired: logout is idempotent
}
if err != nil {
return fmt.Errorf("revoke refresh token: %w", err)
}
if userID, parseErr := uuid.Parse(val); parseErr == nil {
s.client.SRem(ctx, userSetKey(aud, userID), token)
}
return nil
}
func (s *redisRefreshStore) RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
setKey := userSetKey(aud, userID)
tokens, err := s.client.SMembers(ctx, setKey).Result()
if err != nil {
return fmt.Errorf("list sessions: %w", err)
}
if len(tokens) > 0 {
keys := make([]string, len(tokens))
for i, t := range tokens {
keys[i] = sessionKey(aud, t)
}
if err := s.client.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("revoke sessions: %w", err)
}
}
s.client.Del(ctx, setKey)
return nil
}