Node.js
NestJS + Flutter 카카오 OAuth 로그인 구현하기
임채성
2022. 9. 24. 18:06
서론
이 글은 2024.02.03일에 업데이트 되었습니다. 클라이언트에서 토큰을 발급받고 발급받은 토큰을 서버로 보내는 방식으로 구현하였었는데 더 효율적이고 안전한 방식으로 개선하였습니다.
본 글보다 우선적으로 읽어야하는 글
- https://developers.kakao.com/docs/latest/ko/kakaologin/flutter | 카카오 플러터 문서
- https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api | 카카오 로그인 REST API 문서
개요
간단한 흐름
- Kakao Developer에 애플리케이션 등록 및 환경설정(Flutter에서 사용할 API 키, Nest.js 서버 주소 등록)
- Flutter SDK를 이용하여 카카오톡에 OAuth 로그인/회원가입 수행
- 카카오 서버에서 로그인 요청을 받으면 Nest.js 서버로 인가코드와 함께 리다이렉트 수행
- Nest.js 서버에서 인가코드를 입력 받는다면 회원가입 / 로그인 처리
상세한 흐름
Kakao Dev 환경설정
카카오 토큰 발급, 수집 동의항목 설정을 해주기 위한 설정이다.
1. https://developers.kakao.com에 접속합니다.
2. "내 애플리케이션"을 클릭하여 애플리케이션을 추가합니다.
다음과 같이 애플리케이션을 생성합니다.
3. 동의항목에 와서 카카오 로그인 시 얻을 데이터를 선택해주세요.
저는 이메일, 성별, 연령대를 입력받겠습니다.
- Android, iOS 플랫폼 등록 (플러터에서 필요함)
Flutter
간단하게 카카오 엑세스 토큰을 반환하는 코드를 작성해봤습니다.
토큰 송신부는 API와 맞게 구현해주세요.
Nest.js
클라이언트(Flutter)에서 발급한 카카오 엑세스 토큰이 유효한지 확인하고, 그에 맞는 처리 후 서버에서 새로운 토큰을 생성해서 클라이언트에게 제공해야합니다.
여긴 백엔드이니까 아래 부분만 처리해주면 됩니다.
- 발급된 카카오 엑세스토큰을 받는 컨트롤러를 구현합니다.
- 토큰을 통해 회원을 조회하는 기능을 구현합니다. (토큰 유효성 확인)
- 회원 데이터를 받아봅시다.
- 회원가입 및 로그인 처리를 해봅시다.
- 로그인 토큰을 수신해줍니다.
컨트롤러
- 플러터에서 받은 카카오 엑세스 토큰을 받습니다.
- 토큰을 반환해야합니다.
// 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
유저 데이터 송신 타입
{
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) // 존재한다고 판단하여 데이터 처리
이런 식으로 데이터에 대한 널체크를 확인해주면 될듯합니다.