들어가기전에…

내가 다니는 회사에서 통합로그인에 대한 이슈가 있었던걸로 기억한다. 그리고, 임대형 사이트를 사용해본적이 있었는데, 소셜 로그인을 지원하느냐 안하느냐에 따라 회원가입 편차가 상당히 컸던걸로 기억한다. 이번 스터디에서는 OAuth, 소셜 로그인 기능에 대해 스터디해보았다. 내가 공부하는 책에서 난이도가 꽤 높음으로 되어있는데, 내용이 어려운 만큼 구조와 동작을 집중하여 봐야할 것 같다.


OAuth 란?

OAuth는 Open Authorization의 약자로서 개방형 인가의 표준이라는 뜻으로, 소셜 로그인이다. 소셜 로그인은 유저가 해당 웹사이트에 가입한 ID와 패스워드 대신 다른 사이트에 있는 유저 정보를 사용해 인증하는 기능이다.

하지만, OAuth는 인증이라기 보다 인가의 관점에서 보아야 한다. 소셜 로그인 후 팝업이 뜨면서 권한을 요청하는데, 이 때 인증에 필요한 정보인 이름, 이메일 정보 등을 다른 사이트에 요청한다.

OAuth는 1.0, 2.0, 2.1 버전이 있으며, 현재 2.0 버전을 가장많이 사용한다.

OAuth2를 본격적으로 알아보기 전에 인증, 인가, 액세스 토큰, 리프레시 토큰 등의 주요 용어를 아래와 같이 알아보자.

  • 인증: 리소스에 접근 자격이 있는지 검증한다. OAuth에서 리소스는 보호된 정보를 의미한다
  • 인가: 자원에 접근할 권한을 부여하는 과정. 인가가 완료되면 리소스의 접근 권한 정보가 있는 액세스 토큰을 클라이언트에게 보낸다
  • 액세스 토큰: 리소스 서버에서 리소스 소유자의 보호된 정보를 획득할 때 사용하는 만료 기간이 있는 토큰
  • 리프레시 토큰: 액세스 토큰이 만료되었을 때 갱신하는 용도로 사용하는 토큰. 액세스 토큰보다 만료 기간을 길게 가져간다
  • 리소스 소유자: 리소스는 사용자의 보호된 정보를 말하며 이런 정보에 접근하도록 자격을 부여하는 사람을 말한다.
  • 클라이언트: 리소스를 사용하려고 접근을 요청하는 앱을 의미한다
  • 리소스 서버: 사용자의 보호된 자원을 가지고 있는 서버
  • 인가 서버: 인증/인가를 수행하는 서버로 클라이언트의 접근 자격을 확인하고 액세스 토큰을 발급해 권한을 부여한다

OAuth 프로토콜 흐름

image-20230901092607210

<참조: p.430(패스포트와 세션을 사용한 인증 과정) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

1번. 클라이언트가 리소스 소유자에게 권한 부여를 요청

2번. 클라이언트는 권한을 부여 받음

  • 인증 코드(Autorization Code)
  • 암묵적인(Implicit) 방법
  • 리소스 소유자의 암호 자격증명(Resource Owner Password Credentials)
  • 클라이언트 자격증명(Client Credentials)

3번. 클라이언트는 2번에서 받은 정보를 통해 액세스 토큰을 인가 서버에 요구

4번. 인가 서버에서는 클라이언트를 인증하고 유혀성 검사를 함. 유효한 경우 액세스 토큰을 발급

5번. 클라이언트가 리소스 서버에 보호된 리소스를 요청. 요청 시에는 액세스 토큰을 사용

6번. 리소스 서버는 액세스 토큰의 유효성을 검사하고 유효한 경우 보호된 자원 정보를 보내줌


액세스 토큰을 재발행하는 흐름

액세스 토큰 만료 시 리프레시 토큰을 사용해 액세스 토큰을 재발행할 수 있다

image-20230901093649280

<참조: p.431(패스포트와 세션을 사용한 인증 과정) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

1번. 클라이언트는 인가 서버에 인증을 하고 액세스 토큰을 요청한다

2번. 인가 서버는 클라이언트를 인증하고 유효성 검증을 한 후에 문제가 없으면 액세스 토큰과 리프레시 토큰을 발급한다

3번. 클라이언트가 리소스 서버에 액세스 토큰을 보내면서 보호된 리소스를 요청

4번. 리소스 서버가 액세스 토큰의 유효성을 검증하고 유효한 경우 리소스를 내려준다

5번. 액세스 토큰 만료 시에도 클라이언트는 액세스 토큰을 리소스 서버에 전달한다. 클라이언트가 액세스 토큰이 만료된 것을 알고 있다면, 7로 가게 되고, 모른다면 만료된 액세스 토큰을 전달한다

6번. 리소스 서버에서는 액세스 토큰이 만료되었으므로 잘못된 토큰 에러를 발생시킨다

7번. 클라이언트에서는 액세스 토큰이 만료되어 에러가 발생했으므로 리프레시 토큰을 인가 서버로 전달해 새 액세스 토큰을 요청한다

8번. 인가 서버는 리프레시 토큰이 유효한 경우 새로운 액세스 토큰을 발급한다. 선택적으로 리프레시 토큰도 재발급한다


1. 구글 OAuth를 사용하기 위한 준비

클라이언트에서 구글의 OAuth 인가 서버에 접속하려면 OAuth 클라이언트 ID와 비밀번호가 필요하다. 아래는 생성을 위한 순서이다.

  • (1) 구글 클라우드에서 프로젝트 생성
  • (2) OAuth 동의 화면을 생성
  • (3) 클라이언트 ID 생성

순서 1. 구글 클라우드에서 프로젝트 생성

구글 클라우드(console.cloud.google.com)에 접속한다

순서 1-1. 프로젝트 선택 > 새 프로젝트

image-20230901095425247


순서 1-2. 프로젝트 이름 입력 > 만들기

image-20230901095545895


순서 1-3. 생성한 프로젝트 선택 > API 및 서비스 > OAuth 동의 화면

image-20230901095822413


순서 1-4. 외부 > 만들기

image-20230901095919511


순서 1-5. [OAuth 동의 화면]에서 앱 이름 > 사용자 지원 이메일 > 개발자 연락처 청보 > 저장 후 계속

image-20230901100122524

image-20230901100128695


순서 1-6. [범위]에서 설정 없이 저장 후 계속


순서 1-7. [테스트 사용자]에서 설정 없이 저장 후 계속


순서 1-8. [요약]에서 대시보드로 돌아가기


순서 2. OAuth 클라이언트의 ID와 비밀번호 만들기

순서 2-1. 사용자 인증 정보 > 사용자 인증정보 만들기 > OAuth 클라이언트 ID를 눌러서 인증 정보 설정 화면으로 이동

image-20230901101735129


순서 2-2. [OAuth 클라이언트 ID 만들기] 에서 애플리케이션 유형: 웹 애플리케이션 > 이름: 웹 클라이언트 1 > 승인된 자바스크립트 원본: http://localhost:3000 > 승인된 리디렉션 URI: http://localhost:3000/auth/google > 만들기

image-20230901102138178


순서 2-3. 생성된 클라이언트 ID, 클라이언트 보안 비밀번호를 기억해두고, 유출되지 않도록 조심하자

image-20230901102755848


2. 구글 OAuth 구현 순서

image-20230901103322161

1번. 구글 OAuth를 사용해 구글에 사용자 정보를 요청하면 이메일과 프로필 정보를 OAuth 스트래티지 파일(GoogleStrategy)의 validate() 메서드에서 콜백으로 받는다

2번. 이때 넘어오는 데이터는 액세스 토큰(리프레시 토큰), 프로필 정보다

3번. 프로필에는 식별자로 사용되는 ID가 있으며 providerId로 부른다. name 객체도 넘어오는데 성(familyName)과 이름(givenName) 속성을 가지고 있다. 프로젝트에서 유저 정보의 키로 사용하는 이메일 정보도 가지고 있다

그래서, 받아온 데이터를 어떻게 다룰거지?

책에서 가이드해주는 대로 구글 OAuth로 받은 데이터를 지난 장에서 다뤘던 ‘패스포트와 세션을 사용한 인증 구현‘에서 살펴본 세션에 저장해서 인증하는 방법을 사용하려 한다.

책에 따르면, 앞으로 만들 앱에서 유저 식별자는 이메일이다. 구글 OAuth로 가입한 유저는 패스워드가 없으므로, 구글 OAuth로 가입한 유저라는 것을 알 수 있도록 구글 OAuth의 식별자인 providerId를 같이 저장한다. 그러므로 User 엔티티 수정이 필요하다.

※ 엔티티란? SQLite에서 다루었었는데, 데이터베이스에서 엔티티는 한 건의 자료를 구성하는 데이터를 의미한다. ORM 프레임워크에서는 테이블과 매핑하는 클래스를 엔티티라고 하며 TypeORM의 경우 @Entity() 데코레이터를 붙인 클래스를 의미한다

GoogleStrategy의 validate() 메서드에서는 인증 시 유저 데이터가 있으면 가져오고 없으면 저장하는 로직이 필요하다. 이 로직을 UserService에 작성한다. 관련 유저 데이터는 User 엔티티에 담는다.

마지막으로 Auth 컨트롤러의 테스트에 사용할 메서드 두 개를 추가한다. 하나는 OAuth 로그인 화면을 띄울 메서드이고 다른 하나는 OAuth 리다이렉트에 사용할 메서드이다. 컨트롤러에는 가드가 필요하다. GoogleAuthGuard도 만든다. 리다이렉트 시 GoogleStrategy의 validate 메서드가 실행된다

그리고 GoogleStrategy 설정 시 민감한 정보가 들어가므로 NestJS config도 설정해야 한다.

image-20230901105752335

3. NestJS 환경 설정 파일 추가하기

순서 1. 의존성 패키지 설치

npm i @nestjs/config

순서 2. 구글 OAuth용 환경 설정 파일을 만들고 값을 설정하기 위해, root 경로에 환경 설정 파일을 만든다

GOOGLE_CLIENT_ID={구글OAuth 클라이언트 ID}
GOOGLE_CLIENT_SECRET={구글OAuth 클라이언트 시크릿}

※ 구글 OAuth 환경 설정 파일은 .gitignore에 추가하자

image-20230901111322514


순서 3. NestJS config를 활성화하려면 ConfigModule을 설정한다. app.module.ts에 설정을 추가한다

// src > app.module.ts

... 생략 ...
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'nest-auth-test.sqlite',
      entities: [User], // 엔티티 등록
      synchronize: true,
      logging: true
    })
    , UserModule
    , AuthModule
    , ConfigModule.forRoot(), // 1. .env 설정을 읽어오도록 ConfigModule 설정
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }

1번. 서버 기동 시, .env 파일을 읽어 환경 변수에 GOOGLE_CLIENT_ID와 GOOGLE_CLIENT_SECRET을 추가한다


4. 구글 OAuth 스트래티지 만들기

구글 OAuth 스트래티지를 만든다. 스트래티지는 구글 OAuth 인증의 핵심 로직을 추가하는 곳이다. 구글에서 인증을 마치고 콜백을 받는 메서드를 작성한다

순서 1. 먼저 구글 OAuth 스트래티지를 지원하는 의존성 패키지 passport-google-oauth20을 설치한다

npm i passport-google-oauth20
npm i -D @types/passport-google-oauth20

순서 2. google.strategy.ts 클래스를 아래와 같이 작성한다

// src > auth > google.strategy.ts

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()
// 1. PassportStrategy(Strategy) 상속
export class GoogleStrategy extends PassportStrategy(Strategy) {
    constructor(private userService: UserService) { // 2. 생성자

        // 3. 부모 클래스의 생성자를 호출
        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
        });
    }

    // 4. 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;

        console.log(providerId, email, name.familyName, name.givenName);
        return profile;
    }
}

1번. 구글 OAuth 인증을 지원하는 클래스는 passport-google-oauth20 패키지다. 추가한 이유는 PassPortStrategy의 메서드인 validate()를 추가할 목적으로 사용했다. PassportStrategy는 NestJS에서 패스포트를 사용하는 방법을 일원화하는데 사용한 믹스인이다.

2번. (설명 생략)

3번. 부모 클래스의 생성자를 호출하여 매개변수로 clientID, clientSecret, callbackURL, scope를 받는다. 각각 구글 OAuth 클라이언트 ID, 구글 OAuth 클라이언트 시크릿, 구글 OAuth 인증 후 실행되는 URL, 구글 OAuth 인증 시 요청하는 데이터이다.

4번. 구글의 OAuth 인증이 끝나고 콜백 URL을 실행하기 전에 유효성 검증하는 메서드다. 콜백의 매개변수로 access_token, refresh_token, profile을 받는다. access_token과 refresh_token을 받기는 하지만 딱히 필요없다. 왜냐하면 최초 인증 시 유저 데이터를 데이터베이스에 저장하기 때문이다. 따라서 저장한 이후에는 구글의 리소스 서버에 인증을 요청하지 않아도 된다. 실습에서 사용할 값은 email[0].value 만 필요하다


순서 3. Strategy는 프로바이더이므로 등록을 해야 한다. AuthModule에 등록한다

// src > auth > auth.module.ts

... 생략 ...
import { GoogleStrategy } from './google.strategy'; // 추가 1

@Module({
  imports: [UserModule, PassportModule.register({ session: true })],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, SessionSerializer, GoogleStrategy] //GoogleStrategy 추가
})
export class AuthModule { }


5. GoogleAuthGuard 만들기

순서 1. auth.guard.ts에 GoogleAuthGuard 클래스를 추가한다. 다른 가드와 형태는 크게 다르지 않으며, 상속 시 추가하는 매개변수 이름만 약간 다르다.

// src > auth > auth.guard.ts

... 생략 ...
@Injectable()
// 1. google 스트래티지 사용
export class GoogleAuthGuard extends AuthGuard('google') {
    async canActivate(context: any): Promise<boolean> {
        // 2. 부모 클래스의 메서드 사용
        const result = (await super.canActivate(context)) as boolean;
        // 3. 컨텍스트에서 리퀘스트 객체를 꺼냄
        const request = context.switchToHttp().getRequest();
        return result;
    }
}

1번. AuthGuard는 @nestjs/passport의 클래스이다. passport의 Strategy를 쉽게 사용하기 위한 클래스로 생성자의 매개변수에 사용할 스트래티지 문자열(google)을 넣었다.

2번. super.canActivate() 메서드에서 GoogleStrategy의 validate() 메서드를 실행한다. 실행 결과가 null 혹은 false이면 401 에러가 난다

3번. nestjs에서는 context에서 리퀘스트 객체를 꺼낼 수 있다


6. 컨트롤러에 핸들러 메서드 추가

// src > auth > auth.controller.ts

... 생략 ...
import { LoginGuard, AuthenticatedGuard, LocalAuthGuard, GoogleAuthGuard } from './auth.guard'; // GoogleAuthGuard추가

@Controller('auth')
export class AuthController {
    constructor(private authService: AuthService) { }

    ... 생략 ...
    // 2. 구글 로그인으로 이동하는 라우터
    @Get('to-google')
    @UseGuards(GoogleAuthGuard)
    async googleAuth(@Request() req) { }

    // 3. 구글 로그인 후 콜백 실행 후 이동 시 실행되는 라우터
    @Get('google')
    @UseGuards(GoogleAuthGuard)
    async googleAuthRedirect(@Request() req, @Response() res) {
        const { user } = req;
        return res.send(user);
    }

1번. GoogleAuthGuard를 임포트한다. 새로 추가할 라우터 메서드인 googleAuth()와 googleAuthRedicrect()에서 사용

2번. googleAuth()는 구글 로그인 창을 띄우는 메서드

3번. googleAuthRedirect() 메서드는 구글 로그인 성공 시 실행하는 라우터 메서드이다. GoogleAuthGuard에서 GoogleStrategy의 validate() 메서드를 실행한 다음에 googleAuthRedirect() 메서드를 실행한다. googleAuthRedirect() 메서드에서는 request에서 user 정보를 뽑아낸 다음, res.send() 메서드를 실행해 화면에 뿌리는 역할을 한다


7. 테스트와 문제 발생, 문제 해결

npm run start:dev 로 서버를 기동했는데, http://localhost:3000/auth/to-google 호출 시, 아래와 같이 500 에러를 뱉어냈는데, 원인을 찾아보니 google 전략 파일쪽에 문제가 있는 것 같았다.

image-20230902174134440

그래서, 구글 인가 관련 모듈도 다시 설치도 해보고, 코드를 뒤져봤는데 문제가 딱히 없었다. 그러다가 provider에 스트래지가 등록이 안되어있어서 발생할 수 있다고 해서 뒤져봤는데도 문제가 없었다. 혹시나 싸한 생각에 GoogleStrategy 부분만 프로바이더에서 지웠다 다시 입력하니 정상적으로 되었다 ㄷㄷ…

image-20230902174543820


아래는 정상적으로 테스트된 화면이다. http://localhost:3000/auth/to-google 로 로그인하면, (사진1)아이디 선택창이 뜨고, 아이디를 선택하면, (사진2)http://localhost:3000/auth/google로 이동하면서, Profile 정보가 보여진다

(사진1)

image-20230902174659085

(사진2)

image-20230902175140155


GoogleStrategy의 validate() 메서드에서 profile을 반환했고, GoogleAuthGuard의 canActivate에서 해당 값을 request.user에 저장한다. AuthController의 googleAuthRedirect에서는 request에서 값을 받아서 화면에 보여주는 역할을 한다.


GoogleAuthGuard의 동작순서

image-20230901121513856

<참조: p.433(패스포트와 세션을 사용한 인증 과정) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

1번. 클라이언트가 /auth/to-google을 호출하면 구글로 리다이렉트된다. 로그인하면 /auth/google이 호출된다

2번. /auth/google 호출 시 GoogleAuthGuard가 실행된다

3번. GoogleAuthGuard의 canActivate() 메서드가 실행되며, 내부 로직에서 GoogleStrategy를 실행한다

4번. GoogleStrategy의 validate() 메서드에서 인증이 문제가 없는 경우 true를 반환. 실패하면 401 에러

5번. GoogleAuthGuard의 canActivate() 메서드에서 SessionSerializer를 실행

6번. SessionSerializer는 세션에 유저의 인증 정보를 저장

7번. 인증 및 세션 저장이 완료되었으므로 클라이언트에 세션 정보 조회를 위한 쿠키를 포함해 응답 전송

참고

다음에 다루게 될 것

구글 OAuth로 유저 정보를 잘 받아오는지 확인했으니, UserEntity를 수정하고, 구글 로그인 시 회원 정보를 저장하는 로직도 추가하자. 그리고 세션을 활용해 인증 시에 확인하는 기능도 추가하자.

  • OAuth를 사용한 구글 로그인 인증하기(2) - 스트래티지(Google)에 구글 유저 저장하기

댓글남기기