Ayden's journal

10. 소셜 로그인 구현

현대 웹 어플리케이션에서는 간단한 회원가입을 통한 사용자 편의성 및 유저 증가를 위해 소셜 로그인 기능이 거의 필수가 되어가고 있다. 소설 로그인을 통해 사용자는 복잡한 가입 절차 없이 구글이나 카카오 등 익숙한 플랫폼 계정을 사용해 간단히 로그인할 수 있다. 이 포스트에서는 OpenID Connect 및 이를 활용하여 소셜 로그인을 구현하는 방법에 대해 알아보고자 한다.

 

OpenID Connect

일반적으로 소셜 로그인 기능에 대해 이야기할 때 OAuth 2.0 프로토콜이라고들 하지만, 실제로 사용되는 것은 OpenID Connect이다. OAuth 2.0은 주로 권한 부여에 초점을 두고, 어플리케이션이 사용자 데이터에 안전하게 접근할 수 있도록 액세스 토큰을 발급받아 다른 서비스의 API를 호출하는 데 그 목적이 있다. OpenID Connect는 유저 인증 기능을 추가하기 위해 OAuth 2.0 표준을 확장한 프로토콜이다.

이미지 출처는 코드잇

 

Passport를 활용한 strategy 구현

Passport는 Node.js 환경에서 OAuth 2.0 및 OpenID Connect 같은 인증 프로토콜을 쉽게 구현할 수 있도록 돕는 미들웨어로, 각 인증 방식에 맞는 전략(strategy)을 적용해 사용자가 안전하고 간편하게 애플리케이션에 로그인할 수 있게 한다. Nest.js에서 각 인증 방식에 따른 strategy는 프로바이더로 취급되기 때문에 반드시 모듈에 연결해주어야 한다.

참고로 strategy를 구현하기 위해서는 clientID와 clientSecret, 그리고 callbackURL이 필요하다. clientID와 clientSecret은 OpenID Connect API를 제공하고 있는 각각의 프로바이더(구글, 카카오 등)에 맞게 발급 받으면 되고, callbackURL은 각 프로바이더를 통해 로그인을 하고난 뒤 이동할 URL을 지정해주게 된다. callbackURL의 경우 백엔드 주소를 사용해도 되지만, 에러 처리 등의 문제로 인해 프론트엔드 주소를 권장한다.

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

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor() {
    super({
      clientID: process.env.OAUTH_GOOGLE_CLIENT_ID,
      clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET,
      callbackURL: process.env.BASE_URL + '/auth/google/callback',
      scope: ['email', 'profile'],
    });
  }

  // refreshToken을 얻고 싶다면 해당 메서드 설정 필수
  authorizationParams(): { [key: string]: string } {
    return {
      access_type: 'offline',
      prompt: 'select_account',
    };
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: Profile,
    done: VerifyCallback,
  ): Promise<void> {
    const { name, emails, provider } = profile;
    const socialLoginUserInfo = {
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      socialProvider: provider,
      accessToken,
      refreshToken,
    };
    try {
      done(null, socialLoginUserInfo, accessToken);
    } catch (err) {
      done(err, false);
    }
  }
}
// kakaoAuth.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Profile, VerifyCallback } from 'passport-kakao';

@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
  constructor() {
    super({
      clientID: process.env.OAUTH_KAKAO_CLIENT_ID,
      callbackURL: process.env.BASE_URL + '/auth/kakao/callback',
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: Profile,
    done: VerifyCallback,
  ): Promise<void> {
    const { id, username, _json } = profile;
    const socialLoginUserInfo = {
      id,
      username,
      email: _json.kakao_account.email,
      accessToken,
      refreshToken,
    };
    try {
      done(null, socialLoginUserInfo);
    } catch (err) {
      done(err, false);
    }
  }
}

가드와 유저 인증에서 나는 passport 없이 커스텀 가드를 사용해 accessToken을 처리하는 로직을 구현했었다. 이유는 간단했는데, 커스텀 가드는 내가 필요한 방식으로 로직을 자유롭게 구성할 수 있기 때문이다. 하지만 OpenID Connect는 프로젝트 내부의 규칙이 아니고, 이런 경우라면 커스텀 가드보다 passport를 사용하는 편이 더 효율적이라 생각한다.

 

Social Login Route 구현

Nest.js에서는 크게 두 개의 경로를 구현해주어야 한다. 첫째는 간편 로그인 요청을 매개해주는 @Get("google")이고, 그 다음으로는 구글이 보내준 이것저것을 사용해 로그인/회원가입을 진행하는 @Post("google/callback")이다. 참고로 유저 기능을 담당하는 커스텀 가드 이름이 AuthGuard라서 passport가 제공하는 AuthGuard 데코레이터를 as Auth로 불러왔다. 만약 카카오 OpenID라면 Auth('google')대신 Auth('kakao')를 사용해주면 된다.

import { AuthGuard } from './auth.guard';
import { AuthGuard as Auth } from '@nestjs/passport';

@UseGuards(Auth('google'))
@Get('google')
googleAuth() {
  return 'Google OAuth';
}

@UseGuards(Auth('google'))
@Post('google/callback')
async loginGoogle(@Req() req, @Res() res: Response) {
  console.log(req.user);
  //1. 가입확인
  const user = await this.authService.getUserByEmail(req.user.email);

  const input = {
    email: req.user.email,
    password: 'OAuth',
    nickname: req.user.firstName,
  };

  const { accessToken, refreshToken } = user
    ? await this.authService.signIn(input)
    : await this.authService.signup(input);

  res
    .cookie('accessToken', accessToken, this.cookieOptions)
    .cookie('refreshToken', refreshToken, this.cookieOptions)
    .send({ message: 'google 로그인 성공' });
}

 

클라이언트에서의 처리

먼저 클라이언트에서는 a 태그나 Link 컴포넌트를 사용해 @Get("google")로 요청을 보내야 한다. 이는 간편 로그인 버튼을 클릭했을 때 곧바로 google의 로그인 페이지가 뜨는 것이 아니라, 백엔드를 경유해서 ─ 위대하신 @nestjs/passport 선생의 신묘한 도술을 통해 ─ 로그인 페이지를 받아오기 때문이다.

<Link href={process.env.NEXT_PUBLIC_BASE_URL} + "/api/auth/google"}>
  구글 로그인
</Link>

 

로그인을 하고 나면 구글은 우리가 strategy에서 정해놓은 callbackURL으로 리다이렉션 시킨다. 이때 callbackURL의 뒤에 알 수 없는 쿼리 스트링이 포함되는데, 이 값이 바로 유저를 특정하기 위한 장치이다. 따라서 백엔드로 post 요청을 보낼 때 쿼리 스트링을 포함시켜야 Auth('google')이 clientID와 clientSecret을 사용해서 해당 유저의 이메일과 닉네임 등을 알아낼 수 있다.

useEffect를 사용한 이유는 페이지가 ready 되기 전까지 asPath의 값이 "/auth/[openID]/callback"이기 때문이다. 같은 이유로 isReady 값을 확인해 준비되지 않은 경우 백엔드로 아무런 요청을 보내지 않도록 했다.

export default function Callback() {
  const { asPath, query, push, isReady } = useRouter();
  const { openId } = query;
  const [errorMessage, setErrorMessage] = useState("");

  useEffect(() => {
    if (!isReady) return;

    fetcher<Response>({
      url: process.env.NEXT_PUBLIC_BASE_URL + asPath,
      method: "post",
    }).then((res) => {
      if (res.message === `${openId} 로그인 성공`) return push("/");
      else if (res.message === "Internal server error") push("/");
      else setErrorMessage(res.message);
    });
  }, [asPath, openId, push, isReady]);

  return <>{errorMessage}</>;
}

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기