상똥이의 Back-End 공부방

[Nest.js] REST API 방식으로 소셜로그인 구현 샘플 코드 (naver, google, kakao) 본문

Nest.JS

[Nest.js] REST API 방식으로 소셜로그인 구현 샘플 코드 (naver, google, kakao)

상똥백 2024. 10. 16. 03:31

목표

- 코드 중복 최소화하기

- 소셜로그인 로직 이해하기

- 소셜로그인 환경설정하기

 

목차

0. 들어가며 (전체 코드)

1. 기본 설정 (유저 생성 로직)

2. 플랫폼별 로그인 과정 이해하기

3. 플랫폼별 환경 설정하기(naver, google, kakao)

4. 코드 작성하기

 


[0. 들어가며]

1. 전체 코드는 아래에서 확인하세요

https://github.com/Sangddong/Social-log-in

 

2. 코드를 적용할 때 필요한 것

- 사실 코드 전체를 복붙해도 잘 돌아갈 것이다

- 필요한 것은 목차 3번 과정 수행과, 그를 통해 나오는 env에 저장해야 할 값이다

 

3. 왜 이걸 글로 쓰고 있냐면

더보기

- 카카오 소셜로그인을 한 번 구현해본 후 다른 소셜로그인들을 구현할 일이 생겼다

- 이를 구현하기 위해 naver, google, kakao 폴더를 다 나눠서 만들었음

- 구현하면서 실질적인 로직은 같다는 걸 느끼고 코드 중복을 크게 줄일 수 있겠다 뒤늦게 생각했다

- 난 바보구나

- 그래서 줄임

- 대부분은 알겠지만 나 같은 바보가 분명히 있어

- 그 사람들도 알아야 해

- 티스토리에 글쓰기 시작

 


[1. 기본 설정]

1. 설정할 것들

- 회원가입을 위해 데이터베이스에 유저를 등록하는 로직을 미리 작성해둬야 한다

- Nest.js, TypeOrm을 사용할 것이다

 

2. TypeOrm + User entity

더보기

app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { User } from './users/users.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: process.env.PGHOST,
      username: process.env.PGUSER,
      password: process.env.PGPASSWORD,
      database: process.env.PGDATABASE,
      entities: [User],
      synchronize: true,
      ssl: {
        rejectUnauthorized: false,
      },
    }),
    UsersModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

users.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './users.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}
}

 

users.module.ts

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [TypeOrmModule],
})
export class UsersModule {}

 

users.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  social: string;

  @Column()
  socialId: string;
}

3. 유저 등록 및 유저 검색

더보기

users.service.ts

  async createUser(data: DUser) {
    const user = this.usersRepository.create(data);
    return await this.usersRepository.save(user);
  }
  
  async findUser(id: string) {
    return await this.usersRepository.findOne({ where: { socialId: id } });
  }

 

users.dto.ts (잠시 작성해뒀을 뿐 필요하진 않다)

export class DUser {
  social: string;
  socialId: string;
}

 


[2. 플랫폼별 로그인/회원가입 과정 이해하기]

1. 플랫폼별 과정

더보기

1. 네이버

- 사용자가 서버에 네이버 로그인/회원가입 요청
    - 서버에서 네이버에게 사용자 정보 요청
    - 네이버에서 서버에게 사용자 정보 지급
    - 미확인 시 회원 등록
- 서비스 서버가 네이버에 인가 코드 발급 요청
- 네이버에서 사용자에게 인증, 인가(동의) 요청
- 사용자가 네이버에게 인증, 인가(동의) 완료
- 네이버에서 Redirect URI(인가 코드)로 리다이렉션
- 서버에서 네이버에 토큰 발급 요청
- 네이버에서 서버에 토큰 발급

 

2. 구글

- 사용자가 서버에 구글 로그인/회원가입 요청
    - 서버에서 구글에게 사용자 정보 요청
    - 구글에서 서버에게 사용자 정보 지급
    - 미확인 시 회원 등록
- 서비스 서버가 구글에 인가 코드 발급 요청
- 구글에서 사용자에게 인증, 인가(동의) 요청
- 사용자가 구글에게 인증, 인가(동의) 완료
- 구글에서 Redirect URI(인가 코드)로 리다이렉션
- 서버에서 구글에 토큰 발급 요청
- 구글에서 서버에 토큰 발급

 

3. 카카오

- 사용자가 서버에 카카오 로그인/회원가입 요청
    - 서버에서 카카오에게 사용자 정보 요청
    - 카카오에서 서버에게 사용자 정보 지급
    - 미확인 시 회원 등록
- 서비스 서버가 카카오에 인가 코드 발급 요청
- 카카오에서 사용자에게 인증, 인가(동의) 요청
- 사용자가 카카오에게 인증, 인가(동의) 완료
- 카카오에서 Redirect URI(인가 코드)로 리다이렉션
- 서버에서 카카오에 토큰 발급 요청
- 카카오에서 서버에 토큰 발급

 

2. 추상화 (공통점 추출)

- 공통 로직은 아래와 같다

회원가입 로그인
- 위의 공통 로직을 추출하여 작성하면 대표적인 소셜로그인 플랫폼(네 구 카) 모두 구현 가능하다

 

 

 


[3. 플랫폼별 환경 설정하기]

1. 네이버

- 다음 사이트에 접속하여 애플리케이션을 등록한다 https://developers.naver.com/apps/#/list

- 앱 이름 기재

- 사용 API: 네이버 로그인, 사용할 API 아무거나 선택 (필자는 이름만 했음)

- 로그인 open API 서비스 환경: 모바일 웹/pc웹 추가

- 서비스 URL: 사용중인 URL (필자는 개발중이라 http://localhost:3000)

- Redirect URL: http://localhost:3000/auth/naver/callback

- N_REDIRECT_URI로 .env에 저장

- 등록 버튼을 누르면 바로 Client ID, Client Secret이 있는 화면으로 이동된다

- .env에 각각 복사하여 N_CLIENT_ID, N_SECRET_KEY로 저장

- .env에 아무 문자열을 조합한 후 N_STATE로 저장

 

2. 구글

- 다음 사이트에 접속하여 좌측 상단의 프로젝트 선택을 눌러 프로젝트를 새로 생성한다 https://console.cloud.google.com/customer-identity/providers?hl=ko&_ga=2.135474609.-188788975.1728469285&project=criends

- 프로젝트명 기재 후 만들기

- 결제정보를 입력해야 한다!!!!

- 대시보드로 이동하면 좌측 메뉴 중 'API 및 서비스'에서 '사용자 인증 정보'로 이동

- '+사용자 인증 정보 만들기'에서 'API 키' 클릭, 발급된 키 복사해서 .env파일에 G_REST_API_KEY로 저장

- 다시 '+사용자 인증 정보 만들기'에서 'OAuth 클라이언트 ID 만들기' 선택

- 동의화면 구성에서 User Type = 외부 선택

- 다음 페이지에서 앱 이름, 사용자 지원 이메일, 승인된 도메인(test.com 이런거 아무거나), 개발자 연락처 정보만 기재

- 테스트 사용자에 테스트할 이메일 기재 (다른 플랫폼과 다르게 개발 단계에서는 접근이 제한된다)

- 저장 후 대시보드로 돌아오게 된다

- 다시 '+사용자 인증 정보 만들기'에서 'OAuth 클라이언트 ID 만들기' 선택

- 애플리케이션 유형: 데스크톱 앱 선택

- 승인된 리디렉션 URI : http://localhost:3000/auth/google/callback

- G_REDIRECT_URI로 .env에 저장

- 그럼 클라이언트 ID와 클라이언트 보안 비밀번호가 뜬다

- .env에 각각 G_CLIENT_ID, G_SECRET_KEY로 저장

 

3. 카카오

- 다음 사이트에 접속하여 애플리케이션 추가하기 클릭 https://developers.kakao.com/console/app

- 앱 이름, 회사명, 카테고리 선택

- 좌측 메뉴 중 앱키에서 REST API 키 복사 후 .env에 K_REST_API_KEY로 저장

- 좌측 메뉴 중 카카오 로그인 클릭 후 카카오 로그인 활성화 ON

- Redirect URI 등록 : http://localhost:3000/auth/kakao/callback

- K_REDIRECT_URI로 .env에 저장

 

- .env 파일에 이렇게 저장되어 있어야 함

 


[4. 코드 작성하기]

1. 인가 코드 받기

- 플랫폼별 인가 코드를 받아야 한다

- 접근할 base url과 플랫폼별 파람값을 URLSearchParams로 조합해 반환한다

- 구글의 경우 scope(가져올 사용자 정보 범위), access-type이 추가로 필요하다

- 구글의 scope는 최소한만 가져오고자 id로 제한했다

// auth.service.ts

  getSocialSignInCode(platform: string) {
    const baseUrl = this.getSocialBaseUrl(platform);
    const params = this.getSocialParams(platform);

    return `${baseUrl}?${new URLSearchParams(params)}`;
  }

  private getSocialBaseUrl(platform: string) {
    const baseUrls = {
      naver: 'https://nid.naver.com/oauth2.0/authorize',
      google: 'https://accounts.google.com/o/oauth2/v2/auth',
      kakao: 'https://kauth.kakao.com/oauth/authorize',
    };

    return baseUrls[platform];
  }

  private getSocialParams(platform: string) {
    const queryParams = {
      naver: {
        response_type: 'code',
        client_id: process.env.N_CLIENT_ID,
        redirect_uri: process.env.N_REDIRECT_URI,
        state: process.env.N_STATE,
      },
      google: {
        response_type: 'code',
        client_id: process.env.G_CLIENT_ID,
        redirect_uri: process.env.G_REDIRECT_URI,
        scope: `https://www.googleapis.com/auth/userinfo.email`,
        access_type: 'offline',
      },
      kakao: {
        response_type: 'code',
        client_id: process.env.K_REST_API_KEY,
        redirect_uri: process.env.K_REDIRECT_URI,
      },
    };

    return queryParams[platform];
  }
// auth.controller.ts
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import { Response } from 'express';

...

  @Get(':platform')
  getSocialCode(
    @Param('platform') platform: string,
    @Res() response: Response,
  ) {
    const url = this.authService.getSocialSignInCode(platform);

    response.redirect(url);
  }

  @Get(':platform/callback')
  async socialCallback(
    @Param('social') social: string,
    @Query('code') code: string,
    @Res() response: Response,
  ) {
    console.log(platform, ' code: ', code);
  }

 

localhost:3000/auth/{platform}으로 이동하면 아래와 같이 뜬다.

동의해도 다음 로직을 아직 작성하지 않았기 때문에 무한 로딩에 걸림

naver google kakao

 

하지만 이동한 url을 보거나 콘솔에 찍어보면 인가코드는 받았음을 알 수 있다

 

2. 인가코드로 액세스 토큰 받기

- 코드를 받으면 이 코드를 통해 액세스 토큰을 받을 수 있게 된다

- 각 플랫폼별로 url, params, headers를 조합해 post 요청을 보내면 된다

- env가 아닌 값은 모두 각 플랫폼별 문서에 공개되어 있으므로, 그냥 코드 복사해서 쓰면 된다

// auth.service.ts

  // 토큰을 발급받아오는 로직
  async getSocialAccessToken(platform: string, code: string) {
    const url = this.getSocialTokenUrl(platform);
    const params = this.getSocialTokenParams(platform, code);
    const header = this.getSocialTokenHeader(platform);

    const response = await axios.post(url, params, { headers: header });

    return response.data.access_token;
  }
  
  // 플랫폼별 토큰을 발급받을 url을 반환하는 로직
  private getSocialTokenUrl(platform: string) {
    const url = {
      naver: `https://nid.naver.com/oauth2.0/token`,
      google: `https://oauth2.googleapis.com/token`,
      kakao: `https://kauth.kakao.com/oauth/token`,
    };

    return url[platform];
  }
  
  // 플랫폼별 필요한 파라미터값을 반환하는 로직
  private getSocialTokenParams(platform: string, code: string) {
    const params = {
      naver: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: process.env.N_CLIENT_ID,
        client_secret: process.env.N_SECRET_KEY,
        redirect_uri: process.env.N_REDIRECT_URI,
        state: process.env.N_STATE,
        code,
      }),
      google: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: process.env.G_CLIENT_ID,
        client_secret: process.env.G_SECRET_KEY,
        redirect_uri: process.env.G_REDIRECT_URI,
        code,
      }),
      kakao: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: process.env.K_REST_API_KEY,
        redirect_uri: process.env.K_REDIRECT_URI,
        code,
      }),
    };

    return params[platform];
  }

  // 플랫폼별 헤더를 반환하는 로직
  private getSocialTokenHeader(platform: string) {
    const header = {
      naver: {
        'X-Naver-Client-Id': process.env.N_CLIENT_ID,
        'X-Naver-Client-Secret': process.env.N_CLIENT_SECRET,
      },
      google: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      kakao: {
        'Content-type': 'application/x-www-form-urlencoded;charset=utf-8',
      },
    };

    return header[platform];
  }
// auth.controller.ts

// callback
  @Get(':platform/callback')
  async socialCallback(
    @Param('platform') platform: string,
    @Query('code') code: string,
    @Res() response: Response,
  ) {
    const access_token = await this.authService.getSocialAccessToken(
      platform,
      code,
    );
    console.log(platform, 'access token: ', access_token);
    return access_token;
  }

 

- 콘솔에는 아래와 같이 나타난다. 액세스 토큰을 성공적으로 받아왔음을 알 수 있다

 

3. access_token으로 사용자 정보 가져오기

- 액세스토큰을 통해 사용자 정보를 받아올 수 있다

- 각 플랫폼마다 제공하는 고유한 id 값으로 데이터베이스에 id를 설정해줄 수 있다

// auth.service.ts

// 플랫폼별 사용자 정보 받아오기
  async getSocialUserInfo(platform: string, accessToken: string) {
    const url = {
      naver: `https://openapi.naver.com/v1/nid/me`,
      google: `https://www.googleapis.com/oauth2/v2/userinfo`,
      kakao: `https://kapi.kakao.com/v2/user/me`,
    };

    return await axios.get(url[platform], {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
  }
// auth.controller.ts  

  @Get(':platform/callback')
  async socialCallback(
    @Param('platform') platform: string,
    @Query('code') code: string,
    @Res() response: Response,
  ) {
    const access_token = await this.authService.getSocialAccessToken(
      platform,
      code,
    );

    const userInfo = await this.authService.getSocialUserInfo(
      platform,
      access_token,
    );

    console.log(platform, userInfo.data);
    return access_token;
  }

 

- 콘솔에 아래와 같이 플랫폼별 제공해주는 정보가 찍힌다

 

4. 제공받은 정보를 통해 사용자 등록하기

- 제공받은 정보에는 id 값이 존재하므로, 이 id 값을 데이터베이스에 unique한 id 값으로 저장할 것이다

- auth 모듈의 providers에 UsersService를 추가하고, user 등록 로직을 구현할 것이다.

- users.service에 사용자를 찾는 로직과 사용자가 없다면 새로 등록하는 로직을 만들 것이다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './users.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

// 사용자 등록 로직
  async createUser(platform: string, userInfo) {
    let id: string;
    if (platform === 'naver') id = userInfo.response.id;
    else id = userInfo.id;

    const checkUser = await this.usersRepository.findOne({
      where: { socialId: id },
    });

    if (!checkUser) {
      const user = this.usersRepository.create({
        social: platform,
        socialId: id,
      });
      return await this.usersRepository.save(user);
    } else return true;
  }

// 사용자 검색 로직
  async findUser(id: string) {
    return await this.usersRepository.findOne({ where: { socialId: id } });
  }
}
// auth.controller.ts

  @Get(':platform/callback')
  async socialCallback(
    @Param('platform') platform: string,
    @Query('code') code: string,
    @Res() response: Response,
  ) {
    const access_token = await this.authService.getSocialAccessToken(
      platform,
      code,
    );

    const userInfo = await this.authService.getSocialUserInfo(
      platform,
      access_token,
    );

    const createdUser = await this.usersService.createUser(
      platform,
      userInfo.data,
    );

    console.log(createdUser);
    return access_token;
  }

 

- 사용자를 생성할 경우 콘솔에 찍어보면 아래와 같이 잘 뜬다

- 데이터베이스에도 들어가 있다

 

- 로그인 로직은 따로 필요하지 않다!! 데이터베이스에 존재하면 자동으로 로그인되기 때문이다~~

 

수고하셨습니다~~