diff --git a/src/app.module.ts b/src/app.module.ts index 36df8b0f7..18f4e68fb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { RateLimiterInterceptor, RateLimiterModule } from 'nestjs-rate-limiter'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import configuration from './config/configuration'; +import { AccessTokensModule } from './modules/access-tokens/access-tokens.module'; import { AuthModule } from './modules/auth/auth.module'; import { EmailModule } from './modules/email/email.module'; import { PrismaModule } from './modules/prisma/prisma.module'; @@ -25,6 +26,7 @@ import { UsersModule } from './modules/user/user.module'; }), EmailModule, SessionsModule, + AccessTokensModule, ], controllers: [AppController], providers: [ diff --git a/src/modules/access-tokens/access-tokens.controller.ts b/src/modules/access-tokens/access-tokens.controller.ts new file mode 100644 index 000000000..9a4175197 --- /dev/null +++ b/src/modules/access-tokens/access-tokens.controller.ts @@ -0,0 +1,106 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { accessTokens } from '@prisma/client'; +import { Expose } from 'src/modules/prisma/prisma.interface'; +import { CursorPipe } from 'src/pipes/cursor.pipe'; +import { OptionalIntPipe } from 'src/pipes/optional-int.pipe'; +import { OrderByPipe } from 'src/pipes/order-by.pipe'; +import { WherePipe } from 'src/pipes/where.pipe'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Scopes } from '../auth/scope.decorator'; +import { ScopesGuard } from '../auth/scope.guard'; +import { + CreateAccessTokenDto, + UpdateAccessTokenDto, + ReplaceAccessTokenDto, +} from './access-tokens.dto'; +import { AccessTokensService } from './access-tokens.service'; + +@Controller('users/:userId/access-tokens') +@UseGuards(JwtAuthGuard) +export class AccessTokenController { + constructor(private accessTokensService: AccessTokensService) {} + + @Post() + @UseGuards(ScopesGuard) + @Scopes('user{userId}:write', 'access-token:write') + async create( + @Param('userId', ParseIntPipe) userId: number, + @Body() data: CreateAccessTokenDto, + ): Promise> { + return this.accessTokensService.createAccessToken(userId, data); + } + + @Get() + @UseGuards(ScopesGuard) + @Scopes('user{userId}:read', 'access-token:read') + async getAll( + @Param('userId', ParseIntPipe) userId: number, + @Query('skip', OptionalIntPipe) skip?: number, + @Query('take', OptionalIntPipe) take?: number, + @Query('cursor', CursorPipe) cursor?: Record, + @Query('where', WherePipe) where?: Record, + @Query('orderBy', OrderByPipe) orderBy?: Record, + ): Promise[]> { + return this.accessTokensService.getAccessTokens(userId, { + skip, + take, + orderBy, + cursor, + where, + }); + } + + @Get(':id') + @UseGuards(ScopesGuard) + @Scopes('user{userId}:read', 'access-token{id}:read') + async get( + @Param('userId', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) id: number, + ): Promise> { + return this.accessTokensService.getAccessToken(userId, Number(id)); + } + + @Patch(':id') + @UseGuards(ScopesGuard) + @Scopes('user{userId}:write', 'access-token{id}:write') + async update( + @Body() data: UpdateAccessTokenDto, + @Param('userId', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) id: number, + ): Promise> { + return this.accessTokensService.updateAccessToken(userId, Number(id), data); + } + + @Put(':id') + @UseGuards(ScopesGuard) + @Scopes('user{userId}:write', 'access-token{id}:write') + async replace( + @Body() data: ReplaceAccessTokenDto, + @Param('userId', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) id: number, + ): Promise> { + return this.accessTokensService.updateAccessToken(userId, Number(id), data); + } + + @Delete(':id') + @UseGuards(ScopesGuard) + @Scopes('user{userId}:delete', 'access-token{id}:delete') + async remove( + @Param('userId', ParseIntPipe) userId: number, + @Param('id', ParseIntPipe) id: number, + ): Promise> { + return this.accessTokensService.deleteAccessToken(userId, Number(id)); + } +} diff --git a/src/modules/access-tokens/access-tokens.dto.ts b/src/modules/access-tokens/access-tokens.dto.ts new file mode 100644 index 000000000..32d44e68b --- /dev/null +++ b/src/modules/access-tokens/access-tokens.dto.ts @@ -0,0 +1,76 @@ +import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateAccessTokenDto { + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + name?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + scopes?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + ipRestrictions?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + referrerRestrictions?: string[]; +} + +export class UpdateAccessTokenDto { + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + name?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + scopes?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + ipRestrictions?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + referrerRestrictions?: string[]; +} + +export class ReplaceAccessTokenDto { + @IsString() + @IsNotEmpty() + description: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsArray() + @IsString({ each: true }) + @IsNotEmpty() + scopes: string[]; + + @IsArray() + @IsString({ each: true }) + @IsNotEmpty() + ipRestrictions: string[]; + + @IsArray() + @IsString({ each: true }) + @IsNotEmpty() + referrerRestrictions: string[]; +} diff --git a/src/modules/access-tokens/access-tokens.module.ts b/src/modules/access-tokens/access-tokens.module.ts new file mode 100644 index 000000000..8cb805239 --- /dev/null +++ b/src/modules/access-tokens/access-tokens.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AccessTokenController } from './access-tokens.controller'; +import { AccessTokensService } from './access-tokens.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AccessTokenController], + providers: [AccessTokensService], +}) +export class AccessTokensModule {} diff --git a/src/modules/access-tokens/access-tokens.service.ts b/src/modules/access-tokens/access-tokens.service.ts new file mode 100644 index 000000000..25c36a436 --- /dev/null +++ b/src/modules/access-tokens/access-tokens.service.ts @@ -0,0 +1,111 @@ +import { + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; +import { + accessTokens, + accessTokensCreateInput, + accessTokensOrderByInput, + accessTokensUpdateInput, + accessTokensWhereInput, + accessTokensWhereUniqueInput, +} from '@prisma/client'; +import { Expose } from 'src/modules/prisma/prisma.interface'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AccessTokensService { + constructor(private prisma: PrismaService) {} + async createAccessToken( + userId: number, + data: Omit, 'user'>, + ): Promise { + const accessToken = randomStringGenerator(); + return this.prisma.accessTokens.create({ + data: { ...data, accessToken, user: { connect: { id: userId } } }, + }); + } + + async getAccessTokens( + userId: number, + params: { + skip?: number; + take?: number; + cursor?: accessTokensWhereUniqueInput; + where?: accessTokensWhereInput; + orderBy?: accessTokensOrderByInput; + }, + ): Promise[]> { + const { skip, take, cursor, where, orderBy } = params; + const accessTokens = await this.prisma.accessTokens.findMany({ + skip, + take, + cursor, + where: { ...where, user: { id: userId } }, + orderBy, + }); + return accessTokens.map(user => this.prisma.expose(user)); + } + + async getAccessToken( + userId: number, + id: number, + ): Promise | null> { + const accessToken = await this.prisma.accessTokens.findOne({ + where: { id }, + }); + if (accessToken.userId !== userId) throw new UnauthorizedException(); + if (!accessToken) + throw new HttpException('AccessToken not found', HttpStatus.NOT_FOUND); + return this.prisma.expose(accessToken); + } + + async updateAccessToken( + userId: number, + id: number, + data: accessTokensUpdateInput, + ): Promise> { + const testAccessToken = await this.prisma.accessTokens.findOne({ + where: { id }, + }); + if (testAccessToken.userId !== userId) throw new UnauthorizedException(); + const accessToken = await this.prisma.accessTokens.update({ + where: { id }, + data, + }); + return this.prisma.expose(accessToken); + } + + async replaceAccessToken( + userId: number, + id: number, + data: accessTokensCreateInput, + ): Promise> { + const testAccessToken = await this.prisma.accessTokens.findOne({ + where: { id }, + }); + if (testAccessToken.userId !== userId) throw new UnauthorizedException(); + const accessToken = await this.prisma.accessTokens.update({ + where: { id }, + data, + }); + return this.prisma.expose(accessToken); + } + + async deleteAccessToken( + userId: number, + id: number, + ): Promise> { + const testAccessToken = await this.prisma.accessTokens.findOne({ + where: { id }, + }); + if (testAccessToken.userId !== userId) throw new UnauthorizedException(); + const accessToken = await this.prisma.accessTokens.delete({ + where: { id }, + }); + return this.prisma.expose(accessToken); + } +} diff --git a/src/modules/sessions/sessions.service.ts b/src/modules/sessions/sessions.service.ts index 49bcbc103..df3acd6d0 100644 --- a/src/modules/sessions/sessions.service.ts +++ b/src/modules/sessions/sessions.service.ts @@ -17,15 +17,6 @@ import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class SessionsService { constructor(private prisma: PrismaService) {} - async createSession( - userId: number, - data: sessionsCreateInput, - ): Promise { - return this.prisma.sessions.create({ - data: { ...data, user: { connect: { id: userId } } }, - }); - } - async getSessions( userId: number, params: {