feat(auth): add scheduled auth artifact cleanup
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
159
src/auth/services/auth-cleanup.service.ts
Normal file
159
src/auth/services/auth-cleanup.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user