diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..d618ef24 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,33 @@ +# PR Title + +fix(gdpr): revoke active sessions during user erasure + +## Summary + +Fixes #821. + +GDPR erasure previously anonymized the user profile but left Redis-backed sessions and refresh-token material intact. This allowed an erased user to continue authenticating with previously issued session state. + +## What changed + +- Added session revocation during GDPR erasure by deleting all Redis sessions belonging to the user. +- Cleared the user's refresh token during erasure so old refresh-token-based flows are invalidated. +- Added regression tests covering both: + - GDPR erasure invoking session cleanup, and + - session service removal of all sessions for a specific user. + +## Why + +This brings the erasure flow into compliance with GDPR data-erasure expectations by ensuring previously valid session state is invalidated immediately when a user is erased. + +## Testing + +Verified locally with: + +```bash +cd /home/gift/teachLink_backend && npx jest --runInBand src/modules/gdpr/tests/gdpr.service.spec.ts src/session/session.service.spec.ts +``` + +Result: +- 2/2 test suites passed +- 17/17 tests passed diff --git a/src/modules/gdpr/gdpr.module.ts b/src/modules/gdpr/gdpr.module.ts index 87b8167f..caa73f96 100644 --- a/src/modules/gdpr/gdpr.module.ts +++ b/src/modules/gdpr/gdpr.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; +import { SessionModule } from '../../session/session.module'; @Module({ + imports: [SessionModule], controllers: [GdprController], - providers: [GdprService], }) export class GdprModule {} diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index 57085613..ab1a3c24 100644 --- a/src/modules/gdpr/gdpr.service.ts +++ b/src/modules/gdpr/gdpr.service.ts @@ -5,6 +5,7 @@ import { plainToInstance, instanceToPlain } from 'class-transformer'; import { UserConsent } from './entities/user-consent.entity'; import { ConsentDto } from './dto/consent.dto'; import { GdprExportDto } from './dto/gdpr-export.dto'; +import { SessionService } from '../../session/session.service'; @Injectable() export class GdprService { @@ -17,6 +18,8 @@ export class GdprService { @InjectRepository(UserConsent) private readonly consentRepository: Repository, + + private readonly sessionService: SessionService, ) {} async exportUserData(userId: string) { @@ -50,6 +53,8 @@ export class GdprService { throw new NotFoundException('User not found'); } + await this.sessionService.deleteAllSessionsForUser(userId); + await this.usersService.update(userId, { email: null, firstName: '[DELETED]', @@ -57,6 +62,7 @@ export class GdprService { phone: null, address: null, deletedAt: new Date(), + refreshToken: null, }); await this.auditService.log('GDPR_ERASURE', userId); diff --git a/src/modules/gdpr/tests/gdpr.service.spec.ts b/src/modules/gdpr/tests/gdpr.service.spec.ts index c5bd101d..912bbae1 100644 --- a/src/modules/gdpr/tests/gdpr.service.spec.ts +++ b/src/modules/gdpr/tests/gdpr.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { GdprService } from '../gdpr.service'; import { UserConsent } from '../entities/user-consent.entity'; +import { SessionService } from '../../../session/session.service'; const mockUsersService = { findById: jest.fn().mockResolvedValue({ @@ -22,6 +23,10 @@ const mockAuditService = { log: jest.fn().mockResolvedValue(undefined), }; +const mockSessionService = { + deleteAllSessionsForUser: jest.fn().mockResolvedValue(undefined), +}; + const mockConsentRepository = { find: jest.fn().mockResolvedValue([]), create: jest.fn((dto) => ({ ...dto, id: 'consent-1' })), @@ -37,6 +42,7 @@ describe('GdprService', () => { GdprService, { provide: 'UsersService', useValue: mockUsersService }, { provide: 'AuditService', useValue: mockAuditService }, + { provide: SessionService, useValue: mockSessionService }, { provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository }, ], }).compile(); @@ -62,9 +68,23 @@ describe('GdprService', () => { expect(result.profile.lastName).toBe('Doe'); }); - it('erases user data', async () => { + it('erases user data and invalidates sessions', async () => { const result = await service.eraseUserData('user-1'); + expect(result.success).toBe(true); + expect(mockSessionService.deleteAllSessionsForUser).toHaveBeenCalledWith('user-1'); + expect(mockUsersService.update).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + email: null, + firstName: '[DELETED]', + lastName: '[DELETED]', + phone: null, + address: null, + deletedAt: expect.any(Date), + refreshToken: null, + }), + ); }); it('stores consent changes', async () => { diff --git a/src/session/session.service.spec.ts b/src/session/session.service.spec.ts index 700ed47d..5b256361 100644 --- a/src/session/session.service.spec.ts +++ b/src/session/session.service.spec.ts @@ -13,6 +13,7 @@ const mockRedis = { zadd: jest.fn().mockResolvedValue(1), zrem: jest.fn().mockResolvedValue(1), zrange: jest.fn().mockResolvedValue([]), + scan: jest.fn(), status: 'ready', quit: jest.fn(), }; @@ -170,6 +171,22 @@ describe('SessionService', () => { }); }); + describe('deleteAllSessionsForUser', () => { + it('should remove all Redis sessions for a user', async () => { + mockRedis.scan.mockResolvedValueOnce(['0', ['auth:sess:one', 'auth:sess:two']]); + mockRedis.get + .mockResolvedValueOnce(JSON.stringify({ sid: 'one', userId: 'user-123' })) + .mockResolvedValueOnce(JSON.stringify({ sid: 'two', userId: 'user-999' })); + mockRedis.del.mockResolvedValue(1); + + const deletedCount = await service.deleteAllSessionsForUser('user-123'); + + expect(deletedCount).toBe(1); + expect(mockRedis.del).toHaveBeenCalledWith('auth:sess:one'); + expect(mockRedis.zrem).toHaveBeenCalledWith('user:sessions:user-123', 'one'); + }); + }); + describe('migrateSession', () => { it('should migrate session to new sid and delete old one', async () => { const sessionData = { diff --git a/src/session/session.service.ts b/src/session/session.service.ts index 6031a503..fbb52086 100644 --- a/src/session/session.service.ts +++ b/src/session/session.service.ts @@ -139,6 +139,37 @@ export class SessionService implements OnModuleDestroy { } } + async deleteAllSessionsForUser(userId: string): Promise { + const pattern = `${this.sessionPrefix}*`; + let cursor = '0'; + let deletedCount = 0; + + do { + const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + + for (const key of keys) { + const sessionData = await this.redis.get(key); + if (!sessionData) { + continue; + } + + try { + const session = JSON.parse(sessionData) as ISessionRecord; + if (session.userId === userId) { + await this.redis.del(key); + await this.removeSessionFromUserIndex(userId, session.sid); + deletedCount += 1; + } + } catch { + this.logger.warn(`Invalid session payload for key=${key}`); + } + } + } while (cursor !== '0'); + + return deletedCount; + } + async addSessionToUserIndex(userId: string, sid: string): Promise { await this.redis.zadd(`user:sessions:${userId}`, Date.now(), sid); }