서비스를 만들 때 사용자 인증은 꼭 필요한데, 이걸 직접 관리하는 건 생각보다 까다롭고 위험할 수 있습니다. 비밀번호 같은 민감한 정보를 안전하게 보관하려면 강력한 보안 체계가 필요하고, 해킹 시도에도 항상 대비해야 합니다...!
그래서 저는 사용자 인증을 Google, Kakao 같은 신뢰할 수 있는 타사 플랫폼에 맡기기로 했습니다. 이렇게 하면 보안 부담을 덜 수 있을 뿐만 아니라, 서비스는 더 중요한 본연의 기능에 집중할 수 있습니다! 결과적으로 사용자 경험도 좋아집니다!
OAuth의 기본 개념
OAuth는 Open Authorization의 약자로, 사용자가 특정 애플리케이션(클라이언트)에 자신이 소유한 리소스에 접근할 수 있는 권한(인가)을 안전하게 부여할 수 있도록 도와주는 프로토콜입니다.
1. 인가 (Authorization)
- OAuth의 핵심은 사용자가 애플리케이션에 특정 리소스에 접근할 수 있는 권한을 부여하는 데 있습니다.
- 예: 사용자가 자신의 Google 계정을 통해 이메일 정보에 접근하도록 서비스에 승인
2. 인증 (Authentication)
- OAuth 자체는 인증을 목적으로 설계되지 않았지만, OAuth를 활용해 간접적으로 인증 과정을 수행할 수 있습니다.
- 예: Google 계정을 사용해 사용자가 누구인지 확인(인증)하고 서비스에 로그인하는 소셜 로그인 흐름
OAuth 인증 흐름
Kakao, Naver, Google과 같은 OAuth 인증 제공자들은 비슷한 인증 흐름을 따릅니다. 여기서는 Kakao를 기준으로 설명합니다.
1. 인가 코드 요청
클라이언트에서 사용자의 로그인 창을 요청합니다. 사용자가 로그인 정보를 입력하면 Kakao는 인가 코드를 클라이언트로 반환합니다.
- 요청 URL:
https://kauth.kakao.com/oauth/authorize - 필수 파라미터:
response_type, client_id, redirect_uri - 응답:
인가 코드를 포함한 리다이렉트 URI 반환
2. 토큰 요청
클라이언트는 발급받은 인가 코드를 사용해 Kakao에 토큰을 요청합니다.
- 요청 URL:
https://kauth.kakao.com/oauth/token - 필수 파라미터:
grant_type, code, client_id, redirect_uri - 응답:
- access_token: 사용자 리소스 접근을 위한 토큰
- refresh_token: 만료된 액세스 토큰을 갱신하기 위한 토큰
3. 사용자 정보 요청
발급받은 액세스 토큰을 사용해 사용자 정보를 요청합니다.
- 요청 URL:
https://kapi.kakao.com/v2/user/me - 헤더:
Authorization: Bearer {access_token} - 응답:
사용자 ID, 이메일 등 개인 정보 포함
코드
GoogleStrategy
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Profile, Strategy } from 'passport-google-oauth20';
import { OAuthUser } from '@libs/common/interfaces/oauth-user.interface';
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private readonly configService: ConfigService) {
super({
clientID: configService.get('GOOGLE_CLIENT_ID'),
clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
scope: ['email', 'profile'],
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: Profile,
): Promise<OAuthUser> {
try {
const { id, name, emails, photos } = profile;
if (!emails || !emails.length) {
throw new UnauthorizedException('Email is required from Google OAuth');
}
const fullName =
name?.familyName && name?.givenName
? `${name.familyName}${name.givenName}`
: name?.givenName || 'Unknown User';
const user: OAuthUser = {
provider: 'google',
providerId: id,
email: emails[0].value,
name: fullName,
imageUrl: photos?.[0]?.value || null,
};
return user;
} catch (error) {
throw new UnauthorizedException('Google OAuth validation failed');
}
}
}
Google OAuth 인증은 passport-google-oauth20 라이브러리를 사용해 구현했습니다. Passport를 활용하면 인증 로직을 간단히 커스터마이징할 수 있어 효율적입니다! clientID, clientSecret 같은 민감한 정보는 .env 파일에서 관리해 보안을 강화했습니다. 코드에 직접 작성하지 않으니 나중에 변경도 편리합니다. Google에서 받은 데이터를 검증하고 필요한 값만 가공해 반환했습니다. 특히 이메일이 없는 경우 인증을 실패로 처리해 문제가 발생하지 않도록 대비했습니다. Google OAuth는 이메일을 항상 반환하지 않을 수 있습니다. 이를 고려해 이메일이 없으면 예외를 발생시켜 인증을 실패로 처리하도록 구현했습니다!
KakaoStrategy
import { OAuthUser } from '@libs/common/interfaces/oauth-user.interface';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-kakao';
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
constructor(private readonly configService: ConfigService) {
super({
clientID: configService.get('KAKAO_CLIENT_ID'),
clientSecret: configService.get('KAKAO_CLIENT_SECRET'),
callbackURL: configService.get('KAKAO_CALLBACK_URL'),
scope: ['profile_nickname', 'profile_image'],
});
}
async validate(accessToken: string, refreshToken: string, profile: Profile): Promise<OAuthUser> {
try {
const { id, displayName, _json } = profile;
const nickname = displayName; // 닉네임 (필수 동의 항목)
const profileImage = _json?.properties?.profile_image || null;
const email = _json?.kakao_account?.email || null;
if (!nickname) {
throw new UnauthorizedException('Nickname is required from Kakao OAuth');
}
const user: OAuthUser = {
provider: 'kakao',
providerId: id.toString(),
email: email,
name: nickname,
imageUrl: profileImage,
};
return user;
} catch (error) {
throw new UnauthorizedException('Kakao OAuth validation failed');
}
}
}
Kakao의 프로필 정보 구조는 Google과 조금 다릅니다. profile_nickname과 profile_image는 Kakao에서 제공하는 스코프(scope)이며, 이를 기반으로 사용자 정보를 구성했습니다.
필수 데이터 검증
닉네임(nickname)은 필수 동의 항목으로 설정하였습니다. 만약 닉네임이 제공되지 않으면 인증을 실패로 처리하도록 구현했습니다.
이메일이 null로 처리된 이유
이메일 정보가 필요할 경우 : 카카오계정(이메일) 상태 권한 없음일 경우 권한 받는 방법
Kakao에서 이메일을 받으려면 비즈니스 앱으로 전환해야 하는데, 저는 아직 사업자 정보가 없어서 이메일은 비워두었습니다... 😅 대신, 필요한 경우 나중에 사업자 정보를 등록해 비즈 앱으로 전환할 수 있습니다!
Guard
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class KakaoAuthGuard extends AuthGuard('kakao') { // google로도 변경 가능
async canActivate(context: any): Promise<boolean> {
const can = await super.canActivate(context);
if (can) {
const request = context.switchToHttp().getRequest();
await super.logIn(request);
}
return true;
}
}
AuthGuard는 요청의 인증 상태를 확인하는 역할을 합니다. KakaoStrategy에서 처리된 인증 결과를 활용해, 인증 과정을 간단히 처리하도록 구현했습니다! 인증이 성공하면 super.logIn(request)를 호출해 사용자의 세션을 초기화합니다. 이렇게 하면 사용자가 로그인 상태를 유지할 수 있어 매번 인증을 반복하지 않아도 됩니다.
세션 설정
import session from 'express-session';
import passport from 'passport';
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export function configureSession(app: INestApplication, configService: ConfigService) {
const sessionSecret = configService.get<string>('SESSION_SECRET');
const isProduction = configService.get<boolean>('IS_PRODUCTION');
const cookieMaxAge = Number(configService.get('SESSION_COOKIE_MAX_AGE'));
app.use(
session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: cookieMaxAge,
httpOnly: true,
secure: isProduction,
},
}),
);
app.use(passport.initialize());
app.use(passport.session());
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((user, done) => {
done(null, user);
});
}
직렬화와 역직렬화가 왜 중요할까요?
로그인한 사용자를 관리하려면 어떻게 해야 할까요? 바로 Passport의 직렬화와 역직렬화를 활용하는 겁니다! 이 과정이 세션 기반 인증의 핵심이에요.
- 직렬화(Serialization): 사용자가 로그인하면, 사용자의 정보를 세션에 저장하기 위해 변환하는 단계입니다. 보통 사용자 ID 같은 간단한 정보만 저장합니다.
- 역직렬화(Deserialization): 사용자의 요청이 들어올 때 세션에 저장된 데이터를 사용해 원래의 사용자 정보를 복원하는 단계입니다. 덕분에 매번 사용자 인증을 다시 하지 않아도 됩니다.
보안을 더 탄탄하게 만드는 팁
- SESSION_SECRET은 환경 변수로 관리하기! 중요한 값은 코드에 드러내지 말고 환경 변수에 안전하게 저장하기!
- 쿠키를 더 안전하게! 프로덕션 환경에서는 쿠키의 secure 옵션을 켜서 HTTPS를 통해서만 전달되도록 설정하기!
Passport의 직렬화와 역직렬화를 잘 활용하면 사용자 상태를 효율적으로 관리할 수 있어요. 이렇게 하면 로그인 상태 유지도 쉽고 보안도 챙길 수 있답니다!
인증 성공 후 세션 관리 방식
인증이 성공하면, 사용자 데이터를 서버의 세션에 저장해서 관리합니다. 이렇게 하면 사용자가 로그인 상태를 유지할 수 있고, 매번 요청할 때마다 다시 인증할 필요가 없게 됩니다. 세션 덕분에 사용자 경험도 좋아지고, 서버는 로그인 상태를 효율적으로 관리할 수 있습니다!
메모리 기반 세션을 선택한 이유
사실 메모리 기반 세션은 간단하고, 초기 단계에서 적용하기에 딱 좋아요. 설정도 쉽고, 데이터를 메모리에 저장하기 때문에 속도도 빠릅니다. 하지만 한 가지 아쉬운 점은 확장성이 부족하다는 거예요. 예를 들어, 서버를 여러 대로 늘리거나 재시작했을 때 세션 데이터가 사라지는 문제가 생길 수 있습니다. 세션 기반 인증을 선택한 이유에 대해서는 이 글에서 확인하실 수 있습니다!
세션 vs JWT: 세션을 선택하며 얻은 고찰
대부분의 개발자들이 프로젝트를 시작할 때 회원가입과 로그인을 구현하며 인증 방식을 고민하게 됩니다. 이 과정에서 흔히 접하게 되는 두 가지 방식이 바로 세션(Session)과 JWT(Json Web Token)입니
remazitensi.tistory.com
현재 구현의 장단점
좋은 점은 뭐니 뭐니 해도 간편합니다. 세션을 이용해 인증 상태를 관리하니까 사용자는 로그인한 상태를 유지할 수 있고, OAuth를 통해 비밀번호 같은 민감한 데이터를 직접 다루지 않아도 되니 보안적으로도 더 안전합니다. 또한, 세션 관리를 추상화해서 Redis 같은 외부 저장소로 쉽게 전환할 수 있는 유연함도 확보했습니다! 하지만 아쉬운 점도 있습니다. 메모리 기반 세션은 서버를 재시작하면 데이터가 날아가기 때문에 안정성이 부족하고, 서버를 여러 대로 확장했을 때 세션 동기화가 어렵다는 단점이 있습니다.
Redis를 활용한 세션 관리 방식은 다음 글에서 자세히 다룰 예정입니다!
Reference
Nest.js : Session을 이용한 Google OAuth 구현
이 글은 Nest.js 에서 Google OAuth를 이용해서 세션 로그인을 구현하는 법을 다루고 있습니다. 또한, 이 글은 전에 작성한 세션 글에서 이어서 쓰는 글입니다. 작성자의 뇌피셜이 적당히 들어가니 주
suloth.tistory.com
[NestJS] OAuth 2.0 로그인/회원가입: Google, Kakao, Naver
댕댕워크 프로젝트를 하며 소셜 로그인을 구현했는데 그 내용에 대해 정리한다. REST API로 개발했다.
velog.io
'Framework > Nest.js' 카테고리의 다른 글
[Nest.js] 유연한 날짜 포맷팅 시스템 구현 (0) | 2024.10.14 |
---|---|
Nestjs 프로젝트에서 Swagger 사용하기 (0) | 2024.08.30 |