diff --git a/packages/nestjs-auth-apple/package.json b/packages/nestjs-auth-apple/package.json index b206335a..9723b9e8 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.1", "@concepta/nestjs-common": "^6.0.0-alpha.1", + "@concepta/nestjs-event": "^6.0.0-alpha.1", "@concepta/nestjs-exception": "^6.0.0-alpha.1", "@concepta/nestjs-federated": "^6.0.0-alpha.1", "@concepta/nestjs-jwt": "^6.0.0-alpha.1", 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.controller.ts b/packages/nestjs-auth-apple/src/auth-apple.controller.ts index 4afafd38..4d6e8cdf 100644 --- a/packages/nestjs-auth-apple/src/auth-apple.controller.ts +++ b/packages/nestjs-auth-apple/src/auth-apple.controller.ts @@ -1,8 +1,11 @@ import { Controller, Inject, Get, UseGuards, Post } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { + AuthenticatedEventInterface, AuthenticatedUserInterface, AuthenticationResponseInterface, + AuthenticatedUserInfoInterface, + AuthInfo, } from '@concepta/nestjs-common'; import { AuthUser, @@ -10,8 +13,12 @@ import { AuthenticationJwtResponseDto, AuthPublic, } from '@concepta/nestjs-authentication'; -import { AUTH_APPLE_ISSUE_TOKEN_SERVICE_TOKEN } from './auth-apple.constants'; +import { + AUTH_APPLE_AUTHENTICATION_TYPE, + AUTH_APPLE_ISSUE_TOKEN_SERVICE_TOKEN, +} from './auth-apple.constants'; import { AuthAppleGuard } from './auth-apple.guard'; +import { AuthAppleAuthenticatedEventAsync } from './events/auth-apple-authenticated.event'; /** * Apple controller @@ -57,7 +64,31 @@ export class AuthAppleController { @Post('callback') async post( @AuthUser() user: AuthenticatedUserInterface, + @AuthInfo() authInfo: AuthenticatedUserInfoInterface, ): Promise { - return this.issueTokenService.responsePayload(user.id); + const response = this.issueTokenService.responsePayload(user.id); + + await this.dispatchAuthenticatedEvent({ + userInfo: { + userId: user.id, + ipAddress: authInfo?.ipAddress || '', + deviceInfo: authInfo?.deviceInfo || '', + authType: AUTH_APPLE_AUTHENTICATION_TYPE, + }, + }); + + return response; + } + + protected async dispatchAuthenticatedEvent( + payload?: AuthenticatedEventInterface, + ): Promise { + const authenticatedEventAsync = new AuthAppleAuthenticatedEventAsync( + payload, + ); + + const eventResult = await authenticatedEventAsync.emit(); + + return eventResult.every((it) => it === true); } } 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/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 a91fc1b6..f68ca3e2 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.1", "@concepta/nestjs-common": "^6.0.0-alpha.1", + "@concepta/nestjs-event": "^6.0.0-alpha.1", "@concepta/nestjs-exception": "^6.0.0-alpha.1", "@concepta/nestjs-federated": "^6.0.0-alpha.1", "@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..b2051451 100644 --- a/packages/nestjs-auth-github/src/auth-github.controller.ts +++ b/packages/nestjs-auth-github/src/auth-github.controller.ts @@ -1,8 +1,11 @@ import { Controller, Inject, Get, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { + AuthenticatedEventInterface, + AuthenticatedUserInfoInterface, AuthenticatedUserInterface, AuthenticationResponseInterface, + AuthInfo, } from '@concepta/nestjs-common'; import { AuthUser, @@ -10,8 +13,12 @@ import { AuthenticationJwtResponseDto, AuthPublic, } from '@concepta/nestjs-authentication'; -import { AUTH_GITHUB_ISSUE_TOKEN_SERVICE_TOKEN } from './auth-github.constants'; +import { + AUTH_GITHUB_AUTHENTICATION_TYPE, + AUTH_GITHUB_ISSUE_TOKEN_SERVICE_TOKEN, +} from './auth-github.constants'; import { AuthGithubGuard } from './auth-github.guard'; +import { AuthGithubAuthenticatedEventAsync } from './events/auth-github-authenticated.event'; // TODO: improve documentation /** @@ -59,7 +66,31 @@ export class AuthGithubController { @Get('callback') async get( @AuthUser() user: AuthenticatedUserInterface, + @AuthInfo() authInfo: AuthenticatedUserInfoInterface, ): Promise { - return this.issueTokenService.responsePayload(user.id); + const response = this.issueTokenService.responsePayload(user.id); + + await this.dispatchAuthenticatedEvent({ + userInfo: { + userId: user.id, + ipAddress: authInfo?.ipAddress || '', + deviceInfo: authInfo?.deviceInfo || '', + authType: AUTH_GITHUB_AUTHENTICATION_TYPE, + }, + }); + + return response; + } + + protected async dispatchAuthenticatedEvent( + payload?: AuthenticatedEventInterface, + ): Promise { + const authenticatedEventAsync = new AuthGithubAuthenticatedEventAsync( + payload, + ); + + const eventResult = await authenticatedEventAsync.emit(); + + return eventResult.every((it) => it === true); } } 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 721438af..0734bb95 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.1", "@concepta/nestjs-common": "^6.0.0-alpha.1", + "@concepta/nestjs-event": "^6.0.0-alpha.1", "@concepta/nestjs-exception": "^6.0.0-alpha.1", "@concepta/nestjs-federated": "^6.0.0-alpha.1", "@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.controller.ts b/packages/nestjs-auth-google/src/auth-google.controller.ts index 3b1505d0..238bbb0b 100644 --- a/packages/nestjs-auth-google/src/auth-google.controller.ts +++ b/packages/nestjs-auth-google/src/auth-google.controller.ts @@ -1,8 +1,11 @@ import { Controller, Inject, Get, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { + AuthenticatedEventInterface, + AuthenticatedUserInfoInterface, AuthenticatedUserInterface, AuthenticationResponseInterface, + AuthInfo, } from '@concepta/nestjs-common'; import { AuthUser, @@ -10,8 +13,12 @@ import { AuthenticationJwtResponseDto, AuthPublic, } from '@concepta/nestjs-authentication'; -import { AUTH_GOOGLE_ISSUE_TOKEN_SERVICE_TOKEN } from './auth-google.constants'; +import { + AUTH_GOOGLE_AUTHENTICATION_TYPE, + AUTH_GOOGLE_ISSUE_TOKEN_SERVICE_TOKEN, +} from './auth-google.constants'; import { AuthGoogleGuard } from './auth-google.guard'; +import { AuthGoogleAuthenticatedEventAsync } from './events/auth-google-authenticated.event'; /** * Google controller @@ -57,7 +64,31 @@ export class AuthGoogleController { @Get('callback') async get( @AuthUser() user: AuthenticatedUserInterface, + @AuthInfo() authInfo: AuthenticatedUserInfoInterface, ): Promise { - return this.issueTokenService.responsePayload(user.id); + const response = this.issueTokenService.responsePayload(user.id); + + await this.dispatchAuthenticatedEvent({ + userInfo: { + userId: user.id, + ipAddress: authInfo?.ipAddress || '', + deviceInfo: authInfo?.deviceInfo || '', + authType: AUTH_GOOGLE_AUTHENTICATION_TYPE, + }, + }); + + return response; + } + + protected async dispatchAuthenticatedEvent( + payload?: AuthenticatedEventInterface, + ): Promise { + const authenticatedEventAsync = new AuthGoogleAuthenticatedEventAsync( + payload, + ); + + const eventResult = await authenticatedEventAsync.emit(); + + return eventResult.every((it) => it === true); } } 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..ae061f82 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/constants.ts @@ -0,0 +1,21 @@ +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, +}; 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..ce31da18 --- /dev/null +++ b/packages/nestjs-auth-history/src/__fixtures__/create-auth-history-repository.fixture.ts @@ -0,0 +1,73 @@ +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', + 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', + 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', + 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..617098a0 --- /dev/null +++ b/packages/nestjs-auth-history/src/auth-history.controller.e2e-spec.ts @@ -0,0 +1,185 @@ +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, + }) + .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..9fdfdf7e --- /dev/null +++ b/packages/nestjs-auth-history/src/dto/auth-history-create.dto.ts @@ -0,0 +1,20 @@ +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', + ] 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..8ea85b1c --- /dev/null +++ b/packages/nestjs-auth-history/src/dto/auth-history.dto.ts @@ -0,0 +1,57 @@ +import { AuthHistoryInterface, CommonEntityDto } from '@concepta/nestjs-common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; +import { 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; +} 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..f57fe941 --- /dev/null +++ b/packages/nestjs-auth-history/src/entities/auth-history-postgres.entity.ts @@ -0,0 +1,41 @@ +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' }) + deviceInfo!: string; + + /** + * 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..e6920dd4 --- /dev/null +++ b/packages/nestjs-auth-history/src/entities/auth-history-sqlite.entity.ts @@ -0,0 +1,38 @@ +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' }) + deviceInfo!: string; + + /** + * 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 f2853c0a..f00bb91a 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.1", "@concepta/nestjs-common": "^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/typeorm-common": "^6.0.0-alpha.1", 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/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.spec.ts b/packages/nestjs-auth-local/src/auth-local.controller.spec.ts index 35f48082..af08e532 100644 --- a/packages/nestjs-auth-local/src/auth-local.controller.spec.ts +++ b/packages/nestjs-auth-local/src/auth-local.controller.spec.ts @@ -2,15 +2,21 @@ import { IssueTokenServiceInterface } from '@concepta/nestjs-authentication'; import { AuthenticatedUserInterface, AuthenticationResponseInterface, + AuthHistoryLoginInterface, } from '@concepta/nestjs-common'; import { randomUUID } from 'crypto'; import { mock } from 'jest-mock-extended'; import { AuthLocalController } from './auth-local.controller'; +import { EventDispatchService } from '@concepta/nestjs-event'; +import { AUTH_LOCAL_AUTHENTICATION_TYPE } from './auth-local.constants'; +import { AuthLocalAuthenticatedEventAsync } from './events/auth-local-authenticated.event'; describe(AuthLocalController, () => { const accessToken = 'accessToken'; const refreshToken = 'refreshToken'; let controller: AuthLocalController; + let eventDispatchService: EventDispatchService; + let spyOnDispatchService: jest.SpyInstance; const response: AuthenticationResponseInterface = { accessToken, refreshToken, @@ -24,7 +30,15 @@ describe(AuthLocalController, () => { }); }, }); - controller = new AuthLocalController(issueTokenService); + eventDispatchService = mock(); + spyOnDispatchService = jest + .spyOn(eventDispatchService, 'async') + .mockResolvedValue([]); + // eventDispatchService.async. + controller = new AuthLocalController( + issueTokenService, + eventDispatchService, + ); }); describe(AuthLocalController.prototype.login, () => { @@ -32,8 +46,20 @@ describe(AuthLocalController, () => { const user: AuthenticatedUserInterface = { id: randomUUID(), }; - const result = await controller.login(user); + const authLogin: AuthHistoryLoginInterface = { + ipAddress: '127.0.0.1', + deviceInfo: 'IOS', + }; + const result = await controller.login(user, authLogin); expect(result.accessToken).toBe(response.accessToken); + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync({ + userInfo: { + userId: user.id, + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + ...authLogin, + }, + }); + expect(spyOnDispatchService).toBeCalledWith(authenticatedEventAsync); }); }); }); diff --git a/packages/nestjs-auth-local/src/auth-local.controller.ts b/packages/nestjs-auth-local/src/auth-local.controller.ts index bf227ce5..624f9001 100644 --- a/packages/nestjs-auth-local/src/auth-local.controller.ts +++ b/packages/nestjs-auth-local/src/auth-local.controller.ts @@ -1,4 +1,18 @@ -import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; +import { + AuthenticationJwtResponseDto, + AuthPublic, + AuthUser, + IssueTokenServiceInterface, +} from '@concepta/nestjs-authentication'; +import { + AuthenticatedUserInterface, + AuthenticationResponseInterface, + AuthenticatedEventInterface, + AuthInfo, + AuthenticatedUserInfoInterface, +} from '@concepta/nestjs-common'; +import { EventDispatchService } from '@concepta/nestjs-event'; +import { Controller, Inject, Optional, Post, UseGuards } from '@nestjs/common'; import { ApiBody, ApiOkResponse, @@ -6,18 +20,12 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { - AuthenticatedUserInterface, - AuthenticationResponseInterface, -} from '@concepta/nestjs-common'; -import { - AuthUser, - IssueTokenServiceInterface, - AuthenticationJwtResponseDto, - AuthPublic, -} from '@concepta/nestjs-authentication'; -import { AUTH_LOCAL_MODULE_ISSUE_TOKEN_SERVICE_TOKEN } from './auth-local.constants'; -import { AuthLocalLoginDto } from './dto/auth-local-login.dto'; + AUTH_LOCAL_AUTHENTICATION_TYPE, + AUTH_LOCAL_MODULE_ISSUE_TOKEN_SERVICE_TOKEN, +} from './auth-local.constants'; import { AuthLocalGuard } from './auth-local.guard'; +import { AuthLocalLoginDto } from './dto/auth-local-login.dto'; +import { AuthLocalAuthenticatedEventAsync } from './events/auth-local-authenticated.event'; /** * Auth Local controller @@ -30,6 +38,9 @@ export class AuthLocalController { constructor( @Inject(AUTH_LOCAL_MODULE_ISSUE_TOKEN_SERVICE_TOKEN) private issueTokenService: IssueTokenServiceInterface, + @Optional() + @Inject(EventDispatchService) + private readonly eventDispatchService?: EventDispatchService, ) {} /** @@ -47,7 +58,37 @@ export class AuthLocalController { @Post() async login( @AuthUser() user: AuthenticatedUserInterface, + @AuthInfo() authInfo: AuthenticatedUserInfoInterface, ): Promise { - return this.issueTokenService.responsePayload(user.id); + const response = await this.issueTokenService.responsePayload(user.id); + + if (this.eventDispatchService) + await this.dispatchAuthenticatedEvent({ + userInfo: { + userId: user.id, + ipAddress: authInfo?.ipAddress || '', + deviceInfo: authInfo?.deviceInfo || '', + authType: AUTH_LOCAL_AUTHENTICATION_TYPE, + }, + }); + + return response; + } + + protected async dispatchAuthenticatedEvent( + payload?: AuthenticatedEventInterface, + ): Promise { + if (this.eventDispatchService) { + const authenticatedEventAsync = new AuthLocalAuthenticatedEventAsync( + payload, + ); + + const eventResult = await this.eventDispatchService.async( + authenticatedEventAsync, + ); + + return eventResult.every((it) => it === true); + } + return true; } } 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..ad7f1df6 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(); @@ -217,6 +218,7 @@ function testModuleFactory( UserModuleFixture, AuthenticationModule.forRoot({}), JwtModule.forRoot({}), + EventModule.forRoot({}), ...extraImports, ], }; 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/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-common/src/decorators/auth-info.decorator.ts b/packages/nestjs-common/src/decorators/auth-info.decorator.ts new file mode 100644 index 00000000..3e61a891 --- /dev/null +++ b/packages/nestjs-common/src/decorators/auth-info.decorator.ts @@ -0,0 +1,25 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthenticatedUserInfoInterface } from '../domain/authentication/interfaces/authenticated-user-info.interface'; + +export const AuthInfo = createParamDecorator( + ( + data: keyof AuthenticatedUserInfoInterface | undefined, + ctx: ExecutionContext, + ): AuthenticatedUserInfoInterface | string | undefined => { + const request = ctx.switchToHttp().getRequest(); + const ipAddress = + request.ip || + request.connection?.ip || + request.headers['x-forwarded-for'] || + request.connection?.remoteAddress || + request.connection?.remote_addr || + ''; + const deviceInfo = request.headers['user-agent'] || ''; + const userLoginInfo: AuthenticatedUserInfoInterface = { + ipAddress, + deviceInfo, + }; + + return data ? userLoginInfo?.[data] : userLoginInfo; + }, +); 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..f430aa77 --- /dev/null +++ b/packages/nestjs-common/src/domain/auth-history/interfaces/auth-history.interface.ts @@ -0,0 +1,14 @@ +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> {} 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..36001943 --- /dev/null +++ b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-info.interface.ts @@ -0,0 +1,8 @@ +import { UserOwnableInterface } from '../../user/interfaces/user-ownable.interface'; + +export interface AuthenticatedUserRequestInterface + extends Pick { + ipAddress: string; + authType: string; + deviceInfo?: string; +} 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 883eeafd..da5517c0 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'; @@ -24,6 +27,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/index.ts b/packages/nestjs-common/src/index.ts index fbe8b97a..97ccaed9 100644 --- a/packages/nestjs-common/src/index.ts +++ b/packages/nestjs-common/src/index.ts @@ -5,6 +5,7 @@ export { ReferenceIdDto } from './reference/dto/reference-id.dto'; // Decorators export { AuthUser } from './decorators/auth-user.decorator'; +export { AuthInfo } from './decorators/auth-info.decorator'; // Module utilities export { createSettingsProvider } from './modules/utils/create-settings-provider'; 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-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/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 241cd3c3..45536df2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -787,6 +787,7 @@ __metadata: "@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-federated": "npm:^6.0.0-alpha.1" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.1" @@ -818,6 +819,7 @@ __metadata: "@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-federated": "npm:^6.0.0-alpha.1" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.1" @@ -848,6 +850,7 @@ __metadata: "@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-federated": "npm:^6.0.0-alpha.1" "@concepta/nestjs-jwt": "npm:^6.0.0-alpha.1" @@ -870,6 +873,40 @@ __metadata: languageName: unknown linkType: soft +"@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@workspace:packages/nestjs-auth-jwt": version: 0.0.0-use.local resolution: "@concepta/nestjs-auth-jwt@workspace:packages/nestjs-auth-jwt" @@ -898,6 +935,7 @@ __metadata: "@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-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"