mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 05:02:06 +10:00
feat: api keys (EE)
This commit is contained in:
@ -4,6 +4,7 @@ export enum JwtType {
|
||||
EXCHANGE = 'exchange',
|
||||
ATTACHMENT = 'attachment',
|
||||
MFA_TOKEN = 'mfa_token',
|
||||
API_KEY = 'api_key',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
|
||||
workspaceId: string;
|
||||
type: 'mfa_token';
|
||||
}
|
||||
|
||||
export type JwtApiKeyPayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
apiKeyId: string;
|
||||
type: 'api_key';
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
JwtAttachmentPayload,
|
||||
JwtCollabPayload,
|
||||
JwtExchangePayload,
|
||||
@ -77,10 +78,7 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
async generateMfaToken(
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@ -93,6 +91,27 @@ export class TokenService {
|
||||
return this.jwtService.sign(payload, { expiresIn: '5m' });
|
||||
}
|
||||
|
||||
async generateApiToken(opts: {
|
||||
apiKeyId: string;
|
||||
user: User;
|
||||
workspaceId: string;
|
||||
expiresIn?: string | number;
|
||||
}): Promise<string> {
|
||||
const { apiKeyId, user, workspaceId, expiresIn } = opts;
|
||||
if (user.deactivatedAt || user.deletedAt) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const payload: JwtApiKeyPayload = {
|
||||
sub: user.id,
|
||||
apiKeyId: apiKeyId,
|
||||
workspaceId,
|
||||
type: JwtType.API_KEY,
|
||||
};
|
||||
|
||||
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, tokenType: string) {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.environmentService.getAppSecret(),
|
||||
|
||||
@ -2,11 +2,15 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||
import { ApiKeyService } from '@docmost/ee/api-key/api-key.service';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@ -16,6 +20,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
@ -27,8 +33,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(req: any, payload: JwtPayload) {
|
||||
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
|
||||
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
|
||||
if (!payload.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
@ -36,6 +42,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException('Workspace does not match');
|
||||
}
|
||||
|
||||
if (payload.type === JwtType.API_KEY) {
|
||||
return this.validateApiKey(req, payload as JwtApiKeyPayload);
|
||||
}
|
||||
|
||||
if (payload.type !== JwtType.ACCESS) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
||||
|
||||
if (!workspace) {
|
||||
@ -49,4 +63,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
let ApiKeyModule: any;
|
||||
let isApiKeyModuleReady = false;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
isApiKeyModuleReady = true;
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'API Key module requested but EE module not bundled in this build',
|
||||
);
|
||||
isApiKeyModuleReady = false;
|
||||
}
|
||||
|
||||
if (isApiKeyModuleReady) {
|
||||
const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
return ApiKeyService.validateApiKey(payload);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
|
||||
return build();
|
||||
}
|
||||
@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||
|
||||
return build();
|
||||
}
|
||||
@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
|
||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
|
||||
|
||||
return build();
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
|
||||
Space = 'space',
|
||||
Group = 'group',
|
||||
Attachment = 'attachment',
|
||||
API = 'api_key',
|
||||
}
|
||||
|
||||
export type IWorkspaceAbility =
|
||||
@ -18,4 +19,5 @@ export type IWorkspaceAbility =
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('api_keys')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('name', 'text', (col) => col)
|
||||
.addColumn('token_suffix', 'text')
|
||||
.addColumn('expires_at', 'timestamptz')
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.notNull().references('users.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('last_used_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('api_keys').execute();
|
||||
}
|
||||
29
apps/server/src/database/types/db.d.ts
vendored
29
apps/server/src/database/types/db.d.ts
vendored
@ -3,13 +3,18 @@
|
||||
* Please do not edit it manually.
|
||||
*/
|
||||
|
||||
import type { ColumnType } from "kysely";
|
||||
import type { ColumnType } from 'kysely';
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Generated<T> =
|
||||
T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||
export type Int8 = ColumnType<
|
||||
string,
|
||||
bigint | number | string,
|
||||
bigint | number | string
|
||||
>;
|
||||
|
||||
export type Json = JsonValue;
|
||||
|
||||
@ -25,6 +30,19 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export interface ApiKeys {
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
expiresAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
lastUsedAt: Timestamp | null;
|
||||
name: string | null;
|
||||
tokenSuffix: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Attachments {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string;
|
||||
@ -344,6 +362,7 @@ export interface Workspaces {
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
authAccounts: AuthAccounts;
|
||||
authProviders: AuthProviders;
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
Shares,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
ApiKeys,
|
||||
} from './db';
|
||||
|
||||
// Workspace
|
||||
@ -119,3 +120,8 @@ export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
|
||||
export type UserMFA = Selectable<_UserMFA>;
|
||||
export type InsertableUserMFA = Insertable<_UserMFA>;
|
||||
export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
|
||||
|
||||
// Api Keys
|
||||
export type ApiKey = Selectable<ApiKeys>;
|
||||
export type InsertableApiKey = Insertable<ApiKeys>;
|
||||
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
||||
|
||||
Submodule apps/server/src/ee updated: d2ead43181...54494e96bf
Reference in New Issue
Block a user