diff --git a/packages/nestjs-auth-apple/package.json b/packages/nestjs-auth-apple/package.json index 0e3a963b..a24829b4 100644 --- a/packages/nestjs-auth-apple/package.json +++ b/packages/nestjs-auth-apple/package.json @@ -14,6 +14,7 @@ "dependencies": { "@concepta/nestjs-authentication": "^6.0.0-alpha.2", "@concepta/nestjs-common": "^6.0.0-alpha.2", + "@concepta/nestjs-event": "^6.0.0-alpha.2", "@concepta/nestjs-exception": "^6.0.0-alpha.2", "@concepta/nestjs-federated": "^6.0.0-alpha.2", "@concepta/nestjs-jwt": "^6.0.0-alpha.2", diff --git a/packages/nestjs-auth-apple/src/auth-apple.constants.ts b/packages/nestjs-auth-apple/src/auth-apple.constants.ts index 6a7c5ef0..0c60acb0 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.constants.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.constants.ts @@ -18,3 +18,5 @@ export const AUTH_APPLE_VERIFY_ALGORITHM = 'RS256'; export const AUTH_APPLE_TOKEN_ISSUER = 'https://appleid.apple.com'; export const AUTH_APPLE_JWT_KEYS = 'https://appleid.apple.com/auth/keys'; + +export const AUTH_APPLE_AUTHENTICATION_TYPE = 'auth-apple'; diff --git a/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts b/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts index 11e4046c..2b3aa4e3 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.module.spec.ts @@ -16,6 +16,7 @@ import { AuthAppleModule } from './auth-apple.module'; import { FederatedEntityFixture } from './__fixtures__/federated-entity.fixture'; import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; +import { EventModule } from '@concepta/nestjs-event'; describe(AuthAppleModule, () => { let authAppleModule: AuthAppleModule; @@ -31,6 +32,7 @@ describe(AuthAppleModule, () => { entities: [UserEntityFixture, FederatedEntityFixture], }), JwtModule.forRoot({}), + EventModule.forRoot({}), AuthAppleModule.forRoot({}), AuthenticationModule.forRoot({}), AuthJwtModule.forRootAsync({ diff --git a/packages/nestjs-auth-apple/src/auth-apple.strategy.ts b/packages/nestjs-auth-apple/src/auth-apple.strategy.ts index f366006a..57c10c0a 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.strategy.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.strategy.ts @@ -7,6 +7,7 @@ import { } from '@concepta/nestjs-federated'; import { + AUTH_APPLE_AUTHENTICATION_TYPE, AUTH_APPLE_MODULE_SETTINGS_TOKEN, AUTH_APPLE_SERVICE_TOKEN, AUTH_APPLE_STRATEGY_NAME, @@ -19,6 +20,11 @@ import { Strategy } from 'passport-apple'; import { AuthAppleServiceInterface } from './interfaces/auth-apple-service.interface'; import { mapProfile } from './utils/auth-apple-map-profile'; import { AuthAppleCredentialsInterface } from './interfaces/auth-apple-credentials.interface'; +import { AuthAppleAuthenticatedEventAsync } from './events/auth-apple-authenticated.event'; +import { + AuthenticationRequestInterface, + getAuthenticatedUserInfo, +} from '@concepta/nestjs-authentication'; @Injectable() export class AuthAppleStrategy extends PassportStrategy( @@ -41,10 +47,11 @@ export class AuthAppleStrategy extends PassportStrategy( privateKeyString: settings?.privateKeyString, callbackURL: settings?.callbackURL, scope: settings?.scope, - passReqToCallback: false, + passReqToCallback: true, }); } async validate( + req: AuthenticationRequestInterface, _accessToken: string, _refreshToken: string, idToken: string, @@ -73,6 +80,7 @@ export class AuthAppleStrategy extends PassportStrategy( throw new UnauthorizedException(); } + await this.dispatchAuthAttemptEvent(req, user.id, true); return user; } @@ -87,4 +95,27 @@ export class AuthAppleStrategy extends PassportStrategy( throw new AuthAppleMissingEmailException(); } } + + protected async dispatchAuthAttemptEvent( + req: AuthenticationRequestInterface, + userId: string, + success: boolean, + failureReason?: string | null, + ): Promise { + const info = getAuthenticatedUserInfo(req); + + const failMessage = failureReason ? { failureReason } : {}; + const authenticatedEventAsync = new AuthAppleAuthenticatedEventAsync({ + userInfo: { + userId: userId, + ipAddress: info.ipAddress || '', + deviceInfo: info.deviceInfo || '', + authType: AUTH_APPLE_AUTHENTICATION_TYPE, + success, + ...failMessage, + }, + }); + + await authenticatedEventAsync.emit(); + } } diff --git a/packages/nestjs-auth-apple/src/events/auth-apple-authenticated.event.ts b/packages/nestjs-auth-apple/src/events/auth-apple-authenticated.event.ts new file mode 100644 index 00000000..df0e4631 --- /dev/null +++ b/packages/nestjs-auth-apple/src/events/auth-apple-authenticated.event.ts @@ -0,0 +1,7 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { EventAsync } from '@concepta/nestjs-event'; + +export class AuthAppleAuthenticatedEventAsync extends EventAsync< + AuthenticatedEventInterface, + boolean +> {} diff --git a/packages/nestjs-auth-github/package.json b/packages/nestjs-auth-github/package.json index cef4a651..b8720985 100644 --- a/packages/nestjs-auth-github/package.json +++ b/packages/nestjs-auth-github/package.json @@ -14,6 +14,7 @@ "dependencies": { "@concepta/nestjs-authentication": "^6.0.0-alpha.2", "@concepta/nestjs-common": "^6.0.0-alpha.2", + "@concepta/nestjs-event": "^6.0.0-alpha.2", "@concepta/nestjs-exception": "^6.0.0-alpha.2", "@concepta/nestjs-federated": "^6.0.0-alpha.2", "@nestjs/common": "^10.4.1", diff --git a/packages/nestjs-auth-github/src/auth-github.constants.ts b/packages/nestjs-auth-github/src/auth-github.constants.ts index 7f6aa60b..c38959a8 100644 --- a/packages/nestjs-auth-github/src/auth-github.constants.ts +++ b/packages/nestjs-auth-github/src/auth-github.constants.ts @@ -8,3 +8,5 @@ export const AUTH_GITHUB_MODULE_DEFAULT_SETTINGS_TOKEN = 'AUTH_GITHUB_MODULE_DEFAULT_SETTINGS_TOKEN'; export const AUTH_GITHUB_STRATEGY_NAME = 'github'; + +export const AUTH_GITHUB_AUTHENTICATION_TYPE = 'auth-github'; diff --git a/packages/nestjs-auth-github/src/auth-github.controller.ts b/packages/nestjs-auth-github/src/auth-github.controller.ts index b1f0aced..e531a08b 100644 --- a/packages/nestjs-auth-github/src/auth-github.controller.ts +++ b/packages/nestjs-auth-github/src/auth-github.controller.ts @@ -1,15 +1,15 @@ -import { Controller, Inject, Get, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; -import { - AuthUser, - IssueTokenServiceInterface, AuthenticationJwtResponseDto, AuthPublic, + AuthUser, + IssueTokenServiceInterface, } from '@concepta/nestjs-authentication'; +import { + AuthenticatedUserInterface, + AuthenticationResponseInterface, +} from '@concepta/nestjs-common'; +import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AUTH_GITHUB_ISSUE_TOKEN_SERVICE_TOKEN } from './auth-github.constants'; import { AuthGithubGuard } from './auth-github.guard'; diff --git a/packages/nestjs-auth-github/src/auth-github.strategy.ts b/packages/nestjs-auth-github/src/auth-github.strategy.ts index eadae7a2..f20aae74 100644 --- a/packages/nestjs-auth-github/src/auth-github.strategy.ts +++ b/packages/nestjs-auth-github/src/auth-github.strategy.ts @@ -8,6 +8,7 @@ import { } from '@concepta/nestjs-federated'; import { + AUTH_GITHUB_AUTHENTICATION_TYPE, AUTH_GITHUB_MODULE_SETTINGS_TOKEN, AUTH_GITHUB_STRATEGY_NAME, } from './auth-github.constants'; @@ -17,6 +18,9 @@ import { AuthGithubProfileInterface } from './interfaces/auth-github-profile.int import { AuthGithubMissingEmailException } from './exceptions/auth-github-missing-email.exception'; import { AuthGithubMissingIdException } from './exceptions/auth-github-missing-id.exception'; import { mapProfile } from './utils/auth-github-map-profile'; +import { AuthGithubAuthenticatedEventAsync } from './events/auth-github-authenticated.event'; +import { getAuthenticatedUserInfo } from '@concepta/nestjs-authentication/src'; +import { AuthenticationRequestInterface } from '@concepta/nestjs-authentication'; @Injectable() export class AuthGithubStrategy extends PassportStrategy( @@ -32,10 +36,12 @@ export class AuthGithubStrategy extends PassportStrategy( clientID: settings?.clientId, clientSecret: settings?.clientSecret, callbackURL: settings?.callbackURL, + passReqToCallback: true, }); } async validate( + req: AuthenticationRequestInterface, _accessToken: string, _refreshToken: string, profile: AuthGithubProfileInterface, @@ -62,7 +68,30 @@ export class AuthGithubStrategy extends PassportStrategy( if (!user) { throw new UnauthorizedException(); } - + await this.dispatchAuthAttemptEvent(req, user.id, true); return user; } + + protected async dispatchAuthAttemptEvent( + req: AuthenticationRequestInterface, + userId: string, + success: boolean, + failureReason?: string | null, + ): Promise { + const info = getAuthenticatedUserInfo(req); + + const failMessage = failureReason ? { failureReason } : {}; + const authenticatedEventAsync = new AuthGithubAuthenticatedEventAsync({ + userInfo: { + userId: userId, + ipAddress: info.ipAddress || '', + deviceInfo: info.deviceInfo || '', + authType: AUTH_GITHUB_AUTHENTICATION_TYPE, + success, + ...failMessage, + }, + }); + + await authenticatedEventAsync.emit(); + } } diff --git a/packages/nestjs-auth-github/src/events/auth-github-authenticated.event.ts b/packages/nestjs-auth-github/src/events/auth-github-authenticated.event.ts new file mode 100644 index 00000000..c7137696 --- /dev/null +++ b/packages/nestjs-auth-github/src/events/auth-github-authenticated.event.ts @@ -0,0 +1,7 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { EventAsync } from '@concepta/nestjs-event'; + +export class AuthGithubAuthenticatedEventAsync extends EventAsync< + AuthenticatedEventInterface, + boolean +> {} diff --git a/packages/nestjs-auth-google/package.json b/packages/nestjs-auth-google/package.json index 76beaaaf..05161e89 100644 --- a/packages/nestjs-auth-google/package.json +++ b/packages/nestjs-auth-google/package.json @@ -14,6 +14,7 @@ "dependencies": { "@concepta/nestjs-authentication": "^6.0.0-alpha.2", "@concepta/nestjs-common": "^6.0.0-alpha.2", + "@concepta/nestjs-event": "^6.0.0-alpha.2", "@concepta/nestjs-exception": "^6.0.0-alpha.2", "@concepta/nestjs-federated": "^6.0.0-alpha.2", "@nestjs/common": "^10.4.1", diff --git a/packages/nestjs-auth-google/src/auth-google.constants.ts b/packages/nestjs-auth-google/src/auth-google.constants.ts index e694bf42..4224bfcf 100644 --- a/packages/nestjs-auth-google/src/auth-google.constants.ts +++ b/packages/nestjs-auth-google/src/auth-google.constants.ts @@ -8,3 +8,5 @@ export const AUTH_GOOGLE_MODULE_DEFAULT_SETTINGS_TOKEN = 'AUTH_GOOGLE_MODULE_DEFAULT_SETTINGS_TOKEN'; export const AUTH_GOOGLE_STRATEGY_NAME = 'google'; + +export const AUTH_GOOGLE_AUTHENTICATION_TYPE = 'auth-google'; diff --git a/packages/nestjs-auth-google/src/auth-google.strategy.ts b/packages/nestjs-auth-google/src/auth-google.strategy.ts index cd2e65dc..4651d7be 100644 --- a/packages/nestjs-auth-google/src/auth-google.strategy.ts +++ b/packages/nestjs-auth-google/src/auth-google.strategy.ts @@ -7,6 +7,7 @@ import { } from '@concepta/nestjs-federated'; import { + AUTH_GOOGLE_AUTHENTICATION_TYPE, AUTH_GOOGLE_MODULE_SETTINGS_TOKEN, AUTH_GOOGLE_STRATEGY_NAME, } from './auth-google.constants'; @@ -17,6 +18,11 @@ import { AuthGoogleMissingEmailException } from './exceptions/auth-google-missin import { AuthGoogleMissingIdException } from './exceptions/auth-google-missing-id.exception'; import { mapProfile } from './utils/auth-google-map-profile'; import { Strategy } from 'passport-google-oauth20'; +import { + AuthenticationRequestInterface, + getAuthenticatedUserInfo, +} from '@concepta/nestjs-authentication'; +import { AuthGoogleAuthenticatedEventAsync } from './events/auth-google-authenticated.event'; @Injectable() export class AuthGoogleStrategy extends PassportStrategy( @@ -33,10 +39,12 @@ export class AuthGoogleStrategy extends PassportStrategy( clientSecret: settings?.clientSecret, callbackURL: settings?.callbackURL, scope: settings?.scope, + passReqToCallback: true, }); } async validate( + req: AuthenticationRequestInterface, _accessToken: string, _refreshToken: string, profile: AuthGoogleProfileInterface, @@ -64,6 +72,30 @@ export class AuthGoogleStrategy extends PassportStrategy( throw new UnauthorizedException(); } + await this.dispatchAuthAttemptEvent(req, user.id, true); return user; } + + protected async dispatchAuthAttemptEvent( + req: AuthenticationRequestInterface, + userId: string, + success: boolean, + failureReason?: string | null, + ): Promise { + const info = getAuthenticatedUserInfo(req); + + const failMessage = failureReason ? { failureReason } : {}; + const authenticatedEventAsync = new AuthGoogleAuthenticatedEventAsync({ + userInfo: { + userId: userId, + ipAddress: info.ipAddress || '', + deviceInfo: info.deviceInfo || '', + authType: AUTH_GOOGLE_AUTHENTICATION_TYPE, + success, + ...failMessage, + }, + }); + + await authenticatedEventAsync.emit(); + } } diff --git a/packages/nestjs-auth-google/src/events/auth-google-authenticated.event.ts b/packages/nestjs-auth-google/src/events/auth-google-authenticated.event.ts new file mode 100644 index 00000000..b043a6d0 --- /dev/null +++ b/packages/nestjs-auth-google/src/events/auth-google-authenticated.event.ts @@ -0,0 +1,7 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { EventAsync } from '@concepta/nestjs-event'; + +export class AuthGoogleAuthenticatedEventAsync extends EventAsync< + AuthenticatedEventInterface, + boolean +> {} diff --git a/packages/nestjs-auth-history/README.md b/packages/nestjs-auth-history/README.md new file mode 100644 index 00000000..7b94240d --- /dev/null +++ b/packages/nestjs-auth-history/README.md @@ -0,0 +1,18 @@ +# Rockets NestJS Auth History + +A module for tracking authentication history and events, providing services +for creating, reading, updating and deleting auth history records. Includes +event handling for authenticated requests, repository management, and access +control. + +## Project + +[![NPM Latest](https://img.shields.io/npm/v/@concepta/nestjs-auth-history)](https://www.npmjs.com/package/@concepta/nestjs-auth-history) +[![NPM Downloads](https://img.shields.io/npm/dw/@conceptadev/nestjs-auth-history)](https://www.npmjs.com/package/@concepta/nestjs-auth-history) +[![GH Last Commit](https://img.shields.io/github/last-commit/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets) +[![GH Contrib](https://img.shields.io/github/contributors/conceptadev/rockets?logo=github)](https://github.com/conceptadev/rockets/graphs/contributors) +[![NestJS Dep](https://img.shields.io/github/package-json/dependency-version/conceptadev/rockets/@nestjs/common?label=NestJS&logo=nestjs&filename=packages%2Fnestjs-core%2Fpackage.json)](https://www.npmjs.com/package/@nestjs/common) + +## Installation + +`yarn add @concepta/nestjs-auth-history` diff --git a/packages/nestjs-auth-history/package.json b/packages/nestjs-auth-history/package.json new file mode 100644 index 00000000..7026789a --- /dev/null +++ b/packages/nestjs-auth-history/package.json @@ -0,0 +1,46 @@ +{ + "name": "@concepta/nestjs-auth-history", + "version": "6.0.0-alpha.1", + "description": "Rockets NestJS auth history", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "BSD-3-Clause", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/**/!(*.spec|*.e2e-spec|*.fixture).{js,d.ts}" + ], + "dependencies": { + "@concepta/nestjs-access-control": "^6.0.0-alpha.1", + "@concepta/nestjs-common": "^6.0.0-alpha.1", + "@concepta/nestjs-crud": "^6.0.0-alpha.1", + "@concepta/nestjs-event": "^6.0.0-alpha.1", + "@concepta/nestjs-exception": "^6.0.0-alpha.1", + "@concepta/nestjs-password": "^6.0.0-alpha.1", + "@concepta/nestjs-typeorm-ext": "^6.0.0-alpha.1", + "@concepta/typeorm-common": "^6.0.0-alpha.1", + "@nestjs/common": "^10.4.1", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.4.1", + "@nestjs/swagger": "^7.4.0" + }, + "devDependencies": { + "@concepta/nestjs-auth-jwt": "^6.0.0-alpha.1", + "@concepta/nestjs-authentication": "^6.0.0-alpha.1", + "@concepta/nestjs-jwt": "^6.0.0-alpha.1", + "@concepta/nestjs-user": "^6.0.0-alpha.1", + "@concepta/typeorm-seeding": "^4.0.0", + "@faker-js/faker": "^8.4.1", + "@nestjs/testing": "^10.4.1", + "@nestjs/typeorm": "^10.0.2", + "accesscontrol": "^2.2.1", + "supertest": "^6.3.4" + }, + "peerDependencies": { + "@concepta/nestjs-auth-local": "^6.0.0-alpha.1", + "class-transformer": "*", + "class-validator": "*", + "typeorm": "^0.3.0" + } +} diff --git a/packages/nestjs-auth-history/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/app.module.fixture.ts new file mode 100644 index 00000000..10b1be12 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/app.module.fixture.ts @@ -0,0 +1,61 @@ +import { AccessControlModule } from '@concepta/nestjs-access-control'; +import { + AuthLocalAuthenticatedEventAsync, + AuthLocalModule, +} from '@concepta/nestjs-auth-local'; +import { CrudModule } from '@concepta/nestjs-crud'; +import { EventModule } from '@concepta/nestjs-event'; +import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { Module } from '@nestjs/common'; +import { AccessControl } from 'accesscontrol'; + +import { AuthHistoryModule } from '../auth-history.module'; +import { AuthHistoryResource } from '../auth-history.types'; + +import { AuthenticationModule } from '@concepta/nestjs-authentication'; +import { JwtModule } from '@concepta/nestjs-jwt'; +import { AuthHistoryAccessQueryService } from '../services/auth-history-query.service'; +import { AuthHistoryEntityFixture } from './entities/auth-history.entity.fixture'; +import { ormConfig } from './ormconfig.fixture'; +import { UserLookupServiceFixture } from './services/user-lookup.service.fixture'; +import { ValidateUserServiceFixture } from './services/validate-user.service.fixture'; + +const rules = new AccessControl(); +rules + .grant('auth-history') + .resource(AuthHistoryResource.One) + .createOwn() + .readOwn() + .updateOwn() + .deleteOwn(); + +@Module({ + imports: [ + TypeOrmExtModule.forRoot(ormConfig), + CrudModule.forRoot({}), + EventModule.forRoot({}), + JwtModule.forRoot({}), + AuthenticationModule.forRoot({}), + AccessControlModule.forRoot({ + settings: { rules }, + queryServices: [AuthHistoryAccessQueryService], + }), + AuthLocalModule.forRootAsync({ + useFactory: () => ({ + userLookupService: new UserLookupServiceFixture(), + validateUserService: new ValidateUserServiceFixture(), + }), + }), + AuthHistoryModule.forRoot({ + settings: { + authenticatedEvents: [AuthLocalAuthenticatedEventAsync], + }, + entities: { + authHistory: { + entity: AuthHistoryEntityFixture, + }, + }, + }), + ], +}) +export class AppModuleFixture {} diff --git a/packages/nestjs-auth-history/src/__fixtures__/constants.ts b/packages/nestjs-auth-history/src/__fixtures__/constants.ts new file mode 100644 index 00000000..c655ecb1 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/constants.ts @@ -0,0 +1,23 @@ +import { AuthLocalCredentialsInterface } from '@concepta/nestjs-auth-local'; +import { randomUUID } from 'crypto'; + +export const USER_ID = randomUUID(); +export const LOGIN_SUCCESS = { + username: 'random_username', + password: 'random_password', +}; + +export const LOGIN_FAIL = { + username: 'wrong_username', + password: 'wrong_password', +}; + +export const USER_SUCCESS: AuthLocalCredentialsInterface = { + id: USER_ID, + active: true, + passwordHash: LOGIN_SUCCESS.password, + passwordSalt: LOGIN_SUCCESS.password, + username: LOGIN_SUCCESS.username, + lastLogin: null, + loginAttempts: 0, +}; diff --git a/packages/nestjs-auth-history/src/__fixtures__/create-auth-history-repository.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/create-auth-history-repository.fixture.ts new file mode 100644 index 00000000..c8e070cf --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/create-auth-history-repository.fixture.ts @@ -0,0 +1,77 @@ +import { DataSource, FindOneOptions } from 'typeorm'; +import { AuthHistoryEntityInterface } from '../interfaces/auth-history-entity.interface'; +import { AuthHistoryEntityFixture } from './entities/auth-history.entity.fixture'; +import { UserInterface } from '@concepta/nestjs-common'; + +export function createAuthHistoryRepositoryFixture(dataSource: DataSource) { + /** + * Fake authHistory "database" + */ + const user: UserInterface = { + id: '1', + active: false, + email: '1@example.com', + username: '1@example.com', + lastLogin: null, + loginAttempts: 0, + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: new Date(), + version: 1, + }; + const users: AuthHistoryEntityFixture[] = [ + { + id: '1', + userId: '1', + user: { + ...user, + id: '1', + }, + ipAddress: '127.0.0.1', + authType: 'login', + deviceInfo: 'Chrome on Windows', + failureReason: '', + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: new Date(), + version: 1, + }, + { + id: '2', + userId: '2', + user: { + ...user, + id: '2', + }, + ipAddress: '127.0.0.1', + authType: 'login', + deviceInfo: 'Firefox on Mac', + failureReason: '', + dateCreated: new Date(), + dateUpdated: new Date(), + dateDeleted: new Date(), + version: 1, + }, + ]; + + return dataSource.getRepository(AuthHistoryEntityFixture).extend({ + async findOne( + optionsOrConditions?: + | string + | number + | Date + // | ObjectID + | FindOneOptions, + ): Promise { + return ( + users.find((authHistory) => { + if ( + typeof optionsOrConditions === 'object' && + 'id' in optionsOrConditions + ) + return authHistory?.id === optionsOrConditions['id']; + }) ?? null + ); + }, + }); +} diff --git a/packages/nestjs-auth-history/src/__fixtures__/entities/auth-history.entity.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/entities/auth-history.entity.fixture.ts new file mode 100644 index 00000000..616717cb --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/entities/auth-history.entity.fixture.ts @@ -0,0 +1,10 @@ +import { Entity, ManyToOne } from 'typeorm'; +import { AuthHistorySqliteEntity } from '../../entities/auth-history-sqlite.entity'; +import { UserInterface } from '@concepta/nestjs-common'; +import { UserEntityFixture } from './user-entity.fixture'; + +@Entity() +export class AuthHistoryEntityFixture extends AuthHistorySqliteEntity { + @ManyToOne(() => UserEntityFixture, (user) => user.authHistory) + user!: UserInterface; +} diff --git a/packages/nestjs-auth-history/src/__fixtures__/entities/user-entity.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/entities/user-entity.fixture.ts new file mode 100644 index 00000000..b26dcded --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/entities/user-entity.fixture.ts @@ -0,0 +1,12 @@ +import { Entity, OneToMany } from 'typeorm'; +import { UserSqliteEntity } from '@concepta/nestjs-user'; +import { AuthHistoryEntityFixture } from './auth-history.entity.fixture'; + +/** + * User Entity Fixture + */ +@Entity() +export class UserEntityFixture extends UserSqliteEntity { + @OneToMany(() => AuthHistoryEntityFixture, (authHistory) => authHistory.user) + authHistory?: AuthHistoryEntityFixture[]; +} diff --git a/packages/nestjs-auth-history/src/__fixtures__/events/invitation-accepted.event.ts b/packages/nestjs-auth-history/src/__fixtures__/events/invitation-accepted.event.ts new file mode 100644 index 00000000..a43e037a --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/events/invitation-accepted.event.ts @@ -0,0 +1,7 @@ +import { EventAsync } from '@concepta/nestjs-event'; +import { InvitationAcceptedEventPayloadInterface } from '@concepta/nestjs-common'; + +export class InvitationAcceptedEventAsync extends EventAsync< + InvitationAcceptedEventPayloadInterface, + boolean +> {} diff --git a/packages/nestjs-auth-history/src/__fixtures__/events/invitation-get-user.event.ts b/packages/nestjs-auth-history/src/__fixtures__/events/invitation-get-user.event.ts new file mode 100644 index 00000000..683cc4f1 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/events/invitation-get-user.event.ts @@ -0,0 +1,10 @@ +import { EventAsync } from '@concepta/nestjs-event'; +import { + InvitationGetUserEventPayloadInterface, + InvitationGetUserEventResponseInterface, +} from '@concepta/nestjs-common'; + +export class InvitationGetUserEventAsync extends EventAsync< + InvitationGetUserEventPayloadInterface, + InvitationGetUserEventResponseInterface +> {} diff --git a/packages/nestjs-auth-history/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/ormconfig.fixture.ts new file mode 100644 index 00000000..0d1f89ba --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/ormconfig.fixture.ts @@ -0,0 +1,10 @@ +import { DataSourceOptions } from 'typeorm'; +import { AuthHistoryEntityFixture } from './entities/auth-history.entity.fixture'; +import { UserEntityFixture } from './entities/user-entity.fixture'; + +export const ormConfig: DataSourceOptions = { + type: 'sqlite', + database: ':memory:', + synchronize: true, + entities: [AuthHistoryEntityFixture, UserEntityFixture], +}; diff --git a/packages/nestjs-auth-history/src/__fixtures__/services/user-lookup.service.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/services/user-lookup.service.fixture.ts new file mode 100644 index 00000000..2d5ace56 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/services/user-lookup.service.fixture.ts @@ -0,0 +1,19 @@ +import { + AuthLocalCredentialsInterface, + AuthLocalUserLookupServiceInterface, +} from '@concepta/nestjs-auth-local'; +import { ReferenceUsername } from '@concepta/nestjs-common'; +import { Injectable } from '@nestjs/common'; +import { USER_SUCCESS } from '../constants'; + +@Injectable() +export class UserLookupServiceFixture + implements AuthLocalUserLookupServiceInterface +{ + async byUsername( + username: ReferenceUsername, + ): Promise { + if (USER_SUCCESS.username === username) return USER_SUCCESS; + return null; + } +} diff --git a/packages/nestjs-auth-history/src/__fixtures__/services/validate-user.service.fixture.ts b/packages/nestjs-auth-history/src/__fixtures__/services/validate-user.service.fixture.ts new file mode 100644 index 00000000..242e57b6 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/services/validate-user.service.fixture.ts @@ -0,0 +1,27 @@ +import { + AuthLocalUsernameNotFoundException, + AuthLocalValidateUserInterface, + AuthLocalValidateUserServiceInterface, +} from '@concepta/nestjs-auth-local'; +import { ValidateUserService } from '@concepta/nestjs-authentication'; +import { ReferenceIdInterface } from '@concepta/nestjs-common'; +import { Injectable } from '@nestjs/common'; +import { USER_SUCCESS } from '../constants'; + +@Injectable() +export class ValidateUserServiceFixture + extends ValidateUserService<[AuthLocalValidateUserInterface]> + implements AuthLocalValidateUserServiceInterface +{ + constructor() { + super(); + } + + async validateUser( + dto: AuthLocalValidateUserInterface, + ): Promise { + if (USER_SUCCESS.username === dto.username) return USER_SUCCESS; + + throw new AuthLocalUsernameNotFoundException(dto.username); + } +} diff --git a/packages/nestjs-auth-history/src/auth-history.constants.ts b/packages/nestjs-auth-history/src/auth-history.constants.ts new file mode 100644 index 00000000..a759078c --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.constants.ts @@ -0,0 +1,11 @@ +export const AUTH_HISTORY_MODULE_OPTIONS_TOKEN = + 'AUTH_HISTORY_MODULE_OPTIONS_TOKEN'; +export const AUTH_HISTORY_MODULE_SETTINGS_TOKEN = + 'AUTH_HISTORY_MODULE_SETTINGS_TOKEN'; +export const AUTH_HISTORY_MODULE_DEFAULT_SETTINGS_TOKEN = + 'AUTH_HISTORY_MODULE_DEFAULT_SETTINGS_TOKEN'; +export const AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY = 'authHistory'; +export const AUTH_HISTORY_MODULE_AUTH_HISTORY_PASSWORD_HISTORY_ENTITY_KEY = + 'auth-history-password-history'; +export const AUTH_HISTORY_MODULE_AUTH_HISTORY_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT = + 365 * 2; diff --git a/packages/nestjs-auth-history/src/auth-history.controller.e2e-spec.ts b/packages/nestjs-auth-history/src/auth-history.controller.e2e-spec.ts new file mode 100644 index 00000000..1ad4c735 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.controller.e2e-spec.ts @@ -0,0 +1,186 @@ +import { + AccessControlFilter, + AccessControlGuard, +} from '@concepta/nestjs-access-control'; +import { SeedingSource } from '@concepta/typeorm-seeding'; +import { + CallHandler, + ExecutionContext, + INestApplication, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import supertest from 'supertest'; + +import { AuthHistoryFactory } from './auth-history.factory'; + +import { UserInterface } from '@concepta/nestjs-common'; +import { UserFactory } from '@concepta/nestjs-user/src/user.factory'; +import { AppModuleFixture } from './__fixtures__/app.module.fixture'; +import { LOGIN_FAIL, LOGIN_SUCCESS, USER_ID } from './__fixtures__/constants'; +import { AuthHistoryEntityFixture } from './__fixtures__/entities/auth-history.entity.fixture'; +import { UserEntityFixture } from './__fixtures__/entities/user-entity.fixture'; + +describe('AuthHistoryController (e2e)', () => { + describe('Normal CRUD flow', () => { + let app: INestApplication; + let seedingSource: SeedingSource; + let user: UserInterface; + let accessControlGuard: AccessControlGuard; + let accessControlFilter: AccessControlFilter; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + + accessControlGuard = app.get(AccessControlGuard); + + jest.spyOn(accessControlGuard, 'canActivate').mockResolvedValue(true); + + accessControlFilter = app.get(AccessControlFilter); + jest + .spyOn(accessControlFilter, 'intercept') + .mockImplementation((_context: ExecutionContext, next: CallHandler) => { + return Promise.resolve(next.handle()); + }); + + seedingSource = new SeedingSource({ + dataSource: app.get(getDataSourceToken()), + }); + + await seedingSource.initialize(); + + const userFactory = new UserFactory({ + entity: UserEntityFixture, + seedingSource, + }); + + user = await userFactory.create({ + id: USER_ID, + }); + + const authHistoryFactory = new AuthHistoryFactory({ + entity: AuthHistoryEntityFixture, + seedingSource, + }); + + await authHistoryFactory.createMany(2, { + user, + userId: user.id, + }); + }); + + afterEach(async () => { + jest.clearAllMocks(); + return app ? await app.close() : undefined; + }); + + it('GET /auth-history', async () => { + await supertest(app.getHttpServer()) + .get('/auth-history?limit=10') + .expect(200); + }); + + it('GET /auth-history/:id', async () => { + // get a login history so we have an id + const response = await supertest(app.getHttpServer()) + .get('/auth-history?limit=1') + .expect(200); + + // get one using that id + await supertest(app.getHttpServer()) + .get(`/auth-history/${response.body[0].id}`) + .expect(200); + }); + + it('POST /auth-history', async () => { + await supertest(app.getHttpServer()) + .post('/auth-history') + .send({ + ipAddress: '127:0:0:1', + authType: '2FA', + deviceInfo: 'IOS', + user, + userId: user.id, + success: true, + }) + .expect(201); + }); + + it('DELETE /auth-history/:id', async () => { + // get a login history so we have an id + const response = await supertest(app.getHttpServer()) + .get('/auth-history?limit=1') + .expect(200); + + // delete one using that id + await supertest(app.getHttpServer()) + .delete(`/auth-history/${response.body[0].id}`) + .expect(200); + }); + + describe('events', () => { + it('should create auth history on login', async () => { + // attempt login it should trigger the event + await supertest(app.getHttpServer()) + .post('/auth/login') + .send(LOGIN_SUCCESS) + .expect(201); + + // verify auth history was created + const response = await supertest(app.getHttpServer()) + .get('/auth-history?filter=authType||$eq||auth-local') + .expect(200); + + expect(response.body[0]).toMatchObject({ + authType: 'auth-local', + userId: expect.any(String), + ipAddress: expect.any(String), + deviceInfo: expect.any(String), + }); + }); + + it('should login trigger event and validate user agent', async () => { + const userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'; + // attempt login with wrong credentials + await supertest(app.getHttpServer()) + .post('/auth/login') + .set('User-Agent', userAgent) + .send(LOGIN_SUCCESS) + .expect(201); + + const response = await supertest(app.getHttpServer()) + .get('/auth-history?filter=authType||$eq||auth-local') + .expect(200); + + expect(response.body[0]).toMatchObject({ + authType: 'auth-local', + userId: expect.any(String), + ipAddress: expect.any(String), + deviceInfo: userAgent, + }); + }); + + it('should fail login should not trigger event', async () => { + const userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'; + // attempt login with wrong credentials + await supertest(app.getHttpServer()) + .post('/auth/login') + .set('User-Agent', userAgent) + .send(LOGIN_FAIL) + .expect(500); + + const response = await supertest(app.getHttpServer()) + .get('/auth-history?filter=authType||$eq||auth-local') + .expect(200); + + expect(response.body.length).toBe(0); + }); + }); + }); +}); diff --git a/packages/nestjs-auth-history/src/auth-history.controller.ts b/packages/nestjs-auth-history/src/auth-history.controller.ts new file mode 100644 index 00000000..729256b8 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.controller.ts @@ -0,0 +1,107 @@ +import { + AccessControlCreateOne, + AccessControlDeleteOne, + AccessControlReadMany, + AccessControlReadOne, +} from '@concepta/nestjs-access-control'; +import { AuthHistoryCreatableInterface } from '@concepta/nestjs-common'; +import { + CrudBody, + CrudController, + CrudControllerInterface, + CrudCreateOne, + CrudDeleteOne, + CrudReadMany, + CrudReadOne, + CrudRequest, + CrudRequestInterface, +} from '@concepta/nestjs-crud'; +import { ApiTags } from '@nestjs/swagger'; + +import { AuthHistoryResource } from './auth-history.types'; +import { AuthHistoryCreateDto } from './dto/auth-history-create.dto'; +import { AuthHistoryPaginatedDto } from './dto/auth-history-paginated.dto'; +import { AuthHistoryDto } from './dto/auth-history.dto'; +import { AuthHistoryEntityInterface } from './interfaces/auth-history-entity.interface'; + +import { AuthHistoryCrudService } from './services/auth-history-crud.service'; + +/** + * AuthHistory controller. + */ +@CrudController({ + path: 'auth-history', + model: { + type: AuthHistoryDto, + paginatedType: AuthHistoryPaginatedDto, + }, +}) +@ApiTags('auth-history') +export class AuthHistoryController + implements + CrudControllerInterface< + AuthHistoryEntityInterface, + AuthHistoryCreatableInterface, + never + > +{ + /** + * Constructor. + * + * @param authHistoryCrudService - Instance of the auth history CRUD service + * that handles basic CRUD operations + */ + constructor(private authHistoryCrudService: AuthHistoryCrudService) {} + + /** + * Get many + * + * @param crudRequest - the CRUD request object + */ + @CrudReadMany() + @AccessControlReadMany(AuthHistoryResource.Many) + async getMany(@CrudRequest() crudRequest: CrudRequestInterface) { + return this.authHistoryCrudService.getMany(crudRequest); + } + + /** + * Get one + * + * @param crudRequest - the CRUD request object + */ + @CrudReadOne() + @AccessControlReadOne(AuthHistoryResource.One) + async getOne(@CrudRequest() crudRequest: CrudRequestInterface) { + return this.authHistoryCrudService.getOne(crudRequest); + } + + /** + * Create one + * + * @param crudRequest - the CRUD request object + * @param authHistoryCreateDto - auth history create dto + */ + @CrudCreateOne() + @AccessControlCreateOne(AuthHistoryResource.One) + async createOne( + @CrudRequest() crudRequest: CrudRequestInterface, + @CrudBody() authHistoryCreateDto: AuthHistoryCreateDto, + ) { + // call crud service to create + return this.authHistoryCrudService.createOne( + crudRequest, + authHistoryCreateDto, + ); + } + + /** + * Delete one + * + * @param crudRequest - the CRUD request object + */ + @CrudDeleteOne() + @AccessControlDeleteOne(AuthHistoryResource.One) + async deleteOne(@CrudRequest() crudRequest: CrudRequestInterface) { + return this.authHistoryCrudService.deleteOne(crudRequest); + } +} diff --git a/packages/nestjs-auth-history/src/auth-history.factory.ts b/packages/nestjs-auth-history/src/auth-history.factory.ts new file mode 100644 index 00000000..7308d11d --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.factory.ts @@ -0,0 +1,36 @@ +import { Factory } from '@concepta/typeorm-seeding'; +import { faker } from '@faker-js/faker'; +import { AuthHistoryEntityInterface } from './interfaces/auth-history-entity.interface'; +/** + * AuthHistory factory + */ +export class AuthHistoryFactory extends Factory { + /** + * Factory callback function. + */ + protected async entity( + authHistory: AuthHistoryEntityInterface, + ): Promise { + // set random ip address (IPv4 format) + authHistory.ipAddress = faker.internet.ip(); + + // set random auth type + authHistory.authType = faker.helpers.arrayElement([ + 'login', + 'logout', + 'register', + 'password-reset', + ]); + + // set random device info + authHistory.deviceInfo = `${faker.helpers.arrayElement([ + 'Chrome', + 'Firefox', + 'Safari', + 'Edge', + ])}}`; + + // return the new authHistory + return authHistory; + } +} diff --git a/packages/nestjs-auth-history/src/auth-history.module-definition.ts b/packages/nestjs-auth-history/src/auth-history.module-definition.ts new file mode 100644 index 00000000..b46ea770 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.module-definition.ts @@ -0,0 +1,134 @@ +import { createSettingsProvider } from '@concepta/nestjs-common'; +import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { + ConfigurableModuleBuilder, + DynamicModule, + Provider, +} from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { AUTH_HISTORY_MODULE_SETTINGS_TOKEN } from './auth-history.constants'; + +import { AuthHistoryEntitiesOptionsInterface } from './interfaces/auth-history-entities-options.interface'; +import { AuthHistoryOptionsExtrasInterface } from './interfaces/auth-history-options-extras.interface'; +import { AuthHistoryOptionsInterface } from './interfaces/auth-history-options.interface'; +import { AuthHistorySettingsInterface } from './interfaces/auth-history-settings.interface'; + +import { AuthHistoryController } from './auth-history.controller'; +import { AuthHistoryCrudService } from './services/auth-history-crud.service'; + +import { authHistoryDefaultConfig } from './config/auth-history-default.config'; +import { AuthHistoryAccessQueryService } from './services/auth-history-query.service'; +import { AuthenticatedListener } from './listeners/authenticated-listener'; +import { AuthHistoryMutateService } from './services/auth-history-mutate.service'; + +const RAW_OPTIONS_TOKEN = Symbol('__AUTH_HISTORY_MODULE_RAW_OPTIONS_TOKEN__'); + +export const { + ConfigurableModuleClass: AuthHistoryModuleClass, + OPTIONS_TYPE: AUTH_HISTORY_OPTIONS_TYPE, + ASYNC_OPTIONS_TYPE: AuthHistory_ASYNC_OPTIONS_TYPE, +} = new ConfigurableModuleBuilder({ + moduleName: 'AuthHistory', + optionsInjectionToken: RAW_OPTIONS_TOKEN, +}) + .setExtras( + { global: false }, + definitionTransform, + ) + .build(); + +export type AuthHistoryOptions = Omit< + typeof AUTH_HISTORY_OPTIONS_TYPE, + 'global' +>; +export type AuthHistoryAsyncOptions = Omit< + typeof AuthHistory_ASYNC_OPTIONS_TYPE, + 'global' +>; + +function definitionTransform( + definition: DynamicModule, + extras: AuthHistoryOptionsExtrasInterface, +): DynamicModule { + const { providers = [], imports = [] } = definition; + const { controllers, global = false, entities } = extras; + + if (!entities) { + throw new Error('You must provide the entities option'); + } + + return { + ...definition, + global, + imports: createAuthHistoryImports({ imports, entities }), + providers: createAuthHistoryProviders({ providers }), + controllers: createAuthHistoryControllers({ controllers }), + exports: [ConfigModule, RAW_OPTIONS_TOKEN, ...createAuthHistoryExports()], + }; +} + +export function createAuthHistoryImports( + options: Pick & AuthHistoryEntitiesOptionsInterface, +): Required>['imports'] { + return [ + ...(options.imports ?? []), + ConfigModule.forFeature(authHistoryDefaultConfig), + TypeOrmExtModule.forFeature(options.entities), + ]; +} + +export function createAuthHistoryProviders(options: { + overrides?: AuthHistoryOptions; + providers?: Provider[]; +}): Provider[] { + return [ + ...(options.providers ?? []), + AuthHistoryCrudService, + AuthenticatedListener, + AuthHistoryMutateService, + createAuthHistorySettingsProvider(options.overrides), + createAuthHistoryAccessQueryServiceProvider(options.overrides), + ]; +} + +export function createAuthHistoryExports(): Required< + Pick +>['exports'] { + return [AUTH_HISTORY_MODULE_SETTINGS_TOKEN, AuthHistoryCrudService]; +} + +export function createAuthHistoryControllers( + overrides: Pick = {}, +): DynamicModule['controllers'] { + return overrides?.controllers !== undefined + ? overrides.controllers + : [AuthHistoryController]; +} + +export function createAuthHistorySettingsProvider( + optionsOverrides?: AuthHistoryOptions, +): Provider { + return createSettingsProvider< + AuthHistorySettingsInterface, + AuthHistoryOptionsInterface + >({ + settingsToken: AUTH_HISTORY_MODULE_SETTINGS_TOKEN, + optionsToken: RAW_OPTIONS_TOKEN, + settingsKey: authHistoryDefaultConfig.KEY, + optionsOverrides, + }); +} + +export function createAuthHistoryAccessQueryServiceProvider( + optionsOverrides?: AuthHistoryOptions, +): Provider { + return { + provide: AuthHistoryAccessQueryService, + inject: [RAW_OPTIONS_TOKEN], + useFactory: async (options: AuthHistoryOptionsInterface) => + optionsOverrides?.authHistoryAccessQueryService ?? + options.authHistoryAccessQueryService ?? + new AuthHistoryAccessQueryService(), + }; +} diff --git a/packages/nestjs-auth-history/src/auth-history.module.spec.ts b/packages/nestjs-auth-history/src/auth-history.module.spec.ts new file mode 100644 index 00000000..9f31711c --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.module.spec.ts @@ -0,0 +1,48 @@ +import { getDynamicRepositoryToken } from '@concepta/nestjs-typeorm-ext'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Repository } from 'typeorm'; + +import { AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY } from './auth-history.constants'; +import { AuthHistoryController } from './auth-history.controller'; +import { AuthHistoryModule } from './auth-history.module'; +import { AuthHistoryCrudService } from './services/auth-history-crud.service'; + +import { AppModuleFixture } from './__fixtures__/app.module.fixture'; +import { AuthHistoryEntityFixture } from './__fixtures__/entities/auth-history.entity.fixture'; + +describe('AppModule', () => { + let authHistoryModule: AuthHistoryModule; + let authHistoryCrudService: AuthHistoryCrudService; + let authHistoryController: AuthHistoryController; + let authHistoryRepo: Repository; + + beforeEach(async () => { + const testModule: TestingModule = await Test.createTestingModule({ + imports: [AppModuleFixture], + }).compile(); + + authHistoryModule = testModule.get(AuthHistoryModule); + authHistoryRepo = testModule.get( + getDynamicRepositoryToken(AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY), + ); + authHistoryCrudService = testModule.get( + AuthHistoryCrudService, + ); + authHistoryController = testModule.get( + AuthHistoryController, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('module', () => { + it('should be loaded', async () => { + expect(authHistoryModule).toBeInstanceOf(AuthHistoryModule); + expect(authHistoryRepo).toBeInstanceOf(Repository); + expect(authHistoryCrudService).toBeInstanceOf(AuthHistoryCrudService); + expect(authHistoryController).toBeInstanceOf(AuthHistoryController); + }); + }); +}); diff --git a/packages/nestjs-auth-history/src/auth-history.module.ts b/packages/nestjs-auth-history/src/auth-history.module.ts new file mode 100644 index 00000000..d91b23da --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Module } from '@nestjs/common'; + +import { + AuthHistoryAsyncOptions, + AuthHistoryModuleClass, + AuthHistoryOptions, +} from './auth-history.module-definition'; + +/** + * AuthHistory Module + */ +@Module({}) +export class AuthHistoryModule extends AuthHistoryModuleClass { + static register(options: AuthHistoryOptions): DynamicModule { + return super.register(options); + } + + static registerAsync(options: AuthHistoryAsyncOptions): DynamicModule { + return super.registerAsync(options); + } + + static forRoot(options: AuthHistoryOptions): DynamicModule { + return super.register({ ...options, global: true }); + } + + static forRootAsync(options: AuthHistoryAsyncOptions): DynamicModule { + return super.registerAsync({ ...options, global: true }); + } +} diff --git a/packages/nestjs-auth-history/src/auth-history.seeder.ts b/packages/nestjs-auth-history/src/auth-history.seeder.ts new file mode 100644 index 00000000..92e0f600 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.seeder.ts @@ -0,0 +1,34 @@ +import { Seeder } from '@concepta/typeorm-seeding'; +import { UserFactory } from '@concepta/nestjs-user/src/seeding'; +import { AuthHistoryFactory } from './auth-history.factory'; +import { UserEntityFixture } from './__fixtures__/entities/user-entity.fixture'; +/** + * AuthHistory seeder + */ +export class AuthHistorySeeder extends Seeder { + /** + * Runner + */ + public async run(): Promise { + // number of authHistorys to create + const createAmount = process.env?.AUTH_HISTORY_MODULE_SEEDER_AMOUNT + ? Number(process.env.AUTH_HISTORY_MODULE_SEEDER_AMOUNT) + : 50; + + this.factory(UserFactory); + // the factory + const authHistoryFactory = this.factory(AuthHistoryFactory); + + const userFactory = new UserFactory({ + entity: UserEntityFixture, + }); + + const user = await userFactory.create(); + + // create a bunch more + await authHistoryFactory.createMany(createAmount, { + user: user, + userId: user.id, + }); + } +} diff --git a/packages/nestjs-auth-history/src/auth-history.types.spec.ts b/packages/nestjs-auth-history/src/auth-history.types.spec.ts new file mode 100644 index 00000000..de7cc1e5 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.types.spec.ts @@ -0,0 +1,10 @@ +import { AuthHistoryResource } from './auth-history.types'; + +describe('AuthHistory Types', () => { + describe('AuthHistoryResource enum', () => { + it('should match', async () => { + expect(AuthHistoryResource.One).toEqual('auth-history'); + expect(AuthHistoryResource.Many).toEqual('auth-history-list'); + }); + }); +}); diff --git a/packages/nestjs-auth-history/src/auth-history.types.ts b/packages/nestjs-auth-history/src/auth-history.types.ts new file mode 100644 index 00000000..078b8b32 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.types.ts @@ -0,0 +1,4 @@ +export enum AuthHistoryResource { + 'One' = 'auth-history', + 'Many' = 'auth-history-list', +} diff --git a/packages/nestjs-auth-history/src/config/auth-history-default.config.ts b/packages/nestjs-auth-history/src/config/auth-history-default.config.ts new file mode 100644 index 00000000..87240ad2 --- /dev/null +++ b/packages/nestjs-auth-history/src/config/auth-history-default.config.ts @@ -0,0 +1,11 @@ +import { registerAs } from '@nestjs/config'; +import { AUTH_HISTORY_MODULE_DEFAULT_SETTINGS_TOKEN } from '../auth-history.constants'; +import { AuthHistorySettingsInterface } from '../interfaces/auth-history-settings.interface'; + +/** + * Default configuration for AuthHistory module. + */ +export const authHistoryDefaultConfig = registerAs( + AUTH_HISTORY_MODULE_DEFAULT_SETTINGS_TOKEN, + (): AuthHistorySettingsInterface => ({}), +); diff --git a/packages/nestjs-auth-history/src/dto/auth-history-create.dto.ts b/packages/nestjs-auth-history/src/dto/auth-history-create.dto.ts new file mode 100644 index 00000000..26ff66f2 --- /dev/null +++ b/packages/nestjs-auth-history/src/dto/auth-history-create.dto.ts @@ -0,0 +1,21 @@ +import { Exclude } from 'class-transformer'; +import { IntersectionType, PickType } from '@nestjs/swagger'; +import { AuthHistoryCreatableInterface } from '@concepta/nestjs-common'; +import { AuthHistoryDto } from './auth-history.dto'; + +/** + * AuthHistory Create DTO + */ +@Exclude() +export class AuthHistoryCreateDto + extends IntersectionType( + PickType(AuthHistoryDto, [ + 'userId', + 'authType', + 'ipAddress', + 'deviceInfo', + 'success', + ] as const), + PickType(AuthHistoryDto, ['deviceInfo'] as const), + ) + implements AuthHistoryCreatableInterface {} diff --git a/packages/nestjs-auth-history/src/dto/auth-history-paginated.dto.ts b/packages/nestjs-auth-history/src/dto/auth-history-paginated.dto.ts new file mode 100644 index 00000000..bc364fde --- /dev/null +++ b/packages/nestjs-auth-history/src/dto/auth-history-paginated.dto.ts @@ -0,0 +1,20 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; +import { AuthHistoryInterface } from '@concepta/nestjs-common'; +import { CrudResponsePaginatedDto } from '@concepta/nestjs-crud'; +import { AuthHistoryDto } from './auth-history.dto'; + +/** + * AuthHistory paginated DTO + */ +@Exclude() +export class AuthHistoryPaginatedDto extends CrudResponsePaginatedDto { + @Expose() + @ApiProperty({ + type: AuthHistoryDto, + isArray: true, + description: 'Array of AuthHistorys', + }) + @Type(() => AuthHistoryDto) + data: AuthHistoryDto[] = []; +} diff --git a/packages/nestjs-auth-history/src/dto/auth-history.dto.ts b/packages/nestjs-auth-history/src/dto/auth-history.dto.ts new file mode 100644 index 00000000..1caf3e1e --- /dev/null +++ b/packages/nestjs-auth-history/src/dto/auth-history.dto.ts @@ -0,0 +1,68 @@ +import { AuthHistoryInterface, CommonEntityDto } from '@concepta/nestjs-common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +/** + * AuthHistory DTO + */ +@Exclude() +export class AuthHistoryDto + extends CommonEntityDto + implements AuthHistoryInterface +{ + /** + * User ID + */ + @Expose() + @ApiProperty({ + type: 'string', + description: 'User ID', + }) + @IsString() + userId: string = ''; + + /** + * IP Address + */ + @Expose() + @ApiProperty({ + type: 'string', + description: 'IP Address', + }) + @IsString() + ipAddress: string = ''; + + /** + * Auth Type + */ + @Expose() + @ApiProperty({ + type: 'string', + description: 'Auth Type', + }) + @IsString() + authType: string = ''; + + /** + * Device Info + */ + @Expose() + @ApiPropertyOptional({ + type: 'string', + description: 'Device Info', + }) + @IsOptional() + deviceInfo?: string; + + /** + * Success + */ + @Expose() + @ApiProperty({ + type: 'boolean', + description: 'Whether authentication was successful', + }) + @IsBoolean() + success: boolean = false; +} diff --git a/packages/nestjs-auth-history/src/entities/auth-history-postgres.entity.ts b/packages/nestjs-auth-history/src/entities/auth-history-postgres.entity.ts new file mode 100644 index 00000000..d634be8a --- /dev/null +++ b/packages/nestjs-auth-history/src/entities/auth-history-postgres.entity.ts @@ -0,0 +1,47 @@ +import { Column } from 'typeorm'; +import { CommonPostgresEntity } from '@concepta/typeorm-common'; +import { ReferenceId, UserInterface } from '@concepta/nestjs-common'; +import { AuthHistoryEntityInterface } from '../interfaces/auth-history-entity.interface'; + +/** + * AuthHistory Entity + */ +export abstract class AuthHistoryPostgresEntity + extends CommonPostgresEntity + implements AuthHistoryEntityInterface +{ + /** + * ipAddress + */ + @Column({ type: 'text' }) + ipAddress!: string; + + /** + * authType + */ + @Column({ type: 'citext' }) + authType!: string; + + /** + * deviceInfo + */ + @Column({ type: 'citext', nullable: true, default: null }) + deviceInfo!: string | null; + + /** + * Failure reason + */ + @Column({ type: 'citext', nullable: true, default: null }) + failureReason!: string | null; + + /** + * User ID + */ + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + /** + * Should be configured by the implementation + */ + user?: UserInterface; +} diff --git a/packages/nestjs-auth-history/src/entities/auth-history-sqlite.entity.ts b/packages/nestjs-auth-history/src/entities/auth-history-sqlite.entity.ts new file mode 100644 index 00000000..bb722183 --- /dev/null +++ b/packages/nestjs-auth-history/src/entities/auth-history-sqlite.entity.ts @@ -0,0 +1,44 @@ +import { ReferenceId, UserInterface } from '@concepta/nestjs-common'; +import { CommonSqliteEntity } from '@concepta/typeorm-common'; +import { Column } from 'typeorm'; +import { AuthHistoryEntityInterface } from '../interfaces/auth-history-entity.interface'; + +export abstract class AuthHistorySqliteEntity + extends CommonSqliteEntity + implements AuthHistoryEntityInterface +{ + /** + * ipAddress + */ + @Column({ type: 'text' }) + ipAddress!: string; + + /** + * authType + */ + @Column({ type: 'text' }) + authType!: string; + + /** + * deviceInfo + */ + @Column({ type: 'text', nullable: true, default: null }) + deviceInfo!: string | null; + + /** + * Failure reason + */ + @Column({ type: 'text', nullable: true, default: null }) + failureReason!: string | null; + + /** + * User ID + */ + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + /** + * Should be configured by the implementation + */ + user?: UserInterface; +} diff --git a/packages/nestjs-auth-history/src/exceptions/auth-history-bad-request-exception.ts b/packages/nestjs-auth-history/src/exceptions/auth-history-bad-request-exception.ts new file mode 100644 index 00000000..99e6533a --- /dev/null +++ b/packages/nestjs-auth-history/src/exceptions/auth-history-bad-request-exception.ts @@ -0,0 +1,14 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { HttpStatus } from '@nestjs/common'; +import { AuthHistoryException } from './auth-history-exception'; + +export class AuthHistoryBadRequestException extends AuthHistoryException { + constructor(options?: RuntimeExceptionOptions) { + super({ + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'AUTH_HISTORY_BAD_REQUEST_ERROR'; + } +} diff --git a/packages/nestjs-auth-history/src/exceptions/auth-history-exception.ts b/packages/nestjs-auth-history/src/exceptions/auth-history-exception.ts new file mode 100644 index 00000000..5bbb1b80 --- /dev/null +++ b/packages/nestjs-auth-history/src/exceptions/auth-history-exception.ts @@ -0,0 +1,13 @@ +import { + RuntimeException, + RuntimeExceptionOptions, +} from '@concepta/nestjs-exception'; +/** + * Generic login history exception. + */ +export class AuthHistoryException extends RuntimeException { + constructor(options?: RuntimeExceptionOptions) { + super(options); + this.errorCode = 'AUTH_HISTORY_ERROR'; + } +} diff --git a/packages/nestjs-auth-history/src/exceptions/auth-history-not-found-exception.ts b/packages/nestjs-auth-history/src/exceptions/auth-history-not-found-exception.ts new file mode 100644 index 00000000..4bd818b9 --- /dev/null +++ b/packages/nestjs-auth-history/src/exceptions/auth-history-not-found-exception.ts @@ -0,0 +1,15 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { HttpStatus } from '@nestjs/common'; +import { AuthHistoryException } from './auth-history-exception'; + +export class AuthHistoryNotFoundException extends AuthHistoryException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'The login history was not found', + httpStatus: HttpStatus.NOT_FOUND, + ...options, + }); + + this.errorCode = 'AUTH_HISTORY_NOT_FOUND_ERROR'; + } +} diff --git a/packages/nestjs-auth-history/src/index.ts b/packages/nestjs-auth-history/src/index.ts new file mode 100644 index 00000000..376f043d --- /dev/null +++ b/packages/nestjs-auth-history/src/index.ts @@ -0,0 +1,21 @@ +export { AuthHistoryModule } from './auth-history.module'; + +export { AuthHistoryPostgresEntity } from './entities/auth-history-postgres.entity'; +export { AuthHistorySqliteEntity } from './entities/auth-history-sqlite.entity'; + +export { AuthHistoryController } from './auth-history.controller'; +export { AuthHistoryAccessQueryService } from './services/auth-history-query.service'; + +export { AuthHistoryCrudService } from './services/auth-history-crud.service'; + +export { AuthHistoryEntityInterface } from './interfaces/auth-history-entity.interface'; + +export { AuthHistoryCreateDto } from './dto/auth-history-create.dto'; +export { AuthHistoryPaginatedDto } from './dto/auth-history-paginated.dto'; +export { AuthHistoryDto } from './dto/auth-history.dto'; + +export { AuthHistoryResource } from './auth-history.types'; + +export { AuthHistoryBadRequestException } from './exceptions/auth-history-bad-request-exception'; +export { AuthHistoryException } from './exceptions/auth-history-exception'; +export { AuthHistoryNotFoundException } from './exceptions/auth-history-not-found-exception'; diff --git a/packages/nestjs-auth-history/src/interfaces/auth-history-entities-options.interface.ts b/packages/nestjs-auth-history/src/interfaces/auth-history-entities-options.interface.ts new file mode 100644 index 00000000..35ceec4f --- /dev/null +++ b/packages/nestjs-auth-history/src/interfaces/auth-history-entities-options.interface.ts @@ -0,0 +1,9 @@ +import { TypeOrmExtEntityOptionInterface } from '@concepta/nestjs-typeorm-ext'; +import { AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY } from '../auth-history.constants'; +import { AuthHistoryEntityInterface } from './auth-history-entity.interface'; + +export interface AuthHistoryEntitiesOptionsInterface { + entities: { + [AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY]: TypeOrmExtEntityOptionInterface; + }; +} diff --git a/packages/nestjs-auth-history/src/interfaces/auth-history-entity.interface.ts b/packages/nestjs-auth-history/src/interfaces/auth-history-entity.interface.ts new file mode 100644 index 00000000..ec62e5fc --- /dev/null +++ b/packages/nestjs-auth-history/src/interfaces/auth-history-entity.interface.ts @@ -0,0 +1,8 @@ +import { + AuthHistoryInterface, + ReferenceIdInterface, +} from '@concepta/nestjs-common'; + +export interface AuthHistoryEntityInterface + extends ReferenceIdInterface, + AuthHistoryInterface {} diff --git a/packages/nestjs-auth-history/src/interfaces/auth-history-mutate-service.interface.ts b/packages/nestjs-auth-history/src/interfaces/auth-history-mutate-service.interface.ts new file mode 100644 index 00000000..2f740ca5 --- /dev/null +++ b/packages/nestjs-auth-history/src/interfaces/auth-history-mutate-service.interface.ts @@ -0,0 +1,11 @@ +import { + AuthHistoryCreatableInterface, + CreateOneInterface, +} from '@concepta/nestjs-common'; +import { AuthHistoryEntityInterface } from './auth-history-entity.interface'; + +export interface AuthHistoryMutateServiceInterface + extends CreateOneInterface< + AuthHistoryCreatableInterface, + AuthHistoryEntityInterface + > {} diff --git a/packages/nestjs-auth-history/src/interfaces/auth-history-options-extras.interface.ts b/packages/nestjs-auth-history/src/interfaces/auth-history-options-extras.interface.ts new file mode 100644 index 00000000..58b11bdd --- /dev/null +++ b/packages/nestjs-auth-history/src/interfaces/auth-history-options-extras.interface.ts @@ -0,0 +1,6 @@ +import { DynamicModule } from '@nestjs/common'; +import { AuthHistoryEntitiesOptionsInterface } from './auth-history-entities-options.interface'; + +export interface AuthHistoryOptionsExtrasInterface + extends Pick, + Partial {} diff --git a/packages/nestjs-auth-history/src/interfaces/auth-history-options.interface.ts b/packages/nestjs-auth-history/src/interfaces/auth-history-options.interface.ts new file mode 100644 index 00000000..78024640 --- /dev/null +++ b/packages/nestjs-auth-history/src/interfaces/auth-history-options.interface.ts @@ -0,0 +1,7 @@ +import { CanAccess } from '@concepta/nestjs-access-control'; +import { AuthHistorySettingsInterface } from './auth-history-settings.interface'; + +export interface AuthHistoryOptionsInterface { + settings?: AuthHistorySettingsInterface; + authHistoryAccessQueryService?: CanAccess; +} diff --git a/packages/nestjs-auth-history/src/interfaces/auth-history-settings.interface.ts b/packages/nestjs-auth-history/src/interfaces/auth-history-settings.interface.ts new file mode 100644 index 00000000..82a085f4 --- /dev/null +++ b/packages/nestjs-auth-history/src/interfaces/auth-history-settings.interface.ts @@ -0,0 +1,11 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { + EventAsyncInterface, + EventClassInterface, +} from '@concepta/nestjs-event'; + +export interface AuthHistorySettingsInterface { + authenticatedEvents?: EventClassInterface< + EventAsyncInterface + >[]; +} diff --git a/packages/nestjs-auth-history/src/listeners/authenticated-listener.ts b/packages/nestjs-auth-history/src/listeners/authenticated-listener.ts new file mode 100644 index 00000000..aad708e4 --- /dev/null +++ b/packages/nestjs-auth-history/src/listeners/authenticated-listener.ts @@ -0,0 +1,53 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { EventAsyncInterface, EventListenerOn } from '@concepta/nestjs-event'; +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { AUTH_HISTORY_MODULE_SETTINGS_TOKEN } from '../auth-history.constants'; +import { AuthHistoryMutateServiceInterface } from '../interfaces/auth-history-mutate-service.interface'; +import { AuthHistorySettingsInterface } from '../interfaces/auth-history-settings.interface'; +import { AuthHistoryMutateService } from '../services/auth-history-mutate.service'; + +@Injectable() +export class AuthenticatedListener + extends EventListenerOn< + EventAsyncInterface + > + implements OnModuleInit +{ + constructor( + @Inject(AUTH_HISTORY_MODULE_SETTINGS_TOKEN) + private settings: AuthHistorySettingsInterface, + @Inject(AuthHistoryMutateService) + private authHistoryMutateService: AuthHistoryMutateServiceInterface, + ) { + super(); + } + + onModuleInit() { + if (this.settings.authenticatedEvents) { + this.settings.authenticatedEvents.forEach((event) => { + this.on(event); + }); + } + } + + async listen( + event: EventAsyncInterface, + ) { + const { payload } = event; + + if (!payload.userInfo) { + Logger.error('Auth History event data payload is missing.'); + return false; + } + try { + await this.authHistoryMutateService.create( + payload.userInfo, + payload.queryOptions, + ); + } catch (err) { + Logger.error(err); + return false; + } + return true; + } +} diff --git a/packages/nestjs-auth-history/src/seeding.ts b/packages/nestjs-auth-history/src/seeding.ts new file mode 100644 index 00000000..ccec2f42 --- /dev/null +++ b/packages/nestjs-auth-history/src/seeding.ts @@ -0,0 +1,7 @@ +/** + * These exports all you to import seeding related classes + * and tools without loading the entire module which + * runs all of it's decorators and meta data. + */ +export { AuthHistoryFactory } from './auth-history.factory'; +export { AuthHistorySeeder } from './auth-history.seeder'; diff --git a/packages/nestjs-auth-history/src/services/auth-history-crud.service.ts b/packages/nestjs-auth-history/src/services/auth-history-crud.service.ts new file mode 100644 index 00000000..685e688a --- /dev/null +++ b/packages/nestjs-auth-history/src/services/auth-history-crud.service.ts @@ -0,0 +1,24 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { TypeOrmCrudService } from '@concepta/nestjs-crud'; +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY } from '../auth-history.constants'; +import { AuthHistoryEntityInterface } from '../interfaces/auth-history-entity.interface'; + +/** + * AuthHistory CRUD service + */ +@Injectable() +export class AuthHistoryCrudService extends TypeOrmCrudService { + /** + * Constructor + * + * @param authHistoryRepo - instance of the login history repository. + */ + constructor( + @InjectDynamicRepository(AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY) + protected readonly authHistoryRepo: Repository, + ) { + super(authHistoryRepo); + } +} diff --git a/packages/nestjs-auth-history/src/services/auth-history-mutate.service.ts b/packages/nestjs-auth-history/src/services/auth-history-mutate.service.ts new file mode 100644 index 00000000..7bf67e3c --- /dev/null +++ b/packages/nestjs-auth-history/src/services/auth-history-mutate.service.ts @@ -0,0 +1,33 @@ +import { AuthHistoryCreatableInterface } from '@concepta/nestjs-common'; +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; +import { MutateService } from '@concepta/typeorm-common'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; + +import { AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY } from '../auth-history.constants'; +import { AuthHistoryCreateDto } from '../dto/auth-history-create.dto'; +import { AuthHistoryEntityInterface } from '../interfaces/auth-history-entity.interface'; +import { AuthHistoryMutateServiceInterface } from '../interfaces/auth-history-mutate-service.interface'; + +/** + * AuthHistory mutate service + */ +@Injectable() +export class AuthHistoryMutateService + extends MutateService< + AuthHistoryEntityInterface, + AuthHistoryCreatableInterface, + AuthHistoryCreatableInterface + > + implements AuthHistoryMutateServiceInterface +{ + protected createDto = AuthHistoryCreateDto; + protected updateDto = AuthHistoryCreateDto; + + constructor( + @InjectDynamicRepository(AUTH_HISTORY_MODULE_AUTH_HISTORY_ENTITY_KEY) + repo: Repository, + ) { + super(repo); + } +} diff --git a/packages/nestjs-auth-history/src/services/auth-history-query.service.ts b/packages/nestjs-auth-history/src/services/auth-history-query.service.ts new file mode 100644 index 00000000..08d35ec3 --- /dev/null +++ b/packages/nestjs-auth-history/src/services/auth-history-query.service.ts @@ -0,0 +1,13 @@ +import { + AccessControlContext, + CanAccess, +} from '@concepta/nestjs-access-control'; +import { Injectable } from '@nestjs/common'; + +// TODO: check if this is actually needed +@Injectable() +export class AuthHistoryAccessQueryService implements CanAccess { + async canAccess(_context: AccessControlContext): Promise { + return true; + } +} diff --git a/packages/nestjs-auth-history/tsconfig.json b/packages/nestjs-auth-history/tsconfig.json new file mode 100644 index 00000000..0fe61fc6 --- /dev/null +++ b/packages/nestjs-auth-history/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist", + "typeRoots": [ + "./node_modules/@types", + "../../node_modules/@types" + ] + }, + "include": [ + "src/**/*.ts" + ], + "references": [ + { + "path": "../nestjs-crud" + } + ] +} diff --git a/packages/nestjs-auth-history/typedoc.json b/packages/nestjs-auth-history/typedoc.json new file mode 100644 index 00000000..944fda5a --- /dev/null +++ b/packages/nestjs-auth-history/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +} \ No newline at end of file diff --git a/packages/nestjs-auth-local/package.json b/packages/nestjs-auth-local/package.json index 926c0a3d..c5f775ad 100644 --- a/packages/nestjs-auth-local/package.json +++ b/packages/nestjs-auth-local/package.json @@ -14,6 +14,7 @@ "dependencies": { "@concepta/nestjs-authentication": "^6.0.0-alpha.2", "@concepta/nestjs-common": "^6.0.0-alpha.2", + "@concepta/nestjs-event": "^6.0.0-alpha.2", "@concepta/nestjs-exception": "^6.0.0-alpha.2", "@concepta/nestjs-password": "^6.0.0-alpha.2", "@concepta/typeorm-common": "^6.0.0-alpha.2", diff --git a/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts index bf961c25..c7992295 100644 --- a/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts +++ b/packages/nestjs-auth-local/src/__fixtures__/app.module.fixture.ts @@ -7,11 +7,13 @@ import { AuthJwtModule } from '@concepta/nestjs-auth-jwt'; import { AuthLocalModule } from '../auth-local.module'; import { UserLookupServiceFixture } from './user/user-lookup.service.fixture'; import { UserModuleFixture } from './user/user.module.fixture'; +import { EventModule } from '@concepta/nestjs-event'; @Module({ imports: [ JwtModule.forRoot({}), AuthenticationModule.forRoot({}), + EventModule.forRoot({}), AuthJwtModule.forRootAsync({ inject: [UserLookupServiceFixture], useFactory: (userLookupService: UserLookupServiceFixture) => ({ diff --git a/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts b/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts index bd218495..cfe4fb0d 100644 --- a/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts +++ b/packages/nestjs-auth-local/src/__fixtures__/user/constants.ts @@ -12,4 +12,6 @@ export const USER_SUCCESS: AuthLocalCredentialsInterface = { passwordHash: LOGIN_SUCCESS.password, passwordSalt: LOGIN_SUCCESS.password, username: LOGIN_SUCCESS.username, + loginAttempts: 0, + lastLogin: null, }; diff --git a/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts b/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts index 28207679..db993567 100644 --- a/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts +++ b/packages/nestjs-auth-local/src/__fixtures__/user/user.entity.fixture.ts @@ -15,4 +15,8 @@ export class UserFixture passwordHash!: string | null; passwordSalt!: string | null; + + loginAttempts?: number; + + lastLogin?: Date | null; } diff --git a/packages/nestjs-auth-local/src/auth-local.constants.ts b/packages/nestjs-auth-local/src/auth-local.constants.ts index 60dc0df2..0ddc7c12 100644 --- a/packages/nestjs-auth-local/src/auth-local.constants.ts +++ b/packages/nestjs-auth-local/src/auth-local.constants.ts @@ -17,3 +17,5 @@ export const AUTH_LOCAL_MODULE_PASSWORD_VALIDATION_SERVICE_TOKEN = 'AUTH_LOCAL_MODULE_PASSWORD_VALIDATION_SERVICE_TOKEN'; export const AUTH_LOCAL_STRATEGY_NAME = 'local'; + +export const AUTH_LOCAL_AUTHENTICATION_TYPE = 'auth-local'; diff --git a/packages/nestjs-auth-local/src/auth-local.controller.e2e-spec.ts b/packages/nestjs-auth-local/src/auth-local.controller.e2e-spec.ts index 35149e9d..245068fe 100644 --- a/packages/nestjs-auth-local/src/auth-local.controller.e2e-spec.ts +++ b/packages/nestjs-auth-local/src/auth-local.controller.e2e-spec.ts @@ -5,27 +5,31 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppModuleDbFixture } from './__fixtures__/app.module.fixture'; import { PasswordValidationService } from '@concepta/nestjs-password'; -import { LOGIN_SUCCESS } from './__fixtures__/user/constants'; +import { LOGIN_SUCCESS, USER_SUCCESS } from './__fixtures__/user/constants'; import { HttpAdapterHost } from '@nestjs/core'; import { ExceptionsFilter } from '@concepta/nestjs-exception'; import { AUTH_LOCAL_MODULE_VALIDATE_USER_SERVICE_TOKEN } from './auth-local.constants'; import { AuthLocalInvalidCredentialsException } from './exceptions/auth-local-invalid-credentials.exception'; +import { UserLookupServiceFixture } from './__fixtures__/user/user-lookup.service.fixture'; describe('AuthLocalController (e2e)', () => { let app: INestApplication; + let userLookupService: UserLookupServiceFixture; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModuleDbFixture], }) .overrideProvider(PasswordValidationService) .useValue({ - validateObject: () => { - return true; + validateObject: (passwordPlain: string) => { + if (passwordPlain === LOGIN_SUCCESS.password) return true; + else return false; }, }) .compile(); app = moduleFixture.createNestApplication(); + userLookupService = app.get(UserLookupServiceFixture); const exceptionsFilter = app.get(HttpAdapterHost); app.useGlobalFilters(new ExceptionsFilter(exceptionsFilter)); @@ -140,4 +144,43 @@ describe('AuthLocalController (e2e)', () => { expect(response.status).toBe(400); }); }); + + it('POST auth/login password fail message', async () => { + const payload = { + ...LOGIN_SUCCESS, + password: 'wrong_password', + }; + + await supertest(app.getHttpServer()) + .post('/auth/login') + .send(payload) + .then((response) => { + expect(response.body.message).toBe( + `The provided username or password is incorrect. Please try again.`, + ); + expect(response.status).toBe(401); + }); + }); + + it('POST auth/login password fail attempt message', async () => { + const payload = { + ...LOGIN_SUCCESS, + password: 'wrong_password', + }; + + jest.spyOn(userLookupService, 'byUsername').mockResolvedValue({ + ...USER_SUCCESS, + loginAttempts: 4, + }); + + await supertest(app.getHttpServer()) + .post('/auth/login') + .send(payload) + .then((response) => { + expect(response.body.message).toBe( + 'Warning: You have 6 attempts remaining before your account is locked.', + ); + expect(response.status).toBe(401); + }); + }); }); diff --git a/packages/nestjs-auth-local/src/auth-local.controller.spec.ts b/packages/nestjs-auth-local/src/auth-local.controller.spec.ts index 35f48082..fe9e11ee 100644 --- a/packages/nestjs-auth-local/src/auth-local.controller.spec.ts +++ b/packages/nestjs-auth-local/src/auth-local.controller.spec.ts @@ -3,6 +3,7 @@ import { AuthenticatedUserInterface, AuthenticationResponseInterface, } from '@concepta/nestjs-common'; +import { EventDispatchService } from '@concepta/nestjs-event'; import { randomUUID } from 'crypto'; import { mock } from 'jest-mock-extended'; import { AuthLocalController } from './auth-local.controller'; @@ -11,6 +12,7 @@ describe(AuthLocalController, () => { const accessToken = 'accessToken'; const refreshToken = 'refreshToken'; let controller: AuthLocalController; + let eventDispatchService: EventDispatchService; const response: AuthenticationResponseInterface = { accessToken, refreshToken, @@ -24,7 +26,12 @@ describe(AuthLocalController, () => { }); }, }); - controller = new AuthLocalController(issueTokenService); + eventDispatchService = mock(); + + controller = new AuthLocalController( + issueTokenService, + eventDispatchService, + ); }); describe(AuthLocalController.prototype.login, () => { @@ -32,6 +39,7 @@ describe(AuthLocalController, () => { const user: AuthenticatedUserInterface = { id: randomUUID(), }; + const result = await controller.login(user); expect(result.accessToken).toBe(response.accessToken); }); diff --git a/packages/nestjs-auth-local/src/auth-local.controller.ts b/packages/nestjs-auth-local/src/auth-local.controller.ts index bf227ce5..fbe3756a 100644 --- a/packages/nestjs-auth-local/src/auth-local.controller.ts +++ b/packages/nestjs-auth-local/src/auth-local.controller.ts @@ -1,23 +1,24 @@ -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { - ApiBody, - ApiOkResponse, - ApiTags, - ApiUnauthorizedResponse, -} from '@nestjs/swagger'; + AuthenticationJwtResponseDto, + AuthPublic, + AuthUser, + IssueTokenServiceInterface, +} from '@concepta/nestjs-authentication'; import { AuthenticatedUserInterface, AuthenticationResponseInterface, } from '@concepta/nestjs-common'; +import { EventDispatchService } from '@concepta/nestjs-event'; +import { Controller, Inject, Optional, Post, UseGuards } from '@nestjs/common'; import { - AuthUser, - IssueTokenServiceInterface, - AuthenticationJwtResponseDto, - AuthPublic, -} from '@concepta/nestjs-authentication'; + ApiBody, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import { AUTH_LOCAL_MODULE_ISSUE_TOKEN_SERVICE_TOKEN } from './auth-local.constants'; -import { AuthLocalLoginDto } from './dto/auth-local-login.dto'; import { AuthLocalGuard } from './auth-local.guard'; +import { AuthLocalLoginDto } from './dto/auth-local-login.dto'; /** * Auth Local controller @@ -30,6 +31,9 @@ export class AuthLocalController { constructor( @Inject(AUTH_LOCAL_MODULE_ISSUE_TOKEN_SERVICE_TOKEN) private issueTokenService: IssueTokenServiceInterface, + @Optional() + @Inject(EventDispatchService) + private readonly eventDispatchService?: EventDispatchService, ) {} /** @@ -48,6 +52,6 @@ export class AuthLocalController { async login( @AuthUser() user: AuthenticatedUserInterface, ): Promise { - return this.issueTokenService.responsePayload(user.id); + return await this.issueTokenService.responsePayload(user.id); } } diff --git a/packages/nestjs-auth-local/src/auth-local.module-definition.ts b/packages/nestjs-auth-local/src/auth-local.module-definition.ts index 9500402e..ac9cecd8 100644 --- a/packages/nestjs-auth-local/src/auth-local.module-definition.ts +++ b/packages/nestjs-auth-local/src/auth-local.module-definition.ts @@ -134,17 +134,21 @@ export function createAuthLocalValidateUserServiceProvider( RAW_OPTIONS_TOKEN, AUTH_LOCAL_MODULE_USER_LOOKUP_SERVICE_TOKEN, AUTH_LOCAL_MODULE_PASSWORD_VALIDATION_SERVICE_TOKEN, + AUTH_LOCAL_MODULE_SETTINGS_TOKEN, ], useFactory: async ( - options: Pick, + options: Pick, userLookupService: AuthLocalUserLookupServiceInterface, passwordValidationService: PasswordValidationServiceInterface, + settings: AuthLocalSettingsInterface, ) => optionsOverrides?.validateUserService ?? options.validateUserService ?? + // TODO: what do we initialize like this? new AuthLocalValidateUserService( userLookupService, passwordValidationService, + options.settings || settings, ), }; } diff --git a/packages/nestjs-auth-local/src/auth-local.module.spec.ts b/packages/nestjs-auth-local/src/auth-local.module.spec.ts index 2a717735..a48828fd 100644 --- a/packages/nestjs-auth-local/src/auth-local.module.spec.ts +++ b/packages/nestjs-auth-local/src/auth-local.module.spec.ts @@ -34,6 +34,7 @@ import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.int import { UserLookupServiceFixture } from './__fixtures__/user/user-lookup.service.fixture'; import { UserModuleFixture } from './__fixtures__/user/user.module.fixture'; import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; +import { EventModule } from '@concepta/nestjs-event'; describe(AuthLocalModule, () => { const jwtService = new JwtService(); @@ -167,7 +168,6 @@ describe(AuthLocalModule, () => { AuthLocalModule.forFeature({ userLookupService: ffUserLookupService, issueTokenService: ffIssueTokenService, - settings: {}, }), ], providers: [TestService], @@ -217,6 +217,7 @@ function testModuleFactory( UserModuleFixture, AuthenticationModule.forRoot({}), JwtModule.forRoot({}), + EventModule.forRoot({}), ...extraImports, ], }; diff --git a/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts b/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts index 2305e533..d5ec1ebf 100644 --- a/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts +++ b/packages/nestjs-auth-local/src/auth-local.strategy.spec.ts @@ -9,15 +9,32 @@ import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-v import { AuthLocalValidateUserService } from './services/auth-local-validate-user.service'; import { UserFixture } from './__fixtures__/user/user.entity.fixture'; -import { ReferenceIdInterface } from '@concepta/nestjs-common'; +import { + AuthHistoryLoginInterface, + ReferenceIdInterface, +} from '@concepta/nestjs-common'; import { AuthLocalValidateUserInterface } from './interfaces/auth-local-validate-user.interface'; import { AuthLocalException } from './exceptions/auth-local.exception'; import { AuthLocalInvalidCredentialsException } from './exceptions/auth-local-invalid-credentials.exception'; import { AuthLocalInvalidLoginDataException } from './exceptions/auth-local-invalid-login-data.exception'; +import { AuthLocalAuthenticatedEventAsync } from './events/auth-local-authenticated.event'; +import { AUTH_LOCAL_AUTHENTICATION_TYPE } from './auth-local.constants'; +import { + EventDispatchService, + EventListenService, + EventManager, +} from '@concepta/nestjs-event'; +import { AuthLocalInvalidPasswordException } from './exceptions/auth-local-invalid-password.exception'; +import { AuthLocalUserAttemptsException } from './exceptions/auth-local-user-attempts.exception'; +import { AuthenticationRequestInterface } from '@concepta/nestjs-authentication'; describe(AuthLocalStrategy.name, () => { const USERNAME = 'username'; const PASSWORD = 'password'; + const authLogin: AuthHistoryLoginInterface = { + ipAddress: '127.0.0.1', + deviceInfo: 'IOS', + }; let user: UserFixture; let settings: AuthLocalSettingsInterface; @@ -25,34 +42,69 @@ describe(AuthLocalStrategy.name, () => { let validateUserService: AuthLocalValidateUserServiceInterface; let passwordValidationService: PasswordValidationService; let authLocalStrategy: AuthLocalStrategy; + let eventDispatchService: EventDispatchService; + let eventListenService: EventListenService; + let spyOnDispatchService: jest.SpyInstance; + const req: AuthenticationRequestInterface = { + ip: '127.0.0.1', + headers: { + 'user-agent': 'IOS', + }, + }; beforeEach(async () => { - settings = mock>({ + settings = mock({ loginDto: UserFixture, usernameField: USERNAME, passwordField: PASSWORD, + maxAttempts: 10, + minAttempts: 3, }); userLookUpService = mock(); passwordValidationService = mock(); + validateUserService = new AuthLocalValidateUserService( userLookUpService, passwordValidationService, + settings, + ); + eventDispatchService = mock(); + eventListenService = mock(); + authLocalStrategy = new AuthLocalStrategy( + settings, + validateUserService, + userLookUpService, ); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); + + EventManager.initialize(eventDispatchService, eventListenService, { + allowManualShutdown: true, + }); + + spyOnDispatchService = jest + .spyOn(EventManager.dispatch, 'emitAsync') + .mockResolvedValue([]); user = new UserFixture(); user.id = randomUUID(); user.active = true; + user.loginAttempts = 4; + jest.resetAllMocks(); jest.spyOn(userLookUpService, 'byUsername').mockResolvedValue(user); }); it('constructor', async () => { - settings = mock>({ + settings = mock({ loginDto: undefined, + maxAttempts: 10, + minAttempts: 3, }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); + authLocalStrategy = new AuthLocalStrategy( + settings, + validateUserService, + userLookUpService, + ); expect(true).toBeTruthy(); }); @@ -62,8 +114,27 @@ describe(AuthLocalStrategy.name, () => { .spyOn(passwordValidationService, 'validateObject') .mockResolvedValue(true); - const result = await authLocalStrategy.validate(USERNAME, PASSWORD); + const result = await authLocalStrategy.validate(req, USERNAME, PASSWORD); + expect(result.id).toBe(user.id); + }); + + it('should return user and trigger event', async () => { + jest + .spyOn(passwordValidationService, 'validateObject') + .mockResolvedValue(true); + + const result = await authLocalStrategy.validate(req, USERNAME, PASSWORD); expect(result.id).toBe(user.id); + + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync({ + userInfo: { + userId: user.id, + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + success: true, + ...authLogin, + }, + }); + expect(spyOnDispatchService).toBeCalledWith(authenticatedEventAsync); }); it('should fail to validate user', async () => { @@ -73,8 +144,73 @@ describe(AuthLocalStrategy.name, () => { return null as unknown as Promise>; }); - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); + }); + + it('should fail to validate user and trigger event', async () => { + jest + .spyOn(validateUserService, 'validateUser') + .mockImplementationOnce((_dto: AuthLocalValidateUserInterface) => { + return null as unknown as Promise>; + }); + + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); + + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync({ + userInfo: { + userId: user.id, + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + failureReason: 'Unable to validate user with username: username', + success: false, + ...authLogin, + }, + }); + expect(spyOnDispatchService).toBeCalledWith(authenticatedEventAsync); + }); + + it('should fail to validate user and trigger event missing attempt', async () => { + jest + .spyOn(passwordValidationService, 'validateObject') + .mockResolvedValue(false); + + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); + await expect(t).rejects.toThrow(AuthLocalUserAttemptsException); + + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync({ + userInfo: { + userId: user.id, + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + failureReason: + 'Warning: You have 6 attempts remaining before your account is locked.', + success: false, + ...authLogin, + }, + }); + expect(spyOnDispatchService).toBeCalledWith(authenticatedEventAsync); + }); + + it('should fail to validate user and trigger event invalid password', async () => { + jest + .spyOn(validateUserService, 'validateUser') + .mockImplementationOnce((_dto: AuthLocalValidateUserInterface) => { + throw new AuthLocalInvalidPasswordException('fake_username'); + }); + + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); + + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync({ + userInfo: { + userId: user.id, + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + failureReason: 'Invalid password for username: fake_username', + success: false, + ...authLogin, + }, + }); + expect(spyOnDispatchService).toBeCalledWith(authenticatedEventAsync); }); it('should fail to validate user with custom message', async () => { @@ -87,7 +223,7 @@ describe(AuthLocalStrategy.name, () => { }); }); - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); try { await t(); @@ -109,7 +245,7 @@ describe(AuthLocalStrategy.name, () => { throw new Error('This is really bad'); }); - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); try { await t(); @@ -126,7 +262,7 @@ describe(AuthLocalStrategy.name, () => { }); it('should throw error on validateOrReject', async () => { - const t = () => authLocalStrategy.validate(USERNAME, ''); + const t = () => authLocalStrategy.validate(req, USERNAME, ''); await expect(t).rejects.toThrow(); }); @@ -136,14 +272,14 @@ describe(AuthLocalStrategy.name, () => { .spyOn(classValidator, 'validateOrReject') .mockRejectedValueOnce(BadRequestException); - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); await expect(t).rejects.toThrow(AuthLocalInvalidLoginDataException); }); it('should return no user on userLookupService.byUsername', async () => { jest.spyOn(userLookUpService, 'byUsername').mockResolvedValue(null); - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); }); @@ -152,7 +288,7 @@ describe(AuthLocalStrategy.name, () => { .spyOn(passwordValidationService, 'validateObject') .mockResolvedValue(false); - const t = () => authLocalStrategy.validate(USERNAME, PASSWORD); + const t = () => authLocalStrategy.validate(req, USERNAME, PASSWORD); await expect(t).rejects.toThrow(AuthLocalInvalidCredentialsException); }); }); @@ -166,34 +302,51 @@ describe(AuthLocalStrategy.name, () => { }); it('should throw error for no loginDto', async () => { - settings = mock>({ + settings = mock({ loginDto: undefined, usernameField: USERNAME, passwordField: PASSWORD, + maxAttempts: 10, + minAttempts: 3, }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); + + authLocalStrategy = new AuthLocalStrategy( + settings, + validateUserService, + userLookUpService, + ); const t = () => authLocalStrategy['assertSettings'](); expect(t).toThrowError(); }); it('should throw error for no usernameField', async () => { - settings = mock>({ + settings = mock({ loginDto: UserFixture, usernameField: undefined, passwordField: PASSWORD, }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); + authLocalStrategy = new AuthLocalStrategy( + settings, + validateUserService, + userLookUpService, + ); const t = () => authLocalStrategy['assertSettings'](); expect(t).toThrowError(); }); it('should throw error for no passwordField', async () => { - settings = mock>({ + settings = mock({ loginDto: UserFixture, usernameField: USERNAME, passwordField: undefined, + maxAttempts: 10, + minAttempts: 3, }); - authLocalStrategy = new AuthLocalStrategy(settings, validateUserService); + authLocalStrategy = new AuthLocalStrategy( + settings, + validateUserService, + userLookUpService, + ); const t = () => authLocalStrategy['assertSettings'](); expect(t).toThrowError(); }); diff --git a/packages/nestjs-auth-local/src/auth-local.strategy.ts b/packages/nestjs-auth-local/src/auth-local.strategy.ts index cb73ad5b..d5b4520f 100644 --- a/packages/nestjs-auth-local/src/auth-local.strategy.ts +++ b/packages/nestjs-auth-local/src/auth-local.strategy.ts @@ -1,26 +1,35 @@ -import { Strategy } from 'passport-local'; -import { validateOrReject } from 'class-validator'; -import { Inject, Injectable } from '@nestjs/common'; +import { + AuthenticationRequestInterface, + getAuthenticatedUserInfo, + PassportStrategyFactory, +} from '@concepta/nestjs-authentication'; import { ReferenceIdInterface, ReferenceUsername, } from '@concepta/nestjs-common'; -import { PassportStrategyFactory } from '@concepta/nestjs-authentication'; +import { Inject, Injectable } from '@nestjs/common'; +import { validateOrReject } from 'class-validator'; +import { Strategy } from 'passport-local'; import { + AUTH_LOCAL_AUTHENTICATION_TYPE, AUTH_LOCAL_MODULE_SETTINGS_TOKEN, + AUTH_LOCAL_MODULE_USER_LOOKUP_SERVICE_TOKEN, AUTH_LOCAL_MODULE_VALIDATE_USER_SERVICE_TOKEN, AUTH_LOCAL_STRATEGY_NAME, } from './auth-local.constants'; -import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface'; -import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface'; -import { AuthLocalException } from './exceptions/auth-local.exception'; +import { AuthLocalAuthenticatedEventAsync } from './events/auth-local-authenticated.event'; import { AuthLocalInvalidCredentialsException } from './exceptions/auth-local-invalid-credentials.exception'; import { AuthLocalInvalidLoginDataException } from './exceptions/auth-local-invalid-login-data.exception'; import { AuthLocalMissingLoginDtoException } from './exceptions/auth-local-missing-login-dto.exception'; -import { AuthLocalMissingUsernameFieldException } from './exceptions/auth-local-missing-username-field.exception'; import { AuthLocalMissingPasswordFieldException } from './exceptions/auth-local-missing-password-field.exception'; +import { AuthLocalMissingUsernameFieldException } from './exceptions/auth-local-missing-username-field.exception'; +import { AuthLocalException } from './exceptions/auth-local.exception'; +import { AuthLocalSettingsInterface } from './interfaces/auth-local-settings.interface'; +import { AuthLocalUserLookupServiceInterface } from './interfaces/auth-local-user-lookup-service.interface'; +import { AuthLocalValidateUserServiceInterface } from './interfaces/auth-local-validate-user-service.interface'; +import { RuntimeException } from '@concepta/nestjs-exception'; /** * Define the Local strategy using passport. @@ -42,10 +51,13 @@ export class AuthLocalStrategy extends PassportStrategyFactory( private settings: AuthLocalSettingsInterface, @Inject(AUTH_LOCAL_MODULE_VALIDATE_USER_SERVICE_TOKEN) private validateUserService: AuthLocalValidateUserServiceInterface, + @Inject(AUTH_LOCAL_MODULE_USER_LOOKUP_SERVICE_TOKEN) + protected readonly userLookupService: AuthLocalUserLookupServiceInterface, ) { super({ usernameField: settings?.usernameField, passwordField: settings?.passwordField, + passReqToCallback: true, }); } @@ -53,10 +65,19 @@ export class AuthLocalStrategy extends PassportStrategyFactory( * Validate the user based on the username and password * from the request body * + * @param req - The request object * @param username - The username to authenticate * @param password - The plain text password + * @returns A validated user with an ID if successful + * @throws AuthLocalInvalidLoginDataException If login data validation fails + * @throws AuthLocalInvalidCredentialsException If credentials are invalid + * @throws AuthLocalException If another error occurs during validation */ - async validate(username: ReferenceUsername, password: string) { + async validate( + req: AuthenticationRequestInterface, + username: ReferenceUsername, + password: string, + ) { // break out the settings const { loginDto, usernameField, passwordField } = this.assertSettings(); @@ -68,9 +89,18 @@ export class AuthLocalStrategy extends PassportStrategyFactory( try { await validateOrReject(dto); } catch (e) { - throw new AuthLocalInvalidLoginDataException({ + const error = new AuthLocalInvalidLoginDataException({ originalError: e, }); + + // register failed attempt + await this.dispatchAuthAttemptEvent( + req, + username, + false, + error.safeMessage, + ); + throw error; } let validatedUser: ReferenceIdInterface; @@ -82,24 +112,33 @@ export class AuthLocalStrategy extends PassportStrategyFactory( password, }); } catch (e) { - // did they throw an invalid credentials exception? + let throwError: RuntimeException; + if (e instanceof AuthLocalInvalidCredentialsException) { - // yes, use theirs - throw e; + throwError = e; } else { - // something else went wrong - throw new AuthLocalException({ originalError: e }); + throwError = new AuthLocalException({ originalError: e }); } + await this.dispatchAuthAttemptEvent( + req, + username, + false, + throwError.message, + ); + throw throwError; } // did we get a valid user? if (!validatedUser) { - throw new AuthLocalInvalidCredentialsException({ + const error = new AuthLocalInvalidCredentialsException({ message: `Unable to validate user with username: %s`, messageParams: [username], }); + await this.dispatchAuthAttemptEvent(req, username, false, error.message); + throw error; } + await this.dispatchAuthAttemptEvent(req, username, true); return validatedUser; } @@ -107,7 +146,8 @@ export class AuthLocalStrategy extends PassportStrategyFactory( * Return settings asserted as definitely defined. */ protected assertSettings(): Required { - const { loginDto, usernameField, passwordField } = this.settings; + const { loginDto, usernameField, passwordField, maxAttempts, minAttempts } = + this.settings; // is the login dto missing? if (!loginDto) { @@ -124,6 +164,36 @@ export class AuthLocalStrategy extends PassportStrategyFactory( throw new AuthLocalMissingPasswordFieldException(); } - return { loginDto, usernameField, passwordField }; + return { loginDto, usernameField, passwordField, maxAttempts, minAttempts }; + } + + /** + * TODO: review if this should be done in a middleware instead + * and review request with ip property. + */ + protected async dispatchAuthAttemptEvent( + req: AuthenticationRequestInterface, + username: string, + success: boolean, + failureReason?: string | null, + ): Promise { + const user = await this.userLookupService.byUsername(username); + if (user) { + const info = getAuthenticatedUserInfo(req); + + const failMessage = failureReason ? { failureReason } : {}; + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync({ + userInfo: { + userId: user.id, + ipAddress: info.ipAddress || '', + deviceInfo: info.deviceInfo || '', + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + success, + ...failMessage, + }, + }); + + await authenticatedEventAsync.emit(); + } } } diff --git a/packages/nestjs-auth-local/src/config/auth-local-default.config.ts b/packages/nestjs-auth-local/src/config/auth-local-default.config.ts index fc7e3ce1..8ec1b7ff 100644 --- a/packages/nestjs-auth-local/src/config/auth-local-default.config.ts +++ b/packages/nestjs-auth-local/src/config/auth-local-default.config.ts @@ -21,5 +21,17 @@ export const authLocalDefaultConfig = registerAs( * The field name to use for the password. */ passwordField: process.env.AUTH_LOCAL_PASSWORD_FIELD ?? 'password', + /** + * The maximum number of login attempts allowed before account is locked + */ + maxAttempts: process.env?.AUTH_LOCAL_MAX_ATTEMPT + ? Number(process.env?.AUTH_LOCAL_MAX_ATTEMPT) + : 10, + /** + * The minimum number of login attempts to be allowed before account is locked + */ + minAttempts: process.env?.AUTH_LOCAL_MIN_ATTEMPT + ? Number(process.env?.AUTH_LOCAL_MIN_ATTEMPT) + : 3, }), ); diff --git a/packages/nestjs-auth-local/src/events/auth-local-authenticated.event.ts b/packages/nestjs-auth-local/src/events/auth-local-authenticated.event.ts new file mode 100644 index 00000000..b7ac21e5 --- /dev/null +++ b/packages/nestjs-auth-local/src/events/auth-local-authenticated.event.ts @@ -0,0 +1,7 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { EventAsync } from '@concepta/nestjs-event'; + +export class AuthLocalAuthenticatedEventAsync extends EventAsync< + AuthenticatedEventInterface, + boolean +> {} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-user-attempts.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-user-attempts.exception.ts new file mode 100644 index 00000000..4fcd804c --- /dev/null +++ b/packages/nestjs-auth-local/src/exceptions/auth-local-user-attempts.exception.ts @@ -0,0 +1,17 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { AuthLocalInvalidCredentialsException } from './auth-local-invalid-credentials.exception'; + +export class AuthLocalUserAttemptsException extends AuthLocalInvalidCredentialsException { + constructor(attempts: number, options?: RuntimeExceptionOptions) { + const message = `Warning: You have %s attempts remaining before your account is locked.`; + super({ + message, + messageParams: [`${attempts}`], + safeMessage: message, + safeMessageParams: [`${attempts}`], + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_USER_ATTEMPTS_ERROR'; + } +} diff --git a/packages/nestjs-auth-local/src/exceptions/auth-local-user-locked.exception.ts b/packages/nestjs-auth-local/src/exceptions/auth-local-user-locked.exception.ts new file mode 100644 index 00000000..db87f4e1 --- /dev/null +++ b/packages/nestjs-auth-local/src/exceptions/auth-local-user-locked.exception.ts @@ -0,0 +1,13 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { AuthLocalInvalidCredentialsException } from './auth-local-invalid-credentials.exception'; + +export class AuthLocalUserLockedException extends AuthLocalInvalidCredentialsException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: `Your account has been locked due to multiple failed login attempts. Please contact support to unlock your account`, + ...options, + }); + + this.errorCode = 'AUTH_LOCAL_USER_LOCKED_ERROR'; + } +} diff --git a/packages/nestjs-auth-local/src/index.ts b/packages/nestjs-auth-local/src/index.ts index c938215d..b3d94d66 100644 --- a/packages/nestjs-auth-local/src/index.ts +++ b/packages/nestjs-auth-local/src/index.ts @@ -6,6 +6,7 @@ export { AuthLocalCredentialsInterface } from './interfaces/auth-local-credentia // DTOs export { AuthLocalLoginDto } from './dto/auth-local-login.dto'; +export { AuthLocalAuthenticatedEventAsync } from './events/auth-local-authenticated.event'; // module export { AuthLocalModule } from './auth-local.module'; diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts index d1a2c0ce..96450bd3 100644 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts +++ b/packages/nestjs-auth-local/src/interfaces/auth-local-credentials.interface.ts @@ -1,6 +1,7 @@ import { ReferenceActiveInterface, ReferenceIdInterface, + ReferenceLockStatusInterface, ReferenceUsernameInterface, } from '@concepta/nestjs-common'; import { PasswordStorageInterface } from '@concepta/nestjs-password'; @@ -12,4 +13,5 @@ export interface AuthLocalCredentialsInterface extends ReferenceIdInterface, ReferenceUsernameInterface, ReferenceActiveInterface, + ReferenceLockStatusInterface, PasswordStorageInterface {} diff --git a/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts b/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts index 334fe76b..fef1c0a3 100644 --- a/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts +++ b/packages/nestjs-auth-local/src/interfaces/auth-local-settings.interface.ts @@ -4,4 +4,6 @@ export interface AuthLocalSettingsInterface { loginDto?: Type; usernameField?: string; passwordField?: string; + maxAttempts: number; + minAttempts: number; } diff --git a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts index f56126ec..01cf90fe 100644 --- a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts +++ b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.spec.ts @@ -2,6 +2,7 @@ import { PasswordValidationServiceInterface } from '@concepta/nestjs-password'; import { AuthLocalValidateUserService } from './auth-local-validate-user.service'; import { AuthLocalUserLookupServiceInterface } from '../interfaces/auth-local-user-lookup-service.interface'; import { AuthLocalValidateUserInterface } from '../interfaces/auth-local-validate-user.interface'; +import { AuthLocalSettingsInterface } from '../interfaces/auth-local-settings.interface'; describe(AuthLocalValidateUserService.name, () => { const USERNAME = 'test'; @@ -20,9 +21,14 @@ describe(AuthLocalValidateUserService.name, () => { validateObject: jest.fn(), } as unknown as PasswordValidationServiceInterface; + const settings: AuthLocalSettingsInterface = { + maxAttempts: 10, + minAttempts: 3, + }; service = new AuthLocalValidateUserService( userLookupService, passwordValidationService, + settings, ); }); @@ -34,6 +40,8 @@ describe(AuthLocalValidateUserService.name, () => { password: 'password', passwordHash: 'hash', passwordSalt: 'salt', + lastLogin: new Date(), + loginAttempts: 0, }; it('should throw an error if no user is found for the given username', async () => { jest.spyOn(userLookupService, 'byUsername').mockResolvedValue(null); diff --git a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts index 562260f6..0e104ce5 100644 --- a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts +++ b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts @@ -1,9 +1,10 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { ReferenceIdInterface } from '@concepta/nestjs-common'; import { ValidateUserService } from '@concepta/nestjs-authentication'; import { PasswordValidationServiceInterface } from '@concepta/nestjs-password'; import { AUTH_LOCAL_MODULE_PASSWORD_VALIDATION_SERVICE_TOKEN, + AUTH_LOCAL_MODULE_SETTINGS_TOKEN, AUTH_LOCAL_MODULE_USER_LOOKUP_SERVICE_TOKEN, } from '../auth-local.constants'; import { AuthLocalValidateUserInterface } from '../interfaces/auth-local-validate-user.interface'; @@ -12,6 +13,9 @@ import { AuthLocalUserLookupServiceInterface } from '../interfaces/auth-local-us import { AuthLocalUsernameNotFoundException } from '../exceptions/auth-local-username-not-found.exception'; import { AuthLocalUserInactiveException } from '../exceptions/auth-local-user-inactive.exception'; import { AuthLocalInvalidPasswordException } from '../exceptions/auth-local-invalid-password.exception'; +import { AuthLocalSettingsInterface } from '../interfaces/auth-local-settings.interface'; +import { AuthLocalUserLockedException } from '../exceptions/auth-local-user-locked.exception'; +import { AuthLocalUserAttemptsException } from '../exceptions/auth-local-user-attempts.exception'; @Injectable() export class AuthLocalValidateUserService @@ -23,6 +27,9 @@ export class AuthLocalValidateUserService protected readonly userLookupService: AuthLocalUserLookupServiceInterface, @Inject(AUTH_LOCAL_MODULE_PASSWORD_VALIDATION_SERVICE_TOKEN) protected readonly passwordValidationService: PasswordValidationServiceInterface, + @Optional() + @Inject(AUTH_LOCAL_MODULE_SETTINGS_TOKEN) + private settings?: AuthLocalSettingsInterface, ) { super(); } @@ -41,6 +48,13 @@ export class AuthLocalValidateUserService throw new AuthLocalUsernameNotFoundException(dto.username); } + if (this.settings && this.settings?.maxAttempts > 0) { + const isLocked = await this.isLocked(user, this.settings.maxAttempts); + if (isLocked) { + throw new AuthLocalUserLockedException(); + } + } + const isUserActive = await this.isActive(user); // is the user active? @@ -56,7 +70,18 @@ export class AuthLocalValidateUserService // password is valid? if (!isValid) { - throw new AuthLocalInvalidPasswordException(user.username); + const shouldDisplayAttemptsError = + this.settings && + this.settings.maxAttempts > 0 && + this.settings.minAttempts > 0 && + user.loginAttempts && + user.loginAttempts >= this.settings.minAttempts; + + if (shouldDisplayAttemptsError) { + const attemptsLeft = + (this.settings?.maxAttempts ?? 0) - (user.loginAttempts ?? 0); + throw new AuthLocalUserAttemptsException(attemptsLeft); + } else throw new AuthLocalInvalidPasswordException(user.username); } // return the user diff --git a/packages/nestjs-authentication/src/authentication.utils.ts b/packages/nestjs-authentication/src/authentication.utils.ts new file mode 100644 index 00000000..7cac06ba --- /dev/null +++ b/packages/nestjs-authentication/src/authentication.utils.ts @@ -0,0 +1,14 @@ +import { AuthenticationRequestInterface } from './interfaces/authentication-request.interface'; +import { AuthenticatedUserInfoInterface } from './interfaces/authenticated-user-info.interface'; + +export const getAuthenticatedUserInfo = ( + req: AuthenticationRequestInterface, +): AuthenticatedUserInfoInterface => { + const ipAddress = req?.ip ?? ''; + const deviceInfo = req?.headers?.['user-agent'] ?? ''; + + return { + ipAddress, + deviceInfo, + }; +}; diff --git a/packages/nestjs-authentication/src/index.ts b/packages/nestjs-authentication/src/index.ts index 1fa5d565..fe169031 100644 --- a/packages/nestjs-authentication/src/index.ts +++ b/packages/nestjs-authentication/src/index.ts @@ -5,11 +5,14 @@ export * from './config/authentication-default.config'; export { AuthUser } from './decorators/auth-user.decorator'; export { AuthPublic } from './decorators/auth-public.decorator'; +export { getAuthenticatedUserInfo } from './authentication.utils'; + export * from './interfaces/authentication-options.interface'; export { VerifyTokenServiceInterface } from './interfaces/verify-token-service.interface'; export { IssueTokenServiceInterface } from './interfaces/issue-token-service.interface'; export { ValidateUserServiceInterface } from './interfaces/validate-user-service.interface'; +export { AuthenticationRequestInterface } from './interfaces/authentication-request.interface'; export * from './factories/passport-strategy.factory'; export * from './guards/auth.guard'; diff --git a/packages/nestjs-authentication/src/interfaces/authenticated-user-info.interface.ts b/packages/nestjs-authentication/src/interfaces/authenticated-user-info.interface.ts new file mode 100644 index 00000000..d9449efb --- /dev/null +++ b/packages/nestjs-authentication/src/interfaces/authenticated-user-info.interface.ts @@ -0,0 +1,4 @@ +export interface AuthenticatedUserInfoInterface { + ipAddress: string; + deviceInfo: string; +} diff --git a/packages/nestjs-authentication/src/interfaces/authentication-request.interface.ts b/packages/nestjs-authentication/src/interfaces/authentication-request.interface.ts new file mode 100644 index 00000000..22616b6d --- /dev/null +++ b/packages/nestjs-authentication/src/interfaces/authentication-request.interface.ts @@ -0,0 +1,7 @@ +export interface AuthenticationRequestInterface { + ip: string; + headers: { + 'user-agent'?: string; + [key: string]: string | undefined; + }; +} diff --git a/packages/nestjs-authentication/src/services/validate-user.service.ts b/packages/nestjs-authentication/src/services/validate-user.service.ts index 8c52e45a..6b8907a7 100644 --- a/packages/nestjs-authentication/src/services/validate-user.service.ts +++ b/packages/nestjs-authentication/src/services/validate-user.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ReferenceActiveInterface, ReferenceIdInterface, + ReferenceLockStatusInterface, } from '@concepta/nestjs-common'; import { ValidateUserServiceInterface } from '../interfaces/validate-user-service.interface'; @@ -24,4 +25,13 @@ export abstract class ValidateUserService< ): Promise { return user.active === true; } + + async isLocked( + user: ReferenceLockStatusInterface, + maxAttempts: number, + ): Promise { + return ( + user.loginAttempts !== undefined && user.loginAttempts >= maxAttempts + ); + } } diff --git a/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history-creatable.interface.ts b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history-creatable.interface.ts new file mode 100644 index 00000000..168f3b8c --- /dev/null +++ b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history-creatable.interface.ts @@ -0,0 +1,4 @@ +import { AuthenticatedUserRequestInterface } from '../../authentication/interfaces/authenticated-info.interface'; + +export interface AuthHistoryCreatableInterface + extends AuthenticatedUserRequestInterface {} diff --git a/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history-ownable.interface.ts b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history-ownable.interface.ts new file mode 100644 index 00000000..a30b508b --- /dev/null +++ b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history-ownable.interface.ts @@ -0,0 +1,7 @@ +import { ReferenceId } from '../../../reference/interfaces/reference.types'; +import { AuthHistoryInterface } from './auth-history.interface'; + +export interface AuthHistoryOwnableInterface { + authHistoryId: ReferenceId; + authHistory?: AuthHistoryInterface; +} diff --git a/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history.interface.ts b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history.interface.ts new file mode 100644 index 00000000..9a7ab5f9 --- /dev/null +++ b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history.interface.ts @@ -0,0 +1,16 @@ +import { AuditInterface } from '../../../audit/interfaces/audit.interface'; +import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { AuthenticatedUserRequestInterface } from '../../authentication/interfaces/authenticated-info.interface'; +import { UserOwnableInterface } from '../../user/interfaces/user-ownable.interface'; + +export interface AuthHistoryInterface + extends ReferenceIdInterface, + UserOwnableInterface, + AuditInterface, + Pick< + AuthenticatedUserRequestInterface, + 'authType' | 'ipAddress' | 'deviceInfo' + >, + Partial< + Pick + > {} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-event-payload.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-event-payload.interface.ts new file mode 100644 index 00000000..525fb41f --- /dev/null +++ b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-event-payload.interface.ts @@ -0,0 +1,7 @@ +import { ReferenceQueryOptionsInterface } from '../../../reference/interfaces/reference-query-options.interface'; +import { AuthenticatedUserRequestInterface } from './authenticated-info.interface'; + +export interface AuthenticatedEventInterface { + userInfo: AuthenticatedUserRequestInterface; + queryOptions?: ReferenceQueryOptionsInterface; +} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-info.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-info.interface.ts new file mode 100644 index 00000000..eda5921b --- /dev/null +++ b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-info.interface.ts @@ -0,0 +1,10 @@ +import { UserOwnableInterface } from '../../user/interfaces/user-ownable.interface'; + +export interface AuthenticatedUserRequestInterface + extends Pick { + ipAddress: string; + authType: string; + deviceInfo?: string | null; + success: boolean; + failureReason?: string | null; +} diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user-info.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user-info.interface.ts new file mode 100644 index 00000000..dc2ae7c3 --- /dev/null +++ b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user-info.interface.ts @@ -0,0 +1,4 @@ +import { AuthenticatedUserRequestInterface } from './authenticated-info.interface'; + +export interface AuthenticatedUserInfoInterface + extends Pick {} diff --git a/packages/nestjs-common/src/domain/index.ts b/packages/nestjs-common/src/domain/index.ts index 021ba38c..cde066ed 100644 --- a/packages/nestjs-common/src/domain/index.ts +++ b/packages/nestjs-common/src/domain/index.ts @@ -2,11 +2,14 @@ export { EmailSendOptionsInterface } from './email/interfaces/email-send-options export { EmailSendInterface } from './email/interfaces/email-send.interface'; export { AuthenticatedUserInterface } from './authentication/interfaces/authenticated-user.interface'; +export { AuthenticatedUserInfoInterface } from './authentication/interfaces/authenticated-user-info.interface'; export { AuthenticationAccessInterface } from './authentication/interfaces/authentication-access.interface'; export { AuthenticationCodeInterface } from './authentication/interfaces/authentication-code.interface'; export { AuthenticationLoginInterface } from './authentication/interfaces/authentication-login.interface'; export { AuthenticationRefreshInterface } from './authentication/interfaces/authentication-refresh.interface'; export { AuthenticationResponseInterface } from './authentication/interfaces/authentication-response.interface'; +export { AuthenticatedEventInterface } from './authentication/interfaces/authenticated-event-payload.interface'; +export { AuthenticatedUserRequestInterface } from './authentication/interfaces/authenticated-info.interface'; export { AuthorizationPayloadInterface } from './authorization/interfaces/authorization-payload.interface'; @@ -28,6 +31,11 @@ export { UserOwnableInterface } from './user/interfaces/user-ownable.interface'; export { UserUpdatableInterface } from './user/interfaces/user-updatable.interface'; export { UserInterface } from './user/interfaces/user.interface'; +export { AuthHistoryCreatableInterface } from './auth-history/interfaces/auth-history-creatable.interface'; +export { AuthenticatedUserInfoInterface as AuthHistoryLoginInterface } from './authentication/interfaces/authenticated-user-info.interface'; +export { AuthHistoryOwnableInterface } from './auth-history/interfaces/auth-history-ownable.interface'; +export { AuthHistoryInterface } from './auth-history/interfaces/auth-history.interface'; + export { FederatedCreatableInterface } from './federated/interfaces/federated-creatable.interface'; export { FederatedUpdatableInterface } from './federated/interfaces/federated-updatable.interface'; export { FederatedInterface } from './federated/interfaces/federated.interface'; diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts index 25754236..0099c0b4 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts @@ -3,5 +3,5 @@ import { PasswordPlainInterface } from '../../password/interfaces/password-plain export interface UserCreatableInterface extends Pick, - Partial>, + Partial>, Partial {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts index a96d76e9..1cb0a36a 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts @@ -3,6 +3,9 @@ import { PasswordPlainCurrentInterface } from '../../password/interfaces/passwor export interface UserUpdatableInterface extends Partial< - Pick + Pick< + UserCreatableInterface, + 'email' | 'password' | 'active' | 'loginAttempts' | 'lastLogin' + > >, Partial {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts index 25da27d3..7ffc5c0d 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user.interface.ts @@ -2,6 +2,7 @@ import { AuditInterface } from '../../../audit/interfaces/audit.interface'; import { ReferenceActiveInterface } from '../../../reference/interfaces/reference-active.interface'; import { ReferenceEmailInterface } from '../../../reference/interfaces/reference-email.interface'; import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { ReferenceLockStatusInterface } from '../../../reference/interfaces/reference-lock-status.interface'; import { ReferenceUsernameInterface } from '../../../reference/interfaces/reference-username.interface'; export interface UserInterface @@ -9,4 +10,5 @@ export interface UserInterface ReferenceEmailInterface, ReferenceUsernameInterface, ReferenceActiveInterface, + ReferenceLockStatusInterface, AuditInterface {} diff --git a/packages/nestjs-common/src/index.ts b/packages/nestjs-common/src/index.ts index 51366057..8da7aa45 100644 --- a/packages/nestjs-common/src/index.ts +++ b/packages/nestjs-common/src/index.ts @@ -38,6 +38,7 @@ export { // Reference interfaces export { ReferenceActiveInterface } from './reference/interfaces/reference-active.interface'; +export { ReferenceLockStatusInterface } from './reference/interfaces/reference-lock-status.interface'; export { ReferenceAssigneeInterface } from './reference/interfaces/reference-assignee.interface'; export { ReferenceEmailInterface } from './reference/interfaces/reference-email.interface'; export { ReferenceIdInterface } from './reference/interfaces/reference-id.interface'; diff --git a/packages/nestjs-common/src/reference/interfaces/reference-lock-status.interface.ts b/packages/nestjs-common/src/reference/interfaces/reference-lock-status.interface.ts new file mode 100644 index 00000000..51451a20 --- /dev/null +++ b/packages/nestjs-common/src/reference/interfaces/reference-lock-status.interface.ts @@ -0,0 +1,4 @@ +export interface ReferenceLockStatusInterface { + loginAttempts?: number; + lastLogin?: Date | null; +} diff --git a/packages/nestjs-crud/src/services/typeorm-crud.service.ts b/packages/nestjs-crud/src/services/typeorm-crud.service.ts index e5ff7ca6..8e75742e 100644 --- a/packages/nestjs-crud/src/services/typeorm-crud.service.ts +++ b/packages/nestjs-crud/src/services/typeorm-crud.service.ts @@ -92,6 +92,7 @@ export class TypeOrmCrudService< ): ReturnType['createOne']> { // apply options this.crudQueryHelper.modifyRequest(req, queryOptions); + // return parent result try { return super.createOne(req, dto); diff --git a/packages/nestjs-event/src/services/event-dispatch.service.ts b/packages/nestjs-event/src/services/event-dispatch.service.ts index df7bc0c9..9db192ac 100644 --- a/packages/nestjs-event/src/services/event-dispatch.service.ts +++ b/packages/nestjs-event/src/services/event-dispatch.service.ts @@ -91,7 +91,7 @@ export class EventDispatchService { * const myEvent = new MyEvent({...myPayloadType, active: false}); * // dispatch the event * const allPayloads: MyPayloadType[] = - * await this.eventDispatchService.async(myEvent); + * await this.eventDispatchService.emitAsync(myEvent); * // merge it * allPayloads.forEach((payload) => { * // did any listener set it to true? diff --git a/packages/nestjs-samples/src/03-authentication/app.module.ts b/packages/nestjs-samples/src/03-authentication/app.module.ts index 8682f721..3f18c42e 100644 --- a/packages/nestjs-samples/src/03-authentication/app.module.ts +++ b/packages/nestjs-samples/src/03-authentication/app.module.ts @@ -11,6 +11,7 @@ import { CrudModule } from '@concepta/nestjs-crud'; import { CustomUserController } from './user/user.controller'; import { UserEntity } from './user/user.entity'; import { createUserRepository } from './user/create-user-repository'; +import { EventModule } from '@concepta/nestjs-event'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { createUserRepository } from './user/create-user-repository'; JwtModule.forRoot({}), PasswordModule.forRoot({}), CrudModule.forRoot({}), + EventModule.forRoot({}), UserModule.forRoot({ entities: { user: { diff --git a/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts b/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts index b6a7e14f..39264eab 100644 --- a/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts +++ b/packages/nestjs-samples/src/03-authentication/user/create-user-repository.ts @@ -12,6 +12,8 @@ export function createUserRepository(dataSource: DataSource) { email: 'first_user@dispostable.com', username: 'first_user', active: true, + lastLogin: null, + loginAttempts: 0, // hashed for AS12378 passwordHash: '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', @@ -26,6 +28,8 @@ export function createUserRepository(dataSource: DataSource) { email: 'second_user@dispostable.com', username: 'second_user', active: true, + lastLogin: null, + loginAttempts: 0, // hashed for AS12378 passwordHash: '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', diff --git a/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts b/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts index cbdd85f5..0239da63 100644 --- a/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/create-user-repository.fixture.ts @@ -12,6 +12,8 @@ export function createUserRepositoryFixture(dataSource: DataSource) { email: 'first_user@dispostable.com', username: 'first_user', active: true, + lastLogin: null, + loginAttempts: 0, // hashed for AS12378 passwordHash: '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', @@ -26,6 +28,8 @@ export function createUserRepositoryFixture(dataSource: DataSource) { email: 'second_user@dispostable.com', username: 'second_user', active: true, + lastLogin: null, + loginAttempts: 0, // hashed for AS12378 passwordHash: '$2b$10$9y97gOLiusyKnzu7LRdMmOCVpp/xwddaa8M6KtgenvUDao5I.8mJS', diff --git a/packages/nestjs-user/src/dto/user.dto.ts b/packages/nestjs-user/src/dto/user.dto.ts index 0372186d..6b84b2ac 100644 --- a/packages/nestjs-user/src/dto/user.dto.ts +++ b/packages/nestjs-user/src/dto/user.dto.ts @@ -1,6 +1,13 @@ -import { IsBoolean, IsEmail, IsString } from 'class-validator'; +import { + IsBoolean, + IsDate, + IsEmail, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; import { Exclude, Expose } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CommonEntityDto } from '@concepta/nestjs-common'; import { UserInterface } from '@concepta/nestjs-common'; @@ -41,4 +48,29 @@ export class UserDto extends CommonEntityDto implements UserInterface { }) @IsBoolean() active!: boolean; + + /** + * Login Attempts + */ + @Expose() + @IsNumber() + @ApiPropertyOptional({ + type: 'number', + description: 'Number of login attempts', + }) + @IsOptional() + loginAttempts?: number; + + /** + * Last Login + */ + @Expose() + @ApiPropertyOptional({ + type: 'string', + format: 'date-time', + description: 'Last login timestamp', + }) + @IsDate() + @IsOptional() + lastLogin?: Date | null; } diff --git a/packages/nestjs-user/src/entities/user-postgres.entity.ts b/packages/nestjs-user/src/entities/user-postgres.entity.ts index 0a33aa0b..5b641bac 100644 --- a/packages/nestjs-user/src/entities/user-postgres.entity.ts +++ b/packages/nestjs-user/src/entities/user-postgres.entity.ts @@ -1,4 +1,4 @@ -import { Column } from 'typeorm'; +import { Column, CreateDateColumn } from 'typeorm'; import { CommonPostgresEntity } from '@concepta/typeorm-common'; import { UserEntityInterface } from '../interfaces/user-entity.interface'; import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; @@ -39,6 +39,17 @@ export abstract class UserPostgresEntity */ @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; + /** + * Login attempts + */ + @Column({ default: 0 }) + loginAttempts?: number; + + /** + * Last login date + */ + @CreateDateColumn({ type: 'timestamptz', nullable: true, default: null }) + lastLogin?: Date | null = null; userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/entities/user-sqlite.entity.ts b/packages/nestjs-user/src/entities/user-sqlite.entity.ts index 8909ea18..f72b000e 100644 --- a/packages/nestjs-user/src/entities/user-sqlite.entity.ts +++ b/packages/nestjs-user/src/entities/user-sqlite.entity.ts @@ -1,4 +1,4 @@ -import { Column } from 'typeorm'; +import { Column, CreateDateColumn } from 'typeorm'; import { CommonSqliteEntity } from '@concepta/typeorm-common'; import { UserEntityInterface } from '../interfaces/user-entity.interface'; import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; @@ -37,5 +37,21 @@ export abstract class UserSqliteEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; + /** + * Login attempts + */ + @Column({ default: 0 }) + loginAttempts?: number; + + /** + * Last login date + */ + @CreateDateColumn({ + type: 'datetime', + nullable: true, + default: null, + }) + lastLogin?: Date | null = null; + userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/interfaces/user-settings.interface.ts index f13691ba..29acb7b5 100644 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-settings.interface.ts @@ -3,21 +3,43 @@ import { EventClassInterface, } from '@concepta/nestjs-event'; import { + AuthenticatedEventInterface, InvitationAcceptedEventPayloadInterface, InvitationGetUserEventPayloadInterface, InvitationGetUserEventResponseInterface, } from '@concepta/nestjs-common'; +/** + * Interface for user module settings + */ export interface UserSettingsInterface { + /** + * Event class for handling invitation requests + */ invitationRequestEvent?: EventClassInterface< EventAsyncInterface >; + + /** + * Event class for retrieving user information during invitation flow + */ invitationGetUserEvent?: EventClassInterface< EventAsyncInterface< InvitationGetUserEventPayloadInterface, InvitationGetUserEventResponseInterface > >; + + /** + * Event class for handling user authentication events + */ + authenticatedEvent?: EventClassInterface< + EventAsyncInterface + >; + + /** + * Settings for password history functionality + */ passwordHistory?: { /** * password history feature toggle diff --git a/packages/nestjs-user/src/listeners/authenticated-listener.ts b/packages/nestjs-user/src/listeners/authenticated-listener.ts new file mode 100644 index 00000000..541874bf --- /dev/null +++ b/packages/nestjs-user/src/listeners/authenticated-listener.ts @@ -0,0 +1,84 @@ +import { AuthenticatedEventInterface } from '@concepta/nestjs-common'; +import { + EventAsyncInterface, + EventListenerOn, + EventListenService, +} from '@concepta/nestjs-event'; +import { + Inject, + Injectable, + Logger, + OnModuleInit, + Optional, +} from '@nestjs/common'; +import { UserSettingsInterface } from '../interfaces/user-settings.interface'; +import { UserLookupService } from '../services/user-lookup.service'; +import { UserMutateService } from '../services/user-mutate.service'; +import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; + +@Injectable() +export class AuthenticatedListener + extends EventListenerOn< + EventAsyncInterface + > + implements OnModuleInit +{ + constructor( + @Inject(USER_MODULE_SETTINGS_TOKEN) + private settings: UserSettingsInterface, + private userLookupService: UserLookupService, + private userMutateService: UserMutateService, + @Optional() + @Inject(EventListenService) + private eventListenService?: EventListenService, + ) { + super(); + } + + onModuleInit() { + if (this.eventListenService && this.settings.authenticatedEvent) { + this.eventListenService?.on(this.settings.authenticatedEvent, this); + } + } + + async listen( + event: EventAsyncInterface, + ) { + const { payload } = event; + + if (!payload.userInfo) { + Logger.error('Auth History event data payload is missing.'); + return false; + } + try { + if (payload.userInfo.success) { + // update last login and login attempts to zero + await this.userMutateService.update( + { + id: payload.userInfo.userId, + lastLogin: new Date(), + loginAttempts: 0, + }, + payload.queryOptions, + ); + } else { + // get current user + const user = await this.userLookupService.byId(payload.userInfo.userId); + if (user) { + // increment login attempts + await this.userMutateService.update( + { + id: user.id, + loginAttempts: (user.loginAttempts || 0) + 1, + }, + payload.queryOptions, + ); + } + } + } catch (err) { + Logger.error(err); + return false; + } + return true; + } +} diff --git a/tsconfig.json b/tsconfig.json index 15d05093..da745c7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -112,6 +112,9 @@ }, { "path": "packages/nestjs-auth-verify" + }, + { + "path": "packages/nestjs-auth-history" } ] } diff --git a/yarn.lock b/yarn.lock index 1abdfbe1..c831f143 100644 --- a/yarn.lock +++ b/yarn.lock @@ -761,7 +761,7 @@ __metadata: languageName: node linkType: hard -"@concepta/nestjs-access-control@npm:^6.0.0-alpha.2, @concepta/nestjs-access-control@workspace:packages/nestjs-access-control": +"@concepta/nestjs-access-control@npm:^6.0.0-alpha.1, @concepta/nestjs-access-control@npm:^6.0.0-alpha.2, @concepta/nestjs-access-control@workspace:packages/nestjs-access-control": version: 0.0.0-use.local resolution: "@concepta/nestjs-access-control@workspace:packages/nestjs-access-control" dependencies: @@ -787,6 +787,7 @@ __metadata: "@concepta/nestjs-authentication": "npm:^6.0.0-alpha.2" "@concepta/nestjs-common": "npm:^6.0.0-alpha.2" "@concepta/nestjs-crud": "npm:^6.0.0-alpha.2" + "@concepta/nestjs-event": "npm:^6.0.0-alpha.2" "@concepta/nestjs-exception": "npm:^6.0.0-alpha.2" "@concepta/nestjs-federated": "npm:^6.0.0-alpha.2" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.2" @@ -818,6 +819,7 @@ __metadata: "@concepta/nestjs-authentication": "npm:^6.0.0-alpha.2" "@concepta/nestjs-common": "npm:^6.0.0-alpha.2" "@concepta/nestjs-crud": "npm:^6.0.0-alpha.2" + "@concepta/nestjs-event": "npm:^6.0.0-alpha.2" "@concepta/nestjs-exception": "npm:^6.0.0-alpha.2" "@concepta/nestjs-federated": "npm:^6.0.0-alpha.2" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.2" @@ -848,6 +850,7 @@ __metadata: "@concepta/nestjs-authentication": "npm:^6.0.0-alpha.2" "@concepta/nestjs-common": "npm:^6.0.0-alpha.2" "@concepta/nestjs-crud": "npm:^6.0.0-alpha.2" + "@concepta/nestjs-event": "npm:^6.0.0-alpha.2" "@concepta/nestjs-exception": "npm:^6.0.0-alpha.2" "@concepta/nestjs-federated": "npm:^6.0.0-alpha.2" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.2" @@ -870,7 +873,41 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-auth-jwt@npm:^6.0.0-alpha.2, @concepta/nestjs-auth-jwt@workspace:packages/nestjs-auth-jwt": +"@concepta/nestjs-auth-history@workspace:packages/nestjs-auth-history": + version: 0.0.0-use.local + resolution: "@concepta/nestjs-auth-history@workspace:packages/nestjs-auth-history" + dependencies: + "@concepta/nestjs-access-control": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-auth-jwt": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-authentication": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-common": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-crud": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-event": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-exception": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-password": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-typeorm-ext": "npm:^6.0.0-alpha.1" + "@concepta/nestjs-user": "npm:^6.0.0-alpha.1" + "@concepta/typeorm-common": "npm:^6.0.0-alpha.1" + "@concepta/typeorm-seeding": "npm:^4.0.0" + "@faker-js/faker": "npm:^8.4.1" + "@nestjs/common": "npm:^10.4.1" + "@nestjs/config": "npm:^3.2.3" + "@nestjs/core": "npm:^10.4.1" + "@nestjs/swagger": "npm:^7.4.0" + "@nestjs/testing": "npm:^10.4.1" + "@nestjs/typeorm": "npm:^10.0.2" + accesscontrol: "npm:^2.2.1" + supertest: "npm:^6.3.4" + peerDependencies: + "@concepta/nestjs-auth-local": ^6.0.0-alpha.1 + class-transformer: "*" + class-validator: "*" + typeorm: ^0.3.0 + languageName: unknown + linkType: soft + +"@concepta/nestjs-auth-jwt@npm:^6.0.0-alpha.1, @concepta/nestjs-auth-jwt@npm:^6.0.0-alpha.2, @concepta/nestjs-auth-jwt@workspace:packages/nestjs-auth-jwt": version: 0.0.0-use.local resolution: "@concepta/nestjs-auth-jwt@workspace:packages/nestjs-auth-jwt" dependencies: @@ -898,6 +935,7 @@ __metadata: "@concepta/nestjs-auth-jwt": "npm:^6.0.0-alpha.2" "@concepta/nestjs-authentication": "npm:^6.0.0-alpha.2" "@concepta/nestjs-common": "npm:^6.0.0-alpha.2" + "@concepta/nestjs-event": "npm:^6.0.0-alpha.2" "@concepta/nestjs-exception": "npm:^6.0.0-alpha.2" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.2" "@concepta/nestjs-password": "npm:^6.0.0-alpha.2" @@ -1007,7 +1045,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-authentication@npm:^6.0.0-alpha.2, @concepta/nestjs-authentication@workspace:packages/nestjs-authentication": +"@concepta/nestjs-authentication@npm:^6.0.0-alpha.1, @concepta/nestjs-authentication@npm:^6.0.0-alpha.2, @concepta/nestjs-authentication@workspace:packages/nestjs-authentication": version: 0.0.0-use.local resolution: "@concepta/nestjs-authentication@workspace:packages/nestjs-authentication" dependencies: @@ -1060,7 +1098,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-common@npm:^6.0.0-alpha.2, @concepta/nestjs-common@workspace:packages/nestjs-common": +"@concepta/nestjs-common@npm:^6.0.0-alpha.1, @concepta/nestjs-common@npm:^6.0.0-alpha.2, @concepta/nestjs-common@workspace:packages/nestjs-common": version: 0.0.0-use.local resolution: "@concepta/nestjs-common@workspace:packages/nestjs-common" dependencies: @@ -1073,7 +1111,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-crud@npm:^6.0.0-alpha.2, @concepta/nestjs-crud@workspace:packages/nestjs-crud": +"@concepta/nestjs-crud@npm:^6.0.0-alpha.1, @concepta/nestjs-crud@npm:^6.0.0-alpha.2, @concepta/nestjs-crud@workspace:packages/nestjs-crud": version: 0.0.0-use.local resolution: "@concepta/nestjs-crud@workspace:packages/nestjs-crud" dependencies: @@ -1117,7 +1155,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-event@npm:^6.0.0-alpha.2, @concepta/nestjs-event@workspace:packages/nestjs-event": +"@concepta/nestjs-event@npm:^6.0.0-alpha.1, @concepta/nestjs-event@npm:^6.0.0-alpha.2, @concepta/nestjs-event@workspace:packages/nestjs-event": version: 0.0.0-use.local resolution: "@concepta/nestjs-event@workspace:packages/nestjs-event" dependencies: @@ -1131,7 +1169,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-exception@npm:^6.0.0-alpha.2, @concepta/nestjs-exception@workspace:packages/nestjs-exception": +"@concepta/nestjs-exception@npm:^6.0.0-alpha.1, @concepta/nestjs-exception@npm:^6.0.0-alpha.2, @concepta/nestjs-exception@workspace:packages/nestjs-exception": version: 0.0.0-use.local resolution: "@concepta/nestjs-exception@workspace:packages/nestjs-exception" dependencies: @@ -1223,7 +1261,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-jwt@npm:^6.0.0-alpha.2, @concepta/nestjs-jwt@workspace:packages/nestjs-jwt": +"@concepta/nestjs-jwt@npm:^6.0.0-alpha.1, @concepta/nestjs-jwt@npm:^6.0.0-alpha.2, @concepta/nestjs-jwt@workspace:packages/nestjs-jwt": version: 0.0.0-use.local resolution: "@concepta/nestjs-jwt@workspace:packages/nestjs-jwt" dependencies: @@ -1353,7 +1391,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-password@npm:^6.0.0-alpha.2, @concepta/nestjs-password@workspace:packages/nestjs-password": +"@concepta/nestjs-password@npm:^6.0.0-alpha.1, @concepta/nestjs-password@npm:^6.0.0-alpha.2, @concepta/nestjs-password@workspace:packages/nestjs-password": version: 0.0.0-use.local resolution: "@concepta/nestjs-password@workspace:packages/nestjs-password" dependencies: @@ -1465,7 +1503,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-typeorm-ext@npm:^6.0.0-alpha.2, @concepta/nestjs-typeorm-ext@workspace:packages/nestjs-typeorm-ext": +"@concepta/nestjs-typeorm-ext@npm:^6.0.0-alpha.1, @concepta/nestjs-typeorm-ext@npm:^6.0.0-alpha.2, @concepta/nestjs-typeorm-ext@workspace:packages/nestjs-typeorm-ext": version: 0.0.0-use.local resolution: "@concepta/nestjs-typeorm-ext@workspace:packages/nestjs-typeorm-ext" dependencies: @@ -1481,7 +1519,7 @@ __metadata: languageName: unknown linkType: soft -"@concepta/nestjs-user@npm:^6.0.0-alpha.2, @concepta/nestjs-user@workspace:packages/nestjs-user": +"@concepta/nestjs-user@npm:^6.0.0-alpha.1, @concepta/nestjs-user@npm:^6.0.0-alpha.2, @concepta/nestjs-user@workspace:packages/nestjs-user": version: 0.0.0-use.local resolution: "@concepta/nestjs-user@workspace:packages/nestjs-user" dependencies: @@ -1522,7 +1560,7 @@ __metadata: languageName: node linkType: hard -"@concepta/typeorm-common@npm:^6.0.0-alpha.2, @concepta/typeorm-common@workspace:packages/typeorm-common": +"@concepta/typeorm-common@npm:^6.0.0-alpha.1, @concepta/typeorm-common@npm:^6.0.0-alpha.2, @concepta/typeorm-common@workspace:packages/typeorm-common": version: 0.0.0-use.local resolution: "@concepta/typeorm-common@workspace:packages/typeorm-common" dependencies: