미누에요
[NestJS] Google OAuth 2.0 으로 로그인 API 만들기 본문
현재 진행하고 있는 프로젝트에서 Google 로그인, Kakao 로그인이 필요하여 OAuth 2.0 을 사용하여 로그인 API를 구현하려 한다.
우선 Google OAuth 부터 설명해보자.
OAuth란?
"OAuth 또는 Open Authorization은 액세스 위임을 위한 개방형 표준 인가 프로토콜" 이라고 나와있다.
쉽게 말해서, 우리가 해당 웹페이지에서 회원가입을 따로 하지 않고 "Google 로 로그인" 을 할 때 사용하는 방식이다.
사용자들은 따로 회원가입할 필요 없이 이미 존재하던 계정을 가지고 손쉽게 서비스를 사용할 수 있다.
\이 OAuth를 사용해서 NestJS 환경에서 Google 로그인을 만들려면, Passport라는 기술을 사용해야한다.
Passport
passport는 많이 사용되고 있는 node.js 인증 라이브러리이다.
공식 문서에서는 사용자 로그인 정보를 확인하고(JWT, 구글 로그인, 아이디/비밀번호 등), 인증된 사용자인지 판단하며, 요청(Request)에 사용자 정보를 추가해 주는 라이브러리라고 설명하고 있다.
Passport는 Strategy를 사용해서 인증을 수행하는데, 수많은 Strategy를 사용할 수 있고, 간편하게 통합할 수 있다는 강점이 있다.
즉, 쉽게 이야기하자면 Passport는 Stategy(전략)을 사용해서 인증을 하는 것이고, Strategy는 어떻게 인증을 수행할건지 방법에 대해서 구현되는 모듈인 것이다.
예를 들어 아래처럼 사용할 수 있다.
- LocalStrategy → 아이디와 비밀번호로 인증
- JwtStrategy → JWT 토큰을 검증해서 인증
- GoogleStrategy → 구글 OAuth를 이용한 인증
Google OAuth 2.0
구글에서는 OAuth 를 통해 로그인을 진행할 때, 아래와 같은 방식으로 진행한다.
사용자가 로그인 버튼을 누르면, 구글의 로그인 페이지로 Redirect 되고, 로그인을 하면 구글 서버에서 인증 코드(Authorization Code)를 발급하여 해당 코드를 가지고 설정한 Redirect 링크로 이동한다.
정리하자면 아래와 같다.
- 사용자가 /auth/google에 GET 요청
- @UseGuards(GoogleAuthGuard) → Passport가 GoogleStrategy 실행
- Google 로그인 페이지로 리다이렉트
- 로그인 성공 → Google이 /auth/google/redirect로 인증 코드 보내줌
- 다시 Guard가 실행 → GoogleStrategy.validate()에서 사용자 정보 획득
- 성공 시 req.user에 사용자 정보 담기
- 컨트롤러의 googleAuthRedirect() 실행 → JWT 발급
우리는 redirect된 링크에도 api를 만들어, 해당 api에서 구글 서버에서 발급한 인증 코드(Authorization Code)를 가지고 JWT를 통해 Access Token과 Refresh Token을 생성하는 로직을 서버 측에서 구현해야 한다.
(구글 서버에서 사용자 정보를 가져오는 api를 구현하려면 인증 코드(Authorization Code)를 사용해서 구글 서버에 이름, 이메일 등을 요청할 수 있다.)
이를 위해 google.strategy.ts 파일을 생성한다.
google.strategy.ts
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile } from 'passport';
import { User } from 'src/modules/user/user.entity';
import { UserService } from 'src/modules/user/user.service';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
private configService: ConfigService,
private userService: UserService,
) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID') ?? '',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') ?? '',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') ?? '',
scope: ['email', 'profile'],
passReqToCallback: true,
});
}
async validate(
req: Request,
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback,
) {
try {
const { id, name, emails } = profile;
const providerId = id;
const email = emails?.[0]?.value ?? '';
const fullName = `${name?.familyName ?? ''}${name?.givenName ?? ''}`;
const profileImage = profile.photos?.[0]?.value ?? '';
const user: User = await this.userService.findByEmailOrSave(
email,
fullName,
providerId,
profileImage,
);
done(null, user);
} catch (error) {
console.error('OAuth validate error:', error);
done(error, false);
}
}
}
코드에서 PassportStrategy(Strategy, 'google')은 이 Strategy를 'google'이라는 고유 식별자로 등록해둔다는 의미이다. 이는 나중에 이 Passport를 Guard와 연결할 때 사용하게 된다.
구글 로그인이 성공적으로 수행되면 validate()함수를 실행하게 된다.
validate() 함수에서는 유저 정보를 가지고 DB에 저장하는 로직이 담겨 있다.
여기서 가장 의문이 들었던 점이, 인증 코드를 받아와 다시 구글 서버에 요청을 보내고, Access Token과 Refresh Token을 받아와 처리하는 부분이 없는데 왜 동작하는가 ? 였다.
그 이유는 Passport에서 내부적으로 해당 로직을 모두 실행하도록 구현되어 있기 때문이다.
- 인증 코드(code)
- Google에 POST 요청
- accessToken, refreshToken 획득
- https://www.googleapis.com/oauth2/v3/userinfo에서 프로필 가져옴
- 마지막으로 validate() 호출
auth.controller.ts에 처음 로그인을 접속하는 url과 리다이렉션되는 url도 api를 작성해준다.
auth.controller.ts
import { Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { GoogleAuthGuard, JwtRefreshGuard } from './guards/auth.guard';
import { User } from '../user/user.entity';
interface JwtPayload {
sub: string;
email: string;
}
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('google')
@UseGuards(GoogleAuthGuard)
async googleAuth(): Promise<void> {}
@Get('google/redirect')
@UseGuards(GoogleAuthGuard)
async googleAuthRedirect(
@Req() req: Request,
): Promise<ReturnType<AuthService['login']>> {
const user = req.user as User;
return this.authService.login(user);
}
@UseGuards(JwtRefreshGuard)
@Post('refresh')
async refresh(
@Req() req: Request,
): Promise<ReturnType<AuthService['refreshToken']>> {
const user = req.user as JwtPayload;
const userId = user.sub;
const authHeader = req.headers.authorization;
const refreshToken = authHeader?.startsWith('Bearer ')
? authHeader.replace('Bearer ', '')
: null;
if (!refreshToken) {
throw new Error('Refresh token is missing in Authorization header');
}
return this.authService.refreshToken(userId, refreshToken);
}
}
이 코드에는 @UseGuard()가 있는데, 이건 해당 요청을 접속하기 전에 ()안의 가드를 실행시킨다는 의미이다. 나는 auth.guard.ts 파일에 따로 가드를 저장해두었다.
auth.guard.ts
import { AuthGuard } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {}
여기서 보면 GoogleAuthGuard는 Passport 중 'google'을 사용한 Strategy와 연결되고, 나머지도 동일하게 'jwt', 'jwt-refresh'와 연결된다.
이를 통해 Guard와 Strategy, Passport를 연결하게 되는 것이다. GoogleStategy는 PassportStategy(Strategy, 'google')으로 등록되어있고, 이를 Guard로 설정해두면 api 접속 전에 Guard가 실행되어 해당 Strategy가 실행되게 된다.
따라서 /google로 api를 접속하게 되면, 연결된 Guard가 Passport를 통해 Strategy를 수행하게 되는 것이다.
AuthService에는 login이 구현되어 있고, 이는 구글 서버로부터 인증 코드를 받고 난 후에 실행된다.
우리는 인증 코드를 통해 Access Token과 Refresh Token을 설정하고 반환한다.
auth.service.ts
import { ForbiddenException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { User } from '../user/user.entity';
import { UserService } from '../user/user.service';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private userService: UserService,
) {}
async login(user: User) {
const payload = { sub: user.id, email: user.email };
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: '15m',
});
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});
await this.userService.updateRefreshToken(user.id, refreshToken);
return { accessToken, refreshToken };
}
async refreshToken(userId: string, refreshToken: string) {
const user = await this.userService.findById(userId);
if (!user || user.refreshToken !== refreshToken) {
throw new ForbiddenException('Access Denied');
}
return this.login(user);
}
}
이를 적용하기 위해 auth.module.ts를 아래와 같이 작성한다.
auth.module.ts
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ConfigModule } from '@nestjs/config';
import { GoogleStrategy } from './strategies/google.strategy';
import { JwtAccessStrategy } from './strategies/jwt-access.strategy';
import { JwtModule } from '@nestjs/jwt';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { UserModule } from '../user/user.module';
@Module({
imports: [UserModule, PassportModule, JwtModule.register({}), ConfigModule],
controllers: [AuthController],
providers: [
AuthService,
GoogleStrategy,
JwtAccessStrategy,
JwtRefreshStrategy,
],
})
export class AuthModule {}
또한 app.module.ts에도 authModule을 추가해줘야 한다.
app.module.ts
import { AuthModule } from './modules/auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from './modules/user/user.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '3306', 10),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
retryAttempts: 10,
retryDelay: 3000,
}),
UserModule,
AuthModule,
],
})
export class AppModule {}
이렇게 코드를 작성하고 실행하게 되면 아래처럼 된다.
프론트에서는 이 Access Token과 Refresh Token을 가지고 로컬 스토리지에 저장하는 방식으로 진행하면 된다.
NestJS를 사용해서 간단한 API를 만드는 건 해봤지만 로그인 기능, 그중에 OAuth를 구현해보는 건 처음이라, Guard와 Passport, @Req로 데이터를 처리하는 방식 등 여러가지가 너무 새롭다 .. 해당 내용에 대해서도 공부하며 정리해야겠다는 생각이 든다.
(Google OAuth는 구글 서버에서 바로 정보를 반환해주기 때문에 비교적 간단하고 쉽지만, Kakao OAuth는 한단계를 더 거쳐야 한다고 한다.... 이제 Kakao OAuth를 해보고 다시 돌아오겠다 ...)
'Backend' 카테고리의 다른 글
[디자인 패턴] MVVM 패턴, Model - View - View Model (1) | 2024.10.09 |
---|---|
[디자인 패턴] MVC 패턴 (2) | 2024.10.09 |