119 lines
3.7 KiB
Go
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
|
|
}
|