resid pt 3: friendship checks & auth session reads
This commit is contained in:
@@ -37,6 +37,14 @@ import {
|
||||
} from './auth.utils';
|
||||
import type { SsoProvider } from './dto/sso-provider';
|
||||
import { UserEvents } from '../users/events/user.events';
|
||||
import { CacheService } from '../common/cache/cache.service';
|
||||
import {
|
||||
authSessionUserTag,
|
||||
authSessionCacheKey,
|
||||
CACHE_NAMESPACE,
|
||||
CACHE_TTL_SECONDS,
|
||||
} from '../common/cache/cache-keys';
|
||||
import { CacheTagsService } from '../common/cache/cache-tags.service';
|
||||
|
||||
interface SsoStateClaims {
|
||||
provider: SsoProvider;
|
||||
@@ -45,6 +53,28 @@ interface SsoStateClaims {
|
||||
typ: 'sso_state';
|
||||
}
|
||||
|
||||
interface AuthSessionWithUser {
|
||||
id: string;
|
||||
refresh_token_hash: string;
|
||||
expires_at: Date;
|
||||
revoked_at: Date | null;
|
||||
provider: 'GOOGLE' | 'DISCORD' | null;
|
||||
user_id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface CachedAuthSessionWithUser {
|
||||
id: string;
|
||||
refresh_token_hash: string;
|
||||
expires_at: string;
|
||||
revoked_at: string | null;
|
||||
provider: 'GOOGLE' | 'DISCORD' | null;
|
||||
user_id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -59,6 +89,8 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly cacheTagsService: CacheTagsService,
|
||||
) {
|
||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
||||
this.jwtIssuer =
|
||||
@@ -162,7 +194,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (session.refresh_token_hash !== refreshTokenHash) {
|
||||
await this.revokeSessionOnReplay(session.id);
|
||||
await this.revokeSessionOnReplay(session.id, session.user_id);
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
@@ -174,7 +206,7 @@ export class AuthService {
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
await this.revokeSessionOnReplay(session.id);
|
||||
await this.revokeSessionOnReplay(session.id, session.user_id);
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
@@ -560,28 +592,34 @@ export class AuthService {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async getSessionWithUser(sessionId: string): Promise<{
|
||||
id: string;
|
||||
refresh_token_hash: string;
|
||||
expires_at: Date;
|
||||
revoked_at: Date | null;
|
||||
provider: 'GOOGLE' | 'DISCORD' | null;
|
||||
user_id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
} | null> {
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
id: string;
|
||||
refresh_token_hash: string;
|
||||
expires_at: Date;
|
||||
revoked_at: Date | null;
|
||||
provider: 'GOOGLE' | 'DISCORD' | null;
|
||||
user_id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}>
|
||||
>`
|
||||
private async getSessionWithUser(
|
||||
sessionId: string,
|
||||
): Promise<AuthSessionWithUser | null> {
|
||||
const sessionCacheKey = this.getAuthSessionCacheKey(sessionId);
|
||||
const cachedSessionRaw = await this.cacheService.get(sessionCacheKey);
|
||||
|
||||
if (cachedSessionRaw) {
|
||||
try {
|
||||
const cachedSession = JSON.parse(
|
||||
cachedSessionRaw,
|
||||
) as CachedAuthSessionWithUser;
|
||||
return {
|
||||
...cachedSession,
|
||||
expires_at: new Date(cachedSession.expires_at),
|
||||
revoked_at: cachedSession.revoked_at
|
||||
? new Date(cachedSession.revoked_at)
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
this.cacheService.recordError(
|
||||
'auth session parse',
|
||||
sessionCacheKey,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await this.prisma.$queryRaw<Array<AuthSessionWithUser>>`
|
||||
SELECT s.id, s.refresh_token_hash, s.expires_at, s.revoked_at, s.provider, s.user_id, u.email, u.roles
|
||||
FROM auth_sessions AS s
|
||||
INNER JOIN users AS u ON u.id = s.user_id
|
||||
@@ -589,7 +627,29 @@ export class AuthService {
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return rows[0] ?? null;
|
||||
const session = rows[0] ?? null;
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cachePayload: CachedAuthSessionWithUser = {
|
||||
...session,
|
||||
expires_at: session.expires_at.toISOString(),
|
||||
revoked_at: session.revoked_at ? session.revoked_at.toISOString() : null,
|
||||
};
|
||||
|
||||
await this.cacheService.set(
|
||||
sessionCacheKey,
|
||||
JSON.stringify(cachePayload),
|
||||
CACHE_TTL_SECONDS.AUTH_SESSION,
|
||||
);
|
||||
await this.cacheTagsService.rememberKeyForTag(
|
||||
CACHE_NAMESPACE.AUTH_SESSION,
|
||||
authSessionUserTag(session.user_id),
|
||||
authSessionCacheKey(session.id),
|
||||
);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async rotateRefreshSession(
|
||||
@@ -597,6 +657,8 @@ export class AuthService {
|
||||
refreshTokenHash: string,
|
||||
nextRefreshToken: string,
|
||||
): Promise<boolean> {
|
||||
await this.cacheService.del(this.getAuthSessionCacheKey(sessionId));
|
||||
|
||||
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
UPDATE auth_sessions
|
||||
SET refresh_token_hash = ${sha256(nextRefreshToken)},
|
||||
@@ -610,6 +672,10 @@ export class AuthService {
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (rows.length === 1) {
|
||||
await this.cacheService.del(this.getAuthSessionCacheKey(sessionId));
|
||||
}
|
||||
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
@@ -617,6 +683,8 @@ export class AuthService {
|
||||
sessionId: string,
|
||||
refreshTokenHash: string,
|
||||
): Promise<boolean> {
|
||||
await this.cacheService.del(this.getAuthSessionCacheKey(sessionId));
|
||||
|
||||
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
UPDATE auth_sessions
|
||||
SET revoked_at = NOW(),
|
||||
@@ -628,17 +696,41 @@ export class AuthService {
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (rows.length === 1) {
|
||||
await this.cacheService.del(this.getAuthSessionCacheKey(sessionId));
|
||||
}
|
||||
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
private async revokeSessionOnReplay(sessionId: string): Promise<void> {
|
||||
private async revokeSessionOnReplay(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await this.cacheService.del(this.getAuthSessionCacheKey(sessionId));
|
||||
await this.revokeAllUserSessions(userId);
|
||||
}
|
||||
|
||||
private async revokeAllUserSessions(userId: string): Promise<void> {
|
||||
await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||
UPDATE auth_sessions
|
||||
SET revoked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${sessionId}
|
||||
WHERE user_id = ${userId}
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
await this.cacheTagsService.invalidateTag(
|
||||
CACHE_NAMESPACE.AUTH_SESSION,
|
||||
authSessionUserTag(userId),
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthSessionCacheKey(sessionId: string): string {
|
||||
return this.cacheService.getNamespacedKey(
|
||||
CACHE_NAMESPACE.AUTH_SESSION,
|
||||
authSessionCacheKey(sessionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ const DEFAULT_REVOKED_RETENTION_DAYS = 7;
|
||||
const CLEANUP_LOCK_KEY = 'lock:auth:cleanup';
|
||||
const CLEANUP_LOCK_TTL_MS = 55_000;
|
||||
|
||||
const RELEASE_LOCK_SCRIPT = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
|
||||
@Injectable()
|
||||
export class AuthCleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(AuthCleanupService.name);
|
||||
@@ -141,10 +149,12 @@ export class AuthCleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
} 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);
|
||||
}
|
||||
await this.redisClient.eval(
|
||||
RELEASE_LOCK_SCRIPT,
|
||||
1,
|
||||
CLEANUP_LOCK_KEY,
|
||||
lockToken,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Failed to release auth cleanup lock',
|
||||
|
||||
Reference in New Issue
Block a user