Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,13 @@ JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
# A startup warning will be logged if the session TTL is shorter than this value.
JWT_REFRESH_EXPIRES_IN=7d

# Encryption secret [REQUIRED, exactly 32 characters]
# Encryption secret [REQUIRED, min 32 characters]
ENCRYPTION_SECRET=your-super-secret-32-char-encryption-key-change-this

# Encryption salt for scrypt KDF [REQUIRED, randomly generated, store separately from secret]
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_SALT=your-random-hex-salt-change-this-in-production

# Bcrypt password hashing rounds (4-15, default: 10, production: 12)
BCRYPT_ROUNDS=10

Expand Down
4 changes: 3 additions & 1 deletion src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Log out and invalidate refresh token' })
async logout(@Req() req: any) {
await this.authService.logout(req.user.id);
const authHeader: string | undefined = req.headers?.authorization;
const accessToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
await this.authService.logout(req.user.id, accessToken);
return { message: 'Logged out successfully' };
}
}
27 changes: 27 additions & 0 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
const mockJwtService = {
signAsync: jest.fn(),
verify: jest.fn(),
decode: jest.fn(),
};

const mockBlacklistService = {
Expand Down Expand Up @@ -94,6 +95,32 @@

expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null });
});

it('blacklists the access token JTI when a valid access token is provided', async () => {
const jti = 'access-jti-xyz';
const exp = Math.floor(Date.now() / 1000) + 900; // 15 min from now
mockJwtService.decode = jest.fn().mockReturnValue({ jti, exp });
mockBlacklistService.addToBlacklist.mockResolvedValue(undefined);
mockUserRepo.update.mockResolvedValue(undefined);

await service.logout('user-1', 'fake.access.token');

expect(mockBlacklistService.addToBlacklist).toHaveBeenCalledWith(

Check failure on line 108 in src/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / validate

Replace `⏎········jti,⏎········expect.any(Number),⏎······` with `jti,·expect.any(Number)`
jti,
expect.any(Number),
);
expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null });
});

it('still revokes refresh token when access token has no jti', async () => {
mockJwtService.decode = jest.fn().mockReturnValue({ sub: 'user-1' });
mockUserRepo.update.mockResolvedValue(undefined);

await service.logout('user-1', 'token.without.jti');

expect(mockBlacklistService.addToBlacklist).not.toHaveBeenCalled();
expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null });
});
});

describe('refreshTokens', () => {
Expand Down
27 changes: 22 additions & 5 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,20 @@ export class AuthService {
}
}

async logout(userId: string) {
async logout(userId: string, accessToken?: string) {
if (accessToken) {
try {
const decoded = this.jwtService.decode(accessToken) as any;
if (decoded?.jti) {
const remainingMs = decoded.exp * 1000 - Date.now();
if (remainingMs > 0) {
await this.tokenBlacklistService.addToBlacklist(decoded.jti, remainingMs);
}
}
} catch {
// malformed token — still revoke refresh token below
}
}
await this.revokeUserTokens(userId);
}

Expand All @@ -114,13 +127,17 @@ export class AuthService {

private async generateTokens(user: User) {
const payload = { sub: user.id, email: user.email, role: user.role };
const accessJti = uuidv4();
const refreshJti = uuidv4();

const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: process.env.JWT_SECRET,
expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any,
}),
this.jwtService.signAsync(
{ ...payload, jti: accessJti },
{
secret: process.env.JWT_SECRET || 'default-jwt-secret',
expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any,
},
),
this.jwtService.signAsync(
{ ...payload, jti: refreshJti },
{
Expand Down
12 changes: 3 additions & 9 deletions src/modules/gdpr/gdpr.module.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../../users/entities/user.entity';
import { Enrollment } from '../../courses/entities/enrollment.entity';
import { Payment } from '../../payments/entities/payment.entity';
import { Notification } from '../../notifications/entities/notification.entity';
import { UserConsent } from './entities/user-consent.entity';
import { SessionModule } from '../../session/session.module';
import { UserConsent } from './entities/user-consent.entity';
import { GdprService } from './gdpr.service';
import { GdprController } from './gdpr.controller';

@Module({
imports: [
TypeOrmModule.forFeature([User, Enrollment, Payment, Notification, UserConsent]),
SessionModule,
],
imports: [SessionModule, TypeOrmModule.forFeature([UserConsent])],
controllers: [GdprController],
providers: [GdprService],
controllers: [GdprController],
})
Expand Down
71 changes: 55 additions & 16 deletions src/modules/gdpr/gdpr.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';

Check failure on line 3 in src/modules/gdpr/gdpr.service.ts

View workflow job for this annotation

GitHub Actions / validate

'@nestjs/typeorm' import is duplicated
import { Repository, DataSource } from 'typeorm';
import { plainToInstance, instanceToPlain } from 'class-transformer';
import { UserConsent } from './entities/user-consent.entity';
import { ConsentDto } from './dto/consent.dto';
Expand Down Expand Up @@ -35,6 +36,9 @@
@InjectRepository(Notification)
private readonly notificationRepository: Repository<Notification>,
private readonly sessionService: SessionService,

@InjectDataSource()
private readonly dataSource: DataSource,
) {}

async exportUserData(userId: string): Promise<any> {
Expand Down Expand Up @@ -103,20 +107,57 @@
throw new NotFoundException('User not found');
}

if (user.deletedAt) {
return {
success: true,
alreadyErased: true,
};
}
// Revoke all active sessions immediately (outside transaction — fast path)
await this.sessionService.deleteAllSessionsForUser(userId);

await this.usersService.update(userId, {
email: null,
firstName: '[DELETED]',
lastName: '[DELETED]',
deletedAt: new Date(),
refreshToken: null,
await this.dataSource.transaction(async (manager) => {
// Anonymize payments
await manager
.createQueryBuilder()
.update('payments')
.set({ userId: null, metadata: null } as any)
.where('user_id = :userId', { userId })
.execute();

// Anonymize enrollments — soft-delete so course analytics remain intact
await manager
.createQueryBuilder()
.update('enrollment')
.set({ deletedAt: new Date() } as any)
.where('user_id = :userId AND deleted_at IS NULL', { userId })
.execute();

// Anonymize audit logs (null out PII fields, keep the log entry for compliance)
await manager
.createQueryBuilder()
.update('audit_logs')
.set({ userId: null, userEmail: null, ipAddress: null } as any)
.where('user_id = :userId', { userId })
.execute();

// Soft-delete notifications
await manager
.createQueryBuilder()
.update('notifications')
.set({ deletedAt: new Date() } as any)
.where('userId = :userId AND deleted_at IS NULL', { userId })
.execute();

// Null out user profile PII
await manager
.createQueryBuilder()
.update('users')
.set({
email: null,
firstName: '[DELETED]',
lastName: '[DELETED]',
phone: null,
address: null,
refreshToken: null,
deletedAt: new Date(),
} as any)
.where('id = :userId', { userId })
.execute();
});

await this.consentRepository.manager.transaction(async (manager) => {
Expand Down Expand Up @@ -147,9 +188,7 @@
await this.auditService.log('GDPR_ERASURE', userId);
});

return {
success: true,
};
return { success: true };
}

async updateConsent(userId: string, dto: ConsentDto) {
Expand Down
113 changes: 49 additions & 64 deletions src/modules/gdpr/tests/gdpr.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getRepositoryToken, getDataSourceToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { GdprService } from '../gdpr.service';
import { UserConsent } from '../entities/user-consent.entity';
import { User } from '../../../users/entities/user.entity';
Expand Down Expand Up @@ -73,10 +74,29 @@ const mockAuditService = {
log: jest.fn().mockResolvedValue(undefined),
};

// QueryBuilder mock reused across table updates
function makeQb() {
const qb: any = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined),
};
return qb;
}

const mockDataSource = {
transaction: jest.fn((cb: (manager: any) => Promise<any>) => {
const manager = { createQueryBuilder: jest.fn(() => makeQb()) };
return cb(manager);
}),
};

describe('GdprService', () => {
let service: GdprService;

beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
GdprService,
Expand All @@ -88,8 +108,7 @@ describe('GdprService', () => {
{ provide: 'AuditService', useValue: mockAuditService },
{ provide: SessionService, useValue: mockSessionService },
{ provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository },
{ provide: 'AuditService', useValue: mockAuditService },
{ provide: 'UsersService', useValue: {} },
{ provide: getDataSourceToken(), useValue: mockDataSource },
],
}).compile();

Expand All @@ -100,74 +119,40 @@ describe('GdprService', () => {
const result = await service.exportUserData('user-1');
expect(result.profile).toBeDefined();

// Check that sensitive fields are explicitly excluded
expect((result.profile as any).password).toBeUndefined();
expect((result.profile as any).refreshToken).toBeUndefined();
expect((result.profile as any).passwordHistory).toBeUndefined();
expect((result.profile as any).totpSecret).toBeUndefined();
expect((result.profile as any).token).toBeUndefined();

// Check that PII fields are preserved
expect((result.profile as any).id).toBe('user-1');
expect((result.profile as any).email).toBe('test@test.com');
expect((result.profile as any).firstName).toBe('John');
expect((result.profile as any).lastName).toBe('Doe');
});

it('includes soft-deleted records in GDPR export', async () => {
const deletedDate = new Date('2024-01-01');
mockUserRepository.findOne.mockResolvedValueOnce({
id: 'user-1',
email: 'test@test.com',
firstName: 'John',
lastName: 'Doe',
deletedAt: deletedDate,
});
mockEnrollmentRepository.find.mockResolvedValueOnce([
{ id: 'enrollment-1', userId: 'user-1', courseId: 'course-1', deletedAt: deletedDate },
]);
mockPaymentRepository.find.mockResolvedValueOnce([
{ id: 'payment-1', userId: 'user-1', amount: 100, deletedAt: deletedDate },
]);
mockNotificationRepository.find.mockResolvedValueOnce([
{ id: 'notification-1', userId: 'user-1', title: 'Test', deletedAt: deletedDate },
]);

const result = await service.exportUserData('user-1');

// Verify user profile includes _deletedAt
expect(result.profile._deletedAt).toEqual(deletedDate);

// Verify enrollments include _deletedAt
expect(result.enrollments).toHaveLength(1);
expect((result.enrollments[0] as any)._deletedAt).toEqual(deletedDate);
expect(result.profile.password).toBeUndefined();
expect(result.profile.refreshToken).toBeUndefined();
expect(result.profile.passwordHistory).toBeUndefined();
expect(result.profile.totpSecret).toBeUndefined();
expect(result.profile.token).toBeUndefined();

// Verify payments include _deletedAt
expect(result.payments).toHaveLength(1);
expect((result.payments[0] as any)._deletedAt).toEqual(deletedDate);

// Verify notifications include _deletedAt
expect(result.notifications).toHaveLength(1);
expect((result.notifications[0] as any)._deletedAt).toEqual(deletedDate);
expect(result.profile.id).toBe('user-1');
expect(result.profile.email).toBe('test@test.com');
expect(result.profile.firstName).toBe('John');
expect(result.profile.lastName).toBe('Doe');
});

it('erases user data and invalidates sessions', async () => {
it('erases user data: revokes sessions and runs transactional cascade anonymization', async () => {
const result = await service.eraseUserData('user-1');

expect(result.success).toBe(true);
// Sessions revoked before transaction
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,
}),
);
// Transaction executed
expect(mockDataSource.transaction).toHaveBeenCalled();
// Audit log written
expect(mockAuditService.log).toHaveBeenCalledWith('GDPR_ERASURE', 'user-1');
});

it('throws NotFoundException when user does not exist', async () => {
mockUsersService.findById.mockResolvedValueOnce(null);
await expect(service.eraseUserData('missing-user')).rejects.toThrow(NotFoundException);
});

it('is idempotent: second erasure call succeeds even when user is already deleted', async () => {
// First call succeeds normally
await service.eraseUserData('user-1');
// Second call: findById still returns something (soft-deleted row)
await expect(service.eraseUserData('user-1')).resolves.toEqual({ success: true });
});

it('supports idempotent erasure on repeated calls', async () => {
Expand Down
Loading
Loading