Nest.Js에서 네이버 로그인 구현하기 Part.01 (Naver OAuth Login)

Posted by , April 04, 2023
NestJsOAuth
Series ofNestJs

thumbnail


네이버 로그인 포스팅 3부작

(현재 글) Part01 네이버 로그인 구현하기 (Nest.js 코드 구현)
Part02 네이버 로그인 구현하기 (네이버 개발자 센터)
Part03 네이버 로그인 구현하기 (검수 요청 및 거절 그리고 기타 경험)



서비스에 SNS 로그인을 구현하려 했다. (SNS Naver OAuth Login)

흑우집합소를 개발하다가 사용자 계정 관련해서 사용자 계정과 관련하여 고민을 했다.
직접 계정 가입을 받을 것인지... 아니면 SNS를 활용한 계정을 처리할 것인지...

마음은 SNS 로그인쪽으로 기울긴 했다.
왜냐하면 직접 받는다면, 계정의 신원을 E-Mail로 잡을텐데 여기서 검증 이메일을 보내는 로직과 더불어,
일일히 계정 정보를 다 입력받는 창을 만들고 하면 시간이 너무 많이 먹을 듯 싶었다.

그래서 구글 네이버 카카오를 사용하기로 결정했다.

카카오 사태때 SNS 로그인 문제가 있던걸 봐서 고민을 좀 했지만...
이 부분은 내가 따로 구현처리해서 피할 수 있는 것 같아서 SNS 로그인을 선택했다.

내가 직접 한 부분을 다 녹여낸 포스팅이니 참고하면 붙이는데 도움이 되실 것이라 사료된다.

설명에 앞서 Part01에서는 코드 구현부분이 들어가 있다.
그리고 Part02에서는 네이버 개발자 센터에서 할 일이 있고,
Part03에서는 몇 가지 정리할 내용들이 들어간다.

뭐 먼저 할까?

코딩적인 부분은 그냥 쉽게쉽게 하던대로 하면 편하지만, 네이버 개발자 센터가 약간 귀찮다.
아래 순서로 진행해보자.

1. Nest.Js에 라이브러리 설치

먼저 아래의 패키지들을 설치해주자.

yarn add @nestjs/passport
yarn add passport
yarn add passport-naver-v2

뭐 npm을 쓰면 npm으로 바꿔서 설치하면 된다.
참고로 passport-naver-v2는 네이버에서 구현한 passport-naver를 개량한 버전이다.
네이버에서 만든게 아니라 개인이 만드신 듯 하다.


2. Naver AuthGuard 작성

이제 AuthGuard를 작성해야 한다.
Nest.Js에서 AuthGuard에 대해서는 추후 포스팅하고 여기에 업데이트 하도록 하겠다.

아래 나오는 것은 샘플 코드이므로 본인의 취향에 맞게 작성해도 된다.
여기 나온 코드나 위치는 참고만...

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class NaverAuthGuard extends AuthGuard('naver') {
  constructor() {
    super();
  }

  handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser {
    //에러가 존재하면 에러 처리로 넘긴다.
    if (err || !user) {
      throw err;
    }
    return user;
  }
}

handleRequest메서드의 경우 저 nestjs의 AuthGuard의 선언된 타입 중 하나를 구현한 것이다.
그래서 만약 처리 중 에러가 발생할 경우 에러 자체를 넘겨서 처리할 수 있도록 했다.

아래 원형을 참고하자.

export declare type IAuthGuard = CanActivate & {
  logIn<
    TRequest extends {
      logIn: Function;
    } = any,
  >(
    request: TRequest,
  ): Promise<void>;
  handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser;
  getAuthenticateOptions(context: ExecutionContext): IAuthModuleOptions | undefined;
};
export declare const AuthGuard: (type?: string | string[]) => Type<IAuthGuard>;

에러는 보통 ExceptionHandler로 처리를 할텐데 이 부분에 대해서는 각자 처리하는 방식이 있기에,
여기서는 논외로 하겠다.


3. Naver Strategy 수립 및 코드 작성

Strategy의 경우에도 nest.js의 전략(Strategy)를 받아서 구현하는데,
이 부분도 향후에 다시 다뤄볼 예정이다.

일단 내가 구현한 Naver Strategy 코드는 다음과 같다.
보안을 위해 일부 코드는 누락되어 있으니 참고만 하자.

import { Inject, Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { Request } from 'express'
import { Profile, Strategy } from 'passport-naver-v2'
import { AccountErrorCode } from 'src/domain/account/exceptions/account.error.code'
import { AccountException } from 'src/domain/account/exceptions/account.exception'
import {
  IsExistAccountInboundPort,
  IS_EXIST_ACCOUNT_INBOUND_PORT,
} from 'src/domain/account/ports/in/is.exist.account.inbound.port'
import {
  JoinAccountInboundPort,
  JOIN_ACCOUNT_INBOUND_PORT,
} from 'src/domain/account/ports/in/join.account.inbound.port'
import {
  LoginAccountInboundPort,
  LOGIN_ACCOUNT_INBOUND_PORT,
} from 'src/domain/account/ports/in/login.account.inbound.port'
import {
  ACCOUNT_FIND_TYPE,
  ACCOUNT_JOIN_TYPE,
  ACCOUNT_STATUS_TYPE,
} from 'src/domain/account/types/account.enums'
import {
  CreateJwtOutboundPort,
  CREATE_JWT_OUTBOUND_PORT,
} from '../ports/out/jwt/create.jwt.outbound.port'
import { SnsLoginOutputDto } from '../types/strategy.type'
import { snsAccountJoin } from './helper/join.account.helper'
import { snsLoginAccount } from './helper/sns.login.helper'
import { makeNickName } from 'src/domain/account/utils/make.nickname.util'
import {
  IsResignAccountInboundPort,
  IS_RESIGN_ACCOUNT_INBOUND_PORT,
} from 'src/domain/account/ports/in/is.resign.account.inbound.port'

@Injectable()
export class NaverOAuthStrategy extends PassportStrategy(Strategy, 'naver') {
  constructor(
    @Inject(JOIN_ACCOUNT_INBOUND_PORT)
    private readonly joinAccountOutboundPort: JoinAccountInboundPort,
    @Inject(LOGIN_ACCOUNT_INBOUND_PORT)
    private readonly loginAccountInboundPort: LoginAccountInboundPort,

    @Inject(IS_EXIST_ACCOUNT_INBOUND_PORT)
    private readonly isExistAccountInboundPort: IsExistAccountInboundPort,
    @Inject(CREATE_JWT_OUTBOUND_PORT)
    private readonly createJwtOutboundPort: CreateJwtOutboundPort,
    @Inject(IS_RESIGN_ACCOUNT_INBOUND_PORT)
    private readonly isResignAcountInbound: IsResignAccountInboundPort
  ) {
    super({
      clientID: process.env.NAVER_CLIENT_ID,
      clientSecret: process.env.NAVER_CLIENT_PW,
      callbackURL: process.env.NAVER_CALLBACK,
      passReqToCallback: true,
    })
  }

  async validate(
    req: Request,
    accessToken: string,
    refreshToken: string,
    profile: Profile
  ): Promise<SnsLoginOutputDto> {
    const { email } = profile

    if (email === undefined) {
      return {
        email: undefined,
        joinType: undefined,
        lastLoginInfo: undefined,
        accessToken: undefined,
        bcode: undefined,
      }
    }

    //탈퇴 6개월 이내를 체크한다.
    const isResignAccount = await this.isResignAcountInbound.excute({
      email,
      joinType: ACCOUNT_JOIN_TYPE.SNS_GOOGLE,
    })
    if (isResignAccount.isResign) {
      throw new AccountException(AccountErrorCode.AccessResignAccount())
    }

    //계정이 존재하는지 체크
    const isExistAccountType = await this.isExistAccountInboundPort.excute({
      email,
      joinType: ACCOUNT_JOIN_TYPE.SNS_NAVER,
    })

    //회원이 가입 안된 상태 => 회원 가입 진행
    if (isExistAccountType.findType === ACCOUNT_FIND_TYPE.NO_EXIST) {
      //회원가입 진행 로직 수행
      return snsAccountJoin({...})
    }
    //이미 계정이 존재 => 로그인
    else if (isExistAccountType.findType === ACCOUNT_FIND_TYPE.EXIST) {
      return snsLoginAccount({...})
    }
  }
}

상단의 **PassportStrategy(Strategy, 'naver')**클래스를 상속받아서,
validate메서드에서 실질적인 구현을 한다.

PassportStrategy의 경우 Strategy와 구분자 이름을 받는다.
여기는 깊게 갈 필요는 없고, Strategy는 passport-naver-v2의 값을 넣는다.

그리고 생성자에서 부모 생성자에게 넘길 인자로 네이버 개발자 센터에서 값을 받아와야 하는데,
이 부분은 일단 공백으로 둬도 된다.

super({
      clientID: process.env.NAVER_LOGIN_CLIENT_ID,
      clientSecret: process.env.NAVER_LOGIN_CLIENT_PW,
      callbackURL: process.env.NAVER_LOGIN_CALLBACK,
      passReqToCallback: true,
    })

지금 작성된 코드의 경우 내가 구현한 것이라서 env파일에서 값을
끌어오지만 실제로 구현할 때는 잠시 공백으로 둬도 된다.

passReqToCallback옵션의 경우 req객체 접근 여부인데,
나는 req에 실어나를 데이터가 있어서 true로 했다.

validate메서드의 전달인자에는 다음과 같이 되어 있다.

req: Request,
accessToken: string,
refreshToken: string,
profile: Profile

req는 express Request 객체고, accessToken과 refreshToken의 경우 네이버에서 제공하는 토큰이다.
이걸로 사용자 인증을 사용해도 되고, 나처럼 직접 JWT 토콘을 사용하여 구현해도 된다.

Nest.Js에서 Jwt 사용이 궁금한 분은 이곳 포스팅(Nest.Js에서 JWT 사용하기)을 참고하자.

그리고 마지막으로 profile의 경우 네이버 로그인을 통해 동의받은 사용자 정보를 받는 부분이다.
값은 아래를 참고하자.

export type Profile = {
  provider: 'naver';
  id: string;
  nickname?: string;
  profileImage?: string;
  age?: string;
  gender?: string;
  email?: string;
  mobile?: string;
  mobileE164?: string;
  name?: string;
  birthday?: string;
  birthYear?: string;
  _raw: string;
  _json: string;
};

4. 로그인 전략 수립 (생각 및 고민해볼 부분)

개발자가 신경 쓸 부분은 전략부분이다.
사용자가 네이버 로그인을 시 profile객체 값을 통해 이메일 정보를 알아낼 수 있을 것이다.

그럼 이 이메일 정보를 토대로 이미 가입이 되어 있는 계정인지...
아니면 가입이 안되어 있는 계정인지를 확인할 수 있다.

그래서 위 코드에서도 조회를 해서 가입 처리 또는 로그인 처리를 진행한다.
참고로 회원 가입 시 몇 가지 정할 것이 있다.

다중 계정 허용을 할 것인지, 아니면 다중 계정을 막을지,
이 부분은 향후 포스팅에서 다루겠다.

일단은 난 다중 계정은 허용하는 방향으로 갔지만,
다중 게정을 직접적으로 이용하는 것은 막는 방향으로 갔다.

그리고 만약 탈퇴한 회원이 재가입 하는 경우도 생각해 볼 수 있겠다.

무튼 이 부분은 프로젝트 성향에 따라 다르기 때문에 각자 알맞게 구현해서 가도록 하자.


5. 컨트롤러 작성

컨트롤러는 별거 없다.
아래의 코드를 참고하자.

참고로 여기에서 쓰이는 주소 값은 나중에 네이버 개발자 센터에서 사용해야 한다.
이 부분은 Part02 포스트를 참고하자.

import { Controller, Get, Inject, Req, Res, UseFilters, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { AuthExceptionHandler } from 'src/domain/auth/exceptions/auth.exception.handle';
import { NaverAuthGuard } from 'src/domain/auth/guard/naver.guard';
import { CustomLoggerService } from 'src/domain/logger/services/custom.logger';
import { AccountExceptionHandler } from '../../exceptions/account.exception.handler';
import { ACCOUNT_JOIN_TYPE } from '../../types/account.enums';
import LoginControllerHelper from './login.controller.helper';

@Controller('account')
export class LoginSnsNaverController extends LoginControllerHelper {
  constructor(@Inject(CustomLoggerService) readonly logger: CustomLoggerService) {
    super(logger);
  }

  @Get('login/sns/naver')
  @UseGuards(NaverAuthGuard)
  async snsLogin4Naver() {}

  @Get('login/sns/naver/cb')
  @UseGuards(NaverAuthGuard)
  @UseFilters(AccountExceptionHandler)
  @UseFilters(AuthExceptionHandler)
  async snsLogin4NaverCallBack(@Req() req: any, @Res() res: Response) {
    const redirectUrl = this.loginCallback(req, res, ACCOUNT_JOIN_TYPE.SNS_NAVER);
    return res.redirect(redirectUrl);
  }
}

@Get('login/sns/naver') 접근 시 네이버의 로그인 쪽으로 가게 된다.
그래서 여기는 뭔가 따로 처리할 것은 없다.

그리고 **@Get('login/sns/naver/cb')**콜백을 받는 부분은 아까 Strategy쪽에서 언급한 바와 같이,
req 객체에 로그인 또는 회원 가입한 정보를 담고 있다.

아래와 같이 한번 콘솔을 찍어보면 된다.

console.log('req = ', req.user);

물론 저 Strategy쪽에서 return할 때 {} 와 같이 객체로 만들어 보내면 된다.
일단 이렇게 하면 코드에서 할 부분은 끝이다.

정리

코드쪽은 위에서 언급한 바와 같이 그렇게 복잡한 것이 없다.

단지 프로젝트 성향에 따라 다중 계정 및 회원 가입, 로그인 처리,
그리고 사용자 인증을 위한 토큰 처리 정도?

만약 accessToken 및 refreshToken을 직접 구현하는게 아니라,
네이버에게 위임하여 구현할 겅우 접근 토큰을 이용하여 프로필 API 호출 문서를 확인하자.

다음은 네이버 개발자 페이지에서 처리할 것을 다루는 포스팅으로 넘어간다.



네이버 로그인 포스팅 3부작

(현재 글) Part01 네이버 로그인 구현하기 (Nest.js 코드 구현)
Part02 네이버 로그인 구현하기 (네이버 개발자 센터)
Part03 네이버 로그인 구현하기 (검수 요청 및 거절 그리고 기타 경험)