mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 11:31:39 +03:00
1043 timebox prepare zapier integration (#1967)
* Add create api-key route * Import module * Remove required mutation parameter * Fix Authentication * Generate random key * Update Read ApiKeyAbility handler * Add findMany apiKey route * Remove useless attribute * Use signed token for apiKeys * Authenticate with api keys * Fix typo * Add a test for apiKey module * Revoke token when api key does not exist * Handler expiresAt parameter * Fix user passport * Code review returns: Add API_TOKEN_SECRET * Code review returns: Rename variable * Code review returns: Update code style * Update apiKey schema * Update create token route * Update delete token route * Filter revoked api keys from listApiKeys * Rename endpoint * Set default expiry to 2 years * Code review returns: Update comment * Generate token after create apiKey * Code review returns: Update env variable * Code review returns: Move method to proper service --------- Co-authored-by: martmull <martmull@hotmail.com>
This commit is contained in:
parent
6b990c8501
commit
8fbad7d3ba
@ -21,6 +21,8 @@ services:
|
||||
generateValue: true
|
||||
- key: LOGIN_TOKEN_SECRET
|
||||
generateValue: true
|
||||
- key: API_TOKEN_SECRET
|
||||
generateValue: true
|
||||
- key: REFRESH_TOKEN_SECRET
|
||||
generateValue: true
|
||||
- key: PG_DATABASE_URL
|
||||
|
@ -6,6 +6,7 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default?connection_limit
|
||||
FRONT_BASE_URL=http://localhost:3001
|
||||
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string
|
||||
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string
|
||||
API_TOKEN_SECRET=replace_me_with_a_random_string
|
||||
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string
|
||||
SIGN_IN_PREFILLED=true
|
||||
|
||||
@ -14,6 +15,7 @@ SIGN_IN_PREFILLED=true
|
||||
# DEBUG_MODE=true
|
||||
# ACCESS_TOKEN_EXPIRES_IN=30m
|
||||
# LOGIN_TOKEN_EXPIRES_IN=15m
|
||||
# API_TOKEN_EXPIRES_IN=2y
|
||||
# REFRESH_TOKEN_EXPIRES_IN=90d
|
||||
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
|
||||
# AUTH_GOOGLE_ENABLED=false
|
||||
@ -25,4 +27,4 @@ SIGN_IN_PREFILLED=true
|
||||
# LOGGER_DRIVER=console
|
||||
# SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
|
||||
# LOG_LEVEL=error,warn
|
||||
# FLEXIBLE_BACKEND_ENABLED=false
|
||||
# FLEXIBLE_BACKEND_ENABLED=false
|
||||
|
@ -6,9 +6,10 @@ PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test?connection_limit=1
|
||||
# the URL of the front-end app
|
||||
FRONT_BASE_URL=http://localhost:3001
|
||||
# random keys used to generate JWT tokens
|
||||
ACCESS_TOKEN_SECRET=secret_jwt
|
||||
ACCESS_TOKEN_SECRET=secret_jwt
|
||||
LOGIN_TOKEN_SECRET=secret_login_tokens
|
||||
REFRESH_TOKEN_SECRET=secret_refresh_token
|
||||
API_TOKEN_SECRET=secret_api_tokens
|
||||
REFRESH_TOKEN_SECRET=secret_refresh_token
|
||||
|
||||
|
||||
# ———————— Optional ————————
|
||||
@ -20,4 +21,4 @@ REFRESH_TOKEN_SECRET=secret_refresh_token
|
||||
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
|
||||
# AUTH_GOOGLE_ENABLED=false
|
||||
# STORAGE_TYPE=local
|
||||
# STORAGE_LOCAL_PATH=.local-storage
|
||||
# STORAGE_LOCAL_PATH=.local-storage
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
Activity,
|
||||
ActivityTarget,
|
||||
Attachment,
|
||||
ApiKey,
|
||||
Comment,
|
||||
Company,
|
||||
Favorite,
|
||||
@ -30,6 +31,7 @@ type SubjectsAbility = Subjects<{
|
||||
Activity: Activity;
|
||||
ActivityTarget: ActivityTarget;
|
||||
Attachment: Attachment;
|
||||
ApiKey: ApiKey;
|
||||
Comment: Comment;
|
||||
Company: Company;
|
||||
Favorite: Favorite;
|
||||
@ -55,7 +57,7 @@ export type AppAbility = PureAbility<
|
||||
|
||||
@Injectable()
|
||||
export class AbilityFactory {
|
||||
defineAbility(user: User, workspace: Workspace) {
|
||||
defineAbility(workspace: Workspace, user?: User) {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
|
||||
createPrismaAbility,
|
||||
);
|
||||
@ -66,8 +68,18 @@ export class AbilityFactory {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
can(AbilityAction.Update, 'User', { id: user.id });
|
||||
can(AbilityAction.Delete, 'User', { id: user.id });
|
||||
if (user) {
|
||||
can(AbilityAction.Update, 'User', { id: user.id });
|
||||
can(AbilityAction.Delete, 'User', { id: user.id });
|
||||
} else {
|
||||
cannot(AbilityAction.Update, 'User');
|
||||
cannot(AbilityAction.Delete, 'User');
|
||||
}
|
||||
|
||||
// ApiKey
|
||||
can(AbilityAction.Read, 'ApiKey', { workspaceId: workspace.id });
|
||||
can(AbilityAction.Create, 'ApiKey');
|
||||
can(AbilityAction.Update, 'ApiKey', { workspaceId: workspace.id });
|
||||
|
||||
// Workspace
|
||||
can(AbilityAction.Read, 'Workspace');
|
||||
@ -76,12 +88,19 @@ export class AbilityFactory {
|
||||
|
||||
// Workspace Member
|
||||
can(AbilityAction.Read, 'WorkspaceMember', { workspaceId: workspace.id });
|
||||
can(AbilityAction.Delete, 'WorkspaceMember', { workspaceId: workspace.id });
|
||||
cannot(AbilityAction.Delete, 'WorkspaceMember', { userId: user.id });
|
||||
can(AbilityAction.Update, 'WorkspaceMember', {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
if (user) {
|
||||
can(AbilityAction.Delete, 'WorkspaceMember', {
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
cannot(AbilityAction.Delete, 'WorkspaceMember', { userId: user.id });
|
||||
can(AbilityAction.Update, 'WorkspaceMember', {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
} else {
|
||||
cannot(AbilityAction.Delete, 'WorkspaceMember');
|
||||
cannot(AbilityAction.Update, 'WorkspaceMember');
|
||||
}
|
||||
|
||||
// Company
|
||||
can(AbilityAction.Read, 'Company', { workspaceId: workspace.id });
|
||||
@ -107,14 +126,19 @@ export class AbilityFactory {
|
||||
// Comment
|
||||
can(AbilityAction.Read, 'Comment', { workspaceId: workspace.id });
|
||||
can(AbilityAction.Create, 'Comment');
|
||||
can(AbilityAction.Update, 'Comment', {
|
||||
workspaceId: workspace.id,
|
||||
authorId: user.id,
|
||||
});
|
||||
can(AbilityAction.Delete, 'Comment', {
|
||||
workspaceId: workspace.id,
|
||||
authorId: user.id,
|
||||
});
|
||||
if (user) {
|
||||
can(AbilityAction.Update, 'Comment', {
|
||||
workspaceId: workspace.id,
|
||||
authorId: user.id,
|
||||
});
|
||||
can(AbilityAction.Delete, 'Comment', {
|
||||
workspaceId: workspace.id,
|
||||
authorId: user.id,
|
||||
});
|
||||
} else {
|
||||
cannot(AbilityAction.Update, 'Comment');
|
||||
cannot(AbilityAction.Delete, 'Comment');
|
||||
}
|
||||
|
||||
// ActivityTarget
|
||||
can(AbilityAction.Read, 'ActivityTarget');
|
||||
|
@ -122,6 +122,12 @@ import {
|
||||
ReadViewFilterAbilityHandler,
|
||||
UpdateViewFilterAbilityHandler,
|
||||
} from './handlers/view-filter.ability-handler';
|
||||
import {
|
||||
CreateApiKeyAbilityHandler,
|
||||
UpdateApiKeyAbilityHandler,
|
||||
ManageApiKeyAbilityHandler,
|
||||
ReadApiKeyAbilityHandler,
|
||||
} from './handlers/api-key.ability-handler';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@ -229,6 +235,11 @@ import {
|
||||
CreateViewSortAbilityHandler,
|
||||
UpdateViewSortAbilityHandler,
|
||||
DeleteViewSortAbilityHandler,
|
||||
// ApiKey
|
||||
ReadApiKeyAbilityHandler,
|
||||
ManageApiKeyAbilityHandler,
|
||||
CreateApiKeyAbilityHandler,
|
||||
UpdateApiKeyAbilityHandler,
|
||||
],
|
||||
exports: [
|
||||
AbilityFactory,
|
||||
@ -333,6 +344,11 @@ import {
|
||||
CreateViewSortAbilityHandler,
|
||||
UpdateViewSortAbilityHandler,
|
||||
DeleteViewSortAbilityHandler,
|
||||
// ApiKey
|
||||
ReadApiKeyAbilityHandler,
|
||||
ManageApiKeyAbilityHandler,
|
||||
CreateApiKeyAbilityHandler,
|
||||
UpdateApiKeyAbilityHandler,
|
||||
],
|
||||
})
|
||||
export class AbilityModule {}
|
||||
|
85
server/src/ability/handlers/api-key.ability-handler.ts
Normal file
85
server/src/ability/handlers/api-key.ability-handler.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { subject } from '@casl/ability';
|
||||
|
||||
import { IAbilityHandler } from 'src/ability/interfaces/ability-handler.interface';
|
||||
|
||||
import { AppAbility } from 'src/ability/ability.factory';
|
||||
import { AbilityAction } from 'src/ability/ability.action';
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { ApiKeyWhereUniqueInput } from 'src/core/@generated/api-key/api-key-where-unique.input';
|
||||
import { ApiKeyWhereInput } from 'src/core/@generated/api-key/api-key-where.input';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import {
|
||||
convertToWhereInput,
|
||||
relationAbilityChecker,
|
||||
} from 'src/ability/ability.util';
|
||||
|
||||
class ApiKeyArgs {
|
||||
where?: ApiKeyWhereUniqueInput | ApiKeyWhereInput;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ManageApiKeyAbilityHandler implements IAbilityHandler {
|
||||
async handle(ability: AppAbility) {
|
||||
return ability.can(AbilityAction.Manage, 'ApiKey');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ReadApiKeyAbilityHandler implements IAbilityHandler {
|
||||
async handle(ability: AppAbility) {
|
||||
return ability.can(AbilityAction.Read, 'ApiKey');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CreateApiKeyAbilityHandler implements IAbilityHandler {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async handle(ability: AppAbility, context: ExecutionContext) {
|
||||
const gqlContext = GqlExecutionContext.create(context);
|
||||
const args = gqlContext.getArgs();
|
||||
const allowed = await relationAbilityChecker(
|
||||
'ApiKey',
|
||||
ability,
|
||||
this.prismaService.client,
|
||||
args,
|
||||
);
|
||||
if (!allowed) {
|
||||
return false;
|
||||
}
|
||||
return ability.can(AbilityAction.Create, 'ApiKey');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UpdateApiKeyAbilityHandler implements IAbilityHandler {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async handle(ability: AppAbility, context: ExecutionContext) {
|
||||
const gqlContext = GqlExecutionContext.create(context);
|
||||
const args = gqlContext.getArgs<ApiKeyArgs>();
|
||||
const where = convertToWhereInput(args.where);
|
||||
const apiKey = await this.prismaService.client.apiKey.findFirst({
|
||||
where,
|
||||
});
|
||||
assert(apiKey, '', NotFoundException);
|
||||
const allowed = await relationAbilityChecker(
|
||||
'ApiKey',
|
||||
ability,
|
||||
this.prismaService.client,
|
||||
args,
|
||||
);
|
||||
if (!allowed) {
|
||||
return false;
|
||||
}
|
||||
return ability.can(AbilityAction.Update, subject('ApiKey', apiKey));
|
||||
}
|
||||
}
|
@ -74,11 +74,7 @@ import { ExceptionFilter } from './filters/exception.filter';
|
||||
decoded as JwtPayload,
|
||||
);
|
||||
|
||||
const conditionalSchema = await tenantService.createTenantSchema(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return conditionalSchema;
|
||||
return await tenantService.createTenantSchema(workspace.id);
|
||||
} catch (error) {
|
||||
if (error instanceof JsonWebTokenError) {
|
||||
//mockedUserJWT
|
||||
|
12
server/src/core/api-key/api-key.module.ts
Normal file
12
server/src/core/api-key/api-key.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { TokenService } from 'src/core/auth/services/token.service';
|
||||
|
||||
import { ApiKeyResolver } from './api-key.resolver';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@Module({
|
||||
providers: [ApiKeyResolver, ApiKeyService, TokenService, JwtService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
28
server/src/core/api-key/api-key.resolver.spec.ts
Normal file
28
server/src/core/api-key/api-key.resolver.spec.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { AbilityFactory } from 'src/ability/ability.factory';
|
||||
import { TokenService } from 'src/core/auth/services/token.service';
|
||||
|
||||
import { ApiKeyResolver } from './api-key.resolver';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
describe('ApiKeyResolver', () => {
|
||||
let resolver: ApiKeyResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyResolver,
|
||||
{ provide: ApiKeyService, useValue: {} },
|
||||
{ provide: TokenService, useValue: {} },
|
||||
{ provide: JwtService, useValue: {} },
|
||||
{ provide: AbilityFactory, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
resolver = module.get<ApiKeyResolver>(ApiKeyResolver);
|
||||
});
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
});
|
82
server/src/core/api-key/api-key.resolver.ts
Normal file
82
server/src/core/api-key/api-key.resolver.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { NotFoundException, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { accessibleBy } from '@casl/prisma';
|
||||
|
||||
import { AbilityGuard } from 'src/guards/ability.guard';
|
||||
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/core/@generated/workspace/workspace.model';
|
||||
import { CreateOneApiKeyArgs } from 'src/core/@generated/api-key/create-one-api-key.args';
|
||||
import { ApiKey } from 'src/core/@generated/api-key/api-key.model';
|
||||
import { FindManyApiKeyArgs } from 'src/core/@generated/api-key/find-many-api-key.args';
|
||||
import { DeleteOneApiKeyArgs } from 'src/core/@generated/api-key/delete-one-api-key.args';
|
||||
import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
|
||||
import {
|
||||
CreateApiKeyAbilityHandler,
|
||||
UpdateApiKeyAbilityHandler,
|
||||
ReadApiKeyAbilityHandler,
|
||||
} from 'src/ability/handlers/api-key.ability-handler';
|
||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||
import { UserAbility } from 'src/decorators/user-ability.decorator';
|
||||
import { AppAbility } from 'src/ability/ability.factory';
|
||||
import { AuthToken } from 'src/core/auth/dto/token.entity';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Resolver(() => ApiKey)
|
||||
export class ApiKeyResolver {
|
||||
constructor(private readonly apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Mutation(() => AuthToken)
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(CreateApiKeyAbilityHandler)
|
||||
async createOneApiKey(
|
||||
@Args() args: CreateOneApiKeyArgs,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<AuthToken> {
|
||||
return await this.apiKeyService.generateApiKeyToken(
|
||||
workspaceId,
|
||||
args.data.name,
|
||||
args.data.expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => ApiKey)
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(UpdateApiKeyAbilityHandler)
|
||||
async revokeOneApiKey(
|
||||
@Args() args: DeleteOneApiKeyArgs,
|
||||
): Promise<Partial<ApiKey>> {
|
||||
const apiKeyToDelete = await this.apiKeyService.findFirst({
|
||||
where: { ...args.where },
|
||||
});
|
||||
if (!apiKeyToDelete) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return this.apiKeyService.update({
|
||||
where: args.where,
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
@UseGuards(AbilityGuard)
|
||||
@CheckAbilities(ReadApiKeyAbilityHandler)
|
||||
async findManyApiKey(
|
||||
@Args() args: FindManyApiKeyArgs,
|
||||
@UserAbility() ability: AppAbility,
|
||||
) {
|
||||
const filterOptions = [
|
||||
accessibleBy(ability).WorkspaceMember,
|
||||
{ revokedAt: null },
|
||||
];
|
||||
if (args.where) filterOptions.push(args.where);
|
||||
return this.apiKeyService.findMany({
|
||||
...args,
|
||||
where: { AND: filterOptions },
|
||||
});
|
||||
}
|
||||
}
|
63
server/src/core/api-key/api-key.service.ts
Normal file
63
server/src/core/api-key/api-key.service.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { addMilliseconds, addSeconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { AuthToken } from 'src/core/auth/dto/token.entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
findFirst = this.prismaService.client.apiKey.findFirst;
|
||||
findUniqueOrThrow = this.prismaService.client.apiKey.findUniqueOrThrow;
|
||||
findMany = this.prismaService.client.apiKey.findMany;
|
||||
create = this.prismaService.client.apiKey.create;
|
||||
update = this.prismaService.client.apiKey.update;
|
||||
delete = this.prismaService.client.apiKey.delete;
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
name: string,
|
||||
expiresAt?: Date | string,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.environmentService.getApiTokenSecret();
|
||||
let expiresIn: string | number;
|
||||
let expirationDate: Date;
|
||||
const now = new Date().getTime();
|
||||
if (expiresAt) {
|
||||
expiresIn = Math.floor((new Date(expiresAt).getTime() - now) / 1000);
|
||||
expirationDate = addSeconds(now, expiresIn);
|
||||
} else {
|
||||
expiresIn = this.environmentService.getApiTokenExpiresIn();
|
||||
expirationDate = addMilliseconds(now, ms(expiresIn));
|
||||
}
|
||||
assert(expiresIn, '', InternalServerErrorException);
|
||||
const jwtPayload = {
|
||||
sub: workspaceId,
|
||||
};
|
||||
const { id } = await this.prismaService.client.apiKey.create({
|
||||
data: {
|
||||
expiresAt: expiresAt,
|
||||
name: name,
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
token: this.jwtService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: id,
|
||||
}),
|
||||
expiresAt: expirationDate,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,14 +1,19 @@
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { User, Workspace } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from 'src/database/prisma.service';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { assert } from 'src/utils/assert';
|
||||
|
||||
export type JwtPayload = { sub: string; workspaceId: string };
|
||||
export type PassportUser = { user: User; workspace: Workspace };
|
||||
export type JwtPayload = { sub: string; workspaceId: string; jti?: string };
|
||||
export type PassportUser = { user?: User; workspace: Workspace };
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@ -24,22 +29,25 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<PassportUser> {
|
||||
const user = await this.prismaService.client.user.findUniqueOrThrow({
|
||||
where: { id: payload.sub },
|
||||
const workspace = await this.prismaService.client.workspace.findUnique({
|
||||
where: { id: payload.workspaceId ?? payload.sub },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const workspace =
|
||||
await this.prismaService.client.workspace.findUniqueOrThrow({
|
||||
where: { id: payload.workspaceId },
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (payload.jti) {
|
||||
// If apiKey has been deleted or revoked, we throw an error
|
||||
const apiKey = await this.prismaService.client.apiKey.findUniqueOrThrow({
|
||||
where: { id: payload.jti },
|
||||
});
|
||||
assert(!apiKey.revokedAt, 'This API Key is revoked', ForbiddenException);
|
||||
}
|
||||
|
||||
const user = payload.workspaceId
|
||||
? await this.prismaService.client.user.findUniqueOrThrow({
|
||||
where: { id: payload.sub },
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import { AttachmentModule } from './attachment/attachment.module';
|
||||
import { ActivityModule } from './activity/activity.module';
|
||||
import { ViewModule } from './view/view.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { ApiKeyModule } from './api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -31,6 +32,7 @@ import { FavoriteModule } from './favorite/favorite.module';
|
||||
ActivityModule,
|
||||
ViewModule,
|
||||
FavoriteModule,
|
||||
ApiKeyModule,
|
||||
],
|
||||
exports: [
|
||||
AuthModule,
|
||||
@ -43,6 +45,7 @@ import { FavoriteModule } from './favorite/favorite.module';
|
||||
AnalyticsModule,
|
||||
AttachmentModule,
|
||||
FavoriteModule,
|
||||
ApiKeyModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
@ -0,0 +1,22 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ViewFilterOperand" ADD VALUE 'IsNotNull';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_keys" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"workspaceId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_keys_key_key" ON "api_keys"("key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -0,0 +1,12 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `key` on the `api_keys` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "api_keys_key_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "api_keys" DROP COLUMN "key",
|
||||
ADD COLUMN "revokedAt" TIMESTAMP(3);
|
@ -178,6 +178,7 @@ model Workspace {
|
||||
viewFilters ViewFilter[]
|
||||
views View[]
|
||||
viewSorts ViewSort[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
deletedAt DateTime?
|
||||
@ -886,3 +887,23 @@ model ViewField {
|
||||
@@id([viewId, key])
|
||||
@@map("viewFields")
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
/// @Validator.IsString()
|
||||
/// @Validator.IsOptional()
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id])
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
workspaceId String
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
deletedAt DateTime?
|
||||
/// @TypeGraphQL.omit(input: true, output: true)
|
||||
revokedAt DateTime?
|
||||
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
@ -35,8 +35,8 @@ export class AbilityGuard implements CanActivate {
|
||||
assert(passportUser, '', UnauthorizedException);
|
||||
|
||||
const ability = this.abilityFactory.defineAbility(
|
||||
passportUser.user,
|
||||
passportUser.workspace,
|
||||
passportUser.user,
|
||||
);
|
||||
|
||||
request.ability = ability;
|
||||
|
@ -69,10 +69,18 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('LOGIN_TOKEN_SECRET')!;
|
||||
}
|
||||
|
||||
getApiTokenSecret(): string {
|
||||
return this.configService.get<string>('API_TOKEN_SECRET')!;
|
||||
}
|
||||
|
||||
getLoginTokenExpiresIn(): string {
|
||||
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN') ?? '15m';
|
||||
}
|
||||
|
||||
getApiTokenExpiresIn(): string {
|
||||
return this.configService.get<string>('API_TOKEN_EXPIRES_IN') ?? '2y';
|
||||
}
|
||||
|
||||
getFrontAuthCallbackUrl(): string {
|
||||
return (
|
||||
this.configService.get<string>('FRONT_AUTH_CALLBACK_URL') ??
|
||||
|
@ -82,6 +82,8 @@ export class EnvironmentVariables {
|
||||
|
||||
@IsString()
|
||||
LOGIN_TOKEN_SECRET: string;
|
||||
@IsString()
|
||||
API_TOKEN_SECRET: string;
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
LOGIN_TOKEN_EXPIRES_IN: string;
|
||||
|
@ -21,4 +21,5 @@ export type ModelSelectMap = {
|
||||
ViewFilter: Prisma.ViewFilterSelect;
|
||||
ViewSort: Prisma.ViewSortSelect;
|
||||
ViewField: Prisma.ViewFieldSelect;
|
||||
ApiKey: Prisma.ApiKeySelect;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user