본 글에서는 인증 정보를 외부 저장소에서 받아와서 인증하는 방법을 알아봅니다. 2편에서는 OAuth 인증을 구현해서 테스트까지 해보겠습니다.
학습 코스
구글 OAuth 구현 순서
어떤 순서로 구글 OAuth를 만들지 잠깐 정리하고 넘어갑시다. ❶ 구글 OAuth를 사용해 구글에 사용자 정보를 요청하면 이메일과 프로필 정보를 구글 OAuth 스트래티지 파일(이하 GoogleStrategy)의 validate( ) 메서드에서 콜백으로 받습니다. ❷ 이때 넘어오는 데이터는 액세스 토큰, (때에 따라) 리프레시 토큰, 프로필 정보입니다. 프로필에는 식별자로 사용되는 ID가 있으며 providerId로 부릅니다. 또한 name 객체도 넘어오는데 성(familyName)과 이름(givenName) 속성을 가지고 있습니다. 프로젝트에서 유저 정보의 키key로 사용하는 이메일 정보도 가지고 있습니다.
구글 OAuth 인증 시 구글에 데이터를 요청하지만 해당 데이터를 어떻게 다룰지는 애플리케이션마다 다릅니다. 이 책에서는 구글 OAuth로 받은 데이터를 10.6절 ‘패스포트와 세션을 사용한 인증 구현하기’에서 살펴본 세션에 저장해서 인증하는 방법으로 사용하겠습니다.
우리가 만드는 애플리케이션에서 유저 식별자는 이메일입니다. 구글 OAuth로 가입한 유저는 패스워드가 없으므로 구글 OAuth로 가입한 유저라는 것을 알 수 있도록 구글 OAuth의 식별자인 providerId를 같이 저장하는 것이 좋습니다. 그러므로 User 엔티티 수정이 필요합니다.
GoogleStrategy의 validate( ) 메서드에서는 인증 시 유저 데이터가 있으면 가져오고 없으면 저장하는 로직이 필요합니다. 이 로직을 UserService에 작성할 겁니다. 관련 유저 데이터는 User 엔티티에 담습니다.
마지막으로 Auth 컨트롤러의 테스트에 사용할 메서드 두 개를 추가합니다. 하나는 OAuth 로그인 화면을 띄울 메서드이고 다른 하나는 OAuth 리다이렉트에 사용할 메서드입니다. 컨트롤러에는 가드가 필요합니다. GoogleAuthGuard도 만들겠습니다. 리다이렉트 시 GoogleStrategy의 validate 메서드가 실행됩니다.
순서대로 정리하면 다음과 같습니다. 설명에는 없지만, GoogleStrategy 설정 시 민감한 정보가 들어가므로 NestJS config도 설정을 해야 합니다.
▼ 구글 OAuth 구현 순서
이제 하나씩 구현하고 테스트하겠습니다.
NestJS 환경 설정 파일 추가하기
????Notice: 11장의 코드들은 10장의 코드를 기반으로 하므로 10장의 폴더(chapter10/nest-auth-test)를 깃허브에서 받아서 진행해주세요. 폴더명 [chapter10]을 [chapter11]로 변경해주세요.
NestJS 환경 설정은 9장에서 이미 다뤘으므로 빠르게 진행하겠습니다. 의존성 패키지 설치 → .env 파일 생성 → ConfigModule 설정순으로 진행합니다.
Todo
01 의존성 패키지를 설치하겠습니다. 다음의 명령어를 입력해 @nestjs/config를 설치합니다.
$ cd chapter11/nest-auth-test
$ npm i @nestjs/config
02 구글 OAuth용 환경 설정 파일을 만들고 값을 설정합시다.
▼ 구글 OAuth용 환경 설정 파일 만들기
GOOGLE_CLIENT_ID={구글OAuth 클라이언트 ID}
GOOGLE_CLIENT_SECRET={구글 OAuth 클라이언트 시크릿}
???? Warning: 구글 OAuth 환경 설정 파일의 내용이 유출되면 보안 문제가 발생할 수 있으므로 저장소에는 올리지 않도록 .gitignore에 추가하는 것이 좋습니다.
03 NestJS config를 활성화하려면 ConfigModule을 설정해야 합니다. app.module.ts에 설정을 추가합니다.
▼ Config Module 설정 추가
// import문 생략
@Module({
imports: [
TypeOrmModule.forRoot({ // ... 생략 ...
type: 'sqlite',
database: 'auth-test.sqlite',
autoLoadEntities: true, // for data source
synchronize: true,
logging: true,
}),
UserModule,
AuthModule,
ConfigModule.forRoot(), // ❶ .env 설정을 읽어오도록 ConfigModule 설정
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
❶ @nestjs/config를 설정하고 .env 설정 파일을 만들어도 ConfigModule.forRoot( )를 임포트하지 않으면 활성화되지 않습니다. ConfigModule.forRoot( )를 실행해서 기동 시에 .env 파일을 읽어 환경 변수에 GOOGLE_CLIENT_ID와 GOOGLE_CLIENT_SECRET를 추가합니다.
구글 OAuth 스트래티지 만들기
구글 OAuth 스트래티지를 만들어봅시다. 스트래티지는 구글 OAuth 인증의 핵심 로직을 추가하는 곳입니다. 구글에서 인증을 마치고 콜백을 받는 메서드를 작성합니다.
Todo
01 먼저 구글 OAuth 스트래티지를 지원하는 의존성 패키지 passport-google-oauth20을 설치합니다.
@ types/passport-google-oauth20은 타입 정보를 가지고 있는 패키지입니다.
$ npm i passport-google-oauth20
$ npm i -D @types/passport-google-oauth20
02 다음으로 strategy 클래스를 만들어봅시다. src/auth 디렉터리 아래에 google.strategy.ts를 다음과 같이 작성합니다.
▼ 구글 OAuth용 스트래티지 파일
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-google-oauth20';
import { User } from 'src/user/user.entity';
import { UserService } from 'src/user/user.service';
@Injectable()
// ❶ PassportStrategy(Strategy) 상속
export class GoogleStrategy extends PassportStrategy(Strategy) {
constructor(private userService: UserService) { // ❷ 생성자
// ❸ 부모 클래스의 생성자를 호출
super({
clientID: process.env.GOOGLE_CLIENT_ID, // 클라이언트 ID
clientSecret: process.env.GOOGLE_CLIENT_SECRET, // 시크릿
callbackURL: 'http://localhost:3000/auth/google', // 콜백 URL
scope: ['email', 'profile'], // scope
});
}
// ❹ OAuth 인증이 끝나고 콜백으로 실행되는 메서드
async validate(accessToken: string, refreshToken: string, profile: Profile) {
const { id, name, emails } = profile;
console.log(accessToken);
console.log(refreshToken);
const providerId = id;
const email = emails[0].value;
const user: User = await this.userService.findByEmailOrSave(
email,
name.familyName + name.givenName,
providerId,
);
return user;
}
}
❶ 10장에서 패스포트로 여러 가지 Strategy 클래스를 만들어보았으므로 Strategy 클래스는 인증 시에 사용하는 로직을 추가하는 메서드라는 사실은 알고 있을 겁니다. 구글 OAuth 인증을 지원하는 클래스는 passport-google-oauth20 패키지에 있습니다. 여기서는 Strategy에 @nestjs/passport의 클래스인 PassportStrategy의 메서드인 validate( )를 추가할 목적으로 사용했습니다. PassportStrategy는 NestJS에서 패스포트를 사용하는 방법을 일원화하는 데 사용하는 믹스인입니다. 인증의 유효성 검증 시 validate( ) 메서드를 사용할 것이라는 것을 쉽게 유추할 수 있습니다.
❷ 생성자에서는 private userService: UserService를 선언해두었습니다만, 현재는 사용하지 않습니다. ❸ 부모 클래스의 생성자를 호출하며 매개변수로 clientID, clientSecret, callbackURL, scope를 받습니다. 각각 구글 OAuth 클라이언트 ID, 구글 OAuth 클라이언트 시크릿, 구글 OAuth 인증 후 실행되는 URL, 구글 OAuth 인증 시 요청하는 데이터입니다.
❹ 구글의 OAuth 인증이 끝나고 콜백 URL을 실행하기 전에 유효성 검증하는 메서드입니다. 콜백의 매개변수로 access_token, refresh_token, profile을 받습니다. access_token과 refresh_token을 받기는 하지만 딱히 필요는 없습니다. 왜냐하면 최초 인증 시 유저 데이터를 데이터베이스에 저장하기 때문입니다. 따라서 저장한 이후에는 구글의 리소스 서버에 인증을 요청하지 않아도 됩니다. profile은 passport-google-oauth20에 있는 Profile 타입의 인스턴스입니다. Profile에는 id, name, emails 속성이 있습니다. id는 프로바이더 ID로서 해당 프로바이더 내에서 유일한 값입니다. name에는 성(familyName), 이름 (givenName)이 있으며, emails는 말그대로 이메일을 여러 개 사용할 때 쓰는 변수입니다. 우리가 받을 수 있는 값은 하나이므로 email[0].value만 필요합니다.
03 Strategy는 프로바이더이므로 등록을 해야 합니다. AuthModule에 등록을 해줍니다.
▼ AuthModule에 GoogleStrategy 등록
// ... 생략 ...
@Module({
imports: [UserModule, PassportModule.register({ session: true })],
providers: [AuthService, LocalStrategy, SessionSerializer, GoogleStrategy],controllers: [AuthController],
})
export class AuthModule {}
테스트를 실행하려면 가드와 컨트롤러 클래스에 메서드 2개를 추가해야 합니다. 가드를 먼저 만들고 컨트롤러에 메서드를 2개 추가해봅시다.
GoogleAuthGuard 만들기
Todo
01 auth.guard.ts에 GoogleAuthGuard 클래스를 추가해봅시다.
다른 가드와 형태는 크게 다르지 않으며 상속 시 추가하는 매개변수 이름만 약간 다릅니다.
▼ GoogleAuthGuard 추가
// ... 생략 ...
@Injectable()
// ❶ google 스트래티지 사용
export class GoogleAuthGuard extends AuthGuard('google') {
async canActivate(context: any): Promise<boolean> {
// ❷ 부모 클래스의 메서드 사용
const result = (await super.canActivate(context)) as boolean;
// ❸ 컨텍스트에서 리퀘스트 객체를 꺼냄
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return result;
}
}
❶ AuthGuard는 @nestjs/passport의 클래스입니다. passport의 Strategy를 쉽게 사용하기 위한 클래스로 생성자의 매개변수에 사용할 스트래티지를 문자열로 넣으면 됩니다. 이름으로 구분하므로 이름을 잘 넣어주어야 합니다. ❷ super.canActivate( ) 메서드에서 GoogleStrategy의 validate( ) 메서드를 실행합니다. 실행 결과가 null 혹은 false이면 401(권한 없음) 에러가 납니다.
❸ nestjs에서는 context에서 리퀘스트 객체를 꺼낼 수 있습니다.
코드의 실행 순서는 LocalStrategy와 같습니다. GoogleAuthGuard의 동작 순서를 그림으로 살펴보겠습니다.
▼ GoogleAuthGuard의 동작 순서
❶ 클라이언트가 /auth/to-google을 호출하면 구글로 리다이렉트됩니다. 로그인을 하면 /auth/google이 호출됩니다. ❷ /auth/google 호출 시 GoogleAuthGuard가 실행됩니다. ❸ GoogleAuthGuard의 canActivate( ) 메서드가 실행되며, 내부 로직에서 GoogleStrategy를 실행합니다. ❹ GoogleStrategy의 validate( ) 메서드에서 인증이 문제가 없는 경우 true를 반환합니다. 실패하면 401 에러가 납니다. ❺ GoogleAuthGuard의 canActivate( ) 메서드에서SessionSerializer를 실행합니다.
❻ SessionSerializer는 세션에 유저의 인증 정보를 저장합니다. ❼ 인증 및 세션 저장이 완료되었으므로 클라이언트에 세션 정보 조회를 위한 쿠키를 포함해 응답을 전송합니다.
가드를 만들었으므로 다음으로는 컨트롤러에 핸들러 메서드를 추가해봅시다. 구글 OAuth에는 구글 로그인 화면으로 이동시킬 핸들러 메서드와 다른 하나는 구글 로그인 성공 시 콜백을 실행하는 메서드를 사용합니다.
컨트롤러에 핸들러 메서드 추가하기
Todo
01 Auth 컨트롤러에 구글 OAuth 확인(인증)에 사용할 핸들러 메서드를 추가해봅시다.
스트래티지와 가드를 만들었으면 유저의 요청을 받아줄 컨트롤러의 핸들러 메서드가 필요합니다.
▼ Auth 컨트롤러에 구글 OAuth 확인에 사용할 핸들러 메서드 함수 추가
import {
AuthenticatedGuard,
GoogleAuthGuard,
LocalAuthGuard,
LoginGuard,
} from './auth.guard';
import { AuthService } from './auth.service'; // 1 GoogleAuthGuard 임포트
@Controller('auth')
export class AuthController {
// ... 생략 ...
@Get('to-google') // ❷ 구글 로그인으로 이동하는 라우터 메서드
@UseGuards(GoogleAuthGuard)
async googleAuth(@Request() req) {}
@Get('google') // ❸ 구글 로그인 후 콜백 실행 후 이동 시 실행되는 라우터 메서드
@UseGuards(GoogleAuthGuard)
async googleAuthRedirect(@Request() req, @Response() res) {
const { user } = req;
// res.redirect('http://localhost:3000/auth/test-guard2');
return res.send(user);
}
}
❶ 앞서 만든 GoogleAuthGuard를 임포트합니다. 새로 추가할 라우터 메서드인 googleAuth( )와 googleAuthRedirect( )에서 사용합니다. ❷ googleAuth( )는 구글 로그인 창을 띄우는 메서드입니다. @Get(‘to-google’)이 있으므로 localhost:3000/auth/to-google로 접근 시 실행합니다. 가드로 GoogleAuthGuard를 사용합니다. ❸ googleAuthRedirect( ) 메서드는 구글 로그인 성공 시 실행하는 라우터 메서드입니다. GoogleAuthGuard에서 GoogleStrategy의 validate( ) 메서드를 실행한 다음에 googleAuthRedirect( ) 메서드를 실행합니다. googleAuthRedirect( ) 메서드에서는 request에서 user 정보를 뽑아낸 다음, res. send( ) 메서드를 실행해 화면에 뿌리는 역할을 합니다.
테스트하기
Todo
01 콘솔창에서 npm run start:dev를 실행해 서버를 기동합니다. 서버가 기동되었으면 브라우저에서 http://localhost:3000/auth/to-google에 접속해봅시다.
여기까지 작성했으면 이제 구글 로그인을 테스트해볼 수 있습니다. 화면이 리다이렉트되면서 다음과 같은 구글 로그인 창이 나옵니다.
02 본인의 계정을 선택해 로그인을 해봅시다. 로그인 후에는 구글 콘솔에서 입력한 URL인 http:// localhost:3000/auth/google으로 이동하며 다음과 같이 Profile 정보가 나옵니다.
GoogleStrategy의 validate( ) 메서드에서 profile을 반환했고, GoogleAuthGuard의 canActivate에서 해당 값을 request.user에 저장합니다. AuthController의 googleAuthRedirect에서는 request에서 값을 받아서 화면에 보여주는 역할을 합니다. 구글 OAuth로 유저 정보를 잘 받아오는지 확인했으니, UserEntity를 수정하고, 구글 로그인 시 회원 정보를 저장하는 로직도 추가해봅시다. 그리고 세션을 활용해 인증 시에 확인하는 기능도 추가하겠습니다.
User 엔티티 파일 수정하기
Todo
01 user.entity.ts 파일에서는 두 가지 수정을 진행합니다. password가 없는 때도 데이터가 저장하는 기능과 구글 인증 시의 식별자인 providerId를 추가합니다.
▼ 유저정보에 providerId 추가
@Entity()
export class User {
@PrimaryGeneratedColumn()
id?: number;
@Column({ unique: true })
email: string;
@Column({ nullable: true }) // 1 패스워드에 빈 값 허용
password: string;
@Column()
username: string;
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
createdDt: Date;
@Column({ nullable: true }) // 2 providerId에 빈 값 허용
providerId: string; // 3 providerId 추가
}
❶ {nullable: true}로 설정하면 빈 값을 허용한다는 뜻입니다. 구글 OAuth 인증에는 구글에서 인증을 한 다음 돌아오므로 패스워드를 알 수 없으니 빈 값으로 넣습니다. ❷ providerId는 구글 OAuth로 가입하지 않은 경우에는 모르는 값이므로 빈 값을 허용합니다. 3 providerId는 OAuth 인증 시 식별자로 사용할 수 있는 값입니다.
UserService에 구글 유저 검색 및 저장 메서드 추가하기
Todo
01 이메일로 기존 가 입 여부를 확인해 가입되어 있으면 유저 정보를 반환하고, 아니면 회원 정보를 유저 테이블에 저장하는 코드를 작성하겠습니다.
구글 OAuth 인증의 정보를 기반으로 회원 가입을 시켜주는 메서드가 필요합니다. 동시에 이미 회원 정보가 있다면 회원 정보를 반환하는 메서드도 필요합니다. 구글은 providerId로 찾지만 우리가 만드는 애플리케이션에서는 이메일이 회원을 구분하는 단위입니다.
▼ 구글 유저 검색 및 유저 정보 저장 메서드 추가
@Injectable()
export class UserService {
// ... 생략 ...
async findByEmailOrSave(email, username, providerId): Promise<User> {
const foundUser = await this.getUser(email); // 1 이메일로 유저를 찾음
if (foundUser) { // 2 찾았으면
return foundUser; // 3 유저 정보 반환
}
const newUser = await this.userRepository.save({
email,
username,
providerId,
});
return newUser; // 5 저장 후 유저 정보 반환
}
}
❶ 10.2.3 ‘서비스 만들기’에서 만들어둔 getUser(email) 메서드를 사용해 이메일로 가입 정보가 있는지 확인합니다. ❷ 유저 정보가 있으면 ❸ 유저 정보를 바로 반환합니다. 이때 반환값의 타입은 Promise<User>입니다. 호출하는 곳에서 await를 해야 올바르게 받을 수 있습니다. ❹ 유저 정보가 없으면 email, username(성, 이름), providerId를 저장합니다. ❺ 유저 정보 저장 후 바로 저장된 유저 정보를 반환해줍니다.
GoogleStrategy에 구글 유저 저장하는 메서드 적용하기
Todo
01 GoogleStrategy의 validate( ) 메서드에서 구글 유저 정보가 있다면 정보를 데이터베이스에서 가져오고 없다면 저장해야 하므로 findByEmailOrSave( ) 메서드를 GoogleStrategy에 적용해봅시다. validate( ) 메서드에서 profile 정보의 id, name, email을 디비에 저장하도록 User 엔티티에 맞춰서 넘겨주면 됩니다.
▼ userService의 findByEmailOrSave() 메서드 적용
import { User } from 'src/user/user.entity';
export class GoogleStrategy extends PassportStrategy(Strategy) {
// ... 생략 ...
async validate(accessToken: string, refreshToken: string, profile: Profile) {
const { id, name, emails } = profile;
console.log(accessToken);
console.log(refreshToken);
const providerId = id;
const email = emails[0].value;
// ❶ 유저정보저장혹은가져오기
const user: User = await this.userService.findByEmailOrSave(
email,
name.familyName + name.givenName,
providerId,
);
//❷ 유저정보반환
return user;
}
}
❶ userService에 findByEmailOrSave( ) 메서드를 추가해 유저 정보를 가져옵니다. ❷ 수정 전에는 OAuth에 있는 profile을 반환했으나 이제는 DB에 저장한 user 정보를 반환합니다. 세션에서는 유저 정보를 다룰 때 userEntity를 사용합니다. OAuth 정보를 담은 User 엔티티를 사용하도록 수정했으므로 이제 세션을 사용할 수 있습니다. OAuth 정보를 사용하는 세션을 사용하도록 GoogleAuthGuard를 수정해봅시다.
GoogleAuthGuard에 세션을 사용하도록 변경하기
Todo
01 로그인 시에만 구글 OAuth 요청을 하고 그 뒤로는 세션에 저장된 데이 터로 인증을 확인하도록 코드를 변경해봅시다.
Google OAuth 적용의 마지막 단계입니다. 지금까지 구현한 코드들은 클라이언트에서 HTTP 요청 시마다 구글 OAuth 인증을 해야 합니다. 유저가 로그인 상태인지 유지하는 쿠키나 세션 같은 장치가 없기 때문입니다.
▼ GoogleAuthGuard에 세션 적용하기
@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {
async canActivate(context: any): Promise<boolean> {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request); // ❶ 세션 적용
return result;
}
}
❶ 부모 클래스의 logIn(request)를 실행합니다. 세션 기능이 활성화되어 있다면 SessionSerializer를 실행해 reuqest.user의 값을 세션에 저장합니다.
이제 데이터베이스에 유저 데이터가 저장되어 있고 세션도 사용할 수 있게 되었으니, 매번 구글 OAuth 인증을 하지 않아도 됩니다.
테스트하기
Todo
01 의도한 대로 작동하는지 http://localhost:3000/auth/to-google로 가서 구글 로그인을 진행해봅시다.
리다이렉트된 창에는 다음과 같이 userEntity 형태의 유저 정보가 나와야 합니다.
02 SQLite 클라이언트를 열어서 auth-test.sqlite 파일을 확인해봅시다.
다음과 같이 패스워드는 null이고 providerId값이 들어 있어야 합니다.
03 구글 OAuth 로그인을 한 브라우저 창에서 http://localhost:3000/auth/test-guard2로 이동해봅시다.
세션이 잘 동작하는지 확인해봅시다. AuthController 클래스의 testGuardWithSession( ) 메서드(10.6.6절 ‘테스트하기’참조)를 사용하면 됩니다. 다음과 같이 userEntity 정보가 보인다면 성공입니다.
구글 OAuth 구현은 《[되기] Node.js 백엔드 개발자 되기》의 9장과 10장을 참고하면 더 쉽게 학습 코스를 따라오실 수 있습니다.
도움을 많이 받았으나… local strategy와 sessionsSerializer가 뭔가 싶어서 열심히 찾았습니다.. 흑흑 좋은 글 감사합니다
진짜 너무 잘 정리해주셨네요. 헷갈리는 부분이 있었는데 정리가 되었습니다. 그런데 질문이 하나 있습니다. 프론트엔드를 Next.js로 구성했고 localhost:4000으로 띄웠다면, Nest.js에서 인증 후 callbackURL이 localhost:4000이 되어야 할까요? 그리고 Nest.js 세션에도 유저 정보를 저장하고, Next.js 세션에 토큰을 저장해서 인증 과정에서 사용하는건지요? 이 부분에서 계속 헤매서… 부탁드립니다 선배님… ㅠㅠ