Skip to content

Commit feeb512

Browse files
authored
Merge pull request #152 from gsainfoteam/151-kdh-passkey
[FEATURE] passkey CRUD
2 parents 5f38809 + ca9375c commit feeb512

File tree

8 files changed

+221
-26
lines changed

8 files changed

+221
-26
lines changed

docs/erd.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ erDiagram
4848
4949
"authenticator" {
5050
String user_uuid "🗝️"
51+
String name
5152
String credential_id
5253
Bytes public_key
5354
Int counter
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
Warnings:
3+
4+
- Added the required column `name` to the `authenticator` table without a default value. This is not possible if the table is not empty.
5+
6+
*/
7+
-- DropIndex
8+
DROP INDEX "authenticator_credential_id_key";
9+
10+
-- AlterTable
11+
ALTER TABLE "authenticator" ADD COLUMN "name" TEXT NOT NULL;

prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ model Consent {
8888

8989
model Authenticator {
9090
id String @id @default(uuid())
91-
credentialId String @unique @map("credential_id")
91+
name String
92+
credentialId String @map("credential_id")
9293
publicKey Bytes @map("public_key")
9394
counter Int
9495
userUuid String @db.Uuid @map("user_uuid")

src/user/dto/req.dto.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -222,18 +222,11 @@ class RegistrationResponseDto {
222222

223223
export class VerifyPasskeyRegistrationDto {
224224
@ApiProperty({
225-
example: '[email protected]',
226-
description: '유저의 이메일 주소',
225+
example: 'Passkey Name',
226+
description: '패스키 이름',
227227
})
228-
@IsEmail()
229-
@IsGistEmail()
230-
@Transform(({ value }) => {
231-
if (typeof value === 'string') {
232-
return value.toLowerCase();
233-
}
234-
throw new BadRequestException('이메일 형식이 올바르지 않습니다.');
235-
})
236-
email: string;
228+
@IsString()
229+
name: string;
237230

238231
@ApiProperty({
239232
description: '유저의 패스키 등록 응답',
@@ -243,3 +236,12 @@ export class VerifyPasskeyRegistrationDto {
243236
@Type(() => RegistrationResponseDto)
244237
registrationResponse: RegistrationResponseDto;
245238
}
239+
240+
export class ChangePasskeyNameDto {
241+
@ApiProperty({
242+
example: 'Passkey Name',
243+
description: '패스키 이름',
244+
})
245+
@IsString()
246+
name: string;
247+
}

src/user/dto/res.dto.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,17 @@ export class PasskeyRegisterOptionResDto {
305305
})
306306
extensions?: AuthenticationExtensionsDto;
307307
}
308+
309+
export class BasicPasskeyDto {
310+
@ApiProperty({
311+
example: 'ff0e6d1b-c2a4-44fb-aa0e-c20b6a721741',
312+
description: 'Passkey id',
313+
})
314+
id: string;
315+
316+
@ApiProperty({
317+
example: 'passkey-name',
318+
description: 'Passkey name',
319+
})
320+
name: string;
321+
}

src/user/user.controller.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
Controller,
66
Delete,
77
Get,
8+
Param,
89
ParseIntPipe,
10+
ParseUUIDPipe,
911
Patch,
1012
Post,
1113
Query,
@@ -33,13 +35,15 @@ import { GetUser } from 'src/auth/decorator/getUser.decorator';
3335
import { UserGuard } from 'src/auth/guard/auth.guard';
3436

3537
import {
38+
ChangePasskeyNameDto,
3639
ChangePasswordDto,
3740
DeleteUserReqDto,
3841
IssueUserSecretDto,
3942
RegisterDto,
4043
VerifyPasskeyRegistrationDto,
4144
} from './dto/req.dto';
4245
import {
46+
BasicPasskeyDto,
4347
PasskeyRegisterOptionResDto,
4448
UpdateUserPictureResDto,
4549
UserConsentListResDto,
@@ -187,38 +191,101 @@ export class UserController {
187191
return this.userService.deleteUserPicture(user.uuid);
188192
}
189193

194+
@ApiOperation({
195+
summary: 'get the passkey list of user',
196+
description: '사용자의 패스키 목록을 불러옵니다',
197+
})
198+
@ApiBearerAuth('user:jwt')
199+
@ApiOkResponse({
200+
description: 'success',
201+
type: [BasicPasskeyDto],
202+
})
203+
@ApiUnauthorizedResponse({ description: 'token not valid' })
204+
@ApiInternalServerErrorResponse({ description: 'server error' })
205+
@UseGuards(UserGuard)
206+
@Get('passkey')
207+
async getPasskeyList(@GetUser() user: User): Promise<BasicPasskeyDto[]> {
208+
return await this.userService.getPasskeyList(user.uuid);
209+
}
210+
190211
@ApiOperation({
191212
summary: 'register the passkey',
192213
description: '패스키를 등록을 위한 challenge를 발급합니다.',
193214
})
215+
@ApiBearerAuth('user:jwt')
194216
@ApiOkResponse({
195217
description: 'success',
196218
type: PasskeyRegisterOptionResDto,
197219
})
220+
@ApiUnauthorizedResponse({ description: 'token not valid' })
198221
@ApiNotFoundResponse({ description: 'Email is not found' })
199222
@ApiInternalServerErrorResponse({ description: 'server error' })
223+
@UseGuards(UserGuard)
200224
@Post('passkey')
201225
async registerOptions(
202-
@Body() { email }: IssueUserSecretDto,
226+
@GetUser() user: User,
203227
): Promise<PasskeyRegisterOptionResDto> {
204-
return await this.userService.registerOptions(email);
228+
return await this.userService.registerOptions(user.email);
205229
}
206230

207231
@ApiOperation({
208232
summary: 'verify the registration options',
209233
description: '패스키 등록합니다.',
210234
})
235+
@ApiBearerAuth('user:jwt')
211236
@ApiOkResponse({ description: 'success', type: Boolean })
212-
@ApiUnauthorizedResponse({ description: 'Response is invalid' })
237+
@ApiUnauthorizedResponse({ description: 'token not valid' })
213238
@ApiNotFoundResponse({ description: 'Email is not found' })
214239
@ApiInternalServerErrorResponse({ description: 'server error' })
240+
@UseGuards(UserGuard)
215241
@Post('passkey/verify')
216242
async verifyRegistration(
217-
@Body() { email, registrationResponse }: VerifyPasskeyRegistrationDto,
243+
@GetUser() user: User,
244+
@Body() { name, registrationResponse }: VerifyPasskeyRegistrationDto,
218245
): Promise<boolean> {
219246
return await this.userService.verifyRegistration(
220-
email,
247+
user.email,
248+
name,
221249
registrationResponse,
222250
);
223251
}
252+
253+
@ApiOperation({
254+
summary: 'update name of passkey',
255+
description: '패스키의 이름을 수정합니다.',
256+
})
257+
@ApiBearerAuth('user:jwt')
258+
@ApiOkResponse({ description: 'success', type: BasicPasskeyDto })
259+
@ApiUnauthorizedResponse({ description: 'token not valid' })
260+
@ApiForbiddenResponse({ description: 'Invalid user or token' })
261+
@ApiNotFoundResponse({ description: 'Id is not found' })
262+
@ApiInternalServerErrorResponse({ description: 'server error' })
263+
@UseGuards(UserGuard)
264+
@Patch('passkey/:id')
265+
async updatePasskey(
266+
@GetUser() user: User,
267+
@Param('id', ParseUUIDPipe) id: string,
268+
@Body() { name }: ChangePasskeyNameDto,
269+
): Promise<BasicPasskeyDto> {
270+
return await this.userService.updatePasskey(id, name, user.uuid);
271+
}
272+
273+
@ApiOperation({
274+
summary: 'delete passkey',
275+
description: '패스키를 삭제합니다.',
276+
})
277+
@ApiBearerAuth('user:jwt')
278+
@ApiOkResponse({ description: 'success' })
279+
@ApiUnauthorizedResponse({ description: 'token not valid' })
280+
@ApiForbiddenResponse({ description: 'Invalid user or token' })
281+
@ApiNotFoundResponse({ description: 'Id is not found' })
282+
@ApiInternalServerErrorResponse({ description: 'server error' })
283+
@UseGuards(UserGuard)
284+
@Delete('passkey/:id')
285+
async deletePasskey(
286+
@GetUser() user: User,
287+
@Param('id', ParseUUIDPipe) id: string,
288+
): Promise<void> {
289+
return await this.userService.deletePasskey(id, user.uuid);
290+
}
224291
}

src/user/user.repository.ts

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { Authenticator, User } from '@prisma/client';
1111
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
1212

13+
import { BasicPasskeyDto } from './dto/res.dto';
1314
import { UserConsentType } from './types/userConsent.type';
1415
import { UserWithAuthenticators } from './types/userWithAuthenticators';
1516

@@ -231,14 +232,87 @@ export class UserRepository {
231232
});
232233
}
233234

234-
async saveAuthenticator(authenticator: {
235-
credentialId: string;
236-
publicKey: Uint8Array;
237-
counter: number;
238-
userUuid: string;
239-
}): Promise<Authenticator> {
235+
async getPasskeyList(userUuid: string): Promise<BasicPasskeyDto[]> {
236+
return await this.prismaService.authenticator.findMany({
237+
where: { userUuid },
238+
select: {
239+
id: true,
240+
name: true,
241+
},
242+
});
243+
}
244+
245+
async getAuthenticator(id: string): Promise<Authenticator> {
246+
return await this.prismaService.authenticator
247+
.findUniqueOrThrow({
248+
where: { id },
249+
})
250+
.catch((error) => {
251+
if (
252+
error instanceof PrismaClientKnownRequestError &&
253+
error.code === 'P2025'
254+
) {
255+
this.logger.debug(`passkey not found with uuid: ${id}`);
256+
throw new ForbiddenException('user not found');
257+
}
258+
this.logger.error(`find passkey by uuid error: ${error}`);
259+
throw new InternalServerErrorException();
260+
});
261+
}
262+
263+
async saveAuthenticator(
264+
name: string,
265+
authenticator: {
266+
credentialId: string;
267+
publicKey: Uint8Array;
268+
counter: number;
269+
userUuid: string;
270+
},
271+
): Promise<Authenticator> {
240272
return this.prismaService.authenticator.create({
241-
data: authenticator,
273+
data: { ...authenticator, name },
242274
});
243275
}
276+
277+
async updatePasskey(id: string, name: string): Promise<BasicPasskeyDto> {
278+
return await this.prismaService.authenticator
279+
.update({
280+
where: { id },
281+
data: { name },
282+
select: {
283+
id: true,
284+
name: true,
285+
},
286+
})
287+
.catch((error) => {
288+
if (error instanceof PrismaClientKnownRequestError) {
289+
if (error.code === 'P2025' || error.code === 'P2002') {
290+
this.logger.debug(`passkey not found with uuid: ${id}`);
291+
throw new ForbiddenException('passkey not found');
292+
}
293+
this.logger.debug(`prisma error occurred: ${error.code}`);
294+
throw new InternalServerErrorException();
295+
}
296+
this.logger.error(`update passkey name error: ${error}`);
297+
throw new InternalServerErrorException();
298+
});
299+
}
300+
301+
async deletePasskey(id: string): Promise<void> {
302+
await this.prismaService.authenticator
303+
.delete({
304+
where: { id },
305+
})
306+
.catch((error) => {
307+
if (
308+
error instanceof PrismaClientKnownRequestError &&
309+
error.code === 'P2025'
310+
) {
311+
this.logger.debug(`passkey not found with uuid: ${id}`);
312+
throw new ForbiddenException('passkey not found');
313+
}
314+
this.logger.error(`delete passkey error: ${error}`);
315+
throw new InternalServerErrorException();
316+
});
317+
}
244318
}

src/user/user.service.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
IssueUserSecretDto,
3434
RegisterDto,
3535
} from './dto/req.dto';
36-
import { UpdateUserPictureResDto } from './dto/res.dto';
36+
import { BasicPasskeyDto, UpdateUserPictureResDto } from './dto/res.dto';
3737
import { UserConsentType } from './types/userConsent.type';
3838
import { UserRepository } from './user.repository';
3939

@@ -223,6 +223,10 @@ export class UserService {
223223
await this.objectService.deleteObject(`user/${userUuid}/profile.webp`);
224224
}
225225

226+
async getPasskeyList(userUuid: string): Promise<BasicPasskeyDto[]> {
227+
return await this.userRepository.getPasskeyList(userUuid);
228+
}
229+
226230
async registerOptions(
227231
email: string,
228232
): Promise<PublicKeyCredentialCreationOptionsJSON> {
@@ -249,6 +253,7 @@ export class UserService {
249253

250254
async verifyRegistration(
251255
email: string,
256+
name: string,
252257
response: RegistrationResponseJSON,
253258
): Promise<boolean> {
254259
const user = await this.userRepository.findUserByEmail(email);
@@ -274,7 +279,7 @@ export class UserService {
274279

275280
const { id, publicKey, counter } = registrationInfo.credential;
276281

277-
await this.userRepository.saveAuthenticator({
282+
await this.userRepository.saveAuthenticator(name, {
278283
credentialId: id,
279284
publicKey,
280285
counter,
@@ -283,4 +288,24 @@ export class UserService {
283288

284289
return true;
285290
}
291+
292+
async updatePasskey(
293+
id: string,
294+
name: string,
295+
userUuid: string,
296+
): Promise<BasicPasskeyDto> {
297+
const auth = await this.userRepository.getAuthenticator(id);
298+
299+
if (auth.userUuid !== userUuid) throw new ForbiddenException();
300+
301+
return await this.userRepository.updatePasskey(id, name);
302+
}
303+
304+
async deletePasskey(id: string, userUuid: string): Promise<void> {
305+
const auth = await this.userRepository.getAuthenticator(id);
306+
307+
if (auth.userUuid !== userUuid) throw new ForbiddenException();
308+
309+
return await this.userRepository.deletePasskey(id);
310+
}
286311
}

0 commit comments

Comments
 (0)