Skip to content

Commit

Permalink
feat: Features/Auth (#6)
Browse files Browse the repository at this point in the history
* feat: Controller DTO 생성

* feat: Controller DTO 생성

* chore: NodeMailer 모듈 추가

* feat: RedisRepository CRUD 함수 추가

* feat: 인증코드 발급/검증 로직

* feat: 회원가입/로그인 구현

* fix: response가 void인 경우 content가 누락되던 현상 수정

* fix: MemberRepository 조회 시 password가 불러와지던 오류 수정

* feat: UserGuard 및 UserMiddleware 구현

* fix: 코드 인증시 redis 삭제 lazy

* fix: 코드 인증시 가입 확인

* refactor: 리팩토링

* refactor: 리팩토링

* docs: Auth Swagger 문서화

* refactor: validate 오류 문자열 한글화

* refactor: import 최적화

* docs: Swagger 문서에서 인증 토큰 및 uid를 UUIDv4로 표기

* chore: 재사용을 위해 ValidationPipe Factory 생성

* fix: DTO Validation시 하나의 주요 규칙만 적용

* fix: 비밀번호 정규식 수정 (특수문자 선택적 포함)

* test: Auth의 Request DTO 검증 테스트 추가

* chore: 테스트 폴더 분리

* test: Auth의 Request DTO 검증 테스트

* test: Auth Service 테스트

* test: Auth 통합 테스트

* docs: config 변화 README.md 업데이트

* test: test

* test: rollback

* chore: 누락된 yarn.lock 추가

* chore: 폴더 이동 (from dto/spec to spec)

* chore: 폴더 이동 (from dto/spec to spec)
  • Loading branch information
son-daehyeon authored Jul 17, 2024
1 parent 6d97bef commit b3fc882
Show file tree
Hide file tree
Showing 39 changed files with 2,241 additions and 197 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ mongo:
authSource: ${MONGO_AUTH_SOURCE:=''}
database: ${MONGO_DATABASE:=test}

smtp:
host: ${SMTP_HOST:=smtp.gmail.com}
port: ${SMTP_PORT:=465}
secure: ${SMTP_SECURE:=true}
username: ${SMTP_USERNAME:=''}
password: ${SMTP_PASSWORD:=''}

jwt:
secret: ${JWT_SECRET:=secret}
expiresIn: ${JWT_EXPIRES_IN:=14d}


```

### 2. 의존성 설치
Expand Down Expand Up @@ -53,6 +65,10 @@ docker run --name wink-backend \
-e MONGO_HOST=(MONGO_HOST) -e MONGO_PORT=(MONGO_PORT) \
-e MONGO_USERNAME=(MONGO_USERNAME) -e MONGO_PASSWORD=(MONGO_PASSWORD) \
-e MONGO_AUTH_SOURCE=(MONGO_AUTH_SOURCE) -e MONGO_DATABASE=(MONGO_DATABASE) \
-e SMTP_HOST=(SMTP_HOST) -e SMTP_PORT=(SMTP_PORT) \
-e SMTP_USER=(SMTP_USER) -e SMTP_PASS=(SMTP_PASS) \
-e SMTP_SECURE=(SMTP_SECURE) -e \
-e JWT_SECRET=(JWT_SECRET) -e JWT_EXPIRES_IN=(JWT_EXPIRES_IN) \
-p 8080:8080 \
-v /path/to/logs:/app/logs \
-d wink-backend
Expand All @@ -66,6 +82,10 @@ docker run --name wink-backend \
-e REDIS_HOST=redis -e REDIS_PORT=6379 \
-e MONGO_HOST=mongo -e MONGO_PORT=27017 \
-e MONGO_DATABASE=wink \
-e SMTP_HOST=(SMTP_HOST) -e SMTP_PORT=(SMTP_PORT) \
-e SMTP_USER=(SMTP_USER) -e SMTP_PASS=(SMTP_PASS) \
-e SMTP_SECURE=(SMTP_SECURE) -e \
-e JWT_SECRET=(JWT_SECRET) -e JWT_EXPIRES_IN=(JWT_EXPIRES_IN) \
-p 8080:8080 \
-v /path/to/logs:/app/logs \
-d wink-backend
Expand Down
11 changes: 11 additions & 0 deletions config/config.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,14 @@ mongo:
password: ${MONGO_PASSWORD:=''}
authSource: ${MONGO_AUTH_SOURCE:=''}
database: ${MONGO_DATABASE:=test}

smtp:
host: ${SMTP_HOST:=smtp.gmail.com}
port: ${SMTP_PORT:=465}
secure: ${SMTP_SECURE:=true}
username: ${SMTP_USERNAME:=''}
password: ${SMTP_PASSWORD:=''}

jwt:
secret: ${JWT_SECRET:=secret}
expiresIn: ${JWT_EXPIRES_IN:=14d}
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@
"@nestjs/mongoose": "^10.0.10",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/swagger": "^7.4.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"ioredis": "^5.4.1",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.5.1",
"mongoose-autopopulate": "^1.1.0",
"nest-winston": "^1.9.7",
"nodemailer": "^6.9.14",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^10.0.0",
"winston": "^3.13.1",
"winston-daily-rotate-file": "^5.0.0"
},
Expand All @@ -42,7 +46,11 @@
"@types/jest": "^29.5.12",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.14.10",
"@types/nodemailer": "^6.4.15",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.6",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"eslint": "^8.42.0",
Expand Down
6 changes: 4 additions & 2 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { MemberModule } from '../domain/member/member.module';
import { ActivityModule } from '../domain/activity/activity.module';

import { RequestLoggingMiddleware } from '../utils/logger/RequestLoggingMiddleware';
import configuration from '../utils/config/configuration';
import { AuthMiddleware } from '../domain/auth/auth.middleware';

import { MongooseConfigService } from '../utils/mongo/MongooseConfigService';
import configuration from '../utils/config/configuration';

@Module({
imports: [
Expand All @@ -23,10 +25,10 @@ import { MongooseConfigService } from '../utils/mongo/MongooseConfigService';
ActivityModule,
],
controllers: [],
providers: [RequestLoggingMiddleware],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
consumer.apply(AuthMiddleware).forRoutes('*');
}
}
18 changes: 0 additions & 18 deletions src/domain/auth/auth.controller.spec.ts

This file was deleted.

132 changes: 130 additions & 2 deletions src/domain/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,132 @@
import { Controller } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Post, Put, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';

import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
import { ReqMember } from './auth.middleware';

import { Member } from '../member/member.schema';

import { LoginRequest } from './dto/request/LoginRequest';
import { LoginResponse } from './dto/response/LoginResponse';
import { RegisterRequest } from './dto/request/RegisterRequest';
import { SendCodeRequest } from './dto/request/SendCodeRequest';
import { VerifyCodeRequest } from './dto/request/VerifyCodeRequest';
import { VerifyCodeResponse } from './dto/response/VerifyCodeResponse';
import { MyInfoResponse } from './dto/response/MyInfoResponse';

import { ApiCustomResponse } from '../../utils/swagger/ApiCustomResponse.decorator';
import { ApiCustomErrorResponseDecorator } from '../../utils/swagger/ApiCustomErrorResponse.decorator';

import { MemberNotFoundException } from './exception/MemberNotFoundException';
import { WrongPasswordException } from './exception/WrongPasswordException';
import { InvalidVerifyTokenException } from './exception/InvalidVerifyTokenException';
import { AlreadyRegisteredByEmailException } from './exception/AlreadyRegisteredByEmailException';
import { AlreadyRegisteredByStudentIdException } from './exception/AlreadyRegisteredByStudentIdException';
import { InvalidVerifyCodeException } from './exception/InvalidVerifyCodeException';

@Controller('auth')
export class AuthController {}
@ApiTags('Auth')
export class AuthController {
constructor(private readonly service: AuthService) {}

@Post()
@HttpCode(200)
@ApiOperation({ summary: '로그인' })
@ApiProperty({ type: LoginRequest })
@ApiCustomResponse({ type: LoginResponse, status: 200 })
@ApiCustomErrorResponseDecorator([
{
description: '회원을 찾을 수 없음',
error: MemberNotFoundException,
},
{
description: '비밀번호가 틀림',
error: WrongPasswordException,
},
])
async login(@Body() request: LoginRequest): Promise<LoginResponse> {
const { email, password } = request;

const token = await this.service.login(email, password);

return { token };
}

@Put()
@HttpCode(201)
@ApiOperation({ summary: '회원가입' })
@ApiProperty({ type: RegisterRequest })
@ApiCustomResponse({ status: 201 })
@ApiCustomErrorResponseDecorator([
{
description: '이메일 인증 토큰이 잘못됨',
error: InvalidVerifyTokenException,
},
{
description: '이미 가입된 이메일',
error: AlreadyRegisteredByEmailException,
},
{
description: '이미 가입된 학번',
error: AlreadyRegisteredByStudentIdException,
},
])
async register(@Body() request: RegisterRequest): Promise<void> {
const { name, studentId, password, verifyToken } = request;

await this.service.register(name, studentId, password, verifyToken);
}

@Get('/code')
@HttpCode(201)
@ApiOperation({ summary: '인증코드 전송' })
@ApiProperty({ type: SendCodeRequest })
@ApiCustomResponse({ status: 201 })
@ApiCustomErrorResponseDecorator([
{
description: '이미 가입된 이메일',
error: AlreadyRegisteredByEmailException,
},
])
async sendCode(@Body() request: SendCodeRequest): Promise<void> {
const { email } = request;

await this.service.sendCode(email);
}

@Post('/code')
@HttpCode(200)
@ApiOperation({ summary: '인증 토큰 발급' })
@ApiProperty({ type: VerifyCodeRequest })
@ApiCustomResponse({ type: VerifyCodeResponse, status: 200 })
@ApiCustomErrorResponseDecorator([
{
description: '잘못된 인증 코드',
error: InvalidVerifyCodeException,
},
])
async verifyCode(@Body() request: VerifyCodeRequest): Promise<VerifyCodeResponse> {
const { email, code } = request;

const verifyToken = await this.service.verifyCode(email, code);

return { verifyToken };
}

@Get('/me')
@HttpCode(200)
@UseGuards(AuthGuard)
@ApiOperation({ summary: '인증 토큰으로 정보 조회' })
@ApiBearerAuth()
@ApiCustomResponse({ type: MyInfoResponse, status: 200 })
async getMyInfo(@ReqMember() member: Member): Promise<MyInfoResponse> {
const memberDoc = member['_doc'];
const memberId = member['_id'];

delete memberDoc['_id'];
delete memberDoc['__v'];

return { userId: memberId, ...memberDoc };
}
}
35 changes: 35 additions & 0 deletions src/domain/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

import * as jwt from 'jsonwebtoken';

import { MemberRepository } from '../member/member.repository';

@Injectable()
export class AuthGuard implements CanActivate {
private readonly jwtSecret: string;

constructor(
private configService: ConfigService,
private repository: MemberRepository,
) {
this.jwtSecret = this.configService.get<string>('jwt.secret');
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authorization = request.headers['authorization'];

if (authorization && authorization.startsWith('Bearer ')) {
const token = authorization.slice(7);

if (jwt.verify(token, this.jwtSecret)) {
const id = jwt.decode(authorization.slice(7))['id'];

return await this.repository.existsById(id);
}
}

return false;
}
}
29 changes: 29 additions & 0 deletions src/domain/auth/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createParamDecorator, ExecutionContext, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction } from 'express';

import { MemberRepository } from '../member/member.repository';

import * as jwt from 'jsonwebtoken';

@Injectable()
export class AuthMiddleware implements NestMiddleware {
constructor(private repository: MemberRepository) {}

async use(req: Request, res: Response, next: NextFunction) {
const authorization = req.headers['authorization'];

if (authorization) {
const id = jwt.decode(authorization.slice(7))['id'];

req['member'] = await this.repository.findById(id);
}

next();
}
}

export const ReqMember = createParamDecorator((data: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();

return request.member;
});
6 changes: 6 additions & 0 deletions src/domain/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';

import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

import { MemberModule } from '../member/member.module';
import { MailModule } from '../../utils/mail/mail.module';
import { RedisModule } from '../../utils/redis/redis.module';

@Module({
imports: [MemberModule, MailModule, RedisModule],
controllers: [AuthController],
providers: [AuthService],
})
Expand Down
18 changes: 0 additions & 18 deletions src/domain/auth/auth.service.spec.ts

This file was deleted.

Loading

0 comments on commit b3fc882

Please sign in to comment.