[간병인 프로젝트 리팩토링] 휴대폰 인증 검증 (NestJS)

윤학·2023년 7월 26일
0

간병인 프로젝트

목록 보기
2/8
post-thumbnail

이전 글에서 기존 프로젝트의 휴대폰 인증을 위한 문자 발송 로직을 리팩토링하는 시간을 가졌다.

이번 글에서는 발송된 인증 코드를 검증하여 로그인, 회원가입을 수행할 수 있도록 하는 로직을 리팩토링 할 예정이다.

로직 설명

큰 순서는 다음과 같다.
1. 해당 인증코드의 시도 횟수, 유효시간을 체크한다.
2. 위의 검사를 통과하면 코드가 일치하는지 확인한다.
3. 로그인인 사용자에게는 앱 내에서 사용할 토큰을, 회원가입 사용자는 통과만 시킨다.

그럼 한번 살펴보자.

기존 코드

auth.controller.ts

	//사용자 휴대폰 인증 검사
    @Post('sms')
    async validateSms(@Body() checkAuthCodeDto: CheckAuthCodeDto):
        Promise<{ status: string, accessToken?: string, user?: UserDto }> {
        return await this.authService.validateSms(checkAuthCodeDto);
    }

기존에는 CheckAuthCodeDto에 path 필드를 통해 로그인인지 회원가입인지 구분했었다.

그래서 return값을 보면 토큰과 클라이언트에게 넘겨줄 데이터가 Optional인것을 볼 수 있다.

auth.service.ts

   async validateSms(checkAuthCodeDto: CheckAuthCodeDto): Promise<{ status: string, accessToken?: string, user?: UserDto }> {
        const path: string = checkAuthCodeDto.path;
        const id: string = checkAuthCodeDto.id;
        const newUser: boolean = checkAuthCodeDto.newUser;

        const tryBlockKey: string = id + 'tryblock'; //인증번호 일치 시도 횟수 초과여부
        const tryBlock = await this.redis.get(tryBlockKey);
        const tryCountKey: string = id + 'trycount'; //인증번호 일치 시도 횟수
        const cacheAuthCode = await this.redis.get(id);

        //휴대폰
        if (tryBlock === 'true')
            throw new HttpException(
                '인증받은 전화번호가 아닙니다. 다시 받아주세요.',
                HttpStatus.FORBIDDEN
            );
        //인증 유효시간 초과
        else if (cacheAuthCode === null)
            throw new HttpException(
                '인증유효시간이 초과 되었습니다. 다시 시도해주세요.',
                HttpStatus.NOT_FOUND
            );

        //인증 코드 일치 (로그인 성공시)
        if (checkAuthCodeDto.userInputCode === cacheAuthCode.toString()) {

            //일일 인증 횟수, 아이디 인증번호,  초기화 
            const delCountKey = id + 'smscount';
            await this.redis.del(`${delCountKey}`);
            await this.redis.del(`${id}`);
            await this.redis.del(`${tryCountKey}`);

            //경로가 회원가입이면 인증코드 일치 여부만 판단
            if (path === 'register' || newUser)
                return { status: 'success' }
            else if (path === 'login' || newUser == false) {
                const accessToken = await this.userService.setAccessToken(id);
                //RefreshToken 생성
                const user = await this.userService.setRefreshToken(id);

                return { status: 'success', accessToken: accessToken, user: user };
            }
        }

        //인증코드 불일치 (로그인 실패)
        let tryCount: string = await this.redis.get(`${tryCountKey}`);

        //총 인증번호 3번 기회
        if (tryCount === null) {
            await this.redis.set(`${tryCountKey}`, 1, {
                EX: 90
            });
            //처음시도 0회
            tryCount = 0 + '';
        }
        else {
            await this.redis.incr(`${tryCountKey}`);
        }
        const remainCount: number = 2 - parseInt(tryCount);

        if (remainCount == 0) {
            const ttl: number = 180 - await this.redis.ttl(id);
            await this.redis.set(`${tryBlockKey}`, 'true', {
                EX: ttl
            });

            //마지막 시도 직후 
            throw new HttpException(
                '인증번호를 연속으로 틀렸습니다. 다시 받아주세요.',
                HttpStatus.UNAUTHORIZED
            );
        }
        //잔여 카운트가 남았을 경우
        throw new HttpException(
            `인증번호가 일치하지 않습니다.(${remainCount}회 남음)`,
            HttpStatus.UNAUTHORIZED
        );
    }

전체적인 로직은 같지만 기존 코드에서는 redis에 key를 하나씩 설정하고 조회해서 로직을 수행했다.

근데 왜 tryBlockKeytryCountKey에 왜 TTL을 설정했는지 기억이 안난다..

하나의 클래스에 모두 담겨있어 가독성과 이해가 모두 떨어지니 얼른 바꿔보자.

리팩토링 코드

차단이 된 사용자의 요청을 먼저 막고 로그인, 회원가입에 따라 수행되는 이후 로직을 조금 더 깔끔하게 가져갈 수 있을거라 생각하여 guard를 통해 먼저 처리했다.

authentication-code.guard.ts

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = context.switchToHttp().getRequest();
        const { phoneNumber, code, isNewUser } = request.body;

        const existCode = await this.authenticationCodeService.getPhoneCode(phoneNumber); // 코드 유효시간 체크

        const phoneVerificationUsage = await this.verificationUsageService.getPhoneUsageHistory(phoneNumber);
        phoneVerificationUsage.codeAttempCheck(); // 인증번호 시도횟수 체크
        await this.verificationUsageService.addPhoneCodeAttemp(phoneNumber, phoneVerificationUsage); // 인증번호 시도횟수 추가
        
        existCode.verify(code, phoneVerificationUsage.remainChancePerCode()); // 인증번호 일치하는지 체크
        return await this.succededAuthentication(request, phoneNumber, isNewUser);
    };

4번째 줄에 먼저 해당 휴대폰 번호로 발송된 코드를 가져오는 이유는 getPhoneCode()내에서 아래와 같이 TTL이 만료된 경우 에러를 던지기 때문이다.

authentication-code.service.ts

	/* 해당하는 휴대폰의 인증코드 조회 */
    async getPhoneCode(phoneNumber: string): Promise<AuthenticationCode> {
        const existCode =  await this.redis.GET(`phone:${phoneNumber}:code`);
        if( !existCode )
            throw new NotFoundException(ErrorMessage.ExpiredSmsCode);
        return new AuthenticationCode(parseInt(existCode));
    }

아직 인증 시간이 유효하다면 VerificationUsageService를 통해 사용 내역을 받아와 codeAttempCheck()메서드를 통해 해당 인증 코드의 시도 횟수를 체크하고 시도 횟수를 추가한다.

phone-verification-usage.entity.ts

export class PhoneVerificationUsage {
    private readonly MAX_CHANCE_PER_CODE = 3;
    
    private dayAttemp: number;

    private codeAttemp: number;

    constructor(dayAttemp: number = 0, codeAttemp: number = 0) {
        this.dayAttemp = dayAttemp;
        this.codeAttemp = codeAttemp;
    }

    remainChancePerCode(): number { return this.MAX_CHANCE_PER_CODE - this.codeAttemp; }; // 현재 인증번호의 남은 시도횟수

    /* 인증번호당 시도횟수 */
    codeAttempCheck(): void {
        if( this.codeAttemp == this.MAX_CHANCE_PER_CODE )
            throw new ForbiddenException(ErrorMessage.ExceededCodeAttempLimit);
    };

    addCodeAttemp(): void { this.codeAttemp += 1; };
};

다음으로 아까 조회한 발송되었던 인증 코드와 사용자가 입력한 코드를 검사한다.

authentication-code.ts

export class AuthenticationCode {
    private code: number;
    constructor(code: number) {
        this.code = code;
    };
    
    verify(inputCode: number, remainChance: number): void {
        if( this.code != inputCode )
            throw new UnauthorizedException(
                `인증번호가 일치하지 않습니다.${remainChance}회 남음`
            );    
    }
}

일치 여부를 확인하면서 실패하면 객체 내에서 Error를 던져 횟수를 증가시킬 방법이 없기 때문에 이전에 해당 인증 코드의 시도 횟수를 증가시키는 로직을 먼저 수행하였다.

근데 왜 성공해도 시도 횟수를 초기화 하지 않나?

인증 코드를 새로 발송 할 때마다 이전 시도 횟수를 초기화 시키기 때문에 문제가 없었다.

이렇게 모두 통과가 되면 guard에서 마지막에 호출하는 succededAuthentication() 메서드를 봐보자.

authentication-code.guard.ts

	/* guard의 마지막에 호출하는 메서드 */
    return await this.succededAuthentication(request, phoneNumber, isNewUser);

    private async succededAuthentication(request: any, phoneNumber: string, isNewUser: boolean): Promise<boolean> {
        if( !isNewUser ) {
            const user = await this.userRepository.findByPhoneNumber(phoneNumber); // 기존 사용자
            request.user = user;
        }   
        return true;
    }

로그인인지 회원가입인지 여부를 프론트엔드로부터 isNewUser라는 필드로 받아 구별하는데,

로그인일 경우에는 Controller의 파라미터 데코레이터로 해당 사용자를 받아 구별할 수 있게끔 request의 user 필드에 조회한 사용자를 넣었다.

auth.controller.ts

    /* 휴대폰 인증코드 검사 */
    @Public()
    @UseGuards(PhoneAuthenticationCodeGuard)
    @Post('code/sms')
    async validateSmsCode(@AuthenticatedUser() user: User): Promise<ClientDto | void> {
        # 변경 전 if( user ) return await this.authService.changeAuthentication(user);
      	# 변경 후 if( user ) return await this.authService.refreshAuthentication(user);
    }

그래서 회원가입일 경우에는 user가 넘어오지 않으므로 바로 로직이 끝난다.

로그인을 하는 사용자는 기존에 발급받았던 AccessToken, RefresKey, RefreshToken이 있기에 AccessToken만 새로 발급해서 넘겨주자.

로직을 수정한 이유는

RefreshToken이 만료되어 로그아웃이 된 상태에서 로그인을 계속해도 AcessToken만 새로 발급한다면 RefreshToken은 만료된 채로 저장되어 있어 AccessToken이 만료될 때마다 로그인을 해야한다.

그래서 changeAuthentication() 메서드 말고 refreshAuthentication() 메서드를 통해 AccessToken, RefreshKey, RefreshToken을 모두 갱신하자.

auth.service.ts

	/* 전체 갱신 */
    async refreshAuthentication(user: User): Promise<ClientDto> {
        const refreshedAuthentication = await this.generateAuthentication(user);
        user.refreshAuthentication(refreshedAuthentication);
        await this.userRepository.save(user);
        return await this.addToSessionListAndMapToDto(user, refreshedAuthentication.accessToken);
    }
    
    /* 새로운 전체 인증(AccessToken, RefreshToken)을 생성하는 메서드 */
    private async generateAuthentication(user: User): Promise<NewUserAuthentication> {
        return await this.tokenService.generateNewUsersToken(user);
    }
    
    /* 세션 리스트에 사용자를 추가하고 사용자에게 넘겨줄 Dto로 변환 */
    private async addToSessionListAndMapToDto(user: User, newAccessToken: string): Promise<ClientDto> {
        await this.sessionService.addUserToList(user.getId(), newAccessToken);
        return this.authMapper.toDto(user);
    }

물론 AcessToken도 변경이 됐으니 해당 사용자의 유효한 토큰으로 새로 변경된 토큰을 SessionService를 통해 세션리스트에 추가해준다.

이렇게 해서 휴대폰 인증 코드를 검사하는 로직을 완성하였다.

추가로 개선할 점...

  1. 휴대폰 인증 코드를 받아오면서 유효 시간이 끝나버리면 Error를 던졌던 getPhoneCode()와 같은 메서드는 내부에서 찾지 못하면 Error를 던지기에 getPhoneCodeOrFail()과 같은 메서드로 사용하는 것이 좋을 것 같다.

  2. 현재는 VerificationUsageServiceAuthenticationCodeService와 같은 Service들이 다른 수단이 추가된다면 내부에 메서드를 추가해야 하는데 공용 인터페이스를 만들고 수단별로 구현을 하는 방법이 추후 유지보수나 확장성면에서 좋을 것 같다.

  3. 인증 코드의 시도 횟수를 체크하고 증가시키는 codeAttempCheck()addCodeAttemp()메서드를 각각 따로 수행했는데 횟수를 증가시키면 내부에서 횟수를 체크하는 메서드를 호출하도록 변경하는게 좋지 않을까 싶다.

  4. 아직 Service에서 DB에 직접 접근하는 코드가 몇군데 있는데 DB에 접근하는 로직은 Repository를 만들어 접근하는게 좋을 것 같다.

지금은 작성을 해두고 추후에 다시 리팩토링 해보자.

profile
해결한 문제는 그때 기록하자

1개의 댓글

comment-user-thumbnail
2023년 7월 26일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기