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

[이수현] 휴대폰 인증 API #4

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .env.example

This file was deleted.

40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
# 미션 - 휴대폰 인증 API

## 구현 기능

- POST 휴대전화 번호에 인증번호 전송
- POST 휴대전화 번호와 인증번호를 입력받아 인증

## 예외처리

- API에 요청받은 Body 값의 타입을 검증하여 올바르지 않은 타입일 경우 400 BadRequest 에러를 리턴
- API에 요청받은 Body 값의 필수 값이 누락되거나/빈 값인 경우 400 BadRequest 에러를 리턴
- 인증번호가 만료된 경우 400 BadRequest 에러를 리턴
- 인증번호가 다를 시 400 BadRequest 에러를 리턴

## 🔍 진행 방식

- 미션은 **기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항** 세 가지로 구성되어 있다.
Expand All @@ -24,48 +37,55 @@ 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`로 작성한다.

#### 요청

- 휴대전화 번호는 010-1234-5678과 같은 문자열 형식이다.

```
phoneNumber : 010-1234-5678
```

- 인증번호는 6자리 랜덤 숫자 문자열이다.

```
code : 612131
```

#### 응답

- 인증번호 요청시 인증번호를 응답으로 리턴한다.

```
code : 612131
```

- 인증 완료시 `true`를 응답으로 리턴한다.

```
result : true
```
Expand All @@ -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`에 구현할 기능/예외처리를 목록으로 정리**해 추가한다.
- **기능을 구현하기 전 `README.md`에 구현할 기능/예외처리를 목록으로 정리**해 추가한다.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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',
};
Expand All @@ -28,6 +31,7 @@ import { DataSource } from 'typeorm';
return addTransactionalDataSource(new DataSource(options));
},
}),
AuthModule,
],
controllers: [],
providers: [],
Expand Down
18 changes: 18 additions & 0 deletions src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AuthController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
34 changes: 34 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상태 코드 반환을 HttpStatus 객체를 통해 반환할 수 있군요👍
저는 200, 400 이렇게 써서 보냈었는데.. 훨씬 명시적이라 좋습니다! 알아갑니다😄

async verifySigninToken(
@Param() param: SmsCodeDto,
@Body() body: VerifySinginCodeDto,
) {
return await this.authService.verifySigninCode(param, body);
}
}
12 changes: 12 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
18 changes: 18 additions & 0 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AuthService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
76 changes: 76 additions & 0 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<Auth>,
) {}

private generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}

/**
* SMS 인증번호 요청 :
* 실제 휴대전화 번호로 전송되는 것이 아니라, 휴대전화 번호를 입력하면 인증번호가 전송된다고 가정한다.
* 인증번호은 인증 요청시간으로부터 5분간 유효하다고 가정한다.
* 인증번호 전송시에는 API 응답으로 인증번호를 리턴한다
*/
async requestSigninCode(
body: RequestSigninCodeDto,
): Promise<SinginCodeResponseDto> {
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<VerifySinginResponseDto> {
const { code } = param;
const { phone } = body;

const smsCode = await this.authRepository.findOne({
where: { phone },
});

if (!smsCode || smsCode.expires < Date.now()) {
throw new UnauthorizedException(
'인증 시간이 만료되었습니다. 다시 시도해 주세요.',
);
}
Comment on lines +58 to +66
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

smsCode 변수를 보고 인증 번호를 가져오는 줄 알았어요! 변수명이 명시적이면 좋을 것 같아요😄

if (!smsCode || smsCode.expires < Date.now()) 에서 핸드폰 번호가 없을 때도 '인증 시간이 만료' 로 메세지가 날라가는데, 예외처리를 분리하면 좋겠다는 생각이 드네요!


if (smsCode.code !== code) {
throw new UnauthorizedException(
'인증번호가 일치하지 않습니다. 다시 시도해 주세요.',
);
}

return { result: true };
}
}
13 changes: 13 additions & 0 deletions src/auth/dto/request-signin-code.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions src/auth/dto/singin-code-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export class SinginCodeResponseDto {
/** 인증번호 */
code: string;
}
9 changes: 9 additions & 0 deletions src/auth/dto/sms-code.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/auth/dto/verify-singin-code.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions src/auth/dto/verify-singin-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class VerifySinginResponseDto {
result: boolean;
}
17 changes: 17 additions & 0 deletions src/auth/entity/auth.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading