diff --git a/src/app.module.ts b/src/app.module.ts index 809d005..6c80cb4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,12 @@ import { MailerModule } from "@nestjs-modules/mailer"; -import { Module } from "@nestjs/common"; +import { ClassSerializerInterceptor, Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; import { TypeOrmModule } from "@nestjs/typeorm"; import path from "path"; import { EmailOptions } from "./common/config/email-config"; +import { AccessTokenGuard } from "./common/guards/bearer-token.guard"; import { TypeOrmModuleOptions } from "./common/typeorm"; import { AuthModule } from "./modules/auth.module"; import { MembersModule } from "./modules/members.module"; @@ -23,7 +25,16 @@ import { MembersModule } from "./modules/members.module"; MailerModule.forRootAsync(EmailOptions), ], controllers: [], - providers: [], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: ClassSerializerInterceptor, + }, + { + provide: APP_GUARD, + useClass: AccessTokenGuard, + }, + ], exports: [], }) export class AppModule {} diff --git a/src/common/decorators/is-public.decorator.ts b/src/common/decorators/is-public.decorator.ts new file mode 100644 index 0000000..4f4a407 --- /dev/null +++ b/src/common/decorators/is-public.decorator.ts @@ -0,0 +1,8 @@ +import { SetMetadata } from "@nestjs/common"; + +import { IsPublicEnum } from "../enum/is-public.enum"; + +export const IS_PUBLIC_KEY = "is_public"; + +export const IsPublicDecorator = (data: IsPublicEnum) => + SetMetadata(IS_PUBLIC_KEY, data); diff --git a/src/common/enum/is-public.enum.ts b/src/common/enum/is-public.enum.ts new file mode 100644 index 0000000..c4e3ddd --- /dev/null +++ b/src/common/enum/is-public.enum.ts @@ -0,0 +1,4 @@ +export enum IsPublicEnum { + PUBLIC = "public", + REFRESH = "refresh", +} diff --git a/src/common/exception/error-code.ts b/src/common/exception/error-code.ts index 5bf8ef9..24443c5 100644 --- a/src/common/exception/error-code.ts +++ b/src/common/exception/error-code.ts @@ -15,4 +15,8 @@ export const MemberErrorCode = { "2006", "토큰 재발급은 Refresh 토큰으로만 가능합니다!", ), + INVALID_VERIFICATION_CODE: new ErrorCode( + "2007", + "인증 코드가 일치하지 않습니다", + ), }; diff --git a/src/common/filter/business-error.filter.ts b/src/common/filter/business-error.filter.ts index e24ec5c..89dbf08 100644 --- a/src/common/filter/business-error.filter.ts +++ b/src/common/filter/business-error.filter.ts @@ -28,6 +28,9 @@ export class BusinessErrorFilter implements ExceptionFilter { case "2006": status = 401; break; + case "2007": + status = 400; + break; } response.status(status).json({ diff --git a/src/common/guards/bearer-token.guard.ts b/src/common/guards/bearer-token.guard.ts index 65d822d..de83331 100644 --- a/src/common/guards/bearer-token.guard.ts +++ b/src/common/guards/bearer-token.guard.ts @@ -4,21 +4,39 @@ import { Injectable, UnauthorizedException, } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; import * as bcrypt from "bcrypt"; import { AuthService } from "@APP/services/auth.service"; import { MembersService } from "@APP/services/members.service"; +import { IS_PUBLIC_KEY } from "../decorators/is-public.decorator"; +import { IsPublicEnum } from "../enum/is-public.enum"; + @Injectable() export class BearerTokenGuard implements CanActivate { constructor( private readonly authService: AuthService, private readonly membersService: MembersService, + private readonly reflector: Reflector, ) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass, + ]); + + if (isPublic) { + request.isPublic = isPublic; + } + + if (isPublic === IsPublicEnum.PUBLIC) { + return true; + } + const rawToken = request.headers.authorization; if (!rawToken) throw new UnauthorizedException("토큰이 없습니다!"); @@ -46,6 +64,13 @@ export class AccessTokenGuard extends BearerTokenGuard { const req = context.switchToHttp().getRequest(); + if ( + req.isPublic === IsPublicEnum.PUBLIC || + req.isPublic === IsPublicEnum.REFRESH + ) { + return true; + } + if (req.tokenType !== "access") { throw new UnauthorizedException("Access Token이 아닙니다."); } diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 00321c2..fdca72b 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,18 +1,19 @@ import { Body, Controller, Headers, Post, UseGuards } from "@nestjs/common"; import { CurrentMemberDecorator } from "@APP/common/decorators/current-member.decorator"; +import { IsPublicDecorator } from "@APP/common/decorators/is-public.decorator"; +import { IsPublicEnum } from "@APP/common/enum/is-public.enum"; import { BasicTokenGuard } from "@APP/common/guards/basic-token.guard"; -import { - AccessTokenGuard, - RefreshTokenGuard, -} from "@APP/common/guards/bearer-token.guard"; +import { RefreshTokenGuard } from "@APP/common/guards/bearer-token.guard"; import { RegisterMemberDto } from "@APP/dtos/register-member.dto"; +import { VerifyEmailDto } from "@APP/dtos/verify-email.dto"; import { AuthService } from "@APP/services/auth.service"; @Controller("auth") export class AuthController { constructor(private readonly authService: AuthService) {} + @IsPublicDecorator(IsPublicEnum.PUBLIC) @UseGuards(BasicTokenGuard) @Post("sign-in") async signIn(@Headers("authorization") rawToken: string) { @@ -23,17 +24,24 @@ export class AuthController { return await this.authService.signInByEmail(decoded); } + @IsPublicDecorator(IsPublicEnum.PUBLIC) @Post("sign-up") async signUp(@Body() dto: RegisterMemberDto) { return await this.authService.registerByEmail(dto); } - @UseGuards(AccessTokenGuard) @Post("sign-out") signOut(@CurrentMemberDecorator("id") memberId: number) { void this.authService.updateRefreshToken(memberId); } + @IsPublicDecorator(IsPublicEnum.PUBLIC) + @Post("verify-email") + async verifyEmail(@Body() dto: VerifyEmailDto) { + return await this.authService.verifyEmail(dto); + } + + @IsPublicDecorator(IsPublicEnum.REFRESH) @UseGuards(RefreshTokenGuard) @Post("access-token") postAccessToken(@Headers("authorization") rawToken: string) { diff --git a/src/dtos/verify-email.dto.ts b/src/dtos/verify-email.dto.ts index 4b77441..35578b5 100644 --- a/src/dtos/verify-email.dto.ts +++ b/src/dtos/verify-email.dto.ts @@ -1,8 +1,10 @@ import { PickType } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; import { MemberEntity } from "@APP/entities/member.entity"; -export class VerifyEmailDto extends PickType(MemberEntity, [ - "email", - "verificationCode", -] as const) {} +export class VerifyEmailDto extends PickType(MemberEntity, ["email"] as const) { + @IsNotEmpty() + @IsString() + verificationCode!: string; +} diff --git a/src/entities/member.entity.ts b/src/entities/member.entity.ts index 64f7d31..e2b22ef 100644 --- a/src/entities/member.entity.ts +++ b/src/entities/member.entity.ts @@ -54,6 +54,9 @@ export class MemberEntity { @JoinColumn() verificationCode?: VerificationCodeEntity; // 이메일인증코드 + @Column({ type: "boolean", default: false }) + isEmailVerified!: boolean; + @CreateDateColumn({ type: "timestamp", nullable: false }) createdAt!: Date; diff --git a/src/modules/members.module.ts b/src/modules/members.module.ts index 2f7fc2f..3ec35b2 100644 --- a/src/modules/members.module.ts +++ b/src/modules/members.module.ts @@ -3,14 +3,27 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { MemberEntity } from "@APP/entities/member.entity"; import { RefreshTokenEntity } from "@APP/entities/refresh-token.entity"; +import { VerificationCodeEntity } from "@APP/entities/verification-code.entity"; import { MembersRepository } from "@APP/repositories/members.repository"; import { RefreshTokenRepository } from "@APP/repositories/refresh-token.repository"; +import { VerificationCodeRepository } from "@APP/repositories/verification-code.repository"; import { MembersService } from "@APP/services/members.service"; @Module({ - imports: [TypeOrmModule.forFeature([MemberEntity, RefreshTokenEntity])], + imports: [ + TypeOrmModule.forFeature([ + MemberEntity, + RefreshTokenEntity, + VerificationCodeEntity, + ]), + ], controllers: [], - providers: [MembersService, MembersRepository, RefreshTokenRepository], + providers: [ + MembersService, + MembersRepository, + RefreshTokenRepository, + VerificationCodeRepository, + ], exports: [MembersService], }) export class MembersModule {} diff --git a/src/repositories/verification-code.repository.ts b/src/repositories/verification-code.repository.ts new file mode 100644 index 0000000..2fa60bc --- /dev/null +++ b/src/repositories/verification-code.repository.ts @@ -0,0 +1,21 @@ +import { InjectRepository } from "@nestjs/typeorm"; +import { QueryRunner, Repository } from "typeorm"; + +import { VerificationCodeEntity } from "@APP/entities/verification-code.entity"; + +export class VerificationCodeRepository extends Repository { + constructor( + @InjectRepository(VerificationCodeEntity) + private readonly repository: Repository, + ) { + super(repository.target, repository.manager, repository.queryRunner); + } + + getRepository(qr?: QueryRunner) { + return qr + ? qr.manager.getRepository( + VerificationCodeEntity, + ) + : this.repository; + } +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 056a41b..96514cf 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -2,13 +2,16 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import * as bcrypt from "bcrypt"; +import * as crypto from "crypto"; import { ENV_JWT_SECRET_KEY } from "@APP/common/constants/env-keys.const"; import { BusinessErrorException } from "@APP/common/exception/business-error.exception"; import { MemberErrorCode } from "@APP/common/exception/error-code"; import { RegisterMemberDto } from "@APP/dtos/register-member.dto"; +import { VerifyEmailDto } from "@APP/dtos/verify-email.dto"; import { MemberEntity } from "@APP/entities/member.entity"; +import { MailsService } from "./mails.service"; import { MembersService } from "./members.service"; @Injectable() @@ -16,6 +19,7 @@ export class AuthService { constructor( private readonly jwtService: JwtService, private readonly membersService: MembersService, + private readonly mailsService: MailsService, private readonly configService: ConfigService, ) {} @@ -76,6 +80,7 @@ export class AuthService { async registerByEmail(dto: RegisterMemberDto) { const hashedPassword = await bcrypt.hash(dto.password, 10); + const verificationCode = this.generateVerificationCode(); const existMember = await this.membersService.existByEmail(dto.email); @@ -85,10 +90,18 @@ export class AuthService { ); } - const newMember = await this.membersService.createMember({ - ...dto, - password: hashedPassword, - }); + const newMember = await this.membersService.createMember( + { + ...dto, + password: hashedPassword, + }, + verificationCode, + ); + + void this.mailsService.sendVerificationEmail( + newMember.email, + verificationCode, + ); return this.signInMember(newMember); } @@ -136,6 +149,10 @@ export class AuthService { } } + verifyEmail(dto: VerifyEmailDto) { + return this.membersService.verifyEmail(dto); + } + rotateAccessToken(token: string) { const decoded = this.verifyToken(token); @@ -157,4 +174,8 @@ export class AuthService { updateRefreshToken(memberId: number) { return this.membersService.updateRefreshToken(memberId); } + + private generateVerificationCode(): string { + return crypto.randomBytes(3).toString("hex").toUpperCase(); + } } diff --git a/src/services/members.service.ts b/src/services/members.service.ts index 807bbbc..f3a97d4 100644 --- a/src/services/members.service.ts +++ b/src/services/members.service.ts @@ -1,14 +1,19 @@ import { Injectable } from "@nestjs/common"; +import { BusinessErrorException } from "@APP/common/exception/business-error.exception"; +import { MemberErrorCode } from "@APP/common/exception/error-code"; import { RegisterMemberDto } from "@APP/dtos/register-member.dto"; +import { VerifyEmailDto } from "@APP/dtos/verify-email.dto"; import { MembersRepository } from "@APP/repositories/members.repository"; import { RefreshTokenRepository } from "@APP/repositories/refresh-token.repository"; +import { VerificationCodeRepository } from "@APP/repositories/verification-code.repository"; @Injectable() export class MembersService { constructor( private readonly membersRepository: MembersRepository, private readonly refreshTokenRepository: RefreshTokenRepository, + private readonly verificationCodeRepository: VerificationCodeRepository, ) {} findByEmail(email: string) { @@ -19,8 +24,54 @@ export class MembersService { }); } - createMember(dto: RegisterMemberDto) { - const newMember = this.membersRepository.create(dto); + async verifyEmail(dto: VerifyEmailDto) { + const member = await this.findByEmail(dto.email); + + if (member?.isEmailVerified) return true; + + const verifyCodeExists = await this.membersRepository.exists({ + where: { + email: dto.email, + verificationCode: { + code: dto.verificationCode, + }, + }, + relations: { + verificationCode: true, + }, + }); + + if (!verifyCodeExists) { + throw new BusinessErrorException( + MemberErrorCode.INVALID_VERIFICATION_CODE, + ); + } + + await this.membersRepository.update( + { + email: dto.email, + }, + { + isEmailVerified: true, + }, + ); + return verifyCodeExists; + } + + async createMember(dto: RegisterMemberDto, verificationCode: string) { + const newVerificationCode = this.verificationCodeRepository.create({ + code: verificationCode, + }); + + const savedVerificationCode = + await this.verificationCodeRepository.save(newVerificationCode); + + const newMember = this.membersRepository.create({ + ...dto, + verificationCode: { + id: savedVerificationCode.id, + }, + }); return this.membersRepository.save(newMember); }