Blog

[NestJS] 14. Kakao API 소셜 로그인 기능 구현(OAuth)

Category
Author
citeFred
citeFred
PinOnMain
1 more property
NestJS, TypeORM 이해하기
Table of Content

1. OAuth란?

OAuth 2.0(Open Authorization)
외부 애플리케이션이 사용자 계정에 대한 제한된 액세스를 허용할 수 있도록 하는 인증 표준
제3자가 사용자의 비밀번호 없이 안전하게 사용자 정보를 액세스하도록 허용하는 메커니즘
간단히 카카오, 구글, 페이스북 처럼 인증 신뢰도가 높은 기업이 인증을 대신 제공해주는 방식
위 인증 제공자(OAuth Provider) 기업들이 클라이언트(다른 웹 서비스)에 자신들의 회원 계정을 이용 할 수 있도록 함
이를 통해 사용자는 여러 애플리케이션에서 하나의 외부 서비스(소셜 로그인)를 사용해 쉽게 인증할 수 있는 편리함을 제공 받게 됨
사용자의 비밀번호를 공유하지 않고도 애플리케이션이 사용자 정보에 접근하게 되어 안정성을 제공 할 수 있음
OAuth1.0? 2.0?
OAuth 1.0: 복잡한 서명 기반 인증 방식을 사용했으며, 클라이언트와 서버 간의 통신에서 많은 보안 절차를 요구했던 기술
OAuth 2.0: 더 간단한 토큰 기반 인증 방식을 사용하며, 다양한 권한 부여 흐름(Grant Types)을 지원해서 웹, 모바일 앱, 서버 간 통신 등 다양한 시나리오에 쉽게 적용할 수 있게 된 현재의 기술
2012년에 표준으로 발표되어 현재 OAuth라 하면 OAuth2.0을 말함

2. 카카오 개발자(Kakao Developers) 앱 생성

Kakao Developers에 어플리케이션 등록
아래와 같이 정보를 입력하고 어플리케이션을 생성

3. API 사용을 위한 여러 환경 구축

애플리케이션 관리 대시보드 확인
‘앱 키’ 라는 탭에서 각종 비밀키들을 확인 할 수 있다.
REST API 키 가 필요 (= Client ID)
‘카카오 로그인’ 탭에서 Redirect URI 필요
위 두개를 루트폴더에 있는 .env 파일에 아래와 같이 작성해야 한다.
하지만 아래 테스트앱을 생성해야 하는 부분을 보고 테스트앱의 값을 입력해야 하자.
회원 가입을 위해 필요한 필드 요청
기본적으로 Kakao Developer에서 애플리케이션을 생성하면 아래와 같이 닉네임과 프로필 사진 정보만 획득 할 수 있다.
실제로는 비즈앱(사업자 또는 개인개발자용) 심사를 통해서 이름 등 다른 회원정보 접근 권한을 획득 할 수 있다.
하지만 테스트앱을 생성하고 테스트앱의 대시보드에서는 권한을 획득 할 수 있다.
테스트앱 대시보드로 접근
다음 처럼 권한이 없던 부분을 추가 할 수 있도록 활성화되었다.
필요 필드 활성화
서비스마다 필요한 필드를 활성화시키면 된다. 현재 프로젝트에서는 간단하게 회원을 구분하는 email, 이름인 name 이 필요할 것으로 판단되어 해당 부분을 활성화 시켰다.
해당 테스트 앱으로 카카오 로그인 토큰을 받아야 하기 때문에 REST API 키, Redirect URL을 재확인하고 .env에 입력해준다.
이제 로그인 화면에서는 다음과 같이 개인정보 제3자 제공 동의가 나타나게 되며 회원 이름과 이메일 주소를 받아 올 수 있게된다.
passport-kakao 패키지 설치
카카오 OAuth 처리를 위해 passportpassport-kakao 패키지를 설치
passport는 기존 설치되어 있을 것이지만 더블체크겸 설치
npm i passport passport-kakao
Shell
복사
@nestjs/axios 패키지 설치
NestJS에서 카카오 서버로 요청을 보내야 하기 때문에
@nestjs/axiosHttpService 사용하여 HTTP 요청을 구현해야 한다.
npm install @nestjs/axios
Shell
복사
kakao.strategy.ts
Kakao 인증을 구현하기 위한 Passport 인증 모듈의 구현체를 아래처럼 작성해줘야 한다.
.env에 입력한 Client IDRedirect URL이 여기에서 사용된다.
import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-kakao'; import { AuthService } from './auth.service'; @Injectable() export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') { constructor(private readonly authService: AuthService) { super({ clientID: process.env.KAKAO_CLIENT_ID, callbackURL: process.env.KAKAO_CALLBACK_URL, }); } }
TypeScript
복사
auth.module.ts
기존 일반 로그인의 인증 전략을 컨테이너에 추가해주었던것 처럼 Kakao 전략 클래스도 아래와 같이 추가해준다.
import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from "src/user/user.entity"; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import * as dotenv from 'dotenv'; import { JwtStrategy } from './jwt.strategy'; import { HttpModule } from '@nestjs/axios'; import { KakaoStrategy } from './kakao.strategy'; dotenv.config(); @Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_SECRET, signOptions:{ expiresIn: parseInt(process.env.JWT_EXPIRATION, 10) } }), TypeOrmModule.forFeature([User]), HttpModule, ], controllers: [AuthController], providers: [AuthService, JwtStrategy, KakaoStrategy], exports: [JwtModule, PassportModule], }) export class AuthModule {}
TypeScript
복사
auth.service.ts
추가되는 부분이 많아 전체 코드를 넣어두었다.
기존 로그인, 회원가입 로직은 그대로 두고 그 아래 카카오 회원가입, 카카오 로그인 코드가 추가되었다.
기존 로그인 부분의 jwt 토큰을 만드는 부분은 카카오 로그인에서도 필요하기 때문에 공통 메서드로 가장 아래 generateJwtToken() 를 사용하도록 일부 변경사항이 있다.
코드 자체로 보면 복잡해 보일 수 있지만 그 원리를 생각하면 JWT 인증/인가 와 닮은 점이 있다.
결론적으로는 Client:Server의 관계를 잘 생각해보면 구조를 이해하기 쉽다.
JWT 로그인을 구현 할 때 브라우저(Client):나의서버(Server) 와의 관계와 요청, 응답 행동들이 나의서버(Client):카카오서버(Server)의 관계와 동일한 방식인 것을 인지하면 된다.
거기에 가장 처음 요청자가 사용자라는 것만 추가된 것으로 마치 Client, Server가 다른 동일한 로직이 중첩되었다고 생각해보자.
나는 마치 인증() 이라는 어떤 메서드가 있을때 인증( 인증() ) 과 같은 중첩 구조가 떠올랐다.
이는 특히 firstValueFrom(this.httpService.post(tokenUrl, … 이 부분에서 정확하게 나의서버가 카카오 서버로 요청을 하는 상황에서 확인 할 수 있다.
이 때 나의 백엔드 서버가 Client 역할로 카카오 Server에 인증 하는 구조가 내가 일반 유저들의 로그인 요청을 받을때와 동일한 상황이라는 것.
아래 파란 하이라이트 부분이 유사한 흐름을 가지고 있다는 것이다.
일반로그인 : 사용자 → 서버(나의 서버) → 사용자
카카오로그인 : 사용자 → 서버(나의 서버 → 카카오 서버 → 나의 서버) → 사용자
import { ConflictException, Injectable, UnauthorizedException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from "src/user/user.entity"; import { Repository } from 'typeorm'; import { SignUpRequestDto } from './dto/sign-up-request.dto'; import * as bcrypt from 'bcryptjs'; import { SignInRequestDto } from './dto/sign-in-request.dto'; import { JwtService } from '@nestjs/jwt'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); constructor( @InjectRepository(User) private usersRepository: Repository<User>, private jwtService: JwtService, private httpService: HttpService ){} // 회원 가입 async signUp(signUpRequestDto: SignUpRequestDto): Promise<User> { const { username, password, email, role, postalCode, address, detailAddress } = signUpRequestDto; this.logger.verbose(`Attempting to sign up user with email: ${email}`); // 이메일 중복 확인 await this.checkEmailExists(email); // 비밀번호 해싱 const hashedPassword = await this.hashPassword(password); const newUser = this.usersRepository.create({ username, password: hashedPassword, // 해싱된 비밀번호 사용 email, role, postalCode, address, detailAddress, }); const savedUser = await this.usersRepository.save(newUser); this.logger.verbose(`User signed up successfully with email: ${email}`); this.logger.debug(`User details: ${JSON.stringify(savedUser)}`); return savedUser; } // 로그인 async signIn(signInRequestDto: SignInRequestDto): Promise<{ jwtToken: string, user: User }> { const { email, password } = signInRequestDto; this.logger.verbose(`Attempting to sign in user with email: ${email}`); try { const existingUser = await this.findUserByEmail(email); if (!existingUser || !(await bcrypt.compare(password, existingUser.password))) { this.logger.warn(`Failed login attempt for email: ${email}`); throw new UnauthorizedException('Incorrect email or password.'); } // [1] JWT 토큰 생성 (Secret + Payload) const jwtToken = await this.generateJwtToken(existingUser); // [2] 사용자 정보 반환 return { jwtToken, user: existingUser }; } catch (error) { this.logger.error('Signin failed', error.stack); throw error; } } // 이메일 중복 확인 메서드 private async checkEmailExists(email: string): Promise<void> { this.logger.verbose(`Checking if email exists: ${email}`); const existingUser = await this.findUserByEmail(email); if (existingUser) { this.logger.warn(`Email already exists: ${email}`); throw new ConflictException('Email already exists'); } this.logger.verbose(`Email is available: ${email}`); } // 이메일로 유저 찾기 메서드 private async findUserByEmail(email: string): Promise<User | undefined> { return await this.usersRepository.findOne({ where: { email } }); } // 비밀번호 해싱 암호화 메서드 private async hashPassword(password: string): Promise<string> { this.logger.verbose(`Hashing password`); const salt = await bcrypt.genSalt(); // 솔트 생성 return await bcrypt.hash(password, salt); // 비밀번호 해싱 } // 카카오 정보 회원 가입 async signUpWithKakao(kakaoId: string, profile: any): Promise<User> { const kakaoAccount = profile.kakao_account; const kakaoUsername = kakaoAccount.name; const kakaoEmail = kakaoAccount.email; // 카카오 프로필 데이터를 기반으로 사용자 찾기 또는 생성 로직을 구현 const existingUser = await this.usersRepository.findOne({ where: { email: kakaoEmail } }); if (existingUser) { return existingUser; } // 비밀번호 필드에 랜덤 문자열 생성 const temporaryPassword = uuidv4(); // 랜덤 문자열 생성 const hashedPassword = await this.hashPassword(temporaryPassword); // 새 사용자 생성 로직 const newUser = this.usersRepository.create({ username: kakaoUsername, email: kakaoEmail, password: hashedPassword, // 해싱된 임시 비밀번호 사용 // 기타 필요한 필드 설정 }); return this.usersRepository.save(newUser); } // 카카오 로그인 async signInWithKakao(kakaoAuthResCode: string): Promise<{ jwtToken: string, user: User }> { // Authorization Code로 Kakao API에 Access Token 요청 const accessToken = await this.getKakaoAccessToken(kakaoAuthResCode); // Access Token으로 Kakao 사용자 정보 요청 const kakaoUserInfo = await this.getKakaoUserInfo(accessToken); // 카카오 사용자 정보를 기반으로 회원가입 또는 로그인 처리 const user = await this.signUpWithKakao(kakaoUserInfo.id.toString(), kakaoUserInfo); // [1] JWT 토큰 생성 (Secret + Payload) const jwtToken = await this.generateJwtToken(user); // [2] 사용자 정보 반환 return { jwtToken, user }; } // Kakao Authorization Code로 Access Token 요청 async getKakaoAccessToken(code: string): Promise<string> { const tokenUrl = 'https://kauth.kakao.com/oauth/token'; const payload = { grant_type: 'authorization_code', client_id: process.env.KAKAO_CLIENT_ID, // Kakao REST API Key redirect_uri: process.env.KAKAO_REDIRECT_URI, code, client_secret: process.env.KAKAO_CLIENT_SECRET // 필요시 사용 }; const response = await firstValueFrom(this.httpService.post(tokenUrl, null, { params: payload, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })); return response.data.access_token; // Access Token 반환 } // Access Token으로 Kakao 사용자 정보 요청 async getKakaoUserInfo(accessToken: string): Promise<any> { const userInfoUrl = 'https://kapi.kakao.com/v2/user/me'; const response = await firstValueFrom(this.httpService.get(userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}` } })); this.logger.debug(`Kakao User Info: ${JSON.stringify(response.data)}`); // 데이터 확인 return response.data; } // JWT 생성 공통 메서드 async generateJwtToken(user: User): Promise<string> { // [1] JWT 토큰 생성 (Secret + Payload) const payload = { email: user.email, userId: user.id, role: user.role }; const accessToken = await this.jwtService.sign(payload); this.logger.debug(`Generated JWT Token: ${accessToken}`); this.logger.debug(`User details: ${JSON.stringify(user)}`); return accessToken; } }
TypeScript
복사
auth.controller.ts
나의 서버에서는 최초 사용자의 요청을 받을 수 있도록 로그인 페이지 요청 핸들러를 작성해준다.
@UseGuard(@AuthGuard(’kakao’) 를 통해 해당 URL은 카카오 로그인 페이지를 반환한다. 이 부분은 프론트엔드와 상의하여 카카오로그인 이미지 같은 것과 연결하는 것이 일반적이다.
콜백 엔드포인트는 사용자의 카카오 로그인 요청에서 카카오 서버에서 카카오 회원임을 인증하는 최초 code를 보내주는데 이것을 받아주는 핸들러이다.
해당 서비스에서 사용자의 카카오 로그인 시도(요청)
code 란 키 명칭으로 나의 서버는 인증 코드를 받게 된다.
code 를 통해 나의 서버가 카카오 서버에 access token을 요청 및 획득
access token을 통해 나의 서버가 카카오 서버에 회원의 정보를 요청하고 획득 하게 되는 구조
import { Body, Controller, Get, Logger, Post, Query, Req, Res, UseGuards } from '@nestjs/common'; import { Response } from 'express'; import { AuthService } from './auth.service'; import { SignUpRequestDto } from './dto/sign-up-request.dto'; import { User } from "src/user/user.entity"; import { SignInRequestDto } from './dto/sign-in-request.dto'; import { AuthGuard } from '@nestjs/passport'; import { GetUser } from './get-user.decorator'; import { UserResponseDto } from './dto/user-response.dto'; import { ApiResponse } from 'src/common/api-response.dto'; @Controller('api/auth') export class AuthController { private readonly logger = new Logger(AuthController.name); // Logger 인스턴스 생성 constructor(private authService: AuthService){} // 회원 가입 기능 @Post('/signup') async signUp(@Body() signUpRequestDto: SignUpRequestDto): Promise<ApiResponse<UserResponseDto>> { this.logger.verbose(`Attempting to sign up user with email: ${signUpRequestDto.email}`); const user = await this.authService.signUp(signUpRequestDto); const userResponseDto = new UserResponseDto(user); this.logger.verbose(`User signed up successfully: ${JSON.stringify(userResponseDto)}`); return new ApiResponse(true, 201, 'User signed up successfully', userResponseDto); } // 로그인 기능 @Post('/signin') async signIn(@Body() signInRequestDto: SignInRequestDto, @Res() res: Response): Promise<void> { this.logger.verbose(`Attempting to sign in user with email: ${signInRequestDto.email}`); const { jwtToken, user } = await this.authService.signIn(signInRequestDto); const userResponseDto = new UserResponseDto(user); this.logger.verbose(`User signed in successfully: ${JSON.stringify(userResponseDto)}`); // [3] 쿠키 설정 res.cookie('Authorization', jwtToken, { httpOnly: true, // 클라이언트 측 스크립트에서 쿠키 접근 금지 secure: false, // HTTPS에서만 쿠키 전송, 임시 비활성화 maxAge: 3600000, // 1시간 sameSite: 'none', // CSRF 공격 방어 }); res.status(200).json(new ApiResponse(true, 200, 'Sign in successful', { jwtToken, user: userResponseDto })); } // 인증된 회원이 들어갈 수 있는 테스트 URL 경로 @Post('/test') @UseGuards(AuthGuard()) // @UseGuards : 핸들러는 지정한 인증 가드가 적용됨 -> AuthGuard()의 'jwt'는 기본값으로 생략가능 async testForAuth(@GetUser() user: User): Promise<ApiResponse<UserResponseDto>> { this.logger.verbose(`Authenticated user accessing test route: ${user.email}`); const userResponseDto = new UserResponseDto(user); return new ApiResponse(true, 200, 'You are authenticated', userResponseDto); } // 카카오 로그인 페이지 요청 @Get('/kakao') @UseGuards(AuthGuard('kakao')) async kakaoLogin(@Req() req: Request) { // 이 부분은 Passport의 AuthGuard에 의해 카카오 로그인 페이지로 리다이렉트 } // 카카오 로그인 콜백 엔드포인트 @Get('kakao/callback') async kakaoCallback(@Query('code') kakaoAuthResCode: string, @Res() res: Response) { // Authorization Code 받기 const { jwtToken, user } = await this.authService.signInWithKakao(kakaoAuthResCode); // 쿠키에 JWT 설정 res.cookie('Authorization', jwtToken, { httpOnly: true, // 클라이언트 측 스크립트에서 쿠키 접근 금지 secure: false, // HTTPS에서만 쿠키 전송, 임시 비활성화 maxAge: 3600000, // 1시간 // sameSite: 'none', // CSRF 공격 방어 }); const userResponseDto = new UserResponseDto(user); this.logger.verbose(`User signed in successfully: ${JSON.stringify(userResponseDto)}`); res.status(200).json(new ApiResponse(true, 200, 'Sign in successful', { jwtToken, user: userResponseDto })); } }
TypeScript
복사
로그인 페이지 요청 테스트
해당 핸들러로 카카오의 로그인 페이지가 자동 반환
사용자는 본인의 카카오 계정, 비밀번호 입력을 통해 카카오 회원임을 인증 후, 해당 서비스 연결에 계속하기
→ 이때 Kakao Developers에 등록해둔 Redirect URL에 인증 code를 보내줌
콜백 핸들러에서 JSON으로 code를 포함한 정보들이 들어오는지 확인
카카오 계정으로 자동 회원가입
위와 같이 코드를 전부 구성하면 회원가입까지 처리가 된다.
이 부분의 추가 설명을 하자면,
인증 code access token회원 정보 요청 까지의 과정이 이해된다는 가정하에
획득한 회원 정보로 나의 서버의 회원 가입(DB까지)을 연결하는 과정이다.
일반 회원 가입은 개발자가 회원가입에 필요한 정보들(name, email, address 등)을 직접 요구하도록 컨트롤 할 수 있지만
카카오 로그인을 통해서 획득 할 수 있는 정보가 한정적이다. 이는 사용자 정보 탭에서 필드를 추가하여 해결하면 됨. 하지만 비밀번호 같은것은 제공되지 않는 문제가 있음
비밀번호를 필수값으로 설정하는 경우가 많은데, 카카오 로그인 회원은 비밀번호를 알 방법이 없음.
이를 활용해서 우리 서버에서 필요한 필드들로 가공하여 회원 가입을 시키는것이다.
회원가입에 필요한 회원 정보들을 가져 올 수 있기 때문에 카카오로 회원 가입을 진행하고자 한다.
카카오 로그인을 통한 회원가입에서는 비밀번호를 입력하지 않기 때문에, BCrypt로 해싱된 난수를 생성하여 임시 비밀번호로 가입시켜준다.
UserRole같은 경우에도 기본값이 설정되어 있지 않아 오류가 발생했기 때문에 기본값을 USER로 설정했다.
주소 등 제공되지 않은 정보는 null 값이 들어온 상태가 있기 때문에 주소 회원 정보를 더 요청하거나, 주소 정보 입력을 할 수 있도록 사용자 행동을 유도 하는 등 처리가 필요하다.
카카오 로그인에도 JWT+Header(또는 Cookie)가 적용되어야 한다.
컨트롤러 코드에서 일반 로그인과 카카오 로그인의 코드 구조가 거의 유사한 것을 볼 수 있다.
이 말은 곧 카카오 로그인 회원도 jwt로 로그인 상태를 유지 할 수 있는 구조가 되어야 한다는 말이다.
기존 accessToken 명칭으로 사용하던 일반 로그인의 JWT 토큰 이름을 정확하게 jwtToken으로 변경했다.
이는 카카오 로그인의 accessToken과 혼동을 피하기 위함
Search
 | Main Page | Category |  Tags | About Me | Contact | Portfolio