Skip to content

Commit

Permalink
Merge pull request #49 from PBTP/feature/otp
Browse files Browse the repository at this point in the history
Feature/otp
  • Loading branch information
emibgo2 authored Nov 17, 2024
2 parents 0082216 + a1303c7 commit 45f0462
Show file tree
Hide file tree
Showing 20 changed files with 693 additions and 125 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/socket.io": "^3.0.2",
"@types/speakeasy": "^2.0.10",
"@willsoto/nestjs-prometheus": "^6.0.1",
"aws-sdk": "^2.1623.0",
"axios": "^1.7.2",
Expand All @@ -52,6 +53,8 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"socket.io-redis": "^6.1.1",
"solapi": "^5.3.1",
"speakeasy": "^2.0.0",
"tsid-ts": "^0.0.9",
"typeorm": "^0.3.20",
"typeorm-naming-strategies": "^4.1.0"
Expand Down Expand Up @@ -89,7 +92,8 @@
"roots": [
"<rootDir>/src",
"<rootDir>/test"
], "testRegex": ".*\\.(spec|test)\\.ts$",
],
"testRegex": ".*\\.(spec|test)\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
Expand Down
54 changes: 52 additions & 2 deletions src/auth/application/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,41 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { CACHE_SERVICE, ICacheService } from '../../common/cache/cache.service';
import { UnauthorizedException } from '@nestjs/common/exceptions';
import {
BadRequestException,
UnauthorizedException,
} from '@nestjs/common/exceptions';
import { UserService } from './user.service';
import { UserDto } from '../presentation/user.dto';
import { AuthDto } from '../presentation/auth.dto';
import { Builder } from 'builder-pattern';
import { ISecurityService, SECURITY_SERVICE } from './security.service';
import { Sender } from '../../common/sender/sender.interface';
import {
ISmsService,
SMS_SERVICE,
} from '../../common/sender/sms/application/sms.service';

@Injectable()
export class AuthService {
private readonly accessTokenOption: JwtSignOptions;
private readonly refreshTokenOption: JwtSignOptions;
private readonly accessTokenStrategy: string;
private readonly logger = new Logger(AuthService.name);
private readonly senders: {
[key: string]: Sender;
} = {};

constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly userService: UserService,
@Inject(SECURITY_SERVICE)
private readonly securityService: ISecurityService,
@Inject(CACHE_SERVICE)
private readonly cacheService: ICacheService,
private readonly userService: UserService,
@Inject(SMS_SERVICE)
private readonly smsService: ISmsService,
) {
this.accessTokenOption = {
secret: this.configService.get<string>('jwt/access/secret'),
Expand All @@ -35,6 +51,8 @@ export class AuthService {
this.accessTokenStrategy = <string>(
this.configService.get('jwt/access/strategy')
);

this.senders.sms = this.smsService;
}

async login(dto: UserDto): Promise<AuthDto> {
Expand Down Expand Up @@ -193,4 +211,36 @@ export class AuthService {
userId: payload.subject,
});
}

async generateOtp(secret: string): Promise<string> {
return await this.securityService.generateOtp(secret);
}

async sendOtp(sendType: string, secret: string): Promise<string> {
return await this.generateOtp(secret).then(async (otp) => {
const message = `몽글\n인증번호는 [${otp}] 입니다.`;
const sender = this.senders[sendType];

if (!sender) {
throw new BadRequestException('지원하지 않는 전송 방식입니다.');
}

await sender.send(secret, message);
return otp;
});
}

async otpVerifyAndUserUpdate(
user: UserDto,
secret: string,
otp: string,
): Promise<boolean> {
if (await this.securityService.validateOtp(secret, otp)) {
user.phoneNumber = secret;
await this.userService.update(user);
return true;
}

return false;
}
}
16 changes: 13 additions & 3 deletions src/auth/application/security.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { SecurityService } from './security.service';
import { SECURITY_SERVICE, SecurityService } from './security.service';

@Global()
@Module({
providers: [SecurityService],
exports: [SecurityService],
providers: [
{
provide: SECURITY_SERVICE,
useClass: SecurityService,
},
],
exports: [
{
provide: SECURITY_SERVICE,
useClass: SecurityService,
},
],
})
export class SecurityModule {}
41 changes: 39 additions & 2 deletions src/auth/application/security.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import * as crypto from 'crypto';
import * as speakeasy from 'speakeasy';
import { ConfigService } from '@nestjs/config';

export const SECURITY_SERVICE = 'SECURITY_SERVICE';

export interface ISecurityService {
encrypt(text: string): string | undefined;
decrypt(hash: string): string | undefined;

generateOtp(secret: string): Promise<string>;
validateOtp(secret: string, otp: string): Promise<boolean>;
}

@Injectable()
export class SecurityService {
export class SecurityService implements ISecurityService {
private readonly logger = new Logger(SecurityService.name);
private readonly algorithm: string;
private readonly secretKey: string;
private iv = crypto.randomBytes(16); // 초기화 벡터(IV)
Expand All @@ -22,6 +27,38 @@ export class SecurityService {
this.secretKey = <string>this.configService.get('security/crypto/key');
}

async generateOtp(secret: string): Promise<string> {
// remove noise
secret = this.removeNoise(secret);

return speakeasy.totp({
secret: secret,
encoding: 'base32',
digits: 6,
step: 600,
});
}

async validateOtp(secret: string, otp: string): Promise<boolean> {
if (!secret || !otp) {
this.logger.warn('OTP 검증에 필요한 정보가 없습니다.');
return false;
}

secret = this.removeNoise(secret);

return speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: otp,
step: 600,
});
}

private removeNoise(secret: string): string {
return secret.replace(/-/g, '');
}

encrypt(text: string): string | undefined {
if (!text) return undefined;

Expand Down
12 changes: 6 additions & 6 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { CacheModule } from '../common/cache/cache.module';
import { DriverModule } from '../driver/driver.module';
import { BusinessModule } from '../business/business.module';
import { UserModule } from './user.module';
import { SecurityModule } from './application/security.module';
import { SmsModule } from '../common/sender/sms/sms.module';

@Global()
@Module({
Expand All @@ -22,18 +24,16 @@ import { UserModule } from './user.module';
inject: [ConfigService],
}),
PassportModule.register({ defaultStrategy: 'access' }),
SmsModule,
UserModule,
CacheModule,
DriverModule,
CustomerModule,
SecurityModule,
BusinessModule,
CustomerModule,
],
controllers: [AuthController],
providers: [
AuthService,
JwtAccessStrategy,
JwtRefreshStrategy
],
providers: [AuthService, JwtAccessStrategy, JwtRefreshStrategy],
exports: [AuthService, JwtAccessStrategy, JwtRefreshStrategy, PassportModule],
})
export class AuthModule {}
9 changes: 9 additions & 0 deletions src/auth/decorator/auth.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { BusinessEntity } from '../../schemas/business.entity';
import { DriverEntity } from '../../schemas/drivers.entity';
import { UserDto } from '../presentation/user.dto';

export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const req: { user?: UserDto } = context.switchToHttp().getRequest();

return req.user;
},
);

export const CurrentCustomer = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
Expand Down
85 changes: 80 additions & 5 deletions src/auth/presentation/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Body, Controller, HttpStatus, Post, Req } from '@nestjs/common';
import { Body, Controller, HttpStatus, Post, Query, Req } from '@nestjs/common';
import { AuthService } from '../application/auth.service';
import { AuthDto } from './auth.dto';
import { AuthDto, OtpRequestDto, OtpResponseDto } from './auth.dto';
import {
ApiCreatedResponse,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Auth } from '../decorator/auth.decorator';
import { Auth, CurrentUser } from '../decorator/auth.decorator';
import { UserDto, UserGroup } from './user.dto';
import { GroupValidation } from '../../common/validation/validation.decorator';
import { UnauthorizedException } from '@nestjs/common/exceptions';
import { Builder } from 'builder-pattern';
import { CrudGroup } from '../../common/validation/validation.data';

@ApiTags('인증 관련 API')
@Controller('/v1/auth')
Expand Down Expand Up @@ -45,8 +48,16 @@ export class AuthController {
})
@Auth(HttpStatus.CREATED, 'refresh')
@Post('/refresh')
async refresh(@Req() req: Request): Promise<AuthDto> {
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
async refresh(
@Req()
req: Request & {
headers: {
authorization?: string;
};
},
): Promise<AuthDto> {
const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
throw new UnauthorizedException(
'Authorization 헤더에 Bearer Token이 없습니다..',
Expand All @@ -55,4 +66,68 @@ export class AuthController {

return await this.authService.tokenRefresh(token);
}

@ApiOperation({
summary: 'OTP 발급 API',
description: `유저의 수신정보를 통해 OTP를 발급한 후 이를 전달받은 수신정보를 토대로 OTP을 전송합니다.\n
수신 정보는 전화번호, 이메일 등이 될 수 있습니다.\n sendType을 보내지 않으면 OTP를 생성만 합니다.\n
sendType을 보내면 해당 수단으로 OTP를 전송합니다. ex) sms, email\n
OTP는 10분동안 유효합니다.`,
})
@ApiCreatedResponse({ type: OtpResponseDto, description: 'OTP 발급 성공' })
@ApiResponse({
status: 401,
description: 'Unauthorized / 요청한 고객이 없습니다.',
})
@ApiParam({
name: 'sendType',
description: 'OTP 전송 방법 ex) sms, email',
required: false,
type: String,
})
@GroupValidation([CrudGroup.create])
@Auth(HttpStatus.CREATED)
@Post('/otp')
async generatedOtpAndSend(
@Body() dto: OtpRequestDto,
@Query('sendType') sendType?: string,
): Promise<OtpResponseDto> {
if (sendType) {
const generatedOtpNumber = await this.authService.sendOtp(
sendType,
dto.secret,
);

return Builder<OtpResponseDto>().otp(generatedOtpNumber).build();
}

return Builder<OtpResponseDto>()
.otp(await this.authService.generateOtp(dto.secret))
.build();
}

@ApiOperation({
summary: 'OTP 검증',
description: `유저의 초기 발급한 수신정보 통해 OTP를 검증합니다.\n
검증이 성공하면 유저의 정보를 업데이트 합니다.\n`,
})
@ApiResponse({
status: 401,
description: 'Unauthorized / 요청한 고객이 없습니다.',
})
@GroupValidation([CrudGroup.update])
@Auth(HttpStatus.OK)
@Post('/otp/verification')
async otpVerify(
@CurrentUser() user: UserDto,
@Body() dto: OtpRequestDto,
): Promise<OtpResponseDto> {
const validated = await this.authService.otpVerifyAndUserUpdate(
user,
dto.secret,
dto.otp,
);

return Builder<OtpResponseDto>().otp(dto.otp).verified(validated).build();
}
}
Loading

0 comments on commit 45f0462

Please sign in to comment.