From fd2043ba7e03d7c1d4eef103b470ee0cde7d1205 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sun, 29 Mar 2026 19:27:38 +0800 Subject: [PATCH] feat(auth): add scheduled auth artifact cleanup --- .env.example | 5 + src/auth/auth.module.ts | 2 + src/auth/services/auth-cleanup.service.ts | 159 ++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/auth/services/auth-cleanup.service.ts diff --git a/.env.example b/.env.example index b8ebcd6..c0129c6 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ JWT_ISSUER=friendolls JWT_AUDIENCE=friendolls-api JWT_EXPIRES_IN_SECONDS=3600 +# Auth cleanup +AUTH_CLEANUP_ENABLED=true +AUTH_CLEANUP_INTERVAL_MS=900000 +AUTH_SESSION_REVOKED_RETENTION_DAYS=7 + # Google OAuth GOOGLE_CLIENT_ID="replace-with-google-client-id" GOOGLE_CLIENT_SECRET="replace-with-google-client-secret" diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 4bf688a..3723de0 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -10,6 +10,7 @@ import { UsersModule } from '../users/users.module'; import { AuthController } from './auth.controller'; import { GoogleAuthGuard } from './guards/google-auth.guard'; import { DiscordAuthGuard } from './guards/discord-auth.guard'; +import { AuthCleanupService } from './services/auth-cleanup.service'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { DiscordAuthGuard } from './guards/discord-auth.guard'; DiscordAuthGuard, AuthService, JwtVerificationService, + AuthCleanupService, ], exports: [AuthService, PassportModule, JwtVerificationService], }) diff --git a/src/auth/services/auth-cleanup.service.ts b/src/auth/services/auth-cleanup.service.ts new file mode 100644 index 0000000..303fe2c --- /dev/null +++ b/src/auth/services/auth-cleanup.service.ts @@ -0,0 +1,159 @@ +import { + Injectable, + Inject, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../database/prisma.service'; +import Redis from 'ioredis'; +import { + parseBoolean, + parsePositiveInteger, +} from '../../common/config/env.utils'; +import { REDIS_CLIENT } from '../../database/redis.module'; + +const MIN_CLEANUP_INTERVAL_MS = 60_000; +const DEFAULT_CLEANUP_INTERVAL_MS = 15 * 60_000; +const DEFAULT_REVOKED_RETENTION_DAYS = 7; +const CLEANUP_LOCK_KEY = 'lock:auth:cleanup'; +const CLEANUP_LOCK_TTL_MS = 55_000; + +@Injectable() +export class AuthCleanupService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AuthCleanupService.name); + private cleanupTimer: NodeJS.Timeout | null = null; + private isCleanupRunning = false; + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + @Inject(REDIS_CLIENT) private readonly redisClient: Redis | null, + ) {} + + onModuleInit(): void { + const enabled = parseBoolean( + this.configService.get('AUTH_CLEANUP_ENABLED'), + true, + ); + + if (!enabled) { + this.logger.log('Auth cleanup task disabled'); + return; + } + + const configuredInterval = parsePositiveInteger( + this.configService.get('AUTH_CLEANUP_INTERVAL_MS'), + DEFAULT_CLEANUP_INTERVAL_MS, + ); + const cleanupIntervalMs = Math.max( + configuredInterval, + MIN_CLEANUP_INTERVAL_MS, + ); + + this.cleanupTimer = setInterval(() => { + void this.cleanupExpiredAuthData(); + }, cleanupIntervalMs); + this.cleanupTimer.unref(); + + void this.cleanupExpiredAuthData(); + this.logger.log(`Auth cleanup task scheduled every ${cleanupIntervalMs}ms`); + } + + onModuleDestroy(): void { + if (!this.cleanupTimer) { + return; + } + + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + + private async cleanupExpiredAuthData(): Promise { + if (this.isCleanupRunning) { + this.logger.warn( + 'Skipping auth cleanup run because previous run is still in progress', + ); + return; + } + + this.isCleanupRunning = true; + const lockToken = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + let lockAcquired = false; + + try { + if (this.redisClient) { + try { + const lockResult = await this.redisClient.set( + CLEANUP_LOCK_KEY, + lockToken, + 'PX', + CLEANUP_LOCK_TTL_MS, + 'NX', + ); + if (lockResult !== 'OK') { + return; + } + lockAcquired = true; + } catch (error) { + this.logger.warn( + 'Failed to acquire auth cleanup lock; running cleanup without distributed lock', + error as Error, + ); + } + } + + const now = new Date(); + const revokedRetentionDays = parsePositiveInteger( + this.configService.get('AUTH_SESSION_REVOKED_RETENTION_DAYS'), + DEFAULT_REVOKED_RETENTION_DAYS, + ); + const revokedCutoff = new Date( + now.getTime() - revokedRetentionDays * 24 * 60 * 60 * 1000, + ); + + const [codes, sessions] = await Promise.all([ + this.prisma.authExchangeCode.deleteMany({ + where: { + OR: [{ expiresAt: { lt: now } }, { consumedAt: { not: null } }], + }, + }), + this.prisma.authSession.deleteMany({ + where: { + OR: [ + { expiresAt: { lt: now } }, + { revokedAt: { lt: revokedCutoff } }, + ], + }, + }), + ]); + + const totalDeleted = codes.count + sessions.count; + + if (totalDeleted > 0) { + this.logger.log( + `Auth cleanup removed ${totalDeleted} records (${codes.count} exchange codes, ${sessions.count} sessions)`, + ); + } + } catch (error) { + this.logger.error('Auth cleanup task failed', error as Error); + } finally { + if (lockAcquired && this.redisClient) { + try { + const currentLockValue = await this.redisClient.get(CLEANUP_LOCK_KEY); + if (currentLockValue === lockToken) { + await this.redisClient.del(CLEANUP_LOCK_KEY); + } + } catch (error) { + this.logger.warn( + 'Failed to release auth cleanup lock', + error as Error, + ); + } + } + + this.isCleanupRunning = false; + } + } +}