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
10 changes: 10 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IndexOptimizationModule } from './database/index-optimization/index-opt
import { RateLimitingModule } from './rate-limiting/rate-limiting.module';
import { QuotaGuard } from './rate-limiting/guards/quota.guard';
import { getDatabaseConfig } from './config/database.config';
import { GlobalAuthGuard } from './auth/guards/global-auth.guard';
import { loadFeatureFlags } from './config/feature-flags.config';
import { SessionModule } from './session/session.module';
import { DebuggingModule } from './debugging/debugging.module';
Expand Down Expand Up @@ -71,6 +72,15 @@ const featureFlags = loadFeatureFlags();
controllers: [AppController],
providers: [
...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []),
// Global auth guard: enforces JWT or service tokens for all non-public routes
...(featureFlags.ENABLE_AUTH
? [
{
provide: APP_GUARD,
useClass: GlobalAuthGuard,
},
]
: []),
{ provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
Expand Down
15 changes: 15 additions & 0 deletions src/audit-log/services/audit-logger.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveRetentionDays,
} from '../../middleware/audit/log-retention.policy';
import { IAuditLogEntry } from '../interfaces/audit-log.interfaces';
import { encryptString } from '../../common/utils/encryption.utils';

/**
* Provides audit logging operations.
Expand Down Expand Up @@ -38,8 +39,22 @@ export class AuditLoggerService {
async log(entry: IAuditLogEntry): Promise<AuditLog> {
const retentionUntil = buildRetentionUntil(this.retentionDays);

// Optionally encrypt metadata at rest when a key is provided
const encryptionKey = this.configService.get<string>('DATA_ENCRYPTION_KEY');
let metadata = entry.metadata;
if (metadata && encryptionKey) {
try {
const plain = JSON.stringify(metadata);
const encrypted = encryptString(plain, encryptionKey);
metadata = { __encrypted: true, value: encrypted } as any;
} catch (err) {
this.logger.error('Failed to encrypt audit metadata', err as Error);
}
}

const log = this.auditRepo.create({
...entry,
metadata,
severity: (entry.severity || AuditSeverity.INFO) as any,
retentionUntil,
httpMethod: entry.httpMethod as any,
Expand Down
3 changes: 3 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './decorators/public.decorator';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { InjectRepository } from '@nestjs/typeorm';
Expand All @@ -28,6 +29,7 @@ export class AuthController {
) {}

@Post('login')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Log in with email and password' })
@ApiResponse({ status: 200, description: 'Successfully authenticated' })
Expand All @@ -47,6 +49,7 @@ export class AuthController {
}

@Post('refresh')
@Public()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access and refresh tokens' })
@ApiResponse({ status: 200, description: 'Successfully refreshed tokens' })
Expand Down
16 changes: 16 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { JwtStrategy } from './jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { ServiceAuthGuard } from './guards/service-auth.guard';
import { GlobalAuthGuard } from './guards/global-auth.guard';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TokenBlacklistService } from './services/token-blacklist.service';
Expand All @@ -26,11 +29,24 @@ import { SocialAuthController } from './controllers/social-auth.controller';
}),
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
controllers: [AuthController, SocialAuthController],
providers: [
JwtStrategy,
AuthService,
TokenBlacklistService,
JwtAuthGuard,
ServiceAuthGuard,
GlobalAuthGuard,
],
exports: [
PassportModule,
JwtModule,
AuthService,
JwtAuthGuard,
ServiceAuthGuard,
GlobalAuthGuard,
],
GoogleStrategy,
GitHubStrategy,
SocialAuthService,
Expand Down
23 changes: 14 additions & 9 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class AuthService {
decoded = this.jwtService.verify(refreshToken, {
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
});
} catch {
} catch (_e) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
Expand Down Expand Up @@ -109,19 +110,23 @@ export class AuthService {
const payload = { sub: user.id, email: user.email, role: user.role };
const refreshJti = uuidv4();

const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
const accessToken = await this.jwtService.signAsync(
payload as any,
{
secret: process.env.JWT_SECRET || 'default-jwt-secret',
expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any,
} as any,
}),
this.jwtService.signAsync(
{ ...payload, jti: refreshJti },
{
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any,
);

const refreshToken = await this.jwtService.signAsync(
{ ...payload, jti: refreshJti } as any,
{
secret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any,
} as any,
},
),
]);
);

return {
accessToken,
Expand Down
4 changes: 4 additions & 0 deletions src/auth/decorators/public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
46 changes: 46 additions & 0 deletions src/auth/guards/global-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { CanActivate } from '@nestjs/common/interfaces';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { ServiceAuthGuard } from './service-auth.guard';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

/**
* Composite global guard that enforces either user identity (JWT) or
* service-to-service identity (service token). Routes can be marked
* public using the `@Public()` decorator.
*/
@Injectable()
export class GlobalAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtGuard: JwtAuthGuard,
private readonly serviceGuard: ServiceAuthGuard,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;

// Try user JWT first
try {
const result = (await this.jwtGuard.canActivate(context)) as boolean;
if (result) return true;
} catch {
// continue to try service guard
}

// Try service token
try {
const result = (await this.serviceGuard.canActivate(context)) as boolean;
if (result) return true;
} catch {
// both failed
}

throw new UnauthorizedException('Authentication required');
}
}
47 changes: 47 additions & 0 deletions src/auth/guards/service-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';

/**
* Simple service-to-service authentication guard.
* Expects an `x-service-token: Bearer <jwt>` header signed with SERVICE_JWT_SECRET.
*/
@Injectable()
export class ServiceAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}

canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const raw = req.headers['x-service-token'] || req.headers['x-service-auth'];
if (!raw) return false;

const token = Array.isArray(raw) ? raw[0] : raw;
const parts = token.split(' ');
const maybe = parts.length === 2 ? parts[1] : parts[0];

try {
const payload = this.jwtService.verify(maybe, {
secret: process.env.SERVICE_JWT_SECRET || 'default-service-secret',
algorithms: ['HS256'],
});

// Basic validation: service claim and allowed list
const serviceName = (payload as any).service as string | undefined;
const allowed = (process.env.SERVICE_ALLOW_LIST || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);

if (!serviceName) throw new UnauthorizedException('Invalid service token');
if (allowed.length > 0 && !allowed.includes(serviceName)) {
throw new UnauthorizedException('Service not allowed');
}

// attach service identity for downstream usage
(req as any).serviceIdentity = { name: serviceName, claims: payload };
return true;
} catch {
throw new UnauthorizedException('Invalid service token');
}
}
}
23 changes: 23 additions & 0 deletions src/common/utils/encryption.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as crypto from 'crypto';

const ALGO = 'aes-256-gcm';
const IV_LENGTH = 12;

export function encryptString(plaintext: string, key: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGO, Buffer.from(key, 'hex'), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}

export function decryptString(payloadB64: string, key: string): string {
const data = Buffer.from(payloadB64, 'base64');
const iv = data.slice(0, IV_LENGTH);
const tag = data.slice(IV_LENGTH, IV_LENGTH + 16);
const encrypted = data.slice(IV_LENGTH + 16);
const decipher = crypto.createDecipheriv(ALGO, Buffer.from(key, 'hex'), iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return decrypted.toString('utf8');
}
7 changes: 7 additions & 0 deletions src/config/database.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,19 @@ export function getDatabaseConfig(): TypeOrmModuleOptions {
slaves: replicas,
},
...commonOptions,
// Optionally enable SSL for encryption in transit to the database
...(process.env.DATABASE_SSL === 'true'
? { ssl: { rejectUnauthorized: process.env.DATABASE_SSL_REJECT_UNAUTHORIZED !== 'false' } }
: {}),
};
}

return {
type: 'postgres',
...primary,
...commonOptions,
...(process.env.DATABASE_SSL === 'true'
? { ssl: { rejectUnauthorized: process.env.DATABASE_SSL_REJECT_UNAUTHORIZED !== 'false' } }
: {}),
};
}
13 changes: 13 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ async function bootstrapWorker(): Promise<void> {
}),
);

// Enforce TLS in front of the app when configured (useful behind proxies/load-balancers)
if ((process.env.ENFORCE_TLS || 'false') === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => {
const forwardedProto = (req.headers['x-forwarded-proto'] || '').toString();
const isSecure = req.secure || forwardedProto === 'https';
if (!isSecure) {
res.status(426).json({ message: 'TLS Required' });
return;
}
next();
});
}

// =========================
// BODY PARSING
// =========================
Expand Down
4 changes: 2 additions & 2 deletions src/middleware/audit/audit-logger.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ export function createAuditLoggerMiddleware(auditLogService: AuditLogService) {

void auditLogService
.log({
userId: req.user?.id,
userEmail: req.user?.email,
userId: req.user?.id || (req as any).serviceIdentity?.name || undefined,
userEmail: req.user?.email || undefined,
action: userAction.action,
category: userAction.category,
severity,
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.build.tsbuildinfo

Large diffs are not rendered by default.

Loading