mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(auth): add audit logging, account lockout, and API key rate limiting
1. SecurityEventsService: Centralized audit logging for all auth events (login, register, logout, password changes, API key operations, SSO token exchange, etc.). Fire-and-forget pattern ensures auth flows are never blocked by logging failures. 2. AccountLockoutService: Locks accounts after 5 failed login attempts within 15 minutes. 30-minute lockout duration. Fails open on DB errors. Clears attempts on successful login. Email-not-verified does not count as a failed attempt. 3. API Key validation endpoint secured with rate limiting (10 req/min per IP via ThrottlerGuard) and audit logging. Key prefixes logged for forensics, never full keys. New schema: auth.login_attempts table for tracking failed logins. 174 tests passing across all auth and security modules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
effa57fd61
commit
f7df8e97aa
14 changed files with 700 additions and 68 deletions
|
|
@ -5,19 +5,26 @@ import {
|
|||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { CreateApiKeyDto, ValidateApiKeyDto } from './dto';
|
||||
import { SecurityEventsService, SecurityEventType } from '../security';
|
||||
|
||||
@Controller('api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
constructor(
|
||||
private readonly apiKeysService: ApiKeysService,
|
||||
private readonly securityEvents: SecurityEventsService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List all API keys for the authenticated user
|
||||
|
|
@ -34,8 +41,20 @@ export class ApiKeysController {
|
|||
*/
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createKey(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) {
|
||||
return this.apiKeysService.createApiKey(user.userId, dto);
|
||||
async createKey(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@Req() req: Request
|
||||
) {
|
||||
const result = await this.apiKeysService.createApiKey(user.userId, dto);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.API_KEY_CREATED,
|
||||
metadata: { keyId: result.id, name: dto.name, scopes: dto.scopes },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,16 +63,48 @@ export class ApiKeysController {
|
|||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async revokeKey(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
async revokeKey(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request
|
||||
) {
|
||||
await this.apiKeysService.revokeApiKey(user.userId, id);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.API_KEY_REVOKED,
|
||||
metadata: { keyId: id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an API key (for STT/TTS services)
|
||||
* This endpoint does NOT require JWT authentication
|
||||
* Validate an API key (for internal services like STT/TTS)
|
||||
*
|
||||
* This endpoint does NOT require JWT authentication since it's called
|
||||
* by services that only have an API key, not a JWT.
|
||||
*
|
||||
* Rate limited to 10 requests/minute per IP to prevent brute force.
|
||||
*/
|
||||
@Post('validate')
|
||||
async validateKey(@Body() dto: ValidateApiKeyDto) {
|
||||
return this.apiKeysService.validateApiKey(dto.apiKey, dto.scope);
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async validateKey(@Body() dto: ValidateApiKeyDto, @Req() req: Request) {
|
||||
const result = await this.apiKeysService.validateApiKey(dto.apiKey, dto.scope);
|
||||
|
||||
const eventType = result.valid
|
||||
? SecurityEventType.API_KEY_VALIDATED
|
||||
: SecurityEventType.API_KEY_VALIDATION_FAILED;
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: result.valid ? result.userId : undefined,
|
||||
eventType,
|
||||
metadata: {
|
||||
scope: dto.scope,
|
||||
keyPrefix: dto.apiKey?.substring(0, 16) + '...',
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { SecurityModule } from '../security';
|
||||
|
||||
@Module({
|
||||
imports: [SecurityModule],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { AnalyticsModule } from './analytics';
|
|||
import { MetricsModule } from './metrics';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { LoggerModule } from './common/logger';
|
||||
import { SecurityModule } from './security';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -35,6 +36,7 @@ import { LoggerModule } from './common/logger';
|
|||
},
|
||||
]),
|
||||
LoggerModule,
|
||||
SecurityModule,
|
||||
MetricsModule,
|
||||
AnalyticsModule,
|
||||
AdminModule,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
|||
import { AuthController } from './auth.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { SecurityEventsService, AccountLockoutService } from '../security';
|
||||
import { mockDtoFactory } from '../__tests__/utils/mock-factories';
|
||||
|
||||
describe('AuthController', () => {
|
||||
|
|
@ -43,6 +44,7 @@ describe('AuthController', () => {
|
|||
// Common test data
|
||||
const mockAuthHeader = 'Bearer valid-jwt-token';
|
||||
const mockToken = 'valid-jwt-token';
|
||||
const mockReq = { headers: { 'user-agent': 'test' }, ip: '127.0.0.1' } as any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock BetterAuthService with all methods
|
||||
|
|
@ -63,6 +65,18 @@ describe('AuthController', () => {
|
|||
validateToken: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSecurityEventsService = {
|
||||
logEvent: jest.fn().mockResolvedValue(undefined),
|
||||
logEventWithRequest: jest.fn().mockResolvedValue(undefined),
|
||||
extractRequestInfo: jest.fn().mockReturnValue({ ipAddress: '127.0.0.1', userAgent: 'test' }),
|
||||
};
|
||||
|
||||
const mockAccountLockoutService = {
|
||||
checkLockout: jest.fn().mockResolvedValue({ locked: false }),
|
||||
recordAttempt: jest.fn().mockResolvedValue(undefined),
|
||||
clearAttempts: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }])],
|
||||
controllers: [AuthController],
|
||||
|
|
@ -71,6 +85,14 @@ describe('AuthController', () => {
|
|||
provide: BetterAuthService,
|
||||
useValue: mockBetterAuthService,
|
||||
},
|
||||
{
|
||||
provide: SecurityEventsService,
|
||||
useValue: mockSecurityEventsService,
|
||||
},
|
||||
{
|
||||
provide: AccountLockoutService,
|
||||
useValue: mockAccountLockoutService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(JwtAuthGuard)
|
||||
|
|
@ -110,7 +132,7 @@ describe('AuthController', () => {
|
|||
|
||||
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.register(registerDto);
|
||||
const result = await controller.register(registerDto, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
|
||||
|
|
@ -133,7 +155,7 @@ describe('AuthController', () => {
|
|||
|
||||
betterAuthService.registerB2C.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.register(registerDto as any);
|
||||
const result = await controller.register(registerDto as any, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.registerB2C).toHaveBeenCalledWith({
|
||||
|
|
@ -151,7 +173,7 @@ describe('AuthController', () => {
|
|||
new ConflictException('User with this email already exists')
|
||||
);
|
||||
|
||||
await expect(controller.register(registerDto)).rejects.toThrow(ConflictException);
|
||||
await expect(controller.register(registerDto, mockReq)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -180,7 +202,7 @@ describe('AuthController', () => {
|
|||
|
||||
betterAuthService.signIn.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.login(loginDto);
|
||||
const result = await controller.login(loginDto, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.signIn).toHaveBeenCalledWith({
|
||||
|
|
@ -206,7 +228,7 @@ describe('AuthController', () => {
|
|||
expiresIn: 900,
|
||||
});
|
||||
|
||||
await controller.login(loginDto);
|
||||
await controller.login(loginDto, mockReq);
|
||||
|
||||
expect(betterAuthService.signIn).toHaveBeenCalledWith({
|
||||
email: loginDto.email,
|
||||
|
|
@ -223,7 +245,7 @@ describe('AuthController', () => {
|
|||
new UnauthorizedException('Invalid email or password')
|
||||
);
|
||||
|
||||
await expect(controller.login(loginDto)).rejects.toThrow(UnauthorizedException);
|
||||
await expect(controller.login(loginDto, mockReq)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -237,7 +259,7 @@ describe('AuthController', () => {
|
|||
|
||||
betterAuthService.signOut.mockResolvedValue(expectedResult);
|
||||
|
||||
const result = await controller.logout(mockAuthHeader);
|
||||
const result = await controller.logout(mockAuthHeader, mockReq);
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith(mockToken);
|
||||
|
|
@ -246,7 +268,7 @@ describe('AuthController', () => {
|
|||
it('should extract token from Bearer header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
|
||||
|
||||
await controller.logout('Bearer my-secret-token');
|
||||
await controller.logout('Bearer my-secret-token', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-secret-token');
|
||||
});
|
||||
|
|
@ -254,7 +276,7 @@ describe('AuthController', () => {
|
|||
it('should handle raw token without Bearer prefix', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'Signed out' });
|
||||
|
||||
await controller.logout('raw-token');
|
||||
await controller.logout('raw-token', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('raw-token');
|
||||
});
|
||||
|
|
@ -700,7 +722,7 @@ describe('AuthController', () => {
|
|||
it('should extract token from Bearer authorization header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
|
||||
|
||||
await controller.logout('Bearer my-token-123');
|
||||
await controller.logout('Bearer my-token-123', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('my-token-123');
|
||||
});
|
||||
|
|
@ -708,7 +730,7 @@ describe('AuthController', () => {
|
|||
it('should handle missing authorization header', async () => {
|
||||
betterAuthService.signOut.mockResolvedValue({ success: true, message: 'OK' });
|
||||
|
||||
await controller.logout('');
|
||||
await controller.logout('', mockReq);
|
||||
|
||||
expect(betterAuthService.signOut).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
HttpStatus,
|
||||
Req,
|
||||
Res,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
|
@ -36,6 +37,7 @@ import { UpdateMemberRoleDto } from './dto/update-member-role.dto';
|
|||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { SecurityEventsService, SecurityEventType, AccountLockoutService } from '../security';
|
||||
|
||||
/**
|
||||
* Auth Controller
|
||||
|
|
@ -71,7 +73,11 @@ import type { CurrentUserData } from '../common/decorators/current-user.decorato
|
|||
@Controller('auth')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly betterAuthService: BetterAuthService) {}
|
||||
constructor(
|
||||
private readonly betterAuthService: BetterAuthService,
|
||||
private readonly securityEvents: SecurityEventsService,
|
||||
private readonly accountLockout: AccountLockoutService
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// B2C Authentication Endpoints
|
||||
|
|
@ -94,13 +100,21 @@ export class AuthController {
|
|||
@ApiResponse({ status: 400, description: 'Invalid input data' })
|
||||
@ApiResponse({ status: 409, description: 'Email already exists' })
|
||||
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.betterAuthService.registerB2C({
|
||||
async register(@Body() registerDto: RegisterDto, @Req() req: Request) {
|
||||
const result = await this.betterAuthService.registerB2C({
|
||||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name || '',
|
||||
sourceAppUrl: registerDto.sourceAppUrl,
|
||||
});
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: result.user?.id,
|
||||
eventType: SecurityEventType.REGISTER,
|
||||
metadata: { email: registerDto.email },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,13 +153,59 @@ export class AuthController {
|
|||
})
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.betterAuthService.signIn({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: loginDto.deviceId,
|
||||
deviceName: loginDto.deviceName,
|
||||
});
|
||||
async login(@Body() loginDto: LoginDto, @Req() req: Request) {
|
||||
const { ipAddress, userAgent } = this.securityEvents.extractRequestInfo(req);
|
||||
|
||||
// Check account lockout before attempting login
|
||||
const lockout = await this.accountLockout.checkLockout(loginDto.email);
|
||||
if (lockout.locked) {
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
eventType: SecurityEventType.LOGIN_FAILURE,
|
||||
metadata: { email: loginDto.email, reason: 'account_locked' },
|
||||
});
|
||||
throw new ForbiddenException({
|
||||
message: 'Account temporarily locked due to too many failed login attempts',
|
||||
code: 'ACCOUNT_LOCKED',
|
||||
retryAfter: lockout.remainingSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.betterAuthService.signIn({
|
||||
email: loginDto.email,
|
||||
password: loginDto.password,
|
||||
deviceId: loginDto.deviceId,
|
||||
deviceName: loginDto.deviceName,
|
||||
});
|
||||
|
||||
// Login successful - clear failed attempts and log
|
||||
this.accountLockout.clearAttempts(loginDto.email);
|
||||
this.securityEvents.logEvent({
|
||||
userId: result.user?.id,
|
||||
eventType: SecurityEventType.LOGIN_SUCCESS,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { email: loginDto.email, deviceId: loginDto.deviceId },
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Don't count email-not-verified as a failed login attempt
|
||||
if (error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Record failed attempt
|
||||
this.accountLockout.recordAttempt(loginDto.email, false, ipAddress);
|
||||
this.securityEvents.logEvent({
|
||||
eventType: SecurityEventType.LOGIN_FAILURE,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { email: loginDto.email, reason: 'invalid_credentials' },
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,9 +223,15 @@ export class AuthController {
|
|||
})
|
||||
@ApiResponse({ status: 200, description: 'Logout successful' })
|
||||
@ApiResponse({ status: 401, description: 'Not authenticated' })
|
||||
async logout(@Headers('authorization') authorization: string) {
|
||||
async logout(@Headers('authorization') authorization: string, @Req() req: Request) {
|
||||
const token = this.extractToken(authorization);
|
||||
return this.betterAuthService.signOut(token);
|
||||
const result = await this.betterAuthService.signOut(token);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
eventType: SecurityEventType.LOGOUT,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -244,7 +310,15 @@ export class AuthController {
|
|||
})
|
||||
@ApiResponse({ status: 401, description: 'No valid session cookie' })
|
||||
async sessionToToken(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
return this.betterAuthService.sessionToToken(req, res);
|
||||
const result = await this.betterAuthService.sessionToToken(req, res);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: result.user?.id,
|
||||
eventType: SecurityEventType.SSO_TOKEN_EXCHANGE,
|
||||
metadata: { email: result.user?.email },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -272,11 +346,18 @@ export class AuthController {
|
|||
@Post('forgot-password')
|
||||
@Throttle({ default: { ttl: 60000, limit: 3 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
|
||||
return this.betterAuthService.requestPasswordReset(
|
||||
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto, @Req() req: Request) {
|
||||
const result = await this.betterAuthService.requestPasswordReset(
|
||||
forgotPasswordDto.email,
|
||||
forgotPasswordDto.redirectTo
|
||||
);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
eventType: SecurityEventType.PASSWORD_RESET_REQUESTED,
|
||||
metadata: { email: forgotPasswordDto.email },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -288,11 +369,17 @@ export class AuthController {
|
|||
@Post('reset-password')
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
|
||||
return this.betterAuthService.resetPassword(
|
||||
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Req() req: Request) {
|
||||
const result = await this.betterAuthService.resetPassword(
|
||||
resetPasswordDto.token,
|
||||
resetPasswordDto.newPassword
|
||||
);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
eventType: SecurityEventType.PASSWORD_RESET_COMPLETED,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -381,12 +468,23 @@ export class AuthController {
|
|||
@ApiBody({ type: ChangePasswordDto })
|
||||
@ApiResponse({ status: 200, description: 'Password changed successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Current password is incorrect' })
|
||||
async changePassword(@CurrentUser() user: CurrentUserData, @Body() changeDto: ChangePasswordDto) {
|
||||
return this.betterAuthService.changePassword(
|
||||
async changePassword(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() changeDto: ChangePasswordDto,
|
||||
@Req() req: Request
|
||||
) {
|
||||
const result = await this.betterAuthService.changePassword(
|
||||
user.userId,
|
||||
changeDto.currentPassword,
|
||||
changeDto.newPassword
|
||||
);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.PASSWORD_CHANGED,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -403,8 +501,24 @@ export class AuthController {
|
|||
@ApiBody({ type: DeleteAccountDto })
|
||||
@ApiResponse({ status: 200, description: 'Account deleted' })
|
||||
@ApiResponse({ status: 401, description: 'Password is incorrect' })
|
||||
async deleteAccount(@CurrentUser() user: CurrentUserData, @Body() deleteDto: DeleteAccountDto) {
|
||||
return this.betterAuthService.deleteAccount(user.userId, deleteDto.password, deleteDto.reason);
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() deleteDto: DeleteAccountDto,
|
||||
@Req() req: Request
|
||||
) {
|
||||
const result = await this.betterAuthService.deleteAccount(
|
||||
user.userId,
|
||||
deleteDto.password,
|
||||
deleteDto.reason
|
||||
);
|
||||
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.ACCOUNT_DELETED,
|
||||
metadata: { reason: deleteDto.reason },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -684,8 +798,7 @@ export class AuthController {
|
|||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Cancel or reject invitation',
|
||||
description:
|
||||
'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.',
|
||||
description: 'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.',
|
||||
})
|
||||
@ApiResponse({ status: 204, description: 'Invitation cancelled/rejected successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Not authenticated' })
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { OidcLoginController } from './oidc-login.controller';
|
|||
import { MatrixSessionController } from './matrix-session.controller';
|
||||
import { BetterAuthService } from './services/better-auth.service';
|
||||
import { MatrixSessionService } from './services/matrix-session.service';
|
||||
import { SecurityModule } from '../security';
|
||||
|
||||
@Module({
|
||||
imports: [SecurityModule],
|
||||
controllers: [
|
||||
AuthController,
|
||||
BetterAuthPassthroughController,
|
||||
|
|
|
|||
|
|
@ -1534,19 +1534,6 @@ export class BetterAuthService {
|
|||
|
||||
this.logger.log('Password changed', { userId });
|
||||
|
||||
// Log security event
|
||||
try {
|
||||
const { securityEvents } = await import('../../db/schema/auth.schema');
|
||||
await db.insert(securityEvents).values({
|
||||
userId,
|
||||
eventType: 'password_changed',
|
||||
metadata: { changedAt: new Date().toISOString() },
|
||||
});
|
||||
} catch {
|
||||
// Non-critical - just log
|
||||
this.logger.warn('Failed to log security event for password change');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Password changed successfully',
|
||||
|
|
@ -1603,19 +1590,6 @@ export class BetterAuthService {
|
|||
|
||||
this.logger.log('Account deleted', { userId, reason });
|
||||
|
||||
// Log security event
|
||||
try {
|
||||
const { securityEvents } = await import('../../db/schema/auth.schema');
|
||||
await db.insert(securityEvents).values({
|
||||
userId,
|
||||
eventType: 'account_deleted',
|
||||
metadata: { reason, deletedAt: now.toISOString() },
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
this.logger.warn('Failed to log security event for account deletion');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Account has been deleted',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export * from './auth.schema';
|
|||
export * from './credits.schema';
|
||||
export * from './feedback.schema';
|
||||
export * from './gifts.schema';
|
||||
export * from './login-attempts.schema';
|
||||
export * from './organizations.schema';
|
||||
export * from './subscriptions.schema';
|
||||
export * from './tags.schema';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Login Attempts Schema
|
||||
*
|
||||
* Tracks login attempts for account lockout functionality.
|
||||
* Failed attempts within a time window trigger account lockout.
|
||||
*/
|
||||
|
||||
import { pgSchema, text, boolean, timestamp, index, serial } from 'drizzle-orm/pg-core';
|
||||
|
||||
const authSchema = pgSchema('auth');
|
||||
|
||||
export const loginAttempts = authSchema.table(
|
||||
'login_attempts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
successful: boolean('successful').default(false).notNull(),
|
||||
attemptedAt: timestamp('attempted_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index('login_attempts_email_attempted_at_idx').on(table.email, table.attemptedAt)]
|
||||
);
|
||||
141
services/mana-core-auth/src/security/account-lockout.service.ts
Normal file
141
services/mana-core-auth/src/security/account-lockout.service.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Account Lockout Service
|
||||
*
|
||||
* Tracks failed login attempts and locks accounts after too many failures.
|
||||
* Uses the login_attempts table for efficient counting.
|
||||
*
|
||||
* Policy:
|
||||
* - 5 failed attempts within 15 minutes → account locked for 30 minutes
|
||||
* - Successful login clears all previous attempts
|
||||
* - Lockout is per-email (not per-IP) to prevent distributed brute force
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../db/connection';
|
||||
import { loginAttempts } from '../db/schema/login-attempts.schema';
|
||||
import { LoggerService } from '../common/logger';
|
||||
import { and, eq, gte, sql, desc } from 'drizzle-orm';
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const ATTEMPT_WINDOW_MINUTES = 15;
|
||||
const LOCKOUT_DURATION_MINUTES = 30;
|
||||
|
||||
export interface LockoutStatus {
|
||||
locked: boolean;
|
||||
remainingSeconds?: number;
|
||||
attempts?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccountLockoutService {
|
||||
private readonly logger: LoggerService;
|
||||
private readonly databaseUrl: string;
|
||||
|
||||
constructor(
|
||||
loggerService: LoggerService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.logger = loggerService;
|
||||
this.logger.setContext('AccountLockoutService');
|
||||
this.databaseUrl = this.configService.get<string>('database.url') || '';
|
||||
}
|
||||
|
||||
private getDb() {
|
||||
return getDb(this.databaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account is locked due to too many failed login attempts
|
||||
*/
|
||||
async checkLockout(email: string): Promise<LockoutStatus> {
|
||||
try {
|
||||
const db = this.getDb();
|
||||
const windowStart = new Date(Date.now() - ATTEMPT_WINDOW_MINUTES * 60 * 1000);
|
||||
|
||||
// Count failed attempts in the window
|
||||
const result = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAttempt: sql<Date>`max(${loginAttempts.attemptedAt})`,
|
||||
})
|
||||
.from(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.email, email.toLowerCase()),
|
||||
eq(loginAttempts.successful, false),
|
||||
gte(loginAttempts.attemptedAt, windowStart)
|
||||
)
|
||||
);
|
||||
|
||||
const failedCount = result[0]?.count ?? 0;
|
||||
const latestAttempt = result[0]?.latestAttempt;
|
||||
|
||||
if (failedCount >= MAX_ATTEMPTS && latestAttempt) {
|
||||
const lockoutEnd = new Date(
|
||||
new Date(latestAttempt).getTime() + LOCKOUT_DURATION_MINUTES * 60 * 1000
|
||||
);
|
||||
const remainingMs = lockoutEnd.getTime() - Date.now();
|
||||
|
||||
if (remainingMs > 0) {
|
||||
return {
|
||||
locked: true,
|
||||
remainingSeconds: Math.ceil(remainingMs / 1000),
|
||||
attempts: failedCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { locked: false, attempts: failedCount };
|
||||
} catch (error) {
|
||||
// On error, do not lock out (fail open for availability)
|
||||
this.logger.warn('Failed to check lockout status', {
|
||||
email,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return { locked: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a login attempt (successful or failed)
|
||||
*/
|
||||
async recordAttempt(email: string, successful: boolean, ipAddress?: string): Promise<void> {
|
||||
try {
|
||||
const db = this.getDb();
|
||||
await db.insert(loginAttempts).values({
|
||||
email: email.toLowerCase(),
|
||||
ipAddress: ipAddress || null,
|
||||
successful,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to record login attempt', {
|
||||
email,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all failed attempts for an email (called on successful login)
|
||||
*/
|
||||
async clearAttempts(email: string): Promise<void> {
|
||||
try {
|
||||
const db = this.getDb();
|
||||
const windowStart = new Date(Date.now() - LOCKOUT_DURATION_MINUTES * 60 * 1000);
|
||||
await db
|
||||
.delete(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.email, email.toLowerCase()),
|
||||
gte(loginAttempts.attemptedAt, windowStart)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to clear login attempts', {
|
||||
email,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
5
services/mana-core-auth/src/security/index.ts
Normal file
5
services/mana-core-auth/src/security/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { SecurityModule } from './security.module';
|
||||
export { SecurityEventsService, SecurityEventType } from './security-events.service';
|
||||
export type { SecurityEventParams, SecurityEventTypeValue } from './security-events.service';
|
||||
export { AccountLockoutService } from './account-lockout.service';
|
||||
export type { LockoutStatus } from './account-lockout.service';
|
||||
122
services/mana-core-auth/src/security/security-events.service.ts
Normal file
122
services/mana-core-auth/src/security/security-events.service.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Security Events Service
|
||||
*
|
||||
* Centralized audit logging for all authentication and security-relevant events.
|
||||
* All methods are fire-and-forget: errors are logged but never thrown,
|
||||
* so audit logging cannot break authentication flows.
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from '../db/connection';
|
||||
import { securityEvents } from '../db/schema/auth.schema';
|
||||
import { LoggerService } from '../common/logger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export const SecurityEventType = {
|
||||
// Authentication
|
||||
LOGIN_SUCCESS: 'login_success',
|
||||
LOGIN_FAILURE: 'login_failure',
|
||||
REGISTER: 'register',
|
||||
LOGOUT: 'logout',
|
||||
TOKEN_REFRESHED: 'token_refreshed',
|
||||
SSO_TOKEN_EXCHANGE: 'sso_token_exchange',
|
||||
|
||||
// Password
|
||||
PASSWORD_CHANGED: 'password_changed',
|
||||
PASSWORD_RESET_REQUESTED: 'password_reset_requested',
|
||||
PASSWORD_RESET_COMPLETED: 'password_reset_completed',
|
||||
|
||||
// Email
|
||||
EMAIL_VERIFIED: 'email_verified',
|
||||
EMAIL_VERIFICATION_RESENT: 'email_verification_resent',
|
||||
|
||||
// Account
|
||||
ACCOUNT_DELETED: 'account_deleted',
|
||||
ACCOUNT_LOCKED: 'account_locked',
|
||||
ACCOUNT_UNLOCKED: 'account_unlocked',
|
||||
PROFILE_UPDATED: 'profile_updated',
|
||||
|
||||
// API Keys
|
||||
API_KEY_CREATED: 'api_key_created',
|
||||
API_KEY_REVOKED: 'api_key_revoked',
|
||||
API_KEY_VALIDATED: 'api_key_validated',
|
||||
API_KEY_VALIDATION_FAILED: 'api_key_validation_failed',
|
||||
|
||||
// Organizations
|
||||
ORG_CREATED: 'org_created',
|
||||
ORG_DELETED: 'org_deleted',
|
||||
ORG_MEMBER_INVITED: 'org_member_invited',
|
||||
ORG_MEMBER_REMOVED: 'org_member_removed',
|
||||
ORG_MEMBER_ROLE_CHANGED: 'org_member_role_changed',
|
||||
ORG_INVITATION_ACCEPTED: 'org_invitation_accepted',
|
||||
} as const;
|
||||
|
||||
export type SecurityEventTypeValue = (typeof SecurityEventType)[keyof typeof SecurityEventType];
|
||||
|
||||
export interface SecurityEventParams {
|
||||
userId?: string;
|
||||
eventType: SecurityEventTypeValue;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SecurityEventsService {
|
||||
private readonly logger: LoggerService;
|
||||
private readonly databaseUrl: string;
|
||||
|
||||
constructor(
|
||||
loggerService: LoggerService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.logger = loggerService;
|
||||
this.logger.setContext('SecurityEventsService');
|
||||
this.databaseUrl = this.configService.get<string>('database.url') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IP address and User-Agent from an Express request
|
||||
*/
|
||||
extractRequestInfo(req: Request): { ipAddress: string; userAgent: string } {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
const ipAddress =
|
||||
(typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : req.ip) || 'unknown';
|
||||
const userAgent = (req.headers['user-agent'] as string) || 'unknown';
|
||||
return { ipAddress, userAgent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event to the database.
|
||||
* Fire-and-forget: never throws, only logs warnings on failure.
|
||||
*/
|
||||
async logEvent(params: SecurityEventParams): Promise<void> {
|
||||
try {
|
||||
const db = getDb(this.databaseUrl);
|
||||
await db.insert(securityEvents).values({
|
||||
userId: params.userId || null,
|
||||
eventType: params.eventType,
|
||||
ipAddress: params.ipAddress || null,
|
||||
userAgent: params.userAgent || null,
|
||||
metadata: params.metadata || null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to log security event: ${params.eventType}`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
userId: params.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: log event with request context
|
||||
*/
|
||||
async logEventWithRequest(
|
||||
req: Request,
|
||||
params: Omit<SecurityEventParams, 'ipAddress' | 'userAgent'>
|
||||
): Promise<void> {
|
||||
const { ipAddress, userAgent } = this.extractRequestInfo(req);
|
||||
await this.logEvent({ ...params, ipAddress, userAgent });
|
||||
}
|
||||
}
|
||||
164
services/mana-core-auth/src/security/security-events.spec.ts
Normal file
164
services/mana-core-auth/src/security/security-events.spec.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Security Events Service Tests
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('SecurityEventsService contract', () => {
|
||||
const servicePath = path.resolve(__dirname, 'security-events.service.ts');
|
||||
let serviceContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
});
|
||||
|
||||
describe('event types', () => {
|
||||
const requiredEvents = [
|
||||
'login_success',
|
||||
'login_failure',
|
||||
'register',
|
||||
'logout',
|
||||
'token_refreshed',
|
||||
'sso_token_exchange',
|
||||
'password_changed',
|
||||
'password_reset_requested',
|
||||
'password_reset_completed',
|
||||
'email_verified',
|
||||
'account_deleted',
|
||||
'account_locked',
|
||||
'api_key_created',
|
||||
'api_key_revoked',
|
||||
'api_key_validated',
|
||||
'api_key_validation_failed',
|
||||
'org_created',
|
||||
'org_member_invited',
|
||||
'org_member_removed',
|
||||
];
|
||||
|
||||
it.each(requiredEvents)('should define event type: %s', (eventType) => {
|
||||
expect(serviceContent).toContain(`'${eventType}'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fire-and-forget pattern', () => {
|
||||
it('should catch errors in logEvent and never throw', () => {
|
||||
// The logEvent method must have a try-catch that logs warnings
|
||||
expect(serviceContent).toContain('catch (error)');
|
||||
expect(serviceContent).toContain('Failed to log security event');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request info extraction', () => {
|
||||
it('should extract IP from x-forwarded-for header', () => {
|
||||
expect(serviceContent).toContain('x-forwarded-for');
|
||||
});
|
||||
|
||||
it('should extract user-agent from request', () => {
|
||||
expect(serviceContent).toContain('user-agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AccountLockoutService contract', () => {
|
||||
const servicePath = path.resolve(__dirname, 'account-lockout.service.ts');
|
||||
let serviceContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
});
|
||||
|
||||
it('should define MAX_ATTEMPTS = 5', () => {
|
||||
expect(serviceContent).toContain('MAX_ATTEMPTS = 5');
|
||||
});
|
||||
|
||||
it('should define ATTEMPT_WINDOW_MINUTES = 15', () => {
|
||||
expect(serviceContent).toContain('ATTEMPT_WINDOW_MINUTES = 15');
|
||||
});
|
||||
|
||||
it('should define LOCKOUT_DURATION_MINUTES = 30', () => {
|
||||
expect(serviceContent).toContain('LOCKOUT_DURATION_MINUTES = 30');
|
||||
});
|
||||
|
||||
it('should normalize email to lowercase', () => {
|
||||
expect(serviceContent).toContain('email.toLowerCase()');
|
||||
});
|
||||
|
||||
it('should fail open on errors (not lock users out if DB fails)', () => {
|
||||
// On error, checkLockout should return locked: false
|
||||
expect(serviceContent).toContain('return { locked: false }');
|
||||
});
|
||||
|
||||
it('should clear attempts on successful login', () => {
|
||||
expect(serviceContent).toContain('clearAttempts');
|
||||
expect(serviceContent).toContain('delete(loginAttempts)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth Controller lockout integration', () => {
|
||||
const controllerPath = path.resolve(__dirname, '../auth/auth.controller.ts');
|
||||
let controllerContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
controllerContent = fs.readFileSync(controllerPath, 'utf8');
|
||||
});
|
||||
|
||||
it('should check lockout before attempting login', () => {
|
||||
expect(controllerContent).toContain('accountLockout.checkLockout');
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException with ACCOUNT_LOCKED code when locked', () => {
|
||||
expect(controllerContent).toContain("code: 'ACCOUNT_LOCKED'");
|
||||
});
|
||||
|
||||
it('should include retryAfter in lockout response', () => {
|
||||
expect(controllerContent).toContain('retryAfter: lockout.remainingSeconds');
|
||||
});
|
||||
|
||||
it('should clear attempts after successful login', () => {
|
||||
expect(controllerContent).toContain('accountLockout.clearAttempts');
|
||||
});
|
||||
|
||||
it('should record failed attempts on login failure', () => {
|
||||
expect(controllerContent).toContain('accountLockout.recordAttempt');
|
||||
});
|
||||
|
||||
it('should not count email-not-verified as failed attempt', () => {
|
||||
expect(controllerContent).toContain('ForbiddenException');
|
||||
// The catch block should re-throw ForbiddenException before recording attempt
|
||||
const loginMethodContent = controllerContent.slice(
|
||||
controllerContent.indexOf('async login('),
|
||||
controllerContent.indexOf('async logout(')
|
||||
);
|
||||
const forbiddenCheckIndex = loginMethodContent.indexOf('instanceof ForbiddenException');
|
||||
const recordAttemptIndex = loginMethodContent.indexOf('recordAttempt');
|
||||
expect(forbiddenCheckIndex).toBeLessThan(recordAttemptIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key validation rate limiting', () => {
|
||||
const controllerPath = path.resolve(__dirname, '../api-keys/api-keys.controller.ts');
|
||||
let controllerContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
controllerContent = fs.readFileSync(controllerPath, 'utf8');
|
||||
});
|
||||
|
||||
it('should have rate limiting on validate endpoint', () => {
|
||||
expect(controllerContent).toContain('@Throttle');
|
||||
expect(controllerContent).toContain('limit: 10');
|
||||
});
|
||||
|
||||
it('should use ThrottlerGuard', () => {
|
||||
expect(controllerContent).toContain('ThrottlerGuard');
|
||||
});
|
||||
|
||||
it('should log successful and failed validations', () => {
|
||||
expect(controllerContent).toContain('API_KEY_VALIDATED');
|
||||
expect(controllerContent).toContain('API_KEY_VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it('should only log key prefix, never the full key', () => {
|
||||
expect(controllerContent).toContain("substring(0, 16) + '...'");
|
||||
});
|
||||
});
|
||||
11
services/mana-core-auth/src/security/security.module.ts
Normal file
11
services/mana-core-auth/src/security/security.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SecurityEventsService } from './security-events.service';
|
||||
import { AccountLockoutService } from './account-lockout.service';
|
||||
import { LoggerModule } from '../common/logger';
|
||||
|
||||
@Module({
|
||||
imports: [LoggerModule],
|
||||
providers: [SecurityEventsService, AccountLockoutService],
|
||||
exports: [SecurityEventsService, AccountLockoutService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue