Skip to content

Commit dfc5377

Browse files
Merge pull request #563 from BigBen-7/feat/health-2fa-queue-websocket
feat: health check, 2FA TOTP, Bull queue, and WebSocket gateway
2 parents 5a4414a + 296484f commit dfc5377

File tree

15 files changed

+376
-1
lines changed

15 files changed

+376
-1
lines changed

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"dependencies": {
2323
"@aws-sdk/client-s3": "^3.975.0",
2424
"@nestjs/bull": "^11.0.4",
25+
"@nestjs/platform-socket.io": "^10.4.15",
26+
"@nestjs/terminus": "^10.2.3",
2527
"@nestjs/cache-manager": "^3.1.0",
2628
"@nestjs/common": "^10.0.0",
2729
"@nestjs/config": "^4.0.2",

backend/src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ import { ReportsModule } from './reports/reports.module';
7272
AssetsModule,
7373
ReportsModule,
7474
LocationsModule,
75+
HealthModule,
76+
AuthModule,
77+
QueueModule,
78+
NotificationsModule,
7579
],
7680
controllers: [AppController],
7781
providers: [AppService, RolesGuard],

backend/src/assets/assets.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DepartmentsService } from '../departments/departments.service';
2121
import { CategoriesService } from '../categories/categories.service';
2222
import { UsersService } from '../users/users.service';
2323
import { StellarService } from '../stellar/stellar.service';
24+
import { NotificationsService } from '../notifications/notifications.service';
2425
import { User } from '../users/user.entity';
2526
import { StorageService } from '../storage/storage.service';
2627
import { Express } from 'express';
@@ -128,6 +129,7 @@ export class AssetsService {
128129
const saved = await this.assetsRepo.save(asset);
129130

130131
await this.logHistory(saved, AssetHistoryAction.CREATED, 'Asset registered', null, null, currentUser);
132+
this.notificationsService.emit('asset:created', { assetId: saved.id, assetCode: saved.assetId });
131133

132134
// Derive on-chain ID deterministically and mark PENDING (only if Stellar enabled)
133135
if (this.stellarService.isEnabled) {
@@ -175,6 +177,7 @@ export class AssetsService {
175177

176178
await this.assetsRepo.save(asset);
177179
await this.logHistory(asset, AssetHistoryAction.UPDATED, 'Asset updated', before as unknown as Record<string, unknown>, dto as unknown as Record<string, unknown>, currentUser);
180+
this.notificationsService.emit('asset:updated', { assetId: id });
178181

179182
return this.findOne(id);
180183
}
@@ -195,6 +198,7 @@ export class AssetsService {
195198
{ status: dto.status },
196199
currentUser,
197200
);
201+
this.notificationsService.emit('asset:status_changed', { assetId: id, from: prevStatus, to: dto.status });
198202

199203
return this.findOne(id);
200204
}
@@ -219,6 +223,7 @@ export class AssetsService {
219223
{ departmentId: asset.department.name },
220224
currentUser,
221225
);
226+
this.notificationsService.emit('asset:transferred', { assetId: id, from: prevDept, to: asset.department.name });
222227

223228
return this.findOne(id);
224229
}
@@ -302,6 +307,7 @@ export class AssetsService {
302307
{ type: dto.type, scheduledDate: dto.scheduledDate },
303308
currentUser,
304309
);
310+
this.notificationsService.emit('maintenance:scheduled', { assetId, maintenanceId: saved.id, type: dto.type });
305311
return saved;
306312
}
307313

backend/src/auth/auth.controller.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,50 @@ export class AuthController {
134134
firstName: user.firstName,
135135
lastName: user.lastName,
136136
role: user.role,
137+
twoFactorEnabled: user.twoFactorEnabled,
137138
createdAt: user.createdAt,
138139
};
139140
}
141+
142+
// ── 2FA endpoints ────────────────────────────────────────────────
143+
144+
@Post('2fa/setup')
145+
@UseGuards(JwtAuthGuard)
146+
@ApiBearerAuth('JWT-auth')
147+
@ApiOperation({ summary: 'Generate TOTP secret and QR code for 2FA setup' })
148+
@ApiResponse({ status: 201, description: 'Returns otpauthUrl and qrCodeDataUrl' })
149+
twoFactorSetup(@CurrentUser() user: User) {
150+
return this.authService.twoFactorSetup(user.id);
151+
}
152+
153+
@Post('2fa/enable')
154+
@HttpCode(HttpStatus.OK)
155+
@UseGuards(JwtAuthGuard)
156+
@ApiBearerAuth('JWT-auth')
157+
@ApiOperation({ summary: 'Enable 2FA after verifying TOTP code' })
158+
@ApiResponse({ status: 200, description: '2FA enabled' })
159+
@ApiResponse({ status: 401, description: 'Invalid TOTP code' })
160+
twoFactorEnable(@CurrentUser() user: User, @Body() dto: TwoFactorCodeDto) {
161+
return this.authService.twoFactorEnable(user.id, dto.code);
162+
}
163+
164+
@Post('2fa/disable')
165+
@HttpCode(HttpStatus.OK)
166+
@UseGuards(JwtAuthGuard)
167+
@ApiBearerAuth('JWT-auth')
168+
@ApiOperation({ summary: 'Disable 2FA after verifying TOTP code' })
169+
@ApiResponse({ status: 200, description: '2FA disabled' })
170+
@ApiResponse({ status: 401, description: 'Invalid TOTP code' })
171+
twoFactorDisable(@CurrentUser() user: User, @Body() dto: TwoFactorCodeDto) {
172+
return this.authService.twoFactorDisable(user.id, dto.code);
173+
}
174+
175+
@Post('2fa/verify')
176+
@HttpCode(HttpStatus.OK)
177+
@ApiOperation({ summary: 'Complete login by verifying TOTP code against temp token' })
178+
@ApiResponse({ status: 200, description: 'Returns full access and refresh tokens' })
179+
@ApiResponse({ status: 401, description: 'Invalid code or token' })
180+
twoFactorVerify(@Body() dto: TwoFactorVerifyDto) {
181+
return this.authService.twoFactorVerify(dto.tempToken, dto.code);
182+
}
140183
}

backend/src/auth/auth.service.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
import { JwtService } from '@nestjs/jwt';
88
import { ConfigService } from '@nestjs/config';
99
import * as bcrypt from 'bcrypt';
10+
import * as speakeasy from 'speakeasy';
11+
import * as qrcode from 'qrcode';
1012
import { UsersService } from '../users/users.service';
1113
import { RegisterDto } from './dto/register.dto';
1214
import { LoginDto } from './dto/login.dto';
@@ -49,7 +51,12 @@ export class AuthService {
4951
return { user, tokens };
5052
}
5153

52-
async login(dto: LoginDto): Promise<{ user: User; tokens: AuthTokens }> {
54+
async login(
55+
dto: LoginDto,
56+
): Promise<
57+
| { user: User; tokens: AuthTokens }
58+
| { requiresTwoFactor: true; tempToken: string }
59+
> {
5360
const user = await this.usersService.findByEmail(dto.email.toLowerCase());
5461
if (!user) {
5562
throw new UnauthorizedException('Invalid email or password');
@@ -60,6 +67,17 @@ export class AuthService {
6067
throw new UnauthorizedException('Invalid email or password');
6168
}
6269

70+
if (user.twoFactorEnabled) {
71+
const tempToken = await this.jwtService.signAsync(
72+
{ sub: user.id, twoFactorPending: true },
73+
{
74+
secret: this.configService.get<string>('JWT_SECRET', 'change-me-in-env'),
75+
expiresIn: '5m',
76+
},
77+
);
78+
return { requiresTwoFactor: true, tempToken };
79+
}
80+
6381
const tokens = await this.signTokens(user);
6482
await this.storeRefreshToken(user.id, tokens.refreshToken);
6583

@@ -136,4 +154,84 @@ export class AuthService {
136154
const hashed = await bcrypt.hash(token, 10);
137155
await this.usersService.updateRefreshToken(userId, hashed);
138156
}
157+
158+
// ── 2FA ──────────────────────────────────────────────────────────
159+
160+
async twoFactorSetup(userId: string): Promise<{ otpauthUrl: string; qrCodeDataUrl: string }> {
161+
const user = await this.usersService.findById(userId);
162+
const secret = speakeasy.generateSecret({
163+
name: `ManageAssets (${user.email})`,
164+
});
165+
166+
await this.usersService.updateTwoFactor(userId, secret.base32, false);
167+
168+
const qrCodeDataUrl = await qrcode.toDataURL(secret.otpauth_url!);
169+
return { otpauthUrl: secret.otpauth_url!, qrCodeDataUrl };
170+
}
171+
172+
async twoFactorEnable(userId: string, code: string): Promise<void> {
173+
const user = await this.usersService.findByIdWithTwoFactor(userId);
174+
if (!user?.twoFactorSecret) {
175+
throw new BadRequestException('2FA setup not initiated. Call /auth/2fa/setup first.');
176+
}
177+
178+
const valid = speakeasy.totp.verify({
179+
secret: user.twoFactorSecret,
180+
encoding: 'base32',
181+
token: code,
182+
window: 1,
183+
});
184+
if (!valid) throw new UnauthorizedException('Invalid TOTP code');
185+
186+
await this.usersService.updateTwoFactor(userId, user.twoFactorSecret, true);
187+
}
188+
189+
async twoFactorDisable(userId: string, code: string): Promise<void> {
190+
const user = await this.usersService.findByIdWithTwoFactor(userId);
191+
if (!user?.twoFactorSecret) {
192+
throw new BadRequestException('2FA is not set up for this account.');
193+
}
194+
195+
const valid = speakeasy.totp.verify({
196+
secret: user.twoFactorSecret,
197+
encoding: 'base32',
198+
token: code,
199+
window: 1,
200+
});
201+
if (!valid) throw new UnauthorizedException('Invalid TOTP code');
202+
203+
await this.usersService.updateTwoFactor(userId, null, false);
204+
}
205+
206+
async twoFactorVerify(tempToken: string, code: string): Promise<AuthTokens> {
207+
let payload: { sub: string; twoFactorPending?: boolean };
208+
try {
209+
payload = await this.jwtService.verifyAsync(tempToken, {
210+
secret: this.configService.get<string>('JWT_SECRET', 'change-me-in-env'),
211+
});
212+
} catch {
213+
throw new UnauthorizedException('Invalid or expired temp token');
214+
}
215+
216+
if (!payload.twoFactorPending) {
217+
throw new UnauthorizedException('Token is not a 2FA pending token');
218+
}
219+
220+
const user = await this.usersService.findByIdWithTwoFactor(payload.sub);
221+
if (!user?.twoFactorSecret) {
222+
throw new UnauthorizedException('2FA not configured');
223+
}
224+
225+
const valid = speakeasy.totp.verify({
226+
secret: user.twoFactorSecret,
227+
encoding: 'base32',
228+
token: code,
229+
window: 1,
230+
});
231+
if (!valid) throw new UnauthorizedException('Invalid TOTP code');
232+
233+
const tokens = await this.signTokens(user);
234+
await this.storeRefreshToken(user.id, tokens.refreshToken);
235+
return tokens;
236+
}
139237
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { IsString, Length } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class TwoFactorCodeDto {
5+
@ApiProperty({ example: '123456', description: '6-digit TOTP code' })
6+
@IsString()
7+
@Length(6, 6)
8+
code: string;
9+
}
10+
11+
export class TwoFactorVerifyDto {
12+
@ApiProperty({ description: 'Temporary token from login response' })
13+
@IsString()
14+
tempToken: string;
15+
16+
@ApiProperty({ example: '123456', description: '6-digit TOTP code' })
17+
@IsString()
18+
@Length(6, 6)
19+
code: string;
20+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Controller, Get } from '@nestjs/common';
2+
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
3+
import {
4+
HealthCheck,
5+
HealthCheckService,
6+
TypeOrmHealthIndicator,
7+
} from '@nestjs/terminus';
8+
9+
@ApiTags('Health')
10+
@Controller('health')
11+
export class HealthController {
12+
constructor(
13+
private readonly health: HealthCheckService,
14+
private readonly db: TypeOrmHealthIndicator,
15+
) {}
16+
17+
@Get()
18+
@HealthCheck()
19+
@ApiOperation({ summary: 'Check service health and database connectivity' })
20+
@ApiResponse({ status: 200, description: 'Service is healthy' })
21+
@ApiResponse({ status: 503, description: 'Service unavailable' })
22+
check() {
23+
return this.health.check([() => this.db.pingCheck('database')]);
24+
}
25+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { TerminusModule } from '@nestjs/terminus';
3+
import { HealthController } from './health.controller';
4+
5+
@Module({
6+
imports: [TerminusModule],
7+
controllers: [HealthController],
8+
})
9+
export class HealthModule {}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
WebSocketGateway,
3+
OnGatewayInit,
4+
OnGatewayConnection,
5+
OnGatewayDisconnect,
6+
WebSocketServer,
7+
} from '@nestjs/websockets';
8+
import { Logger, UnauthorizedException } from '@nestjs/common';
9+
import { JwtService } from '@nestjs/jwt';
10+
import { ConfigService } from '@nestjs/config';
11+
import { Server, Socket } from 'socket.io';
12+
import { NotificationsService } from './notifications.service';
13+
14+
@WebSocketGateway({ cors: true, namespace: '/notifications' })
15+
export class NotificationsGateway
16+
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
17+
{
18+
@WebSocketServer()
19+
server: Server;
20+
21+
private readonly logger = new Logger(NotificationsGateway.name);
22+
23+
constructor(
24+
private readonly notificationsService: NotificationsService,
25+
private readonly jwtService: JwtService,
26+
private readonly configService: ConfigService,
27+
) {}
28+
29+
afterInit(server: Server): void {
30+
this.notificationsService.setServer(server);
31+
this.logger.log('WebSocket gateway initialized');
32+
}
33+
34+
async handleConnection(client: Socket): Promise<void> {
35+
const token =
36+
(client.handshake.auth?.token as string | undefined) ??
37+
(client.handshake.headers?.authorization as string | undefined)?.replace('Bearer ', '');
38+
39+
if (!token) {
40+
this.logger.warn(`Client ${client.id} disconnected: no token`);
41+
client.disconnect();
42+
return;
43+
}
44+
45+
try {
46+
await this.jwtService.verifyAsync(token, {
47+
secret: this.configService.get<string>('JWT_SECRET', 'change-me-in-env'),
48+
});
49+
this.logger.log(`Client connected: ${client.id}`);
50+
} catch {
51+
this.logger.warn(`Client ${client.id} disconnected: invalid token`);
52+
client.disconnect();
53+
}
54+
}
55+
56+
handleDisconnect(client: Socket): void {
57+
this.logger.log(`Client disconnected: ${client.id}`);
58+
}
59+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { JwtModule } from '@nestjs/jwt';
3+
import { NotificationsGateway } from './notifications.gateway';
4+
import { NotificationsService } from './notifications.service';
5+
6+
@Module({
7+
imports: [JwtModule.register({})],
8+
providers: [NotificationsGateway, NotificationsService],
9+
exports: [NotificationsService],
10+
})
11+
export class NotificationsModule {}

0 commit comments

Comments
 (0)