Merge pull request #813 from bigcapitalhq/api-keys

feat: api keys
This commit is contained in:
Ahmed Bouhuolia
2025-07-01 23:49:43 +02:00
committed by GitHub
17 changed files with 332 additions and 11 deletions

View File

@@ -87,6 +87,7 @@
"object-hash": "^2.0.3", "object-hash": "^2.0.3",
"objection": "^3.1.5", "objection": "^3.1.5",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-headerapikey": "^1.2.2",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"plaid": "^10.3.0", "plaid": "^10.3.0",

View File

@@ -176,7 +176,6 @@ export class AccountsController {
items: { $ref: getSchemaPath(AccountResponseDto) }, items: { $ref: getSchemaPath(AccountResponseDto) },
}, },
}) })
@ApiResponse({})
async getAccounts(@Query() filter: Partial<IAccountsFilter>) { async getAccounts(@Query() filter: Partial<IAccountsFilter>) {
return this.accountsApplication.getAccounts(filter); return this.accountsApplication.getAccounts(filter);
} }

View File

@@ -32,11 +32,22 @@ import { AuthedController } from './Authed.controller';
import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service'; import { GetAuthenticatedAccount } from './queries/GetAuthedAccount.service';
import { TenancyModule } from '../Tenancy/Tenancy.module'; import { TenancyModule } from '../Tenancy/Tenancy.module';
import { EnsureUserVerifiedGuard } from './guards/EnsureUserVerified.guard'; 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({ @Module({
controllers: [AuthController, AuthedController], controllers: [AuthController, AuthedController, AuthApiKeysController],
imports: [ imports: [
MailModule, MailModule,
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
@@ -70,9 +81,15 @@ const models = [InjectSystemModel(PasswordReset)];
SendSignupVerificationMailProcessor, SendSignupVerificationMailProcessor,
GetAuthMetaService, GetAuthMetaService,
GetAuthenticatedAccount, GetAuthenticatedAccount,
ApiKeyAuthGuard,
ApiKeyStrategy,
AuthApiKeyAuthorizeService,
GenerateApiKey,
GetApiKeysService,
JwtAuthGuard,
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: JwtAuthGuard, useClass: MixedAuthGuard,
}, },
{ {
provide: APP_GUARD, provide: APP_GUARD,

View File

@@ -0,0 +1,26 @@
import { Controller, Post, Param, Get, Put } 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();
}
@Put(':id/revoke')
async revoke(@Param('id') id: number) {
return this.generateApiKeyService.revoke(id);
}
@Get()
async getApiKeys() {
return this.getApiKeysService.getApiKeys();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,50 @@
import { Inject, Injectable } from '@nestjs/common';
import { ApiKeyModel } from '../models/ApiKey.model';
import { ClsService } from 'nestjs-cls';
import { TenantModel } from '@/modules/System/models/TenantModel';
@Injectable()
export class AuthApiKeyAuthorizeService {
constructor(
private readonly clsService: ClsService,
@Inject(ApiKeyModel.name)
private readonly apikeyModel: typeof ApiKeyModel,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
) {}
/**
* Authenticate using the given api key.
*/
async authorize(apiKey: string): Promise<boolean> {
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;
}
const tenant = await this.tenantModel
.query()
.findById(apiKeyRecord.tenantId);
if (!tenant) return false;
this.clsService.set('tenantId', tenant.id);
this.clsService.set('organizationId', tenant.organizationId);
this.clsService.set('userId', apiKeyRecord.userId);
return true;
}
}

View File

@@ -0,0 +1,50 @@
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,
) {}
/**
* Generates a new secure API key for the current tenant and system user.
* The key is saved in the database and returned (only the key and id for security).
* @returns {Promise<{ key: string; id: number }>} The generated API key and its database id.
*/
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(),
revokedAt: null,
});
// Return the created API key (not the full record for security)
return { key: apiKeyRecord.key, id: apiKeyRecord.id };
}
/**
* Revokes an API key by setting its revokedAt timestamp.
* @param {number} apiKeyId - The id of the API key to revoke.
* @returns {Promise<{ id: number; revoked: boolean }>} The id of the revoked API key and a revoked flag.
*/
async revoke(apiKeyId: number) {
// Set the revoked flag to true for the given API key
await ApiKeyModel.query()
.findById(apiKeyId)
.patch({ revokedAt: new Date() });
return { id: apiKeyId, revoked: true };
}
}

View File

@@ -16,6 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
} }
canActivate(context: ExecutionContext) { canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const isPublic = this.reflector.getAllAndOverride<boolean>( const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE, IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()], [context.getHandler(), context.getClass()],

View File

@@ -0,0 +1,72 @@
import { SystemModel } from '@/modules/System/models/SystemModel';
import { Model } from 'objection';
export class ApiKeyModel extends SystemModel {
readonly key: string;
readonly name?: string;
readonly createdAt: Date;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
readonly userId: number;
readonly tenantId: number;
get revoked() {
return !!this.revokedAt;
}
static get virtualAttributes() {
return ['revoked'];
}
/**
* Table name
*/
static get tableName() {
return 'api_keys';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt'];
}
/**
* Relation mappings for Objection.js
*/
static get relationMappings() {
const { SystemUser } = require('../../System/models/SystemUser');
const { TenantModel } = require('../../System/models/TenantModel');
return {
user: {
relation: Model.BelongsToOneRelation,
modelClass: SystemUser,
join: {
from: 'api_keys.userId',
to: 'users.id',
},
},
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: TenantModel,
join: {
from: 'api_keys.tenantId',
to: 'tenants.id',
},
},
};
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
notRevoked(query) {
query.whereNull('revokedAt');
},
};
}
}

View File

@@ -0,0 +1,29 @@
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()
.modify('notRevoked')
.where({ tenantId: tenant.id });
return this.injectableTransformer.transform(
apiKeys,
new GetApiKeysTransformer(),
);
}
}

View File

@@ -0,0 +1,7 @@
import { Transformer } from '@/modules/Transformer/Transformer';
export class GetApiKeysTransformer extends Transformer {
public excludeAttributes = (): string[] => {
return ['tenantId'];
};
}

View File

@@ -11,8 +11,6 @@ import {
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { TenantController } from '../Tenancy/Tenant.controller'; import { TenantController } from '../Tenancy/Tenant.controller';
import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard';
import { JwtAuthGuard } from '../Auth/guards/jwt.guard';
import { ItemsApplicationService } from './ItemsApplication.service'; import { ItemsApplicationService } from './ItemsApplication.service';
import { import {
ApiExtraModels, ApiExtraModels,
@@ -34,7 +32,6 @@ import { ItemReceiptsResponseDto } from './dtos/ItemReceiptsResponse.dto';
@Controller('/items') @Controller('/items')
@ApiTags('Items') @ApiTags('Items')
@UseGuards(SubscriptionGuard)
@ApiExtraModels(ItemResponseDto) @ApiExtraModels(ItemResponseDto)
@ApiExtraModels(PaginatedResponseDto) @ApiExtraModels(PaginatedResponseDto)
@ApiExtraModels(ItemInvoiceResponseDto) @ApiExtraModels(ItemInvoiceResponseDto)
@@ -138,7 +135,6 @@ export class ItemsController extends TenantController {
* @returns The updated item id. * @returns The updated item id.
*/ */
@Put(':id') @Put(':id')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Edit the given item (product or service).' }) @ApiOperation({ summary: 'Edit the given item (product or service).' })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,

View File

@@ -63,7 +63,6 @@ export class SaleInvoiceGLEntries {
/** /**
* Rewrites the given invoice GL entries. * Rewrites the given invoice GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId * @param {number} saleInvoiceId
* @param {Knex.Transaction} trx * @param {Knex.Transaction} trx
*/ */

View File

@@ -1,3 +1,4 @@
import { isEmpty } from 'lodash';
import { import {
Injectable, Injectable,
CanActivate, CanActivate,
@@ -24,11 +25,12 @@ export class TenancyGlobalGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const organizationId = request.headers['organization-id']; const organizationId = request.headers['organization-id'];
const authorization = request.headers['authorization']?.trim();
const isPublic = this.reflector.getAllAndOverride<boolean>( const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE, IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()], [context.getHandler(), context.getClass()],
) );
const isTenantAgnostic = this.reflector.getAllAndOverride<boolean>( const isTenantAgnostic = this.reflector.getAllAndOverride<boolean>(
IS_TENANT_AGNOSTIC, IS_TENANT_AGNOSTIC,
[context.getHandler(), context.getClass()], [context.getHandler(), context.getClass()],
@@ -36,7 +38,7 @@ export class TenancyGlobalGuard implements CanActivate {
if (isPublic || isTenantAgnostic) { if (isPublic || isTenantAgnostic) {
return true; return true;
} }
if (!organizationId) { if (!isEmpty(authorization) && !organizationId) {
throw new UnauthorizedException('Organization ID is required.'); throw new UnauthorizedException('Organization ID is required.');
} }
return true; return true;

10
pnpm-lock.yaml generated
View File

@@ -240,6 +240,9 @@ importers:
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
passport-headerapikey:
specifier: ^1.2.2
version: 1.2.2
passport-jwt: passport-jwt:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -23358,6 +23361,13 @@ packages:
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: false 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: /passport-jwt@4.0.1:
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
dependencies: dependencies: