Blog

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

Category
Author
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 처리를 위해 passport와 passport-kakao 패키지를 설치
•
passport는 기존 설치되어 있을 것이지만 더블체크겸 설치
npm i passport passport-kakao
Shell
복사
📌
@nestjs/axios 패키지 설치
•
NestJS에서 카카오 서버로 요청을 보내야 하기 때문에
•
@nestjs/axios의 HttpService 사용하여 HTTP 요청을 구현해야 한다.
npm install @nestjs/axios
Shell
복사
📌
kakao.strategy.ts
•
Kakao 인증을 구현하기 위한 Passport 인증 모듈의 구현체를 아래처럼 작성해줘야 한다.
•
.env에 입력한 Client ID와 Redirect 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