Skip to content

RFC: Session Security Enhancements #507

@lakhansamani

Description

@lakhansamani

RFC: Session Security Enhancements

Phase: 1 — Security Hardening & Enterprise Foundation
Priority: P1 — High
Estimated Effort: Medium
Depends on: Audit Logs


Problem Statement

Authorizer's session management lacks enterprise security features: no session limits per user, no device tracking, no session listing/revocation API, and no admin impersonation. Users cannot see or manage their active sessions. The current Session schema only tracks user_id, user_agent, and ip with no device fingerprinting.


Current Architecture Context

  • Session schema (internal/storage/schemas/session.go): ID, UserID, UserAgent, IP, CreatedAt, UpdatedAt
  • Memory store (internal/memory_store/) handles session tokens with keys like {loginMethod}:{userID}
  • Session tokens are AES-encrypted using ClientSecret as encryption key
  • Token types: access (30min), refresh (1yr), session (1yr)
  • Memory store implementations: Redis, DB-backed, in-memory
  • DeleteSession only deletes by userId — no per-session deletion
  • No device tracking or fingerprinting

Proposed Solution

1. Enhanced Session Schema

Update internal/storage/schemas/session.go:

type Session struct {
    ID            string `json:"id" gorm:"primaryKey;type:char(36)"`
    UserID        string `json:"user_id" gorm:"type:char(36);index"`
    UserAgent     string `json:"user_agent" gorm:"type:text"`
    IP            string `json:"ip" gorm:"type:varchar(45)"`
    DeviceHash    string `json:"device_hash" gorm:"type:varchar(64);index"`  // SHA-256 of device fingerprint
    DeviceName    string `json:"device_name" gorm:"type:varchar(256)"`       // human-readable: "Chrome on macOS"
    LoginMethod   string `json:"login_method" gorm:"type:varchar(50)"`       // password, otp, social, magic_link
    Country       string `json:"country" gorm:"type:varchar(100)"`           // from IP geolocation (if available)
    IsActive      bool   `json:"is_active" gorm:"type:bool;default:true"`
    LastActiveAt  int64  `json:"last_active_at"`
    ExpiresAt     int64  `json:"expires_at"`
    CreatedAt     int64  `json:"created_at" gorm:"autoCreateTime"`
    UpdatedAt     int64  `json:"updated_at" gorm:"autoUpdateTime"`
}

Device hash computation (server-side from available signals):

func ComputeDeviceHash(userAgent string, acceptLanguage string, ip string) string {
    // Combine stable signals (user agent + accept-language)
    // IP excluded from hash since it changes, but used for geo
    data := userAgent + "|" + acceptLanguage
    hash := sha256.Sum256([]byte(data))
    return hex.EncodeToString(hash[:])
}

Parse user agent into human-readable device name using mssola/useragent or similar:

"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)..." → "Chrome 120 on macOS"

2. Session Limits

Configurable max sessions per user via --max-sessions-per-user=5:

On login, before creating a new session:

activeSessions, _ := store.ListSessionsByUserID(ctx, userID)
if len(activeSessions) >= cfg.MaxSessionsPerUser {
    // Evict oldest session (FIFO)
    oldestSession := activeSessions[len(activeSessions)-1]
    store.DeleteSessionByID(ctx, oldestSession.ID)
    memoryStore.DeleteUserSession(userID, oldestSession.ID)
    // Audit log: session.evicted
}

New storage methods:

ListSessionsByUserID(ctx context.Context, userID string) ([]*schemas.Session, error)
GetSessionByID(ctx context.Context, sessionID string) (*schemas.Session, error)
DeleteSessionByID(ctx context.Context, sessionID string) error
CountSessionsByUserID(ctx context.Context, userID string) (int64, error)
UpdateSession(ctx context.Context, session *schemas.Session) error

3. Session Listing & Remote Revocation

GraphQL API (user-facing — users manage their own sessions):

type Session {
    id: ID!
    device_name: String
    ip: String
    login_method: String
    country: String
    is_current: Boolean!    # true if this is the requesting session
    last_active_at: Int64
    created_at: Int64!
}

type Query {
    sessions: [Session!]!   # List current user's active sessions
}

type Mutation {
    revoke_session(id: ID!): Response!             # Revoke a specific session
    revoke_all_sessions: Response!                  # Revoke all except current
}

Admin GraphQL API:

type Query {
    _user_sessions(user_id: ID!): [Session!]!      # List any user's sessions
}

type Mutation {
    _revoke_session(session_id: ID!): Response!     # Admin revoke any session
    _revoke_all_user_sessions(user_id: ID!): Response!
}

Revocation flow:

  1. Mark session as is_active = false in DB
  2. Delete session tokens from memory store
  3. On next request with revoked session cookie, user gets 401 → redirected to login
  4. Audit log: session.terminated

4. Unrecognized Device Notifications

When a login occurs from a new device hash not seen before for that user:

existingSessions, _ := store.ListSessionsByUserID(ctx, userID)
isKnownDevice := false
for _, s := range existingSessions {
    if s.DeviceHash == newDeviceHash {
        isKnownDevice = true
        break
    }
}

if !isKnownDevice && cfg.IsEmailServiceEnabled {
    // Send "new device login" email
    emailProvider.SendNewDeviceAlert(user.Email, NewDeviceAlertData{
        DeviceName: deviceName,    // "Chrome 120 on macOS"
        IPAddress:  clientIP,
        Location:   country,       // from IP geo (if available)
        Time:       time.Now(),
        LoginMethod: loginMethod,
    })
    // Audit log: user.new_device_login
}

New email template event: new_device_login — added to internal/constants/ email template events. Default template included.

5. Admin Impersonation

Off by default--enable-impersonation=false

GraphQL mutation:

type Mutation {
    _impersonate_user(
        user_id: ID!
        reason: String!    # Required — logged in audit trail
    ): AuthResponse!
}

Implementation:

func ImpersonateUser(ctx context.Context, params model.ImpersonateUserInput) (*model.AuthResponse, error) {
    if !cfg.EnableImpersonation {
        return nil, fmt.Errorf("impersonation is disabled")
    }
    
    // Generate short-lived tokens (max 60 minutes, not configurable higher)
    accessToken := generateToken(user, 60*time.Minute)
    
    // Add impersonation claims to token
    claims["impersonator_id"] = adminID
    claims["impersonator_email"] = adminEmail
    claims["is_impersonated"] = true
    
    // Audit log with full impersonation context
    auditProvider.Log(ctx, audit.AuditEvent{
        ActorID:      adminID,
        ActorType:    "admin",
        Action:       "admin.impersonation_started",
        ResourceType: "user",
        ResourceID:   userID,
        Metadata:     map[string]interface{}{"reason": reason},
    })
    
    return &model.AuthResponse{
        AccessToken: accessToken,
        ExpiresIn:   3600,  // 60 min max
        User:        user,
    }, nil
}

Safety guardrails:

  • No refresh token issued for impersonated sessions
  • is_impersonated claim in token for downstream services to detect
  • Reason is mandatory and logged
  • Maximum 60-minute session, no extension
  • Cannot impersonate other admins

CLI Configuration Flags

--max-sessions-per-user=5                  # Max concurrent sessions (0 = unlimited)
--enable-new-device-notification=true      # Email alert on new device login
--enable-impersonation=false               # Admin impersonation (off by default)

Migration Strategy

  1. Add new columns to Session schema across all DB providers
  2. Add new storage methods for session CRUD operations
  3. Update login flows to create enhanced sessions
  4. Add new GraphQL types and resolvers
  5. Add new_device_login email template
  6. Wire impersonation (disabled by default)

Testing Plan

  • Integration tests for session limit enforcement (FIFO eviction)
  • Integration tests for session listing and revocation
  • Test new device detection triggers email
  • Test impersonation generates short-lived tokens with correct claims
  • Test impersonation is blocked when disabled
  • Test session revocation invalidates tokens in memory store

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions