PUBLISHED

10. 소셜 로그인 구현

작성일: 2024.11.10

10. 소셜 로그인 구현

현대 웹 애플리케이션에서는 사용자 편의성과 전환율을 높이기 위해 소셜 로그인 기능이 사실상 필수 요소가 되어가고 있다. 별도의 회원가입 절차 없이 구글이나 카카오처럼 이미 익숙한 플랫폼 계정을 통해 로그인할 수 있다는 점은, 사용자의 진입 장벽을 크게 낮춰준다.

OpenID Connect

소셜 로그인에 대해 이야기할 때 흔히 OAuth 2.0 프로토콜을 사용한다고 말하곤 한다. 하지만 실제로 “로그인”, 즉 사용자 인증(Authentication) 에 사용되는 것은 OAuth 2.0 그 자체가 아니라 OpenID Connect(OIDC) 이다.

OAuth 2.0은 본래 “인가(Authorization)”를 위한 프로토콜이다. 애플리케이션이 사용자를 대신해 다른 서비스의 API를 호출할 수 있도록, 액세스 토큰을 발급받는 것이 목적이다. 반면 OpenID Connect는 OAuth 2.0을 확장해 사용자 신원 인증 기능을 추가한 프로토콜이다.

즉,

우리가 흔히 말하는 구글 로그인, 카카오 로그인은 모두 OpenID Connect 흐름 위에서 동작한다.

이미지 출처는 코드잇

Passport를 활용한 Strategy 구현

Passport는 Node.js 환경에서 OAuth 2.0과 OpenID Connect 같은 인증 프로토콜을 손쉽게 구현할 수 있도록 도와주는 미들웨어다. 인증 방식별로 Strategy라는 개념을 제공하며, Nest.js에서는 이 Strategy들이 모두 프로바이더로 취급된다. 따라서 반드시 모듈에 등록되어야 한다.

Strategy를 구현하기 위해 필요한 값은 다음과 같다.

clientIDclientSecret은 각 OpenID Connect 제공자(구글, 카카오 등)에서 발급받는다.
callbackURL은 인증이 완료된 후 리다이렉트될 URL로, 백엔드 주소를 사용할 수도 있지만 에러 처리 및 UX 관점에서 프론트엔드 주소를 사용하는 편을 권장한다.

Google Strategy

untitled
TS
@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,
    };

    done(null, socialLoginUserInfo);
  }
}

Kakao Strategy

untitled
TS
@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,
    };

    done(null, socialLoginUserInfo);
  }
}

앞서 JWT 인증에서는 커스텀 가드를 사용해 토큰 검증 로직을 직접 구현했다. 이는 프로젝트 내부 규칙이었기 때문에 가능한 선택이었다. 하지만 OpenID Connect는 외부 표준 프로토콜이며, 이런 경우에는 검증된 구현체인 Passport를 사용하는 편이 훨씬 효율적이라고 판단했다.

Social Login Route 구현

Nest.js에서는 소셜 로그인을 위해 크게 두 개의 엔드포인트를 구현한다.

  1. 인증 요청을 시작하는 엔드포인트 @Get("google")

  2. 인증이 완료된 후 호출되는 callback 엔드포인트 @Post("google/callback")

untitled
TS
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) {
  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 로그인 성공' });
}

참고로 유저 기능을 담당하는 커스텀 가드 이름이 AuthGuard라서 passport가 제공하는 AuthGuard 데코레이터를 as Auth로 불러왔다. 만약 카카오 OpenID라면 Auth('google')대신 Auth('kakao')를 사용해주면 된다.

클라이언트에서의 처리

클라이언트에서는 단순히 소셜 로그인 버튼 클릭 시 백엔드의 인증 시작 엔드포인트로 이동시키면 된다. 이는 사용자가 곧바로 구글 로그인 페이지로 이동하는 것이 아니라, 백엔드를 경유해 Passport가 인증 요청을 구성하기 때문이다.

untitled
TSX
<Link href={`${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/google`}>
  구글 로그인
</Link>

로그인이 완료되면 구글은 Strategy에서 설정한 callbackURL로 리다이렉션한다. 이때 callback URL에는 사용자를 식별하기 위한 쿼리 스트링이 함께 포함된다. 이 값이 있어야 Auth('google') 가드가 clientID와 clientSecret을 사용해 사용자 정보를 복원할 수 있다.

Next.js에서는 페이지가 완전히 준비되기 전까지 asPath/auth/[openID]/callback 형태로 유지되기 때문에, isReady를 확인한 뒤 요청을 보내도록 했다.

untitled
JSX
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} 로그인 성공`) push('/');
      else setErrorMessage(res.message);
    });
  }, [asPath, openId, push, isReady]);

  return <>{errorMessage}</>;
}