diff --git a/.env.example b/.env.example deleted file mode 100644 index e084950..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# 서버 애플리케이션 포트 관련 변수입니다. -PORT=8000 - -# DB 세팅 환경 변수입니다 -DB_HOST=localhost -DB_PORT=3306 -DB_USERNAME=user -DB_PASSWORD=password -DB_DATABASE=nest_db - -DB_SYNC=true \ No newline at end of file diff --git a/README.md b/README.md index f9ce765..166daf8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ # 미션 - 휴대폰 인증 API + +## 구현 기능 + +- POST 휴대전화 번호에 인증번호 전송 +- POST 휴대전화 번호와 인증번호를 입력받아 인증 + +## 예외처리 + +- API에 요청받은 Body 값의 타입을 검증하여 올바르지 않은 타입일 경우 400 BadRequest 에러를 리턴 +- API에 요청받은 Body 값의 필수 값이 누락되거나/빈 값인 경우 400 BadRequest 에러를 리턴 +- 인증번호가 만료된 경우 400 BadRequest 에러를 리턴 +- 인증번호가 다를 시 400 BadRequest 에러를 리턴 + ## 🔍 진행 방식 - 미션은 **기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항** 세 가지로 구성되어 있다. @@ -24,26 +37,27 @@ Ran all test suites. 기본적으로 휴대전화 번호 인증을 위한 API를 구현한다. 예시) + 1. 010-1234-5678로 인증번호를 전송한다. 2. 010-1234-5678로 전송된 인증번호를 입력하면 인증이 완료된다. 총 2개의 API 엔드포인트로 구성한다. - - 휴대전화 번호를 인증번호를 전송하는 API - - 실제 휴대전화 번호로 전송되는 것이 아니라, 휴대전화 번호를 입력하면 인증번호가 전송된다고 가정한다. - - 인증번호은 인증 요청시간으로부터 5분간 유효하다고 가정한다. - - 인증번호 전송시에는 API 응답으로 인증번호를 리턴한다. - - - 휴대전화 번호와 인증번호를 입력받아 인증하는 API +- 휴대전화 번호를 인증번호를 전송하는 API + - 실제 휴대전화 번호로 전송되는 것이 아니라, 휴대전화 번호를 입력하면 인증번호가 전송된다고 가정한다. + - 인증번호은 인증 요청시간으로부터 5분간 유효하다고 가정한다. + - 인증번호 전송시에는 API 응답으로 인증번호를 리턴한다. +- 휴대전화 번호와 인증번호를 입력받아 인증하는 API + ### 공통 필수 예외처리 사항 - API에 요청받은 Body 값의 타입을 검증하여 올바르지 않은 타입일 경우 `400 BadRequest` 에러를 리턴해야한다. - API에 요청받은 Body 값의 필수 값이 누락되거나/빈 값인 경우 `400 BadRequest` 에러를 리턴해야한다. - ### API 요청/응답 요구 사항 + 1. 모든 API의 요청/응답은 DTO를 통해 TypeSafe하게 이루어져야한다. 2. DTO의 타입은 `class-validator`를 이용하여 검증한다. 3. DTO 내부 요소의 명칭은 `camelCase`로 작성한다. @@ -51,10 +65,13 @@ Ran all test suites. #### 요청 - 휴대전화 번호는 010-1234-5678과 같은 문자열 형식이다. + ``` phoneNumber : 010-1234-5678 ``` + - 인증번호는 6자리 랜덤 숫자 문자열이다. + ``` code : 612131 ``` @@ -62,10 +79,13 @@ code : 612131 #### 응답 - 인증번호 요청시 인증번호를 응답으로 리턴한다. + ``` code : 612131 ``` + - 인증 완료시 `true`를 응답으로 리턴한다. + ``` result : true ``` @@ -78,13 +98,15 @@ result : true - **Swagger**를 이용하여 API 명세를 작성한다. - **package.json**에 명시된 라이브러리만을 이용하여 구현한다. - **eslint**, **prettier** 등의 코드 포맷팅 라이브러리를 이용하여 제공된 코드 컨벤션에 맞추어 코드를 작성한다. -- `node`, `npm` 버전은 `package.json`에 명시된 버전을 사용한다. [Volta를 이용하여 node 버전을 관리한다.](https://docs.volta.sh/guide/getting-started) +- `node`, `npm` 버전은 `package.json`에 명시된 버전을 + 사용한다. [Volta를 이용하여 node 버전을 관리한다.](https://docs.volta.sh/guide/getting-started) - **(선택 사항)** API 구현이 완료되고, 유닛 테스트, E2E 테스트등 모든 테스트 코드를 작성하여 테스트를 통과하면 굿! + --- ## ✏️ 과제 진행 요구 사항 - 미션은 [nest-phone-verify](https://github.com/eojjeoda-nest/nest-phone-verify-1) 저장소를 Fork & Clone 하고 시작한다. -- **기능을 구현하기 전 `README.md`에 구현할 기능/예외처리를 목록으로 정리**해 추가한다. \ No newline at end of file +- **기능을 구현하기 전 `README.md`에 구현할 기능/예외처리를 목록으로 정리**해 추가한다. diff --git a/package-lock.json b/package-lock.json index a31e1a7..934af5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "attendance-api", + "name": "nest-phone-verify-1", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "attendance-api", + "name": "nest-phone-verify-1", "version": "0.0.1", "license": "UNLICENSED", "dependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index 8309d36..fde5010 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { addTransactionalDataSource } from 'typeorm-transactional'; import { DataSource } from 'typeorm'; +import { AuthModule } from './auth/auth.module'; +import { Auth } from './auth/entity/auth.entity'; @Module({ imports: [ @@ -16,6 +18,7 @@ import { DataSource } from 'typeorm'; username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, + entities: [Auth], synchronize: process.env.DB_SYNC === 'true', timezone: 'Z', }; @@ -28,6 +31,7 @@ import { DataSource } from 'typeorm'; return addTransactionalDataSource(new DataSource(options)); }, }), + AuthModule, ], controllers: [], providers: [], diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..27a31e6 --- /dev/null +++ b/src/auth/auth.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; + +describe('AuthController', () => { + let controller: AuthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + }).compile(); + + controller = module.get(AuthController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..292b97e --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,34 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { RequestSigninCodeDto } from './dto/request-signin-code.dto'; +import { SmsCodeDto } from './dto/sms-code.dto'; +import { VerifySinginCodeDto } from './dto/verify-singin-code.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + /** SMS 인증번호 요청 */ + @Post('signin') + @HttpCode(HttpStatus.OK) + async requestSigninToken(@Body() body: RequestSigninCodeDto) { + return this.authService.requestSigninCode(body); + } + + /** SMS 인증 */ + @Post('signin/:code') + @HttpCode(HttpStatus.OK) + async verifySigninToken( + @Param() param: SmsCodeDto, + @Body() body: VerifySinginCodeDto, + ) { + return await this.authService.verifySigninCode(param, body); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..b857f04 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Auth } from './entity/auth.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Auth])], + providers: [AuthService], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..800ab66 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..d5373a7 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,76 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { RequestSigninCodeDto } from './dto/request-signin-code.dto'; +import { SinginCodeResponseDto } from './dto/singin-code-response.dto'; +import { SmsCodeDto } from './dto/sms-code.dto'; +import { VerifySinginResponseDto } from './dto/verify-singin-response.dto'; +import { TTL } from '../util/consts'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Auth } from './entity/auth.entity'; +import { Repository } from 'typeorm'; +import { VerifySinginCodeDto } from './dto/verify-singin-code.dto'; + +@Injectable() +export class AuthService { + constructor( + @InjectRepository(Auth) + private readonly authRepository: Repository, + ) {} + + private generateCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } + + /** + * SMS 인증번호 요청 : + * 실제 휴대전화 번호로 전송되는 것이 아니라, 휴대전화 번호를 입력하면 인증번호가 전송된다고 가정한다. + * 인증번호은 인증 요청시간으로부터 5분간 유효하다고 가정한다. + * 인증번호 전송시에는 API 응답으로 인증번호를 리턴한다 + */ + async requestSigninCode( + body: RequestSigninCodeDto, + ): Promise { + const { phone } = body; + const code = this.generateCode(); + const expires = Date.now() + TTL.VALIDITY_DURATION * 1000; + + const smsCode = this.authRepository.create({ + phone, + code, + expires, + }); + await this.authRepository.save(smsCode); + + return { code }; + } + + /** + * SMS 인증 + * 인증번호의 유효시간이 5분이 지나면 인증번호가 만료되었다고 반환한다. + * 인증번호가 일치하는지 확인한다. + */ + async verifySigninCode( + param: SmsCodeDto, + body: VerifySinginCodeDto, + ): Promise { + const { code } = param; + const { phone } = body; + + const smsCode = await this.authRepository.findOne({ + where: { phone }, + }); + + if (!smsCode || smsCode.expires < Date.now()) { + throw new UnauthorizedException( + '인증 시간이 만료되었습니다. 다시 시도해 주세요.', + ); + } + + if (smsCode.code !== code) { + throw new UnauthorizedException( + '인증번호가 일치하지 않습니다. 다시 시도해 주세요.', + ); + } + + return { result: true }; + } +} diff --git a/src/auth/dto/request-signin-code.dto.ts b/src/auth/dto/request-signin-code.dto.ts new file mode 100644 index 0000000..8388f47 --- /dev/null +++ b/src/auth/dto/request-signin-code.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RequestSigninCodeDto { + @ApiProperty({ description: 'phone number', example: '010-1234-5678' }) + @IsString() + @IsNotEmpty() + @Matches(/^01[0-9]-[0-9]{3,4}-[0-9]{4}$/, { + message: + 'phoneNumber must be a valid Korean phone number format (e.g., 010-1234-5678)', + }) + phone: string; +} diff --git a/src/auth/dto/singin-code-response.dto.ts b/src/auth/dto/singin-code-response.dto.ts new file mode 100644 index 0000000..5e9e102 --- /dev/null +++ b/src/auth/dto/singin-code-response.dto.ts @@ -0,0 +1,4 @@ +export class SinginCodeResponseDto { + /** 인증번호 */ + code: string; +} diff --git a/src/auth/dto/sms-code.dto.ts b/src/auth/dto/sms-code.dto.ts new file mode 100644 index 0000000..f236e45 --- /dev/null +++ b/src/auth/dto/sms-code.dto.ts @@ -0,0 +1,9 @@ +import { IsNumberString, Length } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SmsCodeDto { + @ApiProperty({ description: 'sms code', example: '123456' }) + @Length(6, 6) + @IsNumberString() + code: string; +} diff --git a/src/auth/dto/verify-singin-code.dto.ts b/src/auth/dto/verify-singin-code.dto.ts new file mode 100644 index 0000000..ec49522 --- /dev/null +++ b/src/auth/dto/verify-singin-code.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class VerifySinginCodeDto { + @ApiProperty({ description: 'phone number', example: '010-1234-5678' }) + @IsString() + @IsNotEmpty() + @Matches(/^01[0-9]-[0-9]{3,4}-[0-9]{4}$/, { + message: + 'phoneNumber must be a valid Korean phone number format (e.g., 010-1234-5678)', + }) + phone: string; +} diff --git a/src/auth/dto/verify-singin-response.dto.ts b/src/auth/dto/verify-singin-response.dto.ts new file mode 100644 index 0000000..b22ee41 --- /dev/null +++ b/src/auth/dto/verify-singin-response.dto.ts @@ -0,0 +1,3 @@ +export class VerifySinginResponseDto { + result: boolean; +} diff --git a/src/auth/entity/auth.entity.ts b/src/auth/entity/auth.entity.ts new file mode 100644 index 0000000..54da79d --- /dev/null +++ b/src/auth/entity/auth.entity.ts @@ -0,0 +1,17 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class Auth { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 50 }) + phone: string; + + @Column() + code: string; + + // 밀리초 단위의 타임스탬프 데이터를 정확하고 안정적으로 저장하기 위함 + @Column('bigint') + expires: number; +} diff --git a/src/main.ts b/src/main.ts index 620bdfe..a0b696f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,26 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { initializeTransactionalContext } from 'typeorm-transactional'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { initializeTransactionalContext(); const app = await NestFactory.create(AppModule); - // TODO: 프로그램 구현 - await app.listen(process.env.PORT || 8000); + const options = new DocumentBuilder() + .setTitle('Phone verify API') + .setDescription('Phone verify API description') + .setVersion('1.0') + .addTag('API') + .build(); + + const document = SwaggerModule.createDocument(app, options); + SwaggerModule.setup('api', app, document); + + app.useGlobalPipes(new ValidationPipe()); + await app.listen(process.env.PORT || 8000); console.log(`Application is running on: ${await app.getUrl()}`); } diff --git a/src/util/consts.ts b/src/util/consts.ts new file mode 100644 index 0000000..b005d99 --- /dev/null +++ b/src/util/consts.ts @@ -0,0 +1,3 @@ +export enum TTL { + VALIDITY_DURATION = 60 * 5, // 5분을 초단위로 변경 +}