Skip to content

Commit

Permalink
Merge pull request #28 from f-lab-edu/feature/15-email-verify-code-ac…
Browse files Browse the repository at this point in the history
…cess-token-guard

[#15] email-verify-code 검증 & global accessToken 적용
  • Loading branch information
yanggwangseong authored Dec 5, 2024
2 parents a451b26 + 7083e4c commit f2f4e3c
Show file tree
Hide file tree
Showing 13 changed files with 193 additions and 19 deletions.
15 changes: 13 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {}
8 changes: 8 additions & 0 deletions src/common/decorators/is-public.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions src/common/enum/is-public.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum IsPublicEnum {
PUBLIC = "public",
REFRESH = "refresh",
}
4 changes: 4 additions & 0 deletions src/common/exception/error-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ export const MemberErrorCode = {
"2006",
"토큰 재발급은 Refresh 토큰으로만 가능합니다!",
),
INVALID_VERIFICATION_CODE: new ErrorCode(
"2007",
"인증 코드가 일치하지 않습니다",
),
};
3 changes: 3 additions & 0 deletions src/common/filter/business-error.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export class BusinessErrorFilter implements ExceptionFilter {
case "2006":
status = 401;
break;
case "2007":
status = 400;
break;
}

response.status(status).json({
Expand Down
25 changes: 25 additions & 0 deletions src/common/guards/bearer-token.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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("토큰이 없습니다!");
Expand Down Expand Up @@ -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이 아닙니다.");
}
Expand Down
18 changes: 13 additions & 5 deletions src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions src/dtos/verify-email.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions src/entities/member.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export class MemberEntity {
@JoinColumn()
verificationCode?: VerificationCodeEntity; // 이메일인증코드

@Column({ type: "boolean", default: false })
isEmailVerified!: boolean;

@CreateDateColumn({ type: "timestamp", nullable: false })
createdAt!: Date;

Expand Down
17 changes: 15 additions & 2 deletions src/modules/members.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
21 changes: 21 additions & 0 deletions src/repositories/verification-code.repository.ts
Original file line number Diff line number Diff line change
@@ -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<VerificationCodeEntity> {
constructor(
@InjectRepository(VerificationCodeEntity)
private readonly repository: Repository<VerificationCodeEntity>,
) {
super(repository.target, repository.manager, repository.queryRunner);
}

getRepository(qr?: QueryRunner) {
return qr
? qr.manager.getRepository<VerificationCodeEntity>(
VerificationCodeEntity,
)
: this.repository;
}
}
29 changes: 25 additions & 4 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@ 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()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly membersService: MembersService,
private readonly mailsService: MailsService,
private readonly configService: ConfigService,
) {}

Expand Down Expand Up @@ -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);

Expand All @@ -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);
}
Expand Down Expand Up @@ -136,6 +149,10 @@ export class AuthService {
}
}

verifyEmail(dto: VerifyEmailDto) {
return this.membersService.verifyEmail(dto);
}

rotateAccessToken(token: string) {
const decoded = this.verifyToken(token);

Expand All @@ -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();
}
}
55 changes: 53 additions & 2 deletions src/services/members.service.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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);
}
Expand Down

0 comments on commit f2f4e3c

Please sign in to comment.