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 @@ -106,9 +106,13 @@ JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
# Refresh token expiration time
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 @@ -67,7 +67,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 @@ -34,6 +34,7 @@ const mockUserRepo = {
const mockJwtService = {
signAsync: jest.fn(),
verify: jest.fn(),
decode: jest.fn(),
};

const mockBlacklistService = {
Expand Down Expand Up @@ -92,6 +93,32 @@ describe('AuthService', () => {

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(
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 @@ -91,7 +91,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 @@ -107,13 +120,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 || 'default-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
6 changes: 5 additions & 1 deletion src/modules/gdpr/gdpr.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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: [SessionModule],
imports: [SessionModule, TypeOrmModule.forFeature([UserConsent])],
controllers: [GdprController],
providers: [GdprService],
})
Expand Down
67 changes: 55 additions & 12 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';
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 All @@ -20,6 +21,9 @@ export class GdprService {
private readonly consentRepository: Repository<UserConsent>,

private readonly sessionService: SessionService,

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

async exportUserData(userId: string) {
Expand Down Expand Up @@ -53,23 +57,62 @@ export class GdprService {
throw new NotFoundException('User not found');
}

// 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]',
phone: null,
address: null,
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.auditService.log('GDPR_ERASURE', userId);

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

async updateConsent(userId: string, dto: ConsentDto) {
Expand Down
56 changes: 40 additions & 16 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 { SessionService } from '../../../session/session.service';
Expand Down Expand Up @@ -33,17 +34,37 @@ const mockConsentRepository = {
save: jest.fn((consent) => Promise.resolve(consent)),
};

// 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,
{ provide: 'UsersService', useValue: mockUsersService },
{ provide: 'AuditService', useValue: mockAuditService },
{ provide: SessionService, useValue: mockSessionService },
{ provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository },
{ provide: getDataSourceToken(), useValue: mockDataSource },
],
}).compile();

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

// Check that sensitive fields are explicitly excluded
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();

// Check that PII fields are preserved
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('stores consent changes', async () => {
Expand Down
65 changes: 65 additions & 0 deletions src/security/encryption/encryption.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as crypto from 'crypto';
import { EncryptionService } from './encryption.service';

const ENCRYPTION_SECRET = 'test-secret-for-scrypt-kdf';
const ENCRYPTION_SALT = 'test-salt-hex';

function makeService(): EncryptionService {
process.env.ENCRYPTION_SECRET = ENCRYPTION_SECRET;
process.env.ENCRYPTION_SALT = ENCRYPTION_SALT;
return new EncryptionService();
}

describe('EncryptionService', () => {
afterEach(() => {
delete process.env.ENCRYPTION_SECRET;
delete process.env.ENCRYPTION_SALT;
});

it('should be defined', () => {
expect(makeService()).toBeDefined();
});

it('throws when ENCRYPTION_SECRET is missing', () => {
delete process.env.ENCRYPTION_SECRET;
process.env.ENCRYPTION_SALT = ENCRYPTION_SALT;
expect(() => new EncryptionService()).toThrow('ENCRYPTION_SECRET');
});

it('throws when ENCRYPTION_SALT is missing', () => {
process.env.ENCRYPTION_SECRET = ENCRYPTION_SECRET;
delete process.env.ENCRYPTION_SALT;
expect(() => new EncryptionService()).toThrow('ENCRYPTION_SALT');
});

it('derives key using scrypt, not a plain SHA-256 hash', () => {
const service = makeService();
// Access private key via casting
const derivedKey = (service as any).key as Buffer;

const sha256Key = crypto.createHash('sha256').update(ENCRYPTION_SECRET).digest();
const scryptKey = crypto.scryptSync(ENCRYPTION_SECRET, ENCRYPTION_SALT, 32, {
N: 16384,
r: 8,
p: 1,
});

expect(derivedKey).toEqual(scryptKey);
expect(derivedKey).not.toEqual(sha256Key);
});

it('encrypts and decrypts round-trip correctly', () => {
const service = makeService();
const plaintext = 'sensitive data';
const payload = service.encrypt(plaintext);
expect(service.decrypt(payload)).toBe(plaintext);
});

it('produces different ciphertext for the same plaintext (random IV)', () => {
const service = makeService();
const a = service.encrypt('same text');
const b = service.encrypt('same text');
expect(a.iv).not.toBe(b.iv);
expect(a.content).not.toBe(b.content);
});
});
Loading