feat(auth): add scheduled auth artifact cleanup
This commit is contained in:
@@ -19,6 +19,11 @@ JWT_ISSUER=friendolls
|
|||||||
JWT_AUDIENCE=friendolls-api
|
JWT_AUDIENCE=friendolls-api
|
||||||
JWT_EXPIRES_IN_SECONDS=3600
|
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 OAuth
|
||||||
GOOGLE_CLIENT_ID="replace-with-google-client-id"
|
GOOGLE_CLIENT_ID="replace-with-google-client-id"
|
||||||
GOOGLE_CLIENT_SECRET="replace-with-google-client-secret"
|
GOOGLE_CLIENT_SECRET="replace-with-google-client-secret"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { UsersModule } from '../users/users.module';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
import { GoogleAuthGuard } from './guards/google-auth.guard';
|
||||||
import { DiscordAuthGuard } from './guards/discord-auth.guard';
|
import { DiscordAuthGuard } from './guards/discord-auth.guard';
|
||||||
|
import { AuthCleanupService } from './services/auth-cleanup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +27,7 @@ import { DiscordAuthGuard } from './guards/discord-auth.guard';
|
|||||||
DiscordAuthGuard,
|
DiscordAuthGuard,
|
||||||
AuthService,
|
AuthService,
|
||||||
JwtVerificationService,
|
JwtVerificationService,
|
||||||
|
AuthCleanupService,
|
||||||
],
|
],
|
||||||
exports: [AuthService, PassportModule, JwtVerificationService],
|
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