feat(auth): add scheduled auth artifact cleanup

This commit is contained in:
2026-03-29 19:27:38 +08:00
parent 765d4507c9
commit fd2043ba7e
3 changed files with 166 additions and 0 deletions

View File

@@ -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"

View File

@@ -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],
})

View File

@@ -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<string>('AUTH_CLEANUP_ENABLED'),
true,
);
if (!enabled) {
this.logger.log('Auth cleanup task disabled');
return;
}
const configuredInterval = parsePositiveInteger(
this.configService.get<string>('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<void> {
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<string>('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;
}
}
}