diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 3a22a943f0..a57409792e 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -21,7 +21,8 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - profile UserProfile? + profile UserProfile? + personalAccessTokens PersonalAccessToken[] } model UserProfile { @@ -189,3 +190,14 @@ model CronTrigger { cloudFunction CloudFunction @relation(fields: [appid, target], references: [appid, name]) } + +model PersonalAccessToken { + id String @id @default(auto()) @map("_id") @db.ObjectId + uid String @db.ObjectId + name String + token String @unique + expiredAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [uid], references: [id]) +} diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index a1bc2c0bfd..4b844c44d5 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Query, Req, Res, UseGuards } from '@nestjs/common' +import { + Body, + Controller, + Get, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common' import { ApiBearerAuth, ApiOperation, @@ -11,6 +20,7 @@ import { IRequest } from '../utils/types' import { UserDto } from '../user/dto/user.response' import { AuthService } from './auth.service' import { JwtAuthGuard } from './jwt.auth.guard' +import { Pat2TokenDto } from './dto/pat2token.dto' @ApiTags('Authentication') @Controller() @@ -58,6 +68,23 @@ export class AuthController { return ResponseUtil.ok(token) } + /** + * Get user token by PAT + * @param pat + * @returns + */ + @ApiOperation({ summary: 'Get user token by PAT' }) + @ApiResponse({ type: ResponseUtil }) + @Post('pat2token') + async pat2token(@Body() dto: Pat2TokenDto) { + const token = await this.authService.pat2token(dto.pat) + if (!token) { + return ResponseUtil.error('invalid pat') + } + + return ResponseUtil.ok(token) + } + /** * Get current user profile * @param request diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index f96089c487..a56733576d 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -8,6 +8,8 @@ import { CasdoorService } from './casdoor.service' import { JwtStrategy } from './jwt.strategy' import { AuthController } from './auth.controller' import { HttpModule } from '@nestjs/axios' +import { PatService } from 'src/user/pat.service' +import { PrismaService } from 'src/prisma.service' @Module({ imports: [ @@ -19,7 +21,13 @@ import { HttpModule } from '@nestjs/axios' UserModule, HttpModule, ], - providers: [AuthService, JwtStrategy, CasdoorService], + providers: [ + AuthService, + JwtStrategy, + CasdoorService, + PatService, + PrismaService, + ], exports: [AuthService], controllers: [AuthController], }) diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index c88fb3ae40..3f93f226a1 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -5,6 +5,7 @@ import { User } from '@prisma/client' import { UserService } from '../user/user.service' import { ServerConfig } from '../constants' import * as assert from 'node:assert' +import { PatService } from 'src/user/pat.service' @Injectable() export class AuthService { @@ -13,6 +14,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly casdoorService: CasdoorService, private readonly userService: UserService, + private readonly patService: PatService, ) {} /** @@ -70,6 +72,22 @@ export class AuthService { } } + /** + * Get token by PAT + * @param user + * @param token + * @returns + */ + async pat2token(token: string): Promise { + const pat = await this.patService.findOne(token) + if (!pat) return null + + // check pat expired + if (pat.expiredAt < new Date()) return null + + return this.getAccessTokenByUser(pat.user) + } + /** * Get access token by user * @param user diff --git a/server/src/auth/dto/pat2token.dto.ts b/server/src/auth/dto/pat2token.dto.ts new file mode 100644 index 0000000000..1a2830a541 --- /dev/null +++ b/server/src/auth/dto/pat2token.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsNotEmpty, IsString } from 'class-validator' + +export class Pat2TokenDto { + @ApiProperty({ + description: 'PAT', + example: + 'laf_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + }) + @IsString() + @IsNotEmpty() + pat: string +} diff --git a/server/src/user/dto/create-pat.dto.ts b/server/src/user/dto/create-pat.dto.ts new file mode 100644 index 0000000000..c522261cbf --- /dev/null +++ b/server/src/user/dto/create-pat.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger' +import { + IsNotEmpty, + IsNumber, + IsString, + Length, + Max, + Min, +} from 'class-validator' + +export class CreatePATDto { + @IsString() + @IsNotEmpty() + @Length(1, 255) + @ApiProperty() + name: string + + @IsNumber() + @IsNotEmpty() + @Min(60) + @Max(3600 * 24 * 365) + @ApiProperty({ minimum: 60 }) + expiresIn: number +} diff --git a/server/src/user/pat.controller.ts b/server/src/user/pat.controller.ts new file mode 100644 index 0000000000..2c44d5b3d8 --- /dev/null +++ b/server/src/user/pat.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + Delete, + Get, + Logger, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common' +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger' +import { JwtAuthGuard } from 'src/auth/jwt.auth.guard' +import { ResponseUtil } from 'src/utils/response' +import { IRequest } from 'src/utils/types' +import { CreatePATDto } from './dto/create-pat.dto' +import { PatService } from './pat.service' + +@ApiTags('Authentication') +@ApiBearerAuth('Authorization') +@Controller('pats') +export class PatController { + private readonly logger = new Logger(PatController.name) + + constructor(private readonly patService: PatService) {} + + /** + * Create a PAT + * @param req + * @param dto + * @returns + */ + @ApiOperation({ summary: 'Create a PAT' }) + @ApiResponse({ type: ResponseUtil }) + @UseGuards(JwtAuthGuard) + @Post() + async create(@Req() req: IRequest, @Body() dto: CreatePATDto) { + const uid = req.user.id + // check max count, 10 + const count = await this.patService.count(uid) + if (count >= 10) { + return ResponseUtil.error('Max count of PAT is 10') + } + + const pat = await this.patService.create(uid, dto) + return ResponseUtil.ok(pat) + } + + /** + * List PATs + * @param req + * @returns + */ + @ApiOperation({ summary: 'List PATs' }) + @ApiResponse({ type: ResponseUtil }) + @UseGuards(JwtAuthGuard) + @Get() + async findAll(@Req() req: IRequest) { + const uid = req.user.id + const pats = await this.patService.findAll(uid) + return ResponseUtil.ok(pats) + } + + /** + * Delete a PAT + * @param req + * @param id + * @returns + */ + @ApiOperation({ summary: 'Delete a PAT' }) + @ApiResponse({ type: ResponseUtil }) + @UseGuards(JwtAuthGuard) + @Delete(':id') + async remove(@Req() req: IRequest, @Param('id') id: string) { + const uid = req.user.id + const pat = await this.patService.remove(uid, id) + return ResponseUtil.ok(pat) + } +} diff --git a/server/src/user/pat.service.ts b/server/src/user/pat.service.ts new file mode 100644 index 0000000000..1f7b0e32a6 --- /dev/null +++ b/server/src/user/pat.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common' +import { GenerateAlphaNumericPassword } from 'src/utils/random' +import { PrismaService } from '../prisma.service' +import { CreatePATDto } from './dto/create-pat.dto' + +@Injectable() +export class PatService { + private readonly logger = new Logger(PatService.name) + + constructor(private readonly prisma: PrismaService) {} + + async create(userid: string, dto: CreatePATDto) { + const { name, expiresIn } = dto + const token = 'laf_' + GenerateAlphaNumericPassword(60) + + const pat = await this.prisma.personalAccessToken.create({ + data: { + name, + token, + expiredAt: new Date(Date.now() + expiresIn * 1000), + user: { + connect: { id: userid }, + }, + }, + }) + return pat + } + + async findAll(userid: string) { + const pats = await this.prisma.personalAccessToken.findMany({ + where: { uid: userid }, + select: { + id: true, + uid: true, + name: true, + expiredAt: true, + createdAt: true, + }, + }) + return pats + } + + async findOne(token: string) { + const pat = await this.prisma.personalAccessToken.findFirst({ + where: { token }, + include: { + user: true, + }, + }) + return pat + } + + async count(userid: string) { + const count = await this.prisma.personalAccessToken.count({ + where: { uid: userid }, + }) + return count + } + + async remove(userid: string, id: string) { + const pat = await this.prisma.personalAccessToken.deleteMany({ + where: { id, uid: userid }, + }) + return pat + } +} diff --git a/server/src/user/user.module.ts b/server/src/user/user.module.ts index 8dba0a8d20..061454cfdb 100644 --- a/server/src/user/user.module.ts +++ b/server/src/user/user.module.ts @@ -1,9 +1,12 @@ import { Module } from '@nestjs/common' import { PrismaService } from '../prisma.service' import { UserService } from './user.service' +import { PatService } from './pat.service' +import { PatController } from './pat.controller' @Module({ - providers: [UserService, PrismaService], + providers: [UserService, PrismaService, PatService], exports: [UserService], + controllers: [PatController], }) export class UserModule {}