feat(auth): add device session tracking with configurable limits
Track authenticated sessions per device (parsing user-agent for desktop vs mobile via mileusna/useragent), enforce configurable concurrent device limits (Auth.DeviceLimits), and surface device status and "remove other devices" management in the account dialog. Adds device/session context to the auth module stores and exposes limits through the API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,174 @@ RETURNING id, region, phone_country_code, phone, email, name, avatar_url, passwo
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateDeviceSession(ctx context.Context, session DeviceSession, activeLimit int64) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := lockDeviceSessions(ctx, tx, session.UserID, session.DeviceType); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO auth_device_sessions (
|
||||
id, user_id, device_type, system, browser, client, user_agent,
|
||||
created_at, last_seen_at, expires_at, revoked_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent,
|
||||
session.CreatedAt, session.LastSeenAt, session.ExpiresAt, session.RevokedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := enforceDeviceSessionLimitPostgres(ctx, tx, session.UserID, session.DeviceType, session.ID, activeLimit, session.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) FindDeviceSession(ctx context.Context, id string) (DeviceSession, error) {
|
||||
return scanDeviceSession(s.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM auth_device_sessions
|
||||
WHERE id = $1
|
||||
`, strings.TrimSpace(id)))
|
||||
}
|
||||
|
||||
func (s *PostgresStore) ListDeviceSessions(ctx context.Context, userID string, now time.Time) ([]DeviceSession, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, device_type, system, browser, client, user_agent, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM auth_device_sessions
|
||||
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > $2
|
||||
ORDER BY last_seen_at DESC, created_at DESC, id ASC
|
||||
`, toPGUUID(userID), now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sessions := make([]DeviceSession, 0)
|
||||
for rows.Next() {
|
||||
session, scanErr := scanDeviceSession(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) TouchDeviceSession(ctx context.Context, session DeviceSession) error {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_device_sessions
|
||||
SET device_type = $3,
|
||||
system = $4,
|
||||
browser = $5,
|
||||
client = $6,
|
||||
user_agent = $7,
|
||||
last_seen_at = GREATEST(last_seen_at, $8)
|
||||
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > $8
|
||||
`, session.ID, toPGUUID(session.UserID), session.DeviceType, session.System, session.Browser, session.Client, session.UserAgent, session.LastSeenAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return design.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) EnforceDeviceSessionLimit(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
deviceType string,
|
||||
keepSessionID string,
|
||||
activeLimit int64,
|
||||
revokedAt time.Time,
|
||||
) (int64, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if err := lockDeviceSessions(ctx, tx, userID, deviceType); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
removed, err := enforceDeviceSessionLimitPostgres(ctx, tx, userID, deviceType, keepSessionID, activeLimit, revokedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func lockDeviceSessions(ctx context.Context, tx pgx.Tx, userID string, deviceType string) error {
|
||||
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, normalizeUUID(userID)+":"+deviceType)
|
||||
return err
|
||||
}
|
||||
|
||||
func enforceDeviceSessionLimitPostgres(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
userID string,
|
||||
deviceType string,
|
||||
keepSessionID string,
|
||||
activeLimit int64,
|
||||
revokedAt time.Time,
|
||||
) (int64, error) {
|
||||
if activeLimit < 1 {
|
||||
activeLimit = 1
|
||||
}
|
||||
result, err := tx.Exec(ctx, `
|
||||
WITH overflow AS (
|
||||
SELECT id
|
||||
FROM auth_device_sessions
|
||||
WHERE user_id = $1 AND device_type = $2 AND revoked_at IS NULL AND expires_at > $4
|
||||
ORDER BY CASE WHEN id = $3 THEN 0 ELSE 1 END,
|
||||
created_at DESC,
|
||||
last_seen_at DESC,
|
||||
id DESC
|
||||
OFFSET $5
|
||||
)
|
||||
UPDATE auth_device_sessions
|
||||
SET revoked_at = $4
|
||||
WHERE id IN (SELECT id FROM overflow)
|
||||
`, toPGUUID(userID), deviceType, strings.TrimSpace(keepSessionID), revokedAt, activeLimit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) RevokeOtherDeviceSessions(ctx context.Context, userID string, currentSessionID string, revokedAt time.Time) (int64, error) {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_device_sessions
|
||||
SET revoked_at = $3
|
||||
WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL AND expires_at > $3
|
||||
`, toPGUUID(userID), strings.TrimSpace(currentSessionID), revokedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) RevokeDeviceSession(ctx context.Context, userID string, sessionID string, revokedAt time.Time) (int64, error) {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
UPDATE auth_device_sessions
|
||||
SET revoked_at = $3
|
||||
WHERE user_id = $1 AND id = $2 AND revoked_at IS NULL AND expires_at > $3
|
||||
`, toPGUUID(userID), strings.TrimSpace(sessionID), revokedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) CreateVerificationCode(ctx context.Context, code VerificationCode) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO auth_verification_codes (id, region, channel, target, purpose, code_hash, attempts, expires_at, created_at)
|
||||
@@ -179,6 +347,31 @@ func scanUser(row rowScanner) (User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func scanDeviceSession(row rowScanner) (DeviceSession, error) {
|
||||
var userID pgtype.UUID
|
||||
var session DeviceSession
|
||||
if err := row.Scan(
|
||||
&session.ID,
|
||||
&userID,
|
||||
&session.DeviceType,
|
||||
&session.System,
|
||||
&session.Browser,
|
||||
&session.Client,
|
||||
&session.UserAgent,
|
||||
&session.CreatedAt,
|
||||
&session.LastSeenAt,
|
||||
&session.ExpiresAt,
|
||||
&session.RevokedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DeviceSession{}, design.ErrNotFound
|
||||
}
|
||||
return DeviceSession{}, err
|
||||
}
|
||||
session.UserID = fromPGUUID(userID)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func toPGUUID(id string) pgtype.UUID {
|
||||
parsed, err := uuid.Parse(normalizeUUID(id))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user