diff --git a/packages/server/package.json b/packages/server/package.json index fafada484..dbd2486c6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -87,6 +87,7 @@ "object-hash": "^2.0.3", "objection": "^3.1.5", "passport": "^0.7.0", + "passport-headerapikey": "^1.2.2", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "plaid": "^10.3.0", diff --git a/packages/server/src/modules/Accounts/Accounts.controller.ts b/packages/server/src/modules/Accounts/Accounts.controller.ts index bbdec663a..f9bf545c3 100644 --- a/packages/server/src/modules/Accounts/Accounts.controller.ts +++ b/packages/server/src/modules/Accounts/Accounts.controller.ts @@ -176,7 +176,6 @@ export class AccountsController { items: { $ref: getSchemaPath(AccountResponseDto) }, }, }) - @ApiResponse({}) async getAccounts(@Query() filter: Partial) { return this.accountsApplication.getAccounts(filter); } diff --git a/packages/server/src/modules/Auth/Auth.module.ts b/packages/server/src/modules/Auth/Auth.module.ts index 644446ec4..0d14b5df1 100644 --- a/packages/server/src/modules/Auth/Auth.module.ts +++ b/packages/server/src/modules/Auth/Auth.module.ts @@ -32,11 +32,22 @@ import { AuthedController } from './Authed.controller'; import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service'; import { TenancyModule } from '../Tenancy/Tenancy.module'; import { EnsureUserVerifiedGuard } from './guards/EnsureUserVerified.guard'; +import { ApiKeyAuthGuard } from './api-key/AuthApiKey.guard'; +import { MixedAuthGuard } from './api-key/MixedAuth.guard'; +import { ApiKeyStrategy } from './api-key/AuthApiKey.strategy'; +import { ApiKeyModel } from './models/ApiKey.model'; +import { AuthApiKeysController } from './AuthApiKeys.controllers'; +import { AuthApiKeyAuthorizeService } from './commands/AuthApiKeyAuthorization.service'; +import { GenerateApiKey } from './commands/GenerateApiKey.service'; +import { GetApiKeysService } from './queries/GetApiKeys.service'; -const models = [InjectSystemModel(PasswordReset)]; +const models = [ + InjectSystemModel(PasswordReset), + InjectSystemModel(ApiKeyModel), +]; @Module({ - controllers: [AuthController, AuthedController], + controllers: [AuthController, AuthedController, AuthApiKeysController], imports: [ MailModule, PassportModule.register({ defaultStrategy: 'jwt' }), @@ -70,9 +81,15 @@ const models = [InjectSystemModel(PasswordReset)]; SendSignupVerificationMailProcessor, GetAuthMetaService, GetAuthenticatedAccount, + ApiKeyAuthGuard, + ApiKeyStrategy, + AuthApiKeyAuthorizeService, + GenerateApiKey, + GetApiKeysService, + JwtAuthGuard, { provide: APP_GUARD, - useClass: JwtAuthGuard, + useClass: MixedAuthGuard, }, { provide: APP_GUARD, diff --git a/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts b/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts new file mode 100644 index 000000000..742635e93 --- /dev/null +++ b/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts @@ -0,0 +1,26 @@ +import { Controller, Post, Param, Get } from '@nestjs/common'; +import { GenerateApiKey } from './commands/GenerateApiKey.service'; +import { GetApiKeysService } from './queries/GetApiKeys.service'; + +@Controller('api-keys') +export class AuthApiKeysController { + constructor( + private readonly getApiKeysService: GetApiKeysService, + private readonly generateApiKeyService: GenerateApiKey, + ) {} + + @Post('generate') + async generate() { + return this.generateApiKeyService.generate(); + } + + @Post(':id/revoke') + async revoke(@Param('id') id: number) { + return this.generateApiKeyService.revoke(id); + } + + @Get() + async getApiKeys() { + return this.getApiKeysService.getApiKeys(); + } +} diff --git a/packages/server/src/modules/Auth/api-key/AuthApiKey.guard.ts b/packages/server/src/modules/Auth/api-key/AuthApiKey.guard.ts new file mode 100644 index 000000000..929549b0c --- /dev/null +++ b/packages/server/src/modules/Auth/api-key/AuthApiKey.guard.ts @@ -0,0 +1,13 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class ApiKeyAuthGuard extends AuthGuard('apiKey') { + constructor() { + super(); + } + + canActivate(context: ExecutionContext) { + return super.canActivate(context); + } +} diff --git a/packages/server/src/modules/Auth/api-key/AuthApiKey.strategy.ts b/packages/server/src/modules/Auth/api-key/AuthApiKey.strategy.ts new file mode 100644 index 000000000..4bc968e5b --- /dev/null +++ b/packages/server/src/modules/Auth/api-key/AuthApiKey.strategy.ts @@ -0,0 +1,26 @@ +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { AuthApiKeyAuthorizeService } from '../commands/AuthApiKeyAuthorization.service'; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy( + HeaderAPIKeyStrategy, + 'apiKey', +) { + constructor( + private readonly authApiKeyAuthorizeService: AuthApiKeyAuthorizeService, + ) { + super( + { + header: 'x-api-key', + prefix: '', + }, + false, + ); + } + + validate(apiKey: string): unknown { + return this.authApiKeyAuthorizeService.authorize(apiKey); + } +} diff --git a/packages/server/src/modules/Auth/api-key/MixedAuth.guard.ts b/packages/server/src/modules/Auth/api-key/MixedAuth.guard.ts new file mode 100644 index 000000000..d7d3b0739 --- /dev/null +++ b/packages/server/src/modules/Auth/api-key/MixedAuth.guard.ts @@ -0,0 +1,23 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { ApiKeyAuthGuard } from './AuthApiKey.guard'; + +// mixed-auth.guard.ts +@Injectable() +export class MixedAuthGuard implements CanActivate { + constructor( + private jwtGuard: JwtAuthGuard, + private apiKeyGuard: ApiKeyAuthGuard, + ) {} + + canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const apiKey = request.headers['x-api-key']; + + if (apiKey) { + return this.apiKeyGuard.canActivate(context); + } else { + return this.jwtGuard.canActivate(context); + } + } +} diff --git a/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts b/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts new file mode 100644 index 000000000..2cd0ef361 --- /dev/null +++ b/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ApiKeyModel } from '../models/ApiKey.model'; + +@Injectable() +export class AuthApiKeyAuthorizeService { + constructor( + @Inject(ApiKeyModel.name) + private readonly apikeyModel: typeof ApiKeyModel, + ) {} + + /** + * Authenticate using the given api key. + */ + async authorize(apiKey: string): Promise { + const apiKeyRecord = await this.apikeyModel + .query() + .findOne({ key: apiKey }); + + if (!apiKeyRecord) { + return false; + } + if (apiKeyRecord.revoked) { + return false; + } + if ( + apiKeyRecord.expiresAt && + new Date(apiKeyRecord.expiresAt) < new Date() + ) { + return false; + } + return true; + } +} diff --git a/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts b/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts new file mode 100644 index 000000000..084f0312a --- /dev/null +++ b/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { ApiKeyModel } from '../models/ApiKey.model'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class GenerateApiKey { + constructor( + private readonly tenancyContext: TenancyContext, + @Inject(ApiKeyModel.name) + private readonly apiKeyModel: typeof ApiKeyModel, + ) {} + + /** + * + * @returns + */ + async generate() { + const tenant = await this.tenancyContext.getTenant(); + const user = await this.tenancyContext.getSystemUser(); + + // Generate a secure random API key + const key = crypto.randomBytes(48).toString('hex'); + // Save the API key to the database + const apiKeyRecord = await this.apiKeyModel.query().insert({ + key, + tenantId: tenant.id, + userId: user.id, + createdAt: new Date(), + revoked: false, + }); + // Return the created API key (not the full record for security) + return { key: apiKeyRecord.key, id: apiKeyRecord.id }; + } + + async revoke(apiKeyId: number) { + // Set the revoked flag to true for the given API key + await ApiKeyModel.query().findById(apiKeyId).patch({ revoked: true }); + return { id: apiKeyId, revoked: true }; + } +} diff --git a/packages/server/src/modules/Auth/guards/jwt.guard.ts b/packages/server/src/modules/Auth/guards/jwt.guard.ts index 00abe879b..bd7965657 100644 --- a/packages/server/src/modules/Auth/guards/jwt.guard.ts +++ b/packages/server/src/modules/Auth/guards/jwt.guard.ts @@ -16,6 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); const isPublic = this.reflector.getAllAndOverride( IS_PUBLIC_ROUTE, [context.getHandler(), context.getClass()], diff --git a/packages/server/src/modules/Auth/models/ApiKey.model.ts b/packages/server/src/modules/Auth/models/ApiKey.model.ts new file mode 100644 index 000000000..b61df86dc --- /dev/null +++ b/packages/server/src/modules/Auth/models/ApiKey.model.ts @@ -0,0 +1,25 @@ +import { SystemModel } from '@/modules/System/models/SystemModel'; + +export class ApiKeyModel extends SystemModel { + readonly key: string; + readonly name?: string; + readonly createdAt: Date; + readonly expiresAt?: Date; + readonly revoked?: boolean; + readonly userId: number; + readonly tenantId: number; + + /** + * Table name + */ + static get tableName() { + return 'api_keys'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt']; + } +} diff --git a/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts b/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts new file mode 100644 index 000000000..c0b1db677 --- /dev/null +++ b/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ApiKeyModel } from '../models/ApiKey.model'; +import { GetApiKeysTransformer } from './GetApiKeys.transformer'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class GetApiKeysService { + constructor( + private readonly injectableTransformer: TransformerInjectable, + private readonly tenancyContext: TenancyContext, + + @Inject(ApiKeyModel.name) + private readonly apiKeyModel: typeof ApiKeyModel, + ) {} + + async getApiKeys() { + const tenant = await this.tenancyContext.getTenant(); + const apiKeys = await this.apiKeyModel + .query() + .where({ tenantId: tenant.id }); + + return this.injectableTransformer.transform( + apiKeys, + new GetApiKeysTransformer(), + ); + } +} diff --git a/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts b/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts new file mode 100644 index 000000000..0ddc1312f --- /dev/null +++ b/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts @@ -0,0 +1,16 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; + +export class GetApiKeysTransformer extends Transformer { + public includeAttributes = (): string[] => { + return [ + 'id', + 'key', + 'name', + 'createdAt', + 'expiresAt', + 'revoked', + 'userId', + 'tenantId', + ]; + }; +} diff --git a/packages/server/src/modules/Items/Item.controller.ts b/packages/server/src/modules/Items/Item.controller.ts index f0193c4c0..3955d1534 100644 --- a/packages/server/src/modules/Items/Item.controller.ts +++ b/packages/server/src/modules/Items/Item.controller.ts @@ -11,8 +11,8 @@ import { Query, } from '@nestjs/common'; import { TenantController } from '../Tenancy/Tenant.controller'; -import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard'; -import { JwtAuthGuard } from '../Auth/guards/jwt.guard'; +// import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard'; +// import { JwtAuthGuard } from '../Auth/guards/jwt.guard'; import { ItemsApplicationService } from './ItemsApplication.service'; import { ApiExtraModels, @@ -31,6 +31,7 @@ import { ItemInvoiceResponseDto } from './dtos/itemInvoiceResponse.dto'; import { ItemEstimatesResponseDto } from './dtos/ItemEstimatesResponse.dto'; import { ItemBillsResponseDto } from './dtos/itemBillsResponse.dto'; import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto'; +import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard'; @Controller('/items') @ApiTags('Items') @@ -138,7 +139,6 @@ export class ItemsController extends TenantController { * @returns The updated item id. */ @Put(':id') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Edit the given item (product or service).' }) @ApiResponse({ status: 200, diff --git a/packages/server/src/modules/SaleInvoices/ledger/InvoiceGLEntries.ts b/packages/server/src/modules/SaleInvoices/ledger/InvoiceGLEntries.ts index 3d52c5963..b7e85ba25 100644 --- a/packages/server/src/modules/SaleInvoices/ledger/InvoiceGLEntries.ts +++ b/packages/server/src/modules/SaleInvoices/ledger/InvoiceGLEntries.ts @@ -63,7 +63,6 @@ export class SaleInvoiceGLEntries { /** * Rewrites the given invoice GL entries. - * @param {number} tenantId * @param {number} saleInvoiceId * @param {Knex.Transaction} trx */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2b64b936..d57b6ec6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: passport: specifier: ^0.7.0 version: 0.7.0 + passport-headerapikey: + specifier: ^1.2.2 + version: 1.2.2 passport-jwt: specifier: ^4.0.1 version: 4.0.1 @@ -23358,6 +23361,13 @@ packages: engines: {node: '>=0.10.0'} dev: false + /passport-headerapikey@1.2.2: + resolution: {integrity: sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==} + dependencies: + lodash: 4.17.21 + passport-strategy: 1.0.0 + dev: false + /passport-jwt@4.0.1: resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} dependencies: