91 lines
3.2 KiB
Go
91 lines
3.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// GetSession returns the active session keyed by deviceSessionID. The
|
|
// lookup is cache-only: the cache is the write-through projection of
|
|
// `device_sessions WHERE status='active'`, so a miss means the session
|
|
// is either revoked or absent. Either way the gateway sees
|
|
// ErrSessionNotFound and treats the calling client as unauthenticated.
|
|
func (s *Service) GetSession(_ 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
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// RevokeSession marks deviceSessionID revoked, 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), not ErrSessionNotFound. 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) (Session, error) {
|
|
if deviceSessionID == uuid.Nil {
|
|
return Session{}, ErrSessionNotFound
|
|
}
|
|
revoked, ok, err := s.deps.Store.RevokeSession(ctx, deviceSessionID)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
if ok {
|
|
s.deps.Cache.Remove(deviceSessionID)
|
|
s.deps.Push.PublishSessionInvalidation(ctx, deviceSessionID, revoked.UserID, "auth.revoke_session")
|
|
s.deps.Logger.Info("auth session revoked",
|
|
zap.String("device_session_id", deviceSessionID.String()),
|
|
zap.String("user_id", revoked.UserID.String()),
|
|
)
|
|
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,
|
|
// 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) ([]Session, error) {
|
|
if userID == uuid.Nil {
|
|
return nil, nil
|
|
}
|
|
revoked, err := s.deps.Store.RevokeAllForUser(ctx, userID)
|
|
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, "auth.revoke_all_for_user")
|
|
}
|
|
if len(revoked) > 0 {
|
|
s.deps.Logger.Info("auth sessions revoked (bulk)",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Int("count", len(revoked)),
|
|
)
|
|
}
|
|
return revoked, nil
|
|
}
|