Node.js

NestJS + Flutter 카카오 OAuth 로그인 구현하기

임채성 2022. 9. 24. 18:06

서론

이 글은 2024.02.03일에 업데이트 되었습니다. 클라이언트에서 토큰을 발급받고 발급받은 토큰을 서버로 보내는 방식으로 구현하였었는데 더 효율적이고 안전한 방식으로 개선하였습니다.

 

본 글보다 우선적으로 읽어야하는 글

 

개요

간단한 흐름

 

  1. Kakao Developer에 애플리케이션 등록 및 환경설정(Flutter에서 사용할 API 키, Nest.js 서버 주소 등록)
  2. Flutter SDK를 이용하여 카카오톡에 OAuth 로그인/회원가입 수행
  3. 카카오 서버에서 로그인 요청을 받으면 Nest.js 서버로 인가코드와 함께 리다이렉트 수행
  4. Nest.js 서버에서 인가코드를 입력 받는다면 회원가입 / 로그인 처리

 

상세한 흐름

 

카카오 OAuth 인증 흐름

 

Kakao Dev 환경설정

카카오 토큰 발급, 수집 동의항목 설정을 해주기 위한 설정이다.

1. https://developers.kakao.com에 접속합니다.

 

 

2. "내 애플리케이션"을 클릭하여 애플리케이션을 추가합니다.

다음과 같이 애플리케이션을 생성합니다.

 

3. 동의항목에 와서 카카오 로그인 시 얻을 데이터를 선택해주세요.

 

저는 이메일, 성별, 연령대를 입력받겠습니다.

  • Android, iOS 플랫폼 등록 (플러터에서 필요함)
 

Kakao Developers

 

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

 

Flutter

간단하게 카카오 엑세스 토큰을 반환하는 코드를 작성해봤습니다.

 

 

토큰 송신부는 API와 맞게 구현해주세요.

 

Nest.js

클라이언트(Flutter)에서 발급한 카카오 엑세스 토큰이 유효한지 확인하고, 그에 맞는 처리 후 서버에서 새로운 토큰을 생성해서 클라이언트에게 제공해야합니다.

여긴 백엔드이니까 아래 부분만 처리해주면 됩니다.

  1. 발급된 카카오 엑세스토큰을 받는 컨트롤러를 구현합니다.
  2. 토큰을 통해 회원을 조회하는 기능을 구현합니다. (토큰 유효성 확인)
  3. 회원 데이터를 받아봅시다.
  4. 회원가입 및 로그인 처리를 해봅시다.
  5. 로그인 토큰을 수신해줍니다.

 

 

컨트롤러

  • 플러터에서 받은 카카오 엑세스 토큰을 받습니다.
  • 토큰을 반환해야합니다.
// authentication.controller.ts

import { Body, Post, Res } from '@nestjs/common';
import { AuthenticationService } from '@app/auth/authentication/authentication.service';
import { LoginRequest } from '@app/auth/authentication/dtos/login.request';
import { TokenResponse } from '@app/auth/authentication/dtos/token.response';

@Controller('auth')
export class AuthenticationController {
  constructor(private readonly authenticationService: AuthenticationService) {}

  @Post('login')
  async login(
    @Body() data: LoginRequest,
    @Res({ passthrough: true }) res,
  ): Promise<TokenResponse> {
    return this.authenticationService.login(data, res);
  }

 

// login.request.ts

import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class LoginRequest {
  @ApiProperty({
    example:
      'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuYXV0aDAuY29tLyIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tL2NhbGFuZGFyL3YxLyIsInN1YiI6InVzcl8xMjMiLCJpYXQiOjE0NTg3ODU3OTYsImV4cCI6MTQ1ODg3MjE5Nn0.CA7eaHjIHz5NxeIJoFK9krqaeZrPLwmMmgI_XiQiIkQ',
    description: '엑세스 토큰',
  })
  @IsNotEmpty()
  @IsString()
  accessToken!: string;

  @ApiProperty({
    example: 'kakao',
    description: 'OAuth제공사명',
  })
  @IsNotEmpty()
  @IsString()
  vendor!: string;
}

 

 

서비스

서비스는 조금 양이 있습니다.

  • 토큰으로 회원을 조회합니다.
    • 없으면 회원가입, 있으면 로그인으로 해줍니다.
  • 토큰을 발급해서 반환합니다.
// authentication.service.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';

import { ACCESS_TOKEN_EXPIRE, REFRESH_TOKEN_EXPIRE } from '../../../constants';

import { LoginRequest } from '@app/auth/authentication/commands/login.request';
import { TokenResponse } from '@app/auth/authentication/dtos/token.response';
import { UserService } from '@app/user/user.service';
import {
  InvalidVendorNameException,
  KakaoOAuthFailedException,
} from '@domain/errors/auth.errors';
import { JwtSubjectType } from '@infrastructure/types/jwt.types';

@Injectable()
export class AuthenticationService {
  constructor(
    private readonly userService: UserService,
    private readonly configService: ConfigService,
    private readonly jwtService: JwtService,
  ) {}

  async login(data: LoginRequest, res): Promise<TokenResponse> {
    let userId;
    switch (data.vendor) {
      case 'kakao': {
        userId = await this.getUserByKakaoAccessToken(data.accessToken);
        break;
      }
      default: {
        throw new InvalidVendorNameException(); //소셜로그인 선택 실패 예외처리
      }
    }

	// accessToken, refreshToken 발급
    const [accessToken, refreshToken] = await Promise.all([
      this.generateAccessToken(userId),
      this.generateRefreshToken(userId),
    ]);

    res.cookie('refresh_token', refreshToken, {
      path: '/auth',
      httpOnly: true,
    });

    return new TokenResponse({ accessToken });
  }

  async getUserByKakaoAccessToken(accessToken: string): Promise<string> {
    // KAKAO LOGIN 회원조회 REST-API
    const user = await axios.get('https://kapi.kakao.com/v2/user/me', {{
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    if (!user) throw new KakaoOAuthFailedException(); //카카오 로그인 실패 예외처리

    const userId = await this.userService.findById(user.data.id);
    if (!userId) return this.userService.createUser(user.data); // 회원이 없으면 회원가입 후 아이디 반환

    return userId.id; // 회원이 이미 있다면 있는 유저의 아이디 반환
  }
  
    protected async generateAccessToken(userId: string): Promise<string> {
    return this.jwtService.signAsync(
      { user_id: userId },
      {
        expiresIn: ACCESS_TOKEN_EXPIRE,
        subject: JwtSubjectType.ACCESS,
      },
    );
  }

  protected async generateRefreshToken(userId: string): Promise<string> {
    return this.jwtService.signAsync(
      { user_id: userId },
      {
        expiresIn: REFRESH_TOKEN_EXPIRE,
        subject: JwtSubjectType.REFRESH,
      },
    );
  }
}
// login.request.ts

export type LoginRequest = {
  accessToken: string;
  vendor: string;
};

 

 

카카오에서 데이터 받아오기

curl -v -X GET "https://kapi.kakao.com/v2/user/me" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"

이렇게 신호를 주면 됩니다.

나는 axios를 통해 카카오로 신호를 보내줬습니다.

const user = await axios.get('https://kapi.kakao.com/v2/user/me', {
  headers: { Authorization: `Bearer ${accessToken}` },
});

이에 해당하는 부분은 사용자 정보 가져오기에서 확인할 수 있습니다.

유저 데이터를 못받아오고 500에러가 뜬다면 아래의 링크를 확인합시다.

https://puleugo.tistory.com/110

 

[NestJS] 카카오 OAuth 로그인 500, connect ECONNREFUSED

카카오 OAuth2 로그인을 시도했을 때 오류가 났다. 항상 그렇지만 코드는 틀린게 없다. 틀린건 나였고.. const user = await axios.get('kapi.kakao.com/v2/user/me', { headers: { Authorization: `Bearer ${acc..

puleugo.tistory.com

 

유저 데이터 송신 타입

{
  id: 1234567890,
  connected_at: '2022-01-01T20:30:24Z',
  kakao_account: {
    has_email: true,
    email_needs_agreement: true,
    has_age_range: true,
    age_range_needs_agreement: true,
    has_gender: true,
    gender_needs_agreement: true
  }
}

위 코드를 통해 클라이언트에서 받아온 데이터입니다.

agreement가 true라면 데이터 수집에 대해 거부한것이고, false라면 동의를 한 것입니다.

 

if (has_email && email_needs_agreement) // 존재한다고 판단하여 데이터 처리

이런 식으로 데이터에 대한 널체크를 확인해주면 될듯합니다.