172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ActorKind enumerates the principals that can drive a session revoke.
|
|
// The values are persisted into `session_revocations.actor_kind` and
|
|
// must stay aligned with `user.SessionRevokeActor*` constants and any
|
|
// admin/operator tooling that joins on the audit table.
|
|
type ActorKind string
|
|
|
|
const (
|
|
// ActorKindUserSelf indicates the session's owner initiated the
|
|
// revoke (logout self / logout-all-self through the user surface).
|
|
ActorKindUserSelf ActorKind = "user_self"
|
|
|
|
// ActorKindAdminSanction indicates an admin-applied sanction (most
|
|
// notably permanent_block) caused the revoke.
|
|
ActorKindAdminSanction ActorKind = "admin_sanction"
|
|
|
|
// ActorKindSoftDeleteUser indicates the session's owner triggered
|
|
// account soft-delete on themselves.
|
|
ActorKindSoftDeleteUser ActorKind = "soft_delete_user"
|
|
|
|
// ActorKindSoftDeleteAdmin indicates an admin soft-deleted the
|
|
// account and the cascade revoked the sessions.
|
|
ActorKindSoftDeleteAdmin ActorKind = "soft_delete_admin"
|
|
)
|
|
|
|
// RevokeContext records the audit metadata persisted alongside every
|
|
// session revoke. ActorID is the stable identifier of the principal (a
|
|
// user UUID for self-driven flows, an admin username for admin-driven
|
|
// flows). Reason is a free-form note kept verbatim.
|
|
type RevokeContext struct {
|
|
ActorKind ActorKind
|
|
ActorID string
|
|
Reason string
|
|
}
|
|
|
|
// GetSession returns the active session keyed by deviceSessionID. The
|
|
// lookup hits the cache; on a miss the session is either revoked or
|
|
// absent. After a hit the call refreshes `last_seen_at` against
|
|
// Postgres so admin observers see when each cached session was last
|
|
// resolved by gateway. The refresh runs after the cache read and
|
|
// updates the cached row in-place; failures are logged but never block
|
|
// the lookup.
|
|
func (s *Service) GetSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, error) {
|
|
if deviceSessionID == uuid.Nil {
|
|
return Session{}, ErrSessionNotFound
|
|
}
|
|
sess, ok := s.deps.Cache.Get(deviceSessionID)
|
|
if !ok {
|
|
return Session{}, ErrSessionNotFound
|
|
}
|
|
now := s.deps.Now()
|
|
if updated, err := s.deps.Store.TouchSessionLastSeen(ctx, deviceSessionID, now); err == nil {
|
|
s.deps.Cache.Add(updated)
|
|
return updated, nil
|
|
} else if errors.Is(err, ErrSessionNotFound) {
|
|
// The row vanished between Cache.Get and the touch — treat as
|
|
// revoked from the caller's perspective.
|
|
s.deps.Cache.Remove(deviceSessionID)
|
|
return Session{}, ErrSessionNotFound
|
|
} else {
|
|
s.deps.Logger.Warn("auth: touch last_seen_at failed",
|
|
zap.String("device_session_id", deviceSessionID.String()),
|
|
zap.Error(err),
|
|
)
|
|
return sess, nil
|
|
}
|
|
}
|
|
|
|
// ListActiveByUser returns the cached active sessions for userID. The
|
|
// user-surface "list my sessions" handler consumes this. The slice is
|
|
// safe for the caller to retain — it is freshly allocated.
|
|
func (s *Service) ListActiveByUser(_ context.Context, userID uuid.UUID) []Session {
|
|
if userID == uuid.Nil {
|
|
return nil
|
|
}
|
|
return s.deps.Cache.ListByUser(userID)
|
|
}
|
|
|
|
// LookupSessionInCache returns the cached session for deviceSessionID
|
|
// without touching last_seen_at. The user-surface revoke handler
|
|
// consumes this to verify ownership before issuing a revoke. A miss
|
|
// means the session is either revoked or absent — handlers must treat
|
|
// the two cases identically so a caller cannot probe whether a foreign
|
|
// device_session_id exists.
|
|
func (s *Service) LookupSessionInCache(deviceSessionID uuid.UUID) (Session, bool) {
|
|
if deviceSessionID == uuid.Nil {
|
|
return Session{}, false
|
|
}
|
|
return s.deps.Cache.Get(deviceSessionID)
|
|
}
|
|
|
|
// RevokeSession marks deviceSessionID revoked atomically with an
|
|
// audit row in `session_revocations`, evicts it from the cache, and
|
|
// emits a session_invalidation push event. The call is idempotent: a
|
|
// second revoke on an already-revoked session returns the existing
|
|
// row with status='revoked' (HTTP 200) and writes no audit row. An
|
|
// unknown device_session_id yields ErrSessionNotFound.
|
|
//
|
|
// Cache eviction and the push emission run after the database UPDATE
|
|
// commits so a failed UPDATE leaves both cache and gateway view
|
|
// intact.
|
|
func (s *Service) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext) (Session, error) {
|
|
if deviceSessionID == uuid.Nil {
|
|
return Session{}, ErrSessionNotFound
|
|
}
|
|
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID, rc, s.deps.Now())
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
if ok {
|
|
s.deps.Cache.Remove(deviceSessionID)
|
|
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, string(rc.ActorKind))
|
|
s.deps.Logger.Info("auth session revoked",
|
|
zap.String("device_session_id", deviceSessionID.String()),
|
|
zap.String("user_id", revoked.UserID.String()),
|
|
zap.String("actor_kind", string(rc.ActorKind)),
|
|
zap.String("actor_id", rc.ActorID),
|
|
)
|
|
return revoked, nil
|
|
}
|
|
// UPDATE matched no rows: the session is either already revoked or
|
|
// never existed. Distinguish by reading the row directly so we can
|
|
// return the idempotent revoked-shape rather than a 404 when the
|
|
// session simply was revoked earlier.
|
|
existing, err := s.deps.Store.LoadSession(ctx, deviceSessionID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrSessionNotFound) {
|
|
return Session{}, ErrSessionNotFound
|
|
}
|
|
return Session{}, err
|
|
}
|
|
return existing, nil
|
|
}
|
|
|
|
// RevokeAllForUser marks every active session for userID revoked
|
|
// atomically with one audit row per revoked session, evicts each from
|
|
// the cache, and emits one session_invalidation push event per
|
|
// revoked row. Returns the list of revoked sessions in the order
|
|
// Postgres returned them. An empty result is a successful idempotent
|
|
// call (handler reports revoked_count=0).
|
|
func (s *Service) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext) ([]Session, error) {
|
|
if userID == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID, rc, s.deps.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, sess := range revoked {
|
|
s.deps.Cache.Remove(sess.DeviceSessionID)
|
|
s.deps.Push.PublishSessionInvalidation(ctx, sess.DeviceSessionID, sess.UserID, string(rc.ActorKind))
|
|
}
|
|
if len(revoked) > 0 {
|
|
s.deps.Logger.Info("auth sessions revoked (bulk)",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Int("count", len(revoked)),
|
|
zap.String("actor_kind", string(rc.ActorKind)),
|
|
zap.String("actor_id", rc.ActorID),
|
|
)
|
|
}
|
|
return revoked, nil
|
|
}
|