본문 바로가기
Back-end/nest.js

Nest.js : Session을 이용한 Google OAuth 구현

by hsloth 2023. 3. 5.

 

이 글은 Nest.js 에서 Google OAuth를 이용해서 세션 로그인을 구현하는 법을 다루고 있습니다.

또한, 이 글은 전에 작성한 세션 글에서 이어서 쓰는 글입니다. 작성자의 뇌피셜이 적당히 들어가니 주의해서 읽어주시기 바랍니다.

https://suloth.tistory.com/22

 

Nest.js : Nest.js로 Redis와 연동하여 세션 로그인 구현하기 (1)

왜 구글에 nest.js 레디스 세션 로그인을 치면 레디스를 캐시모듈로만 활용하는 글들만 나올까... 나의 구글링 실력의 부족일지도 모르겠다. (뭐 결국 삽질하다보면 나오긴 하는데...) (물론, 캐시

suloth.tistory.com

https://suloth.tistory.com/23

 

Nest.js : Nest.js로 Redis와 연동하여 세션 로그인 구현하기 (2)

이번 글은 Nest.js에서 Swagger를 사용하여 로그인 인증을 하는 방법을 찾으면서 쓴 글이다! JWT를 사용할 때에는 ApiBearerAuth를 추가해서 Login해서 나오는 토큰값을 추가해주면 되었는데, 세션은 어떤

suloth.tistory.com

 

구글 클라우드 콘솔 가입

구글 OAuth를 프로젝트에 적용하기 전에 먼저 구글 클라우드 콘솔에 가입해야 한다.

가입 후, 새 프로젝트를 만들고 사용자 인증 정보에 들어가서 oauth client id와 password를 발급받자.

그리고 승인된 리디렉션 URI에 callback url로 사용할 url을 적어주자 (ex. 나의 경우는 로컬에서 돌릴 예정이니, localhost:3000/auth/google/callback)

자세한 건 구글링해서 찾아보자.

 

 

본격적으로 Google OAuth를 프로젝트에 적용해보자

1. Google OAuth와 로그인 관련 라이브러리 다운

npm i @nestjs/passport passport passport-google-oauth20
npm i --save-dev @types/passport @types/passport-google-oauth20

 

2. Strategy 작성

// google.strategy.ts
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';

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_OAUTH_CALLBACK_URL'),
      scope: ['email', 'profile'],
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: Profile,
    done: VerifyCallback,
  ) {
    const { id, name, emails, photos } = profile;

    const user = {
      provider: 'google',
      providerId: id,  // session에서 user.id를 빼려면, providerId가 아닌 id로 설정해주자.
      firstName: name.givenName, // 혹은 name: name.givenName으로만 받자.
      lastName: name.familyName,
      email: emails[0].value,
      picture: photos[0].value,
      accessToken,
      refreshToken,
    };
    return done(null, user);
  }
}

생성자의 clientId, clientSecret, callbackURL에는 각각 구글에서 받은 client ID와 client Password(또는 secret), 그리고 아까 리디렉션 url에서 설정한 url을 입력해준다.

Guard의 super.canActivate(context)를 통해서 이 전략의 validate함수로 들어온다.

그리고 유저 정보와 토큰을 받아서 done을 이용해서 넘겨준다.

 

3. Guard 작성

// google-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const can = await super.canActivate(context);
    if (can) {
      const request = context.switchToHttp().getRequest();
      await super.logIn(request);
    }
    return true;
  }
}

super.canActivate(context)를 통해 GoogleStrategy로 들어가서 done(null, user)를 실행시키고, super.logIn(request)를 통해 LocalSerializer의 serializeUser 함수로 들어간다.

 

(원래라면 그냥 아래와 같이 써도 된다. jwt를 사용할 경우)

// google-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}

하지만 우리는 세션을 사용할 것이기 때문에 canActivate 함수를 구현해주어야 한다.

 

4. GoogleAuthGuard 사용

@UseGuards(GoogleAuthGuard)
  @Get('google')
  async googleAuth() {
    // redirect google login page
  }

  // 구글 로그인 후 callbackURL로 오는 요청을 처리할 API
	// 해당 url은 googleStrategy의 callbackURL과 동일해야한다.
  @UseGuards(GoogleAuthGuard)
  @Get('google/callback')
  async googleAuthCallback(
    @Users() user: ValidateUserForGoogleInboundInputDto,
  ) {
    const googleId = await this.authControllerInboundPort.validateUserForGoogle(
      user,
    );
    console.log(user);

    return { user };
  }

@UseGuards 데코레이터를 컨트롤러에 사용하여 googleAuthgoogleAuthCallback 함수에 붙여준다.

여기서 googleAuthCallback 함수의 경로는 googleStrategy의 callbackURL과 동일해야하며, 구글 클라우드 콘솔에서 설정해준 리디렉션 url과도 동일해야한다.

사용자가 /google 경로를 통해 구글 로그인을 하면 /google/callback 경로로 Get요청을 보내게 되고, 이 함수에서 해당 유저에 대한 처리를 해주면 된다. (ex. 유저가 등록 안되있다면 DB에 등록해주기, 유저 정보 전달 등) 여기서는 this.authControllerInboundPort.validateUserForGoogle(user); 이 유저 등록과 정보전달을 해주는 함수이다 (auth.service.ts)

세션을 통하게 되면, req.user에 user 정보가 들어오기 때문에 @Users 데코레이터로 user를 받아서 사용하면 된다.

 

5. GoogleStrategy를 모듈에 등록하기

AuthModule의 providers에 GoogleStrategy를 등록해주면 된다.

@Module({
  imports: [
    PassportModule.register({ session: true }),
    TypeOrmModule.forFeature([UserEntity]),
    RedisModule,
  ],
  controllers: [AuthController],
  providers: [
    {
      provide: AUTH_CONTROLLER_INBOUND_PORT,
      useClass: AuthService,
    },
    {
      provide: USER_REPOSITORY_OUTBOUND_PORT,
      useClass: UserRepository,
    },
    {
      provide: REDIS_REPOSITORY_OUTBOUND_PORT,
      useClass: RedisRepository,
    },
    {
      provide: CONFIG_SERVICE_OUTBOUND_PORT,
      useClass: EnvService,
    },
    AuthService,
    LocalStrategy,
    LocalSerializer,
    GoogleStrategy,  
  ],
  exports: [AuthService],
})
export class AuthModule {}

 

번외. LocalSerializer 수정

만약에 GoogleStrategy에서 모종의 이유로(나같은 경우는 googleId가 bigint 범위를 넘어버려서 string으로 사용해서...) user에 id가 아닌 providerId를 속성으로 사용했다면, user에 id속성이 넘어오지 않기 때문에 코드를 변경해야 한다(로컬 로그인으로 넘어오는 id와 구글 로그인으로 넘어오는 id가 타입이 다르거나, 속성 이름이 다를 경우 그에 따른 처리를 할 필요가 있다)

// local.serializer.ts
import { Inject, Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import {
  UserRepositoryOutboundPort,
  USER_REPOSITORY_OUTBOUND_PORT,
} from 'src/outbound-ports/user/user-repository.outbound-port';

@Injectable()
export class LocalSerializer extends PassportSerializer {
  constructor(
    @Inject(USER_REPOSITORY_OUTBOUND_PORT)
    private readonly userRepositoryOutboundPort: UserRepositoryOutboundPort,
  ) {
    super();
  }

  serializeUser(user: any, done: Function) {
    done(null, user.id ?? { id: user.providerId, provider: 'google' });
  }

//deserializeUser도 마찬가지로 분기 처리가 필요하다.
  async deserializeUser(payload: any, done: Function) {
    if (payload.provider === 'google') {
      return await this.userRepositoryOutboundPort.findUserByGoogleId({
        googleId: payload.id,
      });
    }
    return await this.userRepositoryOutboundPort.findUserForDeserialize({
      userId: payload,
      done,
    });
  }
}

일단, serializeUser함수를 보면, local 로그인시 user.id 값이 잘 전달되는 반면, google oauth 로그인 시 providerId를 사용했다면 user에 id 속성 대신 providerId 속성이 들어가기 때문에 done(null, user.id ?? { id : user.providerId }) 와 같이 코드를 작성해 주어야 한다. 여기서 ?? 는 user.id가 undefined 이면 뒤의 객체를 리턴하라는 뜻이다.

또한, deserializeUser도 분기처리를 해주어야한다. 나의 경우는 provider 속성이 google인지 확인하는 형식으로 했다. serializeUser에서 providerId값을 id속성에 담아서 넘겨주었으므로 payload.id 로 google id(=providerId)를 꺼낼 수 있다.

 

 

 

저도 이 글을 쓰는 이유가 복습이기 때문에 정확하게 정보 전달이 되지 않을 수 있습니다. 만약 문제가 생기면 댓글을 달아주시면 감사하겠습니다.