Nodejs

디자인 패턴을 적용한 oauth login 구현 Part 2

Sila 2023. 11. 29. 00:52

이어서 실제로 oauth login을 구현하는 코드를 짜보자.

 

1. 코드 작성

controller, service단을 주로 보게 될텐데, 내가 처음 카카오 api를 이용해 oauth login을 구현할 땐 다음과 같았다.

1-1. controller

import { Controller, Get, Query, Res } from "@nestjs/common";
import { LoginService } from "./login.service";
import { ConfigService } from "@nestjs/config";

@Controller("login")
export class LoginController {
        constructor(
                private readonly loginService: LoginService,
                private readonly configService: ConfigService
        ) {}

        @Get("kakaoLogin")
        async tokakaoLoginPage(@Res() res) {
                const url = await this.loginService.toKakaoLoginPage();
                res.status(200).redirect(url);
        }

        @Get("kakaoToken")
        async getKakaoToken(@Query() code: string) {
                const cookieString  = await this.loginService.getKakaoToken(code);
               
                const token = jwt.sign(
                        { data: cookieString },
                        this.configService.get("encode_salt"),
                        {
                                expiresIn: "24h",
                        }
                );

                res.cookie("userInfo", token, {
                        secure: process.env.NODE_ENV === "production",
                        maxAge: 1000 * 60 * 60 * 24,
                });

                res.redirect(this.configService.get("FRONTEND_ADDRESS"));
        }
}

 

kakaoLogin 라우터는 사용자를 지정된 url로 리다이렉트 시킨다.

 

kakaoToken 라우터는 일련의 oauth 과정을 처리한 뒤 (구체적인 구현은 후술) 받아온 사용자 정보를 쿠키로 만든 후

 

브라우저에 셋업하고 사용자를 프론트의 메인 웹 페이지로 이동시킨다.

 

이해를 돕기 위해 덧붙이자면 ConfigService는 .env의 환경 변수들을 의미한다.

 

예를 들어 this.configService.get("FRONTEND_ADDRESS")는 env의 FRONTEND_ADDRESS 변수의 값을 의미한다.

 

1-2. service

service는 controller에서 호출될 함수들을 가지고 있는데,

 

여기서는 2-1 controller의 toKakaoLoginpage(), getKakaoToken() 함수를 구현한다.

 

@Injectable()
export class LoginService {
        constructor(private configService: ConfigService) {}

        async toKakaoLoginPage() {
                const clientId = this.configService.get("clientId");
                const redirectUrl = this.configService.get("redirect_url");

                const url =
                        `https://kauth.kakao.com/oauth/authorize` +
                        `?client_id=${clientId}` +
                        `&redirect_uri=${redirectUrl}` +
                        `&response_type=code`;

                return url;
        }

        async getKakaoToken(code) {
                let access_token;
                let token_type;
                let refresh_token;
                let expires_in;
                let refresh_token_expires_in;

                try {
                        const url = "https://kauth.kakao.com/oauth/token?";

                        const headers = {
                                "content-type":
                                        "application/x-www-form-urlencoded",
                        };

                        const qs =
                                `grant_type=authorization_code` +
                                `&client_id=${this.configService.get(
                                        "clientId"
                                )}` +
                                `&client_secret=${this.configService.get(
                                        "client_secret"
                                )}` +
                                `&redirectUri:${this.configService.get(
                                        "redirect_url"
                                )}` +
                                `&code=${code.code}`;

                        const response = await axios.post(url, qs, {
                                headers: headers,
                        });

                        access_token = response.data.access_token;
                        token_type = response.data.token_type;
                        refresh_token = response.data.refresh_token;
                        expires_in = response.data.expires_in;
                        refresh_token_expires_in =
                                response.data.refresh_token_expires_in;
                } catch (e) {
                        console.log(e);
                }

               
               try {
                        const url = "https://kapi.kakao.com/v2/user/me";
                        const header = {
                                headers: {
                                        Authorization: `${token_type} ${access_token}`,
                                },
                        };

                        const response = await axios.get(url, header);

                        const kakaoData = response.data.kakao_account;

                        const userInfo = {
                                id: response.data.id,
                                name: kakaoData.profile.nickname,
                                email: kakaoData.email,
                                pic: response.data.properties.thumbnail_image,
                        };

                       

                        return userInfo;
                } catch (e) {
                        console.log(e);
                }
        }
}

 

toKakaoLoginPage() 함수는 보면 알 거고, getKakaoToken() 함수에서 oauth login의 거의 대부분을 처리하게 되는데,

 

첫 번째 try-catch문에서 token을 받아올 url과 함께 client Id, secret등 필요한 정보들을

 

qs 형태로 붙여 비동기 요청을 보내면,

 

카카오에서 응답으로 access_token과 기타 등등을 준다. (여기서는 access_token만 알면 된다.)

 

 

두 번째 try-catch문에서 다시 이 access token을 요청 헤더에 담아 지정된 카카오의 url로 요청을 보낸다.

 

그러면 응답으로 사용자 정보를 받을 수 있고, 이를 암호화해서 주든 그냥 주든 반환해주면 컨트롤러에서

 

쿠키로 만들어 주고 사용자를 리다이렉트 시켜줄 것이다.

 

 

 

일단 이 코드가 어떤지는 둘째치고, 이 모듈에 구글 로그인까지 추가한다고 생각해보자.

 

그냥 단순히 추가한다는 가정하에, 코드 양은 두 배정도가 될 것이다.

 

 

1-3. controller (w/ google, kakao login)

@Controller("login")
export class LoginController {
        constructor(
                private readonly loginService: LoginService,
                private readonly configService: ConfigService
        ) {}

        @Get("kakaoLogin")
        async tokakaoLoginPage(@Res() res) {
                const url = await this.loginService.toKakaoLoginPage();
                res.status(200).redirect(url);
        }

        @Get("kakaoToken")
        async getKakaoToken(@Res() res: Response, @Query() code: string) {
                const cookieString = await this.loginService.getKakaoToken(
                        code
                );

                res.cookie("userInfo", cookieString, {
                       
                        secure: process.env.NODE_ENV === "production",
                        maxAge: 1000 * 60 * 60 * 24,
                });
                res.redirect(this.configService.get("FRONTEND_ADDRESS"));
        }

        @Get("googleLogin")
        async googleLoginPage(@Res() res) {
                const url = await this.loginService.toGoogleLoginPage();
                res.status(200).redirect(url);
        }

        @Get("googleToken")
        async getGoogleToken(@Res() res: Response, @Query() code: string) {
                const cookieString = await this.loginService.getGoogleToken(
                        code
                );

                res.cookie("userInfo", cookieString, {
                       
                        secure: process.env.NODE_ENV === "production",
                        maxAge: 1000 * 60 * 60 * 24,
                });

                res.redirect(this.configService.get("FRONTEND_ADDRESS"));
        }
}

 

자잘하게 이상하게 바뀐 부분이 있을텐데, 내가 깃 이전 커밋 버전들을 거꾸로 뒤져가면서 하고 있어서 그렇다.

 

적당히 보면 된다.

 

아무튼 딱 봐도 재사용할 수 있어 보이는 코드가 두 페어가 나온다.

 

service 단으로 가보자.

 

1-4. service (w/ google, kakao login)

@Injectable()
export class LoginService {
        constructor(private configService: ConfigService) {}

        async toKakaoLoginPage() {
                const clientId = this.configService.get("kakao_clientId");
                const redirectUrl =
                        this.configService.get("kakao_redirect_url");

                const url =
                        `https://kauth.kakao.com/oauth/authorize` +
                        `?client_id=${clientId}` +
                        `&redirect_uri=${redirectUrl}` +
                        `&response_type=code`;

                return url;
        }

        async getKakaoToken(code) {
                let access_token;
                let token_type;
                let refresh_token;
                let expires_in;
                let refresh_token_expires_in;

                try {
                        const url = "https://kauth.kakao.com/oauth/token?";

                        const headers = {
                                "Content-type":
                                        "application/x-www-form-urlencoded",
                        };

                        const qs =
                                `grant_type=authorization_code` +
                                `&client_id=${this.configService.get(
                                        "kakao_clientId"
                                )}` +
                                `&client_secret=${this.configService.get(
                                        "kakao_client_secret"
                                )}` +
                                `&redirectUri:${this.configService.get(
                                        "kakao_redirect_url"
                                )}` +
                                `&code=${code.code}`;

                        const response = await axios.post(url, qs, {
                                headers: headers,
                        });

                        access_token = response.data.access_token;
                        token_type = response.data.token_type;
 
 
                } catch (e) {
                        console.log(e);
                }

                try {
                        const url = "https://kapi.kakao.com/v2/user/me";
                        const header = {
                                headers: {
                                        Authorization: `${token_type} ${access_token}`,
                                },
                        };

                        const response = await axios.get(url, header);

                        const kakaoData = response.data.kakao_account;

                        const userInfo = {
                                id: response.data.id,
                                name: kakaoData.profile.nickname,
                                email: kakaoData.email,
                                pic: response.data.properties.thumbnail_image,
                        };
 
                        return userInfo;
                } catch (e) {
                        console.log(e);
                }
        }

        async toGoogleLoginPage() {
                const clientId = this.configService.get("google_clientId");
                const redirectUrl = this.configService.get(
                        "google_redirect_url"
                );

                const url =
                        `https://accounts.google.com/o/oauth2/v2/auth` +
                        `?client_id=${clientId}` +
                        `&redirect_uri=${redirectUrl}` +
                        `&response_type=code` +
                        `&scope=email profile openid`;

                return url;
        }

        async getGoogleToken(code) {
                const url = "https://oauth2.googleapis.com/token";
                const codeForToken = code.code;

                let access_token;

                try {
                        const response = await axios.post(url, {
                                code: codeForToken,
                                client_id: this.configService.get(
                                        "google_clientId"
                                ),
                                client_secret: this.configService.get(
                                        "google_client_secret"
                                ),
                                redirect_uri: this.configService.get(
                                        "google_redirect_url"
                                ),
                                grant_type: "authorization_code",
                        });

                        access_token = response.data.access_token;
                } catch (e) {
                        console.log(e);
                        makeResponseObj(1, "google login failed");
                }

                try {
                        const response = await axios.get(
                                "https://www.googleapis.com/oauth2/v2/userinfo",
                                {
                                        // Request Header에 Authorization 추가
                                        headers: {
                                                Authorization: `Bearer ${access_token}`,
                                        },
                                }
                        );

                        const googleData = response.data;

                        const userInfo = {
                                id: googleData.id,
                                email: googleData.email,
                                name: googleData.name,
                                pic: googleData.picture,
                        };

                        return userInfo;
                } catch (e) {
                        console.log(e);
                        makeResponseObj(1, "google login failed");
                }
        }
}

 

이 쪽도 마찬가지로 뭔가 큰 흐름은 같은데, 재활용을 하자니 자잘한 부분이 달라서 바로 함수로 빼내기는 어려운,

 

그런 관계의 함수들이 두 쌍 생겨버렸다.

 

이걸 할 때쯤 마침 타이밍 좋게 디자인 패턴을 공부하고 있으면서

 

뭔지는 알겠는데 이걸 대체 어떻게 써먹어야 하나 의문이 있던 나는

 

뭔가 이 때가 디자인 패턴들을 활용하는 연습을 할 때라는 느낌이 와서

 

이 컨트롤러와 서비스 단을 어떻게 적절한 디자인 패턴들을 활용해 그럴싸하게 바꿀까하고 고민을 하기 시작했다.

 

우선 서비스 단의 함수들을 좀 밖으로 빼는 것부터 시작을 했다.

 

2. 코드 수정

서비스 단의 함수들을 잘 보면 어떤 oauth login이든

 

요청을 보낼 api url 3개, 리다이렉트를 시켜주는 함수, 토큰을 받아오는 함수, 토큰과 사용자 정보를 교환하는 함수들이

 

필요함을 알 수 있다.

 

이렇게 관련된 값들과 함수들을 묶을 수 있는데 바로 class다.

 

그래서 우선 다음과 같이 다른 ts 파일에 카카오 로그인에 관련된 요소들을 모아 클래스를 만들어주었다.

 

export interface reqTokenDTO {
        client_id: string;
        client_secret: string;
        redirect_uri: string;
        grant_type: string;
}
 
export interface userInfoDTO {
        id: string | number;
        name: string;
        email: string;
        pic?: string;
}
 
export class KakaoOAuth {
        static oAuthUrl = "https://kauth.kakao.com/oauth/authorize";
        static tokenUrl = "https://kauth.kakao.com/oauth/token";
        static exchangeTokenUrl = "https://kapi.kakao.com/v2/user/me";

        redirectToProvider(clientId: any, redirectUrl: any): string {
                const redirect_url =
                        `${KakaoOAuth.oAuthUrl}` +
                        `?client_id=${clientId}` +
                        `&redirect_uri=${redirectUrl}` +
                        `&response_type=code`;

                return redirect_url;
        }

        async getToken(code, reqTokenDTO: reqTokenDTO) {
                const qs =
                        `grant_type=authorization_code` +
                        `&client_id=${reqTokenDTO.client_id}` +
                        `&client_secret=${reqTokenDTO.client_secret}` +
                        `&redirectUri:${reqTokenDTO.redirect_uri}` +
                        `&code=${code}`;

                const headers = {
                        "Content-type": "application/x-www-form-urlencoded",
                };

                try {
                        const response = await axios.post(
                                KakaoOAuth.tokenUrl,
                                qs,
                                {
                                        headers: headers,
                                }
                        );

                        return response.data;
                } catch (e) {
                        console.error(e);
                        throw new ErrorMessage(1, "failed to get kakao token");
                }
        }

        async getUserInfo(tokenResult) {
                try {
                        const response = await axios.get(
                                KakaoOAuth.exchangeTokenUrl,
                                {
                                        headers: {
                                                Authorization: `${tokenResult.token_type} ${tokenResult.access_token}`,
                                        },
                                }
                        );
 
                        const userInfo = {
                                id: response.data.id,
                                name: response.data.properties.nickname,
                                email: response.data.kakao_account.email,
                                pic: response.data.properties.thumbnail_image,
                        };

                        return userInfo;
                } catch (e) {
                        console.log(e);
                        throw new ErrorMessage(
                                2,
                                "failed to exchange kakao token to user info."
                        );
                }
        }
}

 

oauth login 관련 요청을 보낼 url들은 전부 카카오에서 지정한 변하지 않는 값들이니

 

이 클래스의 인스턴스에 따라 달라지지 않는다. 그래서 static을 붙여주었다.

 

나머지 사용자를 리다이렉트 시켜주는 함수, 엑세스 토큰을 받아오는 함수, 엑세스 토큰을 사용자 정보와 교환하는 함수도

 

각각 적절히 수정해 class 안에 넣어주었다.

 

3. Abstract factory

각 oauth login의 요소들은 큰 흐름은 비슷하고, 자잘한 부분이 다르다.

 

각각의 요소들을 재활용 할 수는 없겠지만 그래도 이 후 페이스북 로그인이라던가 등등 유사한 기능이 추가될 경우를 위해

 

메뉴얼이나 가이드가 될만한 걸 만들어둘 수 있다. 그런 디자인 패턴이 바로 Abstract Factory이다.

 

Abstract Factory 패턴에서는 추상적인 클래스를 만들고 (class에 함수와 변수가 있지만 이를 구현하진 않았다.)

 

이 안에 요소들을 정의함으로써, 이 후 이 추상 클래스를 구체화한 클래스를 만들 때,

 

추상 클래스의 요소들을 전부 포함하도록 제한한다.

 

나는 추상 클래스를 다음과 같이 정의했다.

 

// abstract factory pattern을 사용해보자..
abstract class OAuthService {
        oAuthUrl: string;
        tokenUrl: string;
        exchangeTokenUrl: string;

        abstract redirectToProvider(clientId, redirectUrl): string;
        abstract getToken(code, reqTokenDTO: reqTokenDTO);
        abstract getUserInfo(access_token: string);
}

 

이 추상 클래스로 인스턴스를 만들 수는 없다. 하지만 이 후 구글, 카카오 이외에 다른 oauth login을 추가하고 싶을 때

 

(e.g. 깃허브 로그인, 페북 로그인 등..) 이 추상 클래스에 기반해서 클래스를 생성할 수 있고,

 

이 클래스를 구체화한다고 선언했을 경우,

 

반드시 이 추상화 객체의 모든 요소를 조건에 맞게 구현해야 한다는 제한 사항이 붙는다.

 

 OauthService class를 구현한 KakaoOauth class는 다음과 같다.

 

export class KakaoOAuth extends OAuthService {
        static oAuthUrl = "https://kauth.kakao.com/oauth/authorize";
        static tokenUrl = "https://kauth.kakao.com/oauth/token";
        static exchangeTokenUrl = "https://kapi.kakao.com/v2/user/me";

        redirectToProvider(clientId: any, redirectUrl: any): string {
             ...
        }

        async getToken(code, reqTokenDTO: reqTokenDTO) {
             ...
        }

        async getUserInfo(tokenResult) {
             ...
        }
}

 

우리가 어떤 구체화된 클래스가 추상 클래스를 구현했다는 것을 선언하려면 ts에서는 `extends` 키워드를 사용하면 된다.

 

이렇게 class A extends B 형태로 쓴 경우,

 

추상 클래스 B를 구체화한 class A는 classB의 요소들을 조건에 맞춰 구현해야 한다. (안 그러면 빨간줄 그어서 에러 남)

 

마찬가지로, googleOAuth도 하나의 class로 만들어 필요한 요소들을 옮겨주면 된다.

 

export class GoogleOAuth extends OAuthService {
        static oAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth";
        static tokenUrl = "https://oauth2.googleapis.com/token";
        static exchangeTokenUrl =

        redirectToProvider(clientId, redirectUrl): string {
                ...
        }

        async getToken(code, reqTokenDTO: reqTokenDTO) {
                ...
        }

        async getUserInfo(access_token: string) {
                ...
        }
}

 

 

3-1. Service

이제 서비스 단을 다시보면 다음과 같이 코드가 한 결 깨끗해진 것을 볼 수 있다.

 

@Injectable()
export class LoginService {
        private googleOAuthService: GoogleOAuth;
        private kakaoOauthService: KakaoOauth;

        constructor(private configService: ConfigService) {
                this.googleOAuthService = new GoogleOAuth();
                this.kakaoOauthService = new KakaoOauth();
        }

        async toOauthLoginPage(service: string) {
                switch (service) {
                        case "kakao":
                                return this.kakaoOauthService.redirectToProvider(
                                        this.configService.get(
                                                "kakao_clientId"
                                        ),
                                        this.configService.get(
                                                "kakao_redirect_url"
                                        )
                                );
                        case "google":
                                return this.googleOAuthService.redirectToProvider(
                                        this.configService.get(
                                                "google_clientId"
                                        ),
                                        this.configService.get(
                                                "google_redirect_url"
                                        )
                                );
                        default:
                                return "http://localhost:3000";
                }
        }

        async getKakaoToken(code) {
                // TODO : refresh token
                // let refresh_token;
                // let expires_in;
                // let refresh_token_expires_in;

                try {
                        const args: reqTokenDTO = {
                                client_id: this.configService.get(
                                        "kakao_clientId"
                                ),
                                client_secret: this.configService.get(
                                        "kakao_client_secret"
                                ),
                                redirect_uri:
                                        this.configService.get(
                                                "kakao_redirect_url"
                                        ),
                                grant_type: "authorization_code",
                        };

                        const getAccessTokenResult =
                                await this.kakaoOauthService.getToken(
                                        code,
                                        args
                                );

                        const getUserInfoResult =
                                await this.kakaoOauthService.getUserInfo(
                                        getAccessTokenResult.result
                                );

                        const cookieString = encodeUserInfo(
                                encrypter(
                                        userInfoString(
                                                getUserInfoResult.userInfo
                                        ),
                                        this.configService.get("encrypt_code")
                                ),
                                this.configService.get("encode_salt")
                        );

                        return cookieString;
                } catch (e) {
                        console.log(e);
                        if (e instanceof ErrorMessage) {
                                makeResponseObj(
                                        1,
                                        `kakao login failed : ${e.message}`
                                );
                        }
                }
        }

        async getGoogleToken(code) {
                const args: reqTokenDTO = {
                        client_id: this.configService.get("google_clientId"),
                        client_secret: this.configService.get(
                                "google_client_secret"
                        ),
                        redirect_uri: this.configService.get(
                                "google_redirect_url"
                        ),
                        grant_type: "authorization_code",
                };

                try {
                        const getAccessToken =
                                await this.googleOAuthService.getToken(
                                        code,
                                        args
                                );

                        const getUserInfo =
                                await this.googleOAuthService.getUserInfo(
                                        getAccessToken.token
                                );

                        const cookieString = encodeUserInfo(
                                encrypter(
                                        userInfoString(getUserInfo.userInfo),
                                        this.configService.get("encrypt_code")
                                ),
                                this.configService.get("encode_salt")
                        );

                        return cookieString;
                } catch (e) {
                        console.log(e);

                        if (e instanceof ErrorMessage) {
                                makeResponseObj(
                                        1,
                                        `google login failed : ${e.message}`
                                );
                        }
                }
        }
}

 

물론 아직 더 리팩토링할 여지는 남아있다.

 

변수명 같은것도 좀 냄새나고, 리다이렉트 시켜주는 함수도 뭔가 좀 다 잘 할 수 있을 것 같고,

 

무엇보다 여전히 비슷하지만 완전히 같진 않아서 재활용하긴 힘든 함수가 한 쌍 남아있다.

 

3-2. Controller

컨트롤러 단은 다음과 같다.

 

@Controller("login")
export class LoginController {
        constructor(
                private readonly loginService: LoginService,
                private readonly configService: ConfigService
        ) {}

        @Get("OauthLogin")
        async tokakaoLoginPage(@Res() res, @Query() s) {
                const url = await this.loginService.toOauthLoginPage(s.s);
                res.status(200).redirect(url);
        }

        @Get("kakaoToken")
        async getKakaoToken(@Res() res: Response, @Query() code: string) {
                const cookieString = await this.loginService.getKakaoToken(
                        code
                );

               
                res.cookie("userInfo", cookieString, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === "production",
                        maxAge: 1000 * 60 * 60 * 24,
                });
                
                res.redirect(this.configService.get("FRONTEND_ADDRESS"));
        }

        @Get("googleToken")
        async getGoogleToken(@Res() res: Response, @Query() code: string) {
                const cookieString = await this.loginService.getGoogleToken(
                        code
                );

                res.cookie("userInfo", cookieString, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === "production",
                        maxAge: 1000 * 60 * 60 * 24,
                });

                res.redirect(this.configService.get("FRONTEND_ADDRESS"));
        }
}