Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

사용자 이메일 인증, 비밀번호 재설정 추가 #255

Merged
merged 9 commits into from
Feb 26, 2024
2,217 changes: 2,086 additions & 131 deletions backend/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.10.3",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
Expand All @@ -45,6 +46,7 @@
"mongoose": "^8.0.1",
"mysql2": "^3.6.3",
"nest-winston": "^1.9.4",
"nodemailer": "^6.9.9",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
Expand Down
3 changes: 3 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { MONGODB_URL } from './constants';
import { ScheduleModule } from '@nestjs/schedule';
import { RedisModule } from '@songkeys/nestjs-redis';
import { RedisConfig } from './configs/redis.config';
import { MailerModule } from '@nestjs-modules/mailer';
import { MailerConfig } from './configs/mailer.config';

@Module({
imports: [
Expand All @@ -25,6 +27,7 @@ import { RedisConfig } from './configs/redis.config';
MongooseModule.forRoot(MONGODB_URL),
ScheduleModule.forRoot(),
RedisModule.forRoot(RedisConfig),
MailerModule.forRoot(MailerConfig),
],
controllers: [AppController],
providers: [AppService],
Expand Down
28 changes: 26 additions & 2 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import {
ApiBadRequestResponse,
ApiBody,
ApiGoneResponse,
ApiHeader,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Controller, Get, HttpStatus, Req, UseFilters, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpStatus, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { HttpExceptionFilter } from 'src/exceptions/http.exception.filter';
import { AuthSuccess, ExpiredTokenError, InvalidTokenError, RefreshJWTSuccess } from 'src/dto/auth.swagger.dto';
import {
AuthSuccess,
ExpiredTokenError,
InvalidTokenError,
RefreshJWTSuccess,
VerifyEmailSuccess,
} from 'src/dto/auth.swagger.dto';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { Request } from 'express';
import { User } from 'src/entities/user.entity';
import { VerifyEmailDto } from 'src/dto/auth.verify.email.dto';
import { RequestError } from 'src/dto/product.swagger.dto';
import { EmailNotFound } from 'src/dto/user.swagger.dto';

@ApiTags('auth api')
@Controller('auth')
Expand Down Expand Up @@ -48,4 +60,16 @@ export class AuthController {
const { accessToken, refreshToken } = await this.authService.refreshJWT(req.user.id);
return { statusCode: HttpStatus.OK, message: '토큰 재발급 성공', accessToken, refreshToken };
}

@ApiOperation({ summary: '이메일 인증 코드 검증', description: '이메일 인증 코드를 검증한다.' })
@ApiOkResponse({ type: VerifyEmailSuccess, description: '이메일 인증 성공' })
@ApiBadRequestResponse({ type: RequestError, description: '잘못된 요청입니다.' })
@ApiNotFoundResponse({ type: EmailNotFound, description: '해당 이메일로 발급받은 코드가 없거나 만료됨' })
@ApiBody({ type: VerifyEmailDto })
@Post('/verify/email')
async verifyEmail(@Body() verifyEmailDto: VerifyEmailDto) {
await this.authService.verifyEmail(verifyEmailDto.email, verifyEmailDto.verificationCode);
const verifyToken = await this.authService.getVerifyToken(verifyEmailDto.email);
return { statusCode: HttpStatus.OK, message: '이메일 인증 성공', verifyToken };
}
}
27 changes: 24 additions & 3 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import * as bcrypt from 'bcrypt';
import { User } from 'src/entities/user.entity';
import { ValidationException } from 'src/exceptions/validation.exception';
import { JwtService } from '@nestjs/jwt';
import { ACCESS_TOKEN_SECRETS, REFRESH_TOKEN_SECRETS, TWO_MONTHS_TO_SEC, TWO_WEEKS_TO_SEC } from 'src/constants';
import {
ACCESS_TOKEN_SECRETS,
REFRESH_TOKEN_SECRETS,
VERIFY_TOKEN_SECRETS,
sickbirdd marked this conversation as resolved.
Show resolved Hide resolved
TWO_MONTHS_TO_SEC,
TWO_WEEKS_TO_SEC,
} from 'src/constants';
import { InjectRedis } from '@songkeys/nestjs-redis';
import Redis from 'ioredis';

Expand All @@ -30,7 +36,7 @@ export class AuthService {
}

async validateUser(email: string, password: string): Promise<Record<string, string>> {
const user = await this.usersService.findOne(email);
const user = await this.usersService.findUserByEmail(email);
if (!user || !bcrypt.compareSync(password, user.password)) {
throw new ValidationException('로그인 실패');
}
Expand All @@ -41,7 +47,7 @@ export class AuthService {
}

async refreshJWT(userId: string): Promise<Record<string, string>> {
const user = await this.usersService.getUserById(userId);
const user = await this.usersService.findUserById(userId);
if (!user) {
throw new HttpException('유효하지 않은 refreshToken', HttpStatus.BAD_REQUEST);
}
Expand All @@ -59,4 +65,19 @@ export class AuthService {
throw new HttpException('Firebase 토큰 등록 실패', HttpStatus.INTERNAL_SERVER_ERROR);
}
}

async verifyEmail(email: string, code: string) {
const verficationCode = await this.redis.get(`verficationCode:${email}`);
if (!verficationCode) {
throw new HttpException('해당 이메일을 찾을 수 없습니다.', HttpStatus.NOT_FOUND);
}
if (verficationCode !== code) {
throw new HttpException('유효하지 않은 인증 코드', HttpStatus.UNAUTHORIZED);
}
}

async getVerifyToken(email: string) {
const verifyToken = this.jwtService.sign({ email }, { secret: VERIFY_TOKEN_SECRETS, expiresIn: '5m' });
return verifyToken;
}
}
20 changes: 19 additions & 1 deletion backend/src/auth/jwt/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ACCESS_TOKEN_SECRETS, REFRESH_TOKEN_SECRETS } from 'src/constants';
import { ACCESS_TOKEN_SECRETS, REFRESH_TOKEN_SECRETS, VERIFY_TOKEN_SECRETS } from 'src/constants';
import { JWTService } from './jwt.service';

@Injectable()
Expand Down Expand Up @@ -40,3 +40,21 @@ export class RefreshJwtStrategy extends PassportStrategy(Strategy, 'refresh') {
return { id: payload.id };
}
}

@Injectable()
export class VerifyJwtStrategy extends PassportStrategy(Strategy, 'verify') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: true,
secretOrKey: VERIFY_TOKEN_SECRETS,
});
}

async validate(payload: any) {
if (Date.now() >= payload.exp * 1000) {
throw new HttpException('토큰이 만료되었습니다.', HttpStatus.GONE);
}
return { email: payload.email };
}
}
17 changes: 17 additions & 0 deletions backend/src/configs/mailer.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MailerOptions } from '@nestjs-modules/mailer';
import { MAILER_PW, MAILER_USER } from 'src/constants';

export const MailerConfig: MailerOptions = {
transport: {
host: 'smtp.gmail.com',
prot: 587,
secure: false,
auth: {
user: MAILER_USER,
pass: MAILER_PW,
},
},
defaults: {
from: '"PriceGuard" <[email protected]>',
},
};
7 changes: 7 additions & 0 deletions backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const DB_NAME = process.env.DB_NAME;
export const NODE_ENV = process.env.NODE_ENV;
export const ACCESS_TOKEN_SECRETS = process.env.ACCESS_TOKEN_SECRETS;
export const REFRESH_TOKEN_SECRETS = process.env.REFRESH_TOKEN_SECRETS;
export const VERIFY_TOKEN_SECRETS = process.env.VERIFY_TOKEN_SECRETS;
export const OPEN_API_KEY_11ST = process.env.OPEN_API_KEY_11ST as string;
export const BASE_URL_11ST = process.env.BASE_URL_11ST as string;
export const MAX_TRACKING_RANK = parseInt(process.env.MAX_TRACKING_RANK || '50');
Expand Down Expand Up @@ -54,3 +55,9 @@ type AddProductLimit = {
export const ADD_PRODUCT_LIMIT: AddProductLimit = {
tier1: 3,
};
export const MAILER_USER = process.env.MAILER_USER;
export const MAILER_PW = process.env.MAILER_PW;
export const MIN_VERFICATION_CODE = 100000;
export const MAX_VERFICATION_CODE = 999999;
export const MAX_SENDING_EMAIL = 5;
export const THREE_MIN_TO_SEC = 3 * 60;
18 changes: 18 additions & 0 deletions backend/src/dto/auth.swagger.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,21 @@ export class RefreshJWTSuccess {
})
refreshToken: string;
}

export class VerifyEmailSuccess {
@ApiProperty({
example: HttpStatus.OK,
description: 'Http 상태 코드',
})
statusCode: number;
@ApiProperty({
example: '이메일 인증 성공',
description: '메시지',
})
message: string;
@ApiProperty({
example: 'verify token example',
description: 'Verify Token의 만료 기간은 3분이다.',
})
verifyToken: string;
}
21 changes: 21 additions & 0 deletions backend/src/dto/auth.verify.email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class VerifyEmailDto {
@ApiProperty({
example: '[email protected]',
description: '인증을 위한 이메일',
required: true,
})
@IsString()
@IsNotEmpty()
email: string;

@ApiProperty({
example: '345123',
description: '인증코드',
required: true,
})
@IsString()
verificationCode: string;
}
2 changes: 1 addition & 1 deletion backend/src/dto/login.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { IsEmail, IsString } from 'class-validator';

export class LoginDto {
@ApiProperty({
example: 'bichoi0715@naver.com',
example: 'example123@naver.com',
description: '로그인 이메일',
required: true,
})
Expand Down
2 changes: 1 addition & 1 deletion backend/src/dto/user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { IsEmail, IsString } from 'class-validator';

export class UserDto {
@ApiProperty({
example: 'bichoi0715@naver.com',
example: 'example123@naver.com',
description: '사용자 이메일',
required: true,
})
Expand Down
13 changes: 13 additions & 0 deletions backend/src/dto/user.email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class UserEmailDto {
@ApiProperty({
example: '[email protected]',
description: '인증을 위한 이메일',
required: true,
})
@IsString()
@IsNotEmpty()
email: string;
}
13 changes: 13 additions & 0 deletions backend/src/dto/user.password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class UserPasswordDto {
@ApiProperty({
example: '1q2w3e4r',
description: '사용자 비밀번호',
required: true,
})
@IsString()
@IsNotEmpty()
password: string;
}
36 changes: 36 additions & 0 deletions backend/src/dto/user.register.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';

export class UserRegisterDto {
@ApiProperty({
example: '[email protected]',
description: '사용자 이메일',
required: true,
})
@IsEmail()
email: string;

@ApiProperty({
example: '최병익',
description: '사용자 이름',
required: true,
})
@IsString()
userName: string;

@ApiProperty({
example: '345123',
description: '인증코드',
required: true,
})
@IsString()
verificationCode: string;

@ApiProperty({
example: '1q2w3e4r',
description: '사용자 비밀번호',
required: true,
})
@IsString()
password: string;
}
Loading