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) 앱 생성
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
•
콜백 엔드포인트는 사용자의 카카오 로그인 요청에서 카카오 서버에서 카카오 회원임을 인증하는 최초 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과 혼동을 피하기 위함
Related Posts
Search