ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 구글 소셜 로그인 구현하기(node.js, ts) 및 로컬디비 연동
    개발연습/node.js 2021. 10. 1. 14:23

    베타릴리즈를 앞두고 있는 서비스에, 로컬로그인과 함께 사용자의 접근성을 높이기 위해 소셜로그인을 도입하기로 했다.

    그 중에 가장 편리하다고 생각되는, 구글 로그인을 도입하기로 결정했다.

     

    이 글은 https://youtu.be/cD17CYA1dck 해당 영상을 참고했다.

     

    우선.. passport에 대한 기본적인 이해는 있다고 가정한다.(사실 본인도 잘 모르기 때문이다.)

     

    패키지부터 설치를 해주자

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

     

    기존의 passport 구현내용은 다음과 같다.

     

    src/passport/index.ts

    import passport from 'passport';
    import { User } from '@prisma/client';
    import DB from '../db';
    import localStrategy from './localStrategy';
    
    export default function passportConfig(): void {
        passport.serializeUser((user: Express.User, done: (err: Error | null, id: string) => void) => {
            const { userId }: User = user;
            done(null, userId);
        });
    
        passport.deserializeUser((userId: string, done: (err: Error | null, user?: Express.User | null) => void) => {
            DB.prisma.user.findUnique({ where: { userId } })
                .then((user: User | null) => done(null, user))
                .catch((err: Error) => done(err));
        });
    
        localStrategy();
    }

    src/app.ts

    ...
    
    passportConfig();
    app.use(passport.initialize());
    app.use(passport.session());
    
    ...

     

     

    localstrategy와 동일한 방법으로 googleStrategy를 추가해보자.

     

    src/passport/snsLogin.ts파일을 생성하고 다음과 같이 입력해준다.

    import { Request } from 'express';
    import passport from 'passport';
    import googlePassport from 'passport-google-oauth2';
    import { User } from '@prisma/client';
    import DB from '../db';
    
    const googleStrategyConfig: googlePassport.StrategyOptionsWithRequest = {
        clientID: process.env.GOOGLE_CLIENT_ID as string,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
        callbackURL: '/auth/google/callback',
        passReqToCallback: true,
    };
    
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const googleStrategyVerify: googlePassport.VerifyFunctionWithRequest = async (req: Request, accessToken: string, refreshToken: string, profile: any, done: googlePassport.VerifyCallback) => {
        try {
            let user: User | null = await DB.prisma.user.findFirst({ where: { userId: profile.id, deletedAt: null } });
            if (!user) {
                // 이메일, 닉네임 중복체크
                const emailPromise: User | null = await DB.prisma.user.findFirst({ where: { email: profile.email, deletedAt: null } });
                if (emailPromise) {
                    user = emailPromise;
                }
                else {
                    const nicknamePromise: User | null = await DB.prisma.user.findFirst({ where: { nickname: profile.displayName, deletedAt: null } });
                    const CN: number = await DB.prisma.user.count();
                    if (nicknamePromise) {
                        await DB.prisma.user.create({
                            data: {
                                userId: profile.id,
                                password: 'Google',
                                email: profile.email,
                                nickname: profile.displayName + CN,
                                logInType: 'Google',
                                emailToken: '',
                                description: '',
                            },
                        });
                    }
                    else {
                        await DB.prisma.user.create({
                            data: {
                                userId: profile.id,
                                password: 'Google',
                                email: profile.email,
                                nickname: profile.displayName,
                                logInType: 'Google',
                                emailToken: '',
                                description: '',
                            },
                        });
                    }
                    user = await DB.prisma.user.findFirst({ where: { userId: profile.id, deletedAt: null } });
                }
            }
            return done(null, user);
        }
        catch (err) {
            return done(err, null);
        }
    };
    
    export default function googleStrategy(): void {
        passport.use(new googlePassport.Strategy(googleStrategyConfig, googleStrategyVerify));
    }

     

    디비코드는 신경쓰지 말자.

    일단 중요한 부분은 맨 아래의 passport.use(new googlePassport.Strategy(googleStrategyConfig, googleStrategyVerify));

    부분이다.

    googleStrategyConfig의 경우 구글 클라우드 플랫폼에서 clientId, clientSecret을 발급받아야 한다. 해당 내용은 위에 첨부한 유튜브 링크를 참고하면 된다.

    callbackURL은 지금은 이해가 되지 않아도 넘어가자.

    googleStrategyVerify의 경우, 제일 중요한 것은 profile과 done 함수이다.

    우선 profile의 경우, 구글계정으로 로그인 했을 때 해당 유저의 profile이 전부 담겨온다. 여기에 있는 내용을 토대로 필요한 정보를 디비에 저장하면 되는 것이다.

    우리 디비에서는 크게 userId, email, nickname정보만 필요하므로, 해당 정보를 꺼내서 디비에 확인하는 절차를 구현하였다. 

    해당 절차에 대해 간략히 설명해보면

    1. 우선 profile.id로 가입된 계정이 있는지 확인한다. 있다면 해당 정보로 로그인을 시켜준다.

    2. 그다음 해당 이메일로 가입된 계정이 있는지 확인한다. 있다는 것은, 이전에 로컬회원가입을 통해서 해당 이메일로 회원가입을 했다는 뜻이다. 그러므로 해당 계정과 연동을 시켜준다. 즉 해당 정보로 로그인을 시켜준다는 뜻이다.

    3. 1번과 2번이 모두 해당하지 않는다면, 최초 회원가입이라는 의미이다. 따라서 profile에서 가져온 정보를 이용해서 User 테이블에 정보를 생성한다. 그런데 이때, 구글 닉네임의 경우 중복이 가능하다. 하지만 우리 스키마에서는 닉네임 중복을 허용하지 않고 있기 때문에, 뒤에 추가로 유저수를 붙여서 중복을 방지해준다.

     

    그다음 done(null, user) 함수를 실행하는데, 앞부분의 null은 오류를 넣는 부분이고, 뒤에 user는 세션에 저장할 정보를 넣는 부분이다. 우리는 로컬디비를 통해서 이미 로그인을 구현해놓았기 때문에, User테이블에서 값을 꺼내온 뒤 해당 값을 세션에 저장하는 형태로 구현하였다. 그러나 만약 로컬회원가입이 존재하지 않고, 오로지 구글 소셜 로그인만 지원하는 경우에는 세션에 profile을 그대로 저장하기 위해 done(null, profile)형태로 사용하여도 문제가 없다. done함수가 실행되면, serializeUser로 두번째 인자의 값이 저장되고, 해당 정보는 세션에 저장되게 된다. 

     

    이제 googleStrategy작성이 끝났으니, passportConfig에 적용을 해준다.

     

    src/passport/index.ts

    import passport from 'passport';
    import { User } from '@prisma/client';
    import DB from '../db';
    import localStrategy from './localStrategy';
    import googleStrategy from './snsLogin';
    
    export default function passportConfig(): void {
        passport.serializeUser((user: Express.User, done: (err: Error | null, id: string) => void) => {
            const { userId }: User = user;
            done(null, userId);
        });
    
        passport.deserializeUser((userId: string, done: (err: Error | null, user?: Express.User | null) => void) => {
            DB.prisma.user.findUnique({ where: { userId } })
                .then((user: User | null) => done(null, user))
                .catch((err: Error) => done(err));
        });
    
        localStrategy();
        googleStrategy();
    }

     

     

    그러면 패스포트 설정이 끝났으니 api작성을 하러 가자.

     

     

    src/routers/auth.ts

    ...
    
    router.get('/google',
        passport.authenticate('google', { scope: ['profile', 'email'] }));
    
    function authSuccess(req: Request, res: Response) {
        res.redirect('http://localhost:4000/snsAuth');
    }
    
    router.get('/google/callback',
        passport.authenticate('google'), authSuccess);
    
    ...

    구글로그인 이미지를 클릭했을 때, 프론트에서 /auth/google api를 호출하면, 우리에게 익숙한 구글로그인 화면이 나오며, 로그인시 profile값과 email정보가 googleStrategyVerify의 profile로 넘어온다. 우리 서비스에서는 email정보도 필요해서 scope에 명시해 주었는데, 어떤 정보들을 가져올 수 있는지는 구글 공식문서에 잘 나와있다.

     

    이렇게 /auth/google에서 로그인 절차가 진행이 되면, 아까 googleStrategyConfig에서 작성한 callbackURL이 호출된다. 따라서 /auth/google/callback으로 들어오게 되고, 콜백함수에 걸려있는 http://localhost:4000/snsAuth 페이지를 호출하게 된다. 해당 프론트 페이지에서는, 세션의 정보를 로컬에 저장하게 작성하고, 메인화면으로 다시 리다이렉팅을 시켜주면 끝나게 된다.

     

    snsAuth의 프론트코드는 다음과 같다. 리액트장인 팀원에게 경의를 표한다.

    import React, { useEffect } from 'react';
    import { Redirect, RouteComponentProps } from 'react-router';
    import { AnyAction } from 'redux';
    import { SelectorStateType, useAppDispatch, useAppSelector } from '../hooks';
    import { userAction } from '../redux/user/user';
    
    interface FromReducerType {
        User: User | null
        UserFailure: AuthRouter.CheckFailureResponse | null
    }
    type Props = RouteComponentProps;
    const SnsAuthPage: React.FC<Props> = ({ history }: Props) => {
        const dispatch: React.Dispatch<AnyAction> = useAppDispatch();
        const {
            User, UserFailure,
        }: FromReducerType = useAppSelector((state: SelectorStateType) => ({
            User: state.userReducer.user,
            UserFailure: state.userReducer.checkError,
        }));
        React.useEffect(() => {
            dispatch(userAction.check());
        }, [dispatch]);
        React.useEffect(() => {
            if (User) {
                localStorage.setItem('user', JSON.stringify(User));
                history.push('/');
            }
            if (UserFailure) {
                history.push('/404NotFound');
            }
        }, [User, UserFailure, dispatch, history]);
        return (
            <></>
        );
    };
    
    export default SnsAuthPage;

     

    적용하는데 하루나 걸렸다..!

    구글링을 통해 얻을 수 있는 정보는 많은 부분이 생략되어 있었다. 특히 callbackURL와 done함수의 인자의 의미, 그리고 프론트에서 어떻게 구글로그인 api를 호출하고, 세션에 저장하는지에 대한 정보를 찾을 수가 없어서 시간이 많이 들었다.

    게다가 로컬디비 연동하는 부분은 어디에도 존재하지 않았고.. (ㅠㅠ) 그래서 세션정보에 다른 예제처럼 profile을 넘겨서 에러가 나는것을 잡지 못해 시간을 많이 소비했다. 애초에 왜 profile을 done함수의 두번째로 넘기는지 설명이 없었으니 그럴만도..

    그래도 유튜브에서 굉장히 좋은 영상을 찾아서 이후로는 빠르게 적용을 할 수 있었다. 역시 유튜브 최고

    만약 카카오로그인, 깃허브로그인 등을 적용할 때에도 위와 같은 로직으로 적용을 하면 된다.

    댓글

Designed by Tistory.