Node JS #11

Github Login

우리는 요즘 서비스를 이용할때, 웬만해서는 이미 있는 카카오 계정, 깃허브 계정, 구글 계정들을 이용하여 편리하게 사이트에 가입한다.

그리고 이를 구현하기 위한 절차를 알아보기 위해 이번엔 깃허브 계정을 사용하는 방법을 알아보자. 나머지 SNS도 비슷한 방식이다.

깃허브에서 OAuth application을 만들자.

공식 문서

OAuth (short for “Open Authorization”[1][2]) is an open standard for access delegation, commonly used as a way for internet users to grant websites or applications access to their information on other websites but without giving them the passwords.[3][4] This mechanism is used by companies such as Amazon,[5] Google, Meta Platforms, Microsoft, and Twitter to permit users to share information about their accounts with third-party applications or websites.

Generally, the OAuth protocol provides a way for resource owners to provide a client [application] with secure delegated access to server resources. It specifies a process for resource owners to authorize third-party access to their server resources without providing credentials. Designed specifically to work with Hypertext Transfer Protocol (HTTP), OAuth essentially allows access tokens to be issued to third-party clients by an authorization server, with the approval of the resource owner. The third party then uses the access token to access the protected resources hosted by the resource server.[2]

image

Web application flow

Note: If you are building a GitHub App, you can still use the OAuth web application flow, but the setup has some important differences. See “Authenticating with a GitHub App on behalf of a user” for more information.

The web application flow to authorize users for your app is:

  1. Users are redirected to request their GitHub identity
  2. Users are redirected back to your site by GitHub
  3. Your app accesses the API with the user’s access token

큰 흐름은 공식문서를 따라가면 되므로, 이 글에서는 실제 니꼬샘이 작성한 코드를 보며 설명하도록 하겠다.

유저가 깃허브 로그인을 하려면, 가장 먼저 깃허브로 리다이렉트 되어야하고, 공식문서에는 아래와 같은 링크로 리다이렉트 시키라고 한다.

GET https://github.com/login/oauth/authorize

따라서 login 템플릿에 위 링크로 리다이렉트 시키는 링크를 넣자.

extends base

block content
    if errorMessage
        span=errorMessage

    form(method="POST")
        input(placeholder="Username" name="username",type="text",required)
        input(placeholder="Password" name="password",type="password",required)
        input(type="submit",value="Login")
        br
        a(href="https://github.com/login/oauth/authorize?client_id=20f72f7fd7a9a67cd510") Continue with Github →

    hr
    div
        span Don't have an account?
        a(href="/join") Create one now →

여기서 URL 뒤에 붙는 client_id는 깃허브가 준 것이다.

Parameter name Type Description
client_id string Required. The client ID you received from GitHub when you registered.

그래서 링크를 클릭하면 깃허브 로그인 화면으로 리다이렉트 된다.

image

image

그리고 계정을 어느 정도까지 접근할 수 있는지도 나와 있다.

이 접근 권한은 scope parameter로 설정 가능하다.

또한 깃허브 계정이 없을 경우, 이 페이지에서 생성할 수 있게 할 수 있게 할 것인지의 여부는 allow_signup parameter로 결정한다.

Parameter name Type Description
scope string A space-delimited list of scopes. If not provided, scope defaults to an empty list for users that have not authorized any scopes for the application. For users who have authorized scopes for the application, the user won’t be shown the OAuth authorization page with the list of scopes. Instead, this step of the flow will automatically complete with the set of scopes the user has authorized for the application. For example, if a user has already performed the web flow twice and has authorized one token with user scope and another token with repo scope, a third web flow that does not provide a scope will receive a token with user and repo scope.
allow_signup string Whether or not unauthenticated users will be offered an option to sign up for GitHub during the OAuth flow. The default is true. Use false when a policy prohibits signups.
Name Description
user Grants read/write access to profile info only. Note that this scope includes user:email and user:follow.
read:user Grants access to read a user’s profile data.
user:email Grants read access to a user’s email addresses.
user:follow Grants access to follow or unfollow other users.

우리는 계정의 이름과 이메일 정보를 가져올 것이므로 URL에 read:user와 user:email 속성을 넣자.

a(href="https://github.com/login/oauth/authorize?client_id=20f72f7fd7a9a67cd510&scope=read:user user:email") Continue with Github →

image

그러면 이런 창이 뜨면서 이 OAuth가 너의 이메일, 프로필 계정 정보를 접근할 것임을 알려준다.

다만 위 URL은 존나 기므로 이를 간단하게 만들어 주는 코드를 작성하자.

URLSearchParams이라는 utility를 사용하자.

image

URL에 적용 가능한 쿼리로 바꾸어 준다.

이제 아예 이를 컨트롤러로 만들어 주어서 URL을 더 깔끔하게 만들어 주자.

export const startGithubLogin = (req, res) => {
    const baseUrl = `https://github.com/login/oauth/authorize`;
    const config = {
        // 깃허브 문서에 기재된 대로 스펠링도 다 맞아야한다. clientId가 아닌 client_id
        client_id: process.env.GH_CLIENT,
        allow_signup: false,
        scope: "read:user user:email",
    };
    const params = new URLSearchParams(config).toString();
    const finalUrl = `${baseUrl}?${params}`;
    return res.redirect(finalUrl);
};

링크의 PATH 수정해주자

a(href="/users/github/start") Continue with Github →

그리고 당연히 라우팅 처리

userRouter.get("/github/start", startGithubLogin);

이로써 URL을 깔끔하게 정리할 수 있게 되었다.

그리고 유저가 깃허브 인증을 마치면 Callback URL에 접근하게 되는데,

image

위와 같이 http://localhost:4000/users/github/finish로 설정해주자.

그러면 사용자는 깃허브 인증을 마치고 위 URL에 접속하게 된다. 깃허브가 제공해준 code와 함께!

image

이 코드는 깃허브가 ‘이 유저가 자신의 이름과 이메일을 공개하는데 동의했어’ 라는 의미로 발행한 것이다.

이제 이 코드를 깃허브로 보내어 토큰으로 교환받아야 한다.

2. Users are redirected back to your site by GitHub

If the user accepts your request, GitHub redirects back to your site with a temporary code in a code parameter as well as the state you provided in the previous step in a state parameter. The temporary code will expire after 10 minutes. If the states don’t match, then a third party created the request, and you should abort the process.

Exchange this code for an access token:

POST https://github.com/login/oauth/access_token

This endpoint takes the following input parameters.

Parameter name Type Description
client_id string Required. The client ID you received from GitHub for your OAuth app.
client_secret string Required. The client secret you received from GitHub for your OAuth app.
code string Required. The code you received as a response to Step 1.
redirect_uri string The URL in your application where users are sent after authorization.

By default, the response takes the following form:

access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer

You can also receive the response in different formats if you provide the format in the Accept header. For example, Accept: application/json or Accept: application/xml:

Accept: application/json
{
  "access_token":"gho_16C7e42F292c6912E7710c838347Ae178B4a",
  "scope":"repo,gist",
  "token_type":"bearer"
}
Accept: application/xml
<OAuth>
  <token_type>bearer</token_type>
  <scope>repo,gist</scope>
  <access_token>gho_16C7e42F292c6912E7710c838347Ae178B4a</access_token>
</OAuth>
export const finishGithubLogin = async (req, res) => {
    const baseUrl = "https://github.com/login/oauth/access_token";
    const config = {
        client_id: process.env.GH_CLIENT,
        client_secret: process.env.GH_SECRET,
        code: req.query.code, // 깃허브가 준 code를 가져온다.
    };
    const params = new URLSearchParams(config).toString();
    const finialUrl = `${baseUrl}?${params}`; // 깃허브의 특정 URL에 code를 보낸다.
    const data = await fetch(finialUrl, {
        // 깃허브는 이 code를 access_token으로 바꾸어 준다.
        method: "POST",
        headers: {
            Accept: "application/json",
        },
    });
    const json = await data.json();
    console.log(json);
    res.send(JSON.stringify(json));
};

이 코드를 실행시키면 에러가 발생한다.

이유는 node.js에서는 js와 다르게 fetch함수가 동작하지 않아서 그런데, 따라서 이를 가능케하는 패키지를 따로 설치해주어야 한다.

$ npm install node-fetch@2.6.1

이를 설치하면 위 코드는

image

이렇게 깃허브가 준 code를 access_token으로 바꿔온 것을 확인할 수 있다.

이 토큰을 다시 깃허브 API에 보내면, 우리는 비로소 사용자의 정보를 가져올 수 있다.

이 토큰으로 할 수 있는 것은 철저하게 scope 설정에 대해 제한된다.

3. Use the access token to access the API

The access token allows you to make requests to the API on a behalf of a user.

Authorization: Bearer OAUTH-TOKEN
GET https://api.github.com/user

For example, in curl you can set the Authorization header like this:

curl -H "Authorization: Bearer OAUTH-TOKEN" https://api.github.com/user
export const finishGithubLogin = async (req, res) => {
    const baseUrl = "https://github.com/login/oauth/access_token";
    const config = {
        client_id: process.env.GH_CLIENT,
        client_secret: process.env.GH_SECRET,
        code: req.query.code,
    };
    const params = new URLSearchParams(config).toString();
    const finialUrl = `${baseUrl}?${params}`;
    const tokenRequest = await (
        await fetch(finialUrl, {
            method: "POST",
            headers: {
                Accept: "application/json",
            },
        })
    ).json();

    //토큰 정보가 tokenRequest에 저장
    //access_token이 있다면 API call
    if ("access_token" in tokenRequest) {
        const { access_token } = tokenRequest;
        const apiUrl = "https://api.github.com";
        const userData = await (
            await fetch(`${apiUrl}/user`, {
                headers: {
                    Authorization: `token ${access_token}`,
                },
            })
        ).json();
        //API는 계정 정보를 준다.
        return res.send(userData);
    } else {
        return res.redirect("/login");
    }
};

image

그럼 이렇게 깃허브 API가 깃허브 계정 정보를 넘겨주는 것을 확인할 수 있고, 이제 우리는 이를 사용하기만 하면 된다.

그러나 이때 email이 null값인 것을 확인할 수 있는데, email이 private인 경우 null이 발생하고 이는 따로 처리해주어야 한다.

공식문서 참조

export const finishGithubLogin = async (req, res) => {
    const baseUrl = "https://github.com/login/oauth/access_token";
    const config = {
        client_id: process.env.GH_CLIENT,
        client_secret: process.env.GH_SECRET,
        code: req.query.code,
    };
    const params = new URLSearchParams(config).toString();
    const finialUrl = `${baseUrl}?${params}`;
    const tokenRequest = await (
        await fetch(finialUrl, {
            method: "POST",
            headers: {
                Accept: "application/json",
            },
        })
    ).json();

    if ("access_token" in tokenRequest) {
        const { access_token } = tokenRequest;
        const apiUrl = "https://api.github.com";
        const userData = await (
            await fetch(`${apiUrl}/user`, {
                headers: {
                    Authorization: `token ${access_token}`,
                },
            })
        ).json();

        // 따로 이메일 정보를 가져오기 위해 다른 API 사용

        const emailData = await (
            await fetch(`${apiUrl}/user/emails`, {
                headers: {
                    Authorization: `token ${access_token}`,
                },
            })
        ).json();
        return res.send(emailData);
    } else {
        return res.redirect("/login");
    }
};

image

이렇게 이메일 정보가 넘어온 것을 확인할 수 있다.

그리고 이 이메일 정보중에서 primary와 verified가 true인 것을 찾아야 한다. 그래야 우리가 그 이메일로 계정을 만들어 줄 수 있기 때문.

const emailObj = emailData.find(
    (email) => email.primary === true && email.verified === true
);
if (!emailObj) {
    return res.redirect("/login");
}

그리고 로그인 정책을 수립해야 한다.

만일 깃허브 로그인한 사람이 pw로 계정을 또 만들려고 한다면 email이 중복될 텐데 이를 어떻게 처리할 것인가?

만일 카카오톡 로그인도 추가한다면 이 것을 막을것인가? 아니면 또 새로 만들것인가..

에 대한 정책말이다.

우리는 일반 pw로 계정을 만든 사람이 깃허브로 또 만들려고 하면 이 사람이 이 이메일에 대한 접근 권한이 있는지 체크(verified:true)하고 일반 pw로 로그인 시키는 방식을 취하자.

export const finishGithubLogin = async (req, res) => {
    const baseUrl = "https://github.com/login/oauth/access_token";
    const config = {
        client_id: process.env.GH_CLIENT,
        client_secret: process.env.GH_SECRET,
        code: req.query.code,
    };
    const params = new URLSearchParams(config).toString();
    const finialUrl = `${baseUrl}?${params}`;
    const tokenRequest = await (
        await fetch(finialUrl, {
            method: "POST",
            headers: {
                Accept: "application/json",
            },
        })
    ).json();

    if ("access_token" in tokenRequest) {
        const { access_token } = tokenRequest;
        const apiUrl = "https://api.github.com";
        const userData = await (
            await fetch(`${apiUrl}/user`, {
                headers: {
                    Authorization: `token ${access_token}`,
                },
            })
        ).json();

        const emailData = await (
            await fetch(`${apiUrl}/user/emails`, {
                headers: {
                    Authorization: `token ${access_token}`,
                },
            })
        ).json();
        return res.send(emailData);
        const emailObj = emailData.find(
            (email) => email.primary === true && email.verified === true
        );
        if (!emailObj) {
            return res.redirect("/login");
        }
        // 일반 pw로 생성한 계정이더라도 상관없이 처리
        let user = await User.findOne({ email: emailObj.email });
        if (!user) {
            //create an account
            user = await User.create({
                avatarUrl: userData.avatar_url,
                name: userData.name,
                username: userData.login,
                email: emailObj.email,
                password: "", // password isn't required when creating account by social login
                socialOnly: true, // but notify social Logined : 비번 체크 하지 않게 표시
                location: userData.location,
            });
        }
        //auto login
        req.session.loggedIn = true;
        req.session.user = user;
        return res.redirect("/");
    } else {
        return res.redirect("/login");
    }
};

로그아웃은 세션을 destory하면 된다.

export const logout = (req, res) => {
    req.session.destroy();
    return res.redirect("/");
};

관련 라우팅 처리는 알아서..


이렇게 소셜 로그인 기능을 구현해 보았다.

그리고 깃허브 뿐아니라 카카오톡, 구글등의 소셜로그인도 구현해보자.

실제로 추후에는 카카오를 구현해보자.

카카오 면접때 카카오 로그인 구현했다고 하면 좋아하시지 않을까

물론 기술에 대한 명확한 인지가 필요할 것이다.

image

Categories:

Updated:

Leave a comment