Next.js 캐럿마켓 클론코딩( SOCIAL AUTHENTICATION 1)

짜스의 하루 ·2024년 8월 12일

Github Authentication

깃허브를 통해서 웹사이트 로그인을 하는 방법을 알아보려고 한다!

먼저, https://github.com/settings/applications/new 사이트에서
기본 사항을 입력하면 된다. 위에서 Autorization callback URL은 인증 과정이 이루어지고, 후에 callback할 URL 주소를 입력해주는 것인데, http://localhost:3000/github/complete로 입력해 주었다.

간단한 기본 사항을 입력하고 Register application 버튼을 누르게 되면, Client IDClient secrets(API)를 우리에게 주는데, .env 파일에 입력해 두었다.

이제, github 로그인 하기 버튼을 눌렀을 때 -> /github/start로 이동하기 위해서 폴더를 생성해보려고 한다.
github/start/route.ts 폴더를 생성해 주었는데 route.ts를 생성해준 이유는,

route.ts 파일은 Next.js의 API Routes 기능을 활용하는 파일로, 특정 URL 경로에 대한 HTTP 요청을 처리하는 핸들러를 정의할 수 있는 곳이다.
이 파일을 사용하여 React.js나 HTML을 리턴하지 않고, 백엔드에서 데이터 처리, API 응답 생성 등의 작업을 수행할 수 있다.

Next.js에서 github/start/route.ts 파일을 생성하면, /github/start 경로에 대한 HTTP 요청(GET, POST 등)을 처리하는 로직을 이 파일 안에서 작성할 수 있다.
따라서, 이 파일을 이용해 백엔드 작업을 처리하고 클라이언트로 JSON 응답을 보내는 등의 작업을 수행할 수 있다.

export function GET(){}
이 GET함수는 /github/start URL로 GET request를 보낼 때 동작하게 된다.

https://docs.github.com/ko/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
이 수조에 들어가보면, OAuth 앱 권한 부여에 대해서 알아볼 수 있는데,

params에 이 정보들을 불러 올 수 있는 것을 확인할 수 있다.

우리는 client_id, scope, allow_signup 정보를 params로 넘겨줄 것이다.

  • 여기서 중요한 scope는 Github에게 우리가 사용자로부터 원하는 데이터가 무엇인지 알려주는 것이라고 생각하면 된다. 사용자에게 보여주는 부분임

  • 여기서 repo => 우리가 repo scope를 요청하고 사용자가 수락한다면, 그 사용자는 다시 말해 본인 계정의 모든 레파지토리에 대한 접근 권한을 넘겨준 것임.
    --> public, private이든 전부 우리가 읽고 쓸 수 있다
    read:user -> 사용자 프로필 읽기 권한을 주게 되고,
    user:email -> 사용자 메일 주소 읽기 권한을 준다.

  • allow-signup -> 이건 사용자의 Github에 가입을 허용할 지를 구성할 수 있게 해준다. allow-signup false 인 경우, github계정이 이미 있는 경우에만 우리 애플리케이션에 로그인할 수 있음.

이러한 정보들을 가지고 GET()요청을 보내는 함수를 만들어보자

  • export function GET() { ... }
    GET 함수는 HTTP GET 요청이 들어왔을 때 호출된다. Next.js의 API Routes에서 GET 요청에 대한 핸들러로 사용된다.

  • const baseURL = 'https://github.com/login/oauth/authorize'
    baseURL은 GitHub의 OAuth 인증 URL이다. 이 URL을 사용하여 사용자를 GitHub의 로그인 페이지로 리다이렉트하게 된다.

  • const params = { ... }
    params 객체는 GitHub OAuth 인증 URL에 추가될 쿼리 파라미터들을 정의한다.
    client_id: GitHub OAuth 애플리케이션의 클라이언트 ID. process.env.GITHUB_CLIENT_ID! 를 통해 환경 변수에서 이 값을 가져온다.여기서 !는 TypeScript에서 값이 반드시 존재할 것이라고 명시적으로 알려주는 것을 의미한다
    scope: GitHub API에 접근할 수 있는 권한 범위.
    --> 여기서는 read:user (사용자 정보 읽기)와 user:email (사용자의 이메일 정보 접근)을 요청한다.
    allow_signup: 'true' 로 설정하면 GitHub에 가입하지 않은 사용자가 인증 과정에서 새로운 계정을 만들 수 있다.

  • const formattedParams = new URLSearchParams(params).toString()
    URLSearchParams 객체를 사용하여 params 객체를 URL의 쿼리 문자열로 변환한다. 예를 들어, client_id=your_client_id&scope=read:user,user:email&allow_signup=true와 같은 형식의 문자열을 생성한다.

  • const finalUrl = ${baseURL}?${formattedParams}
    finalUrl은 GitHub OAuth 인증 URL에 쿼리 파라미터를 추가한 최종 URL이다.

  • return Response.redirect(finalUrl)
    이 줄은 사용자를 finalUrl로 리다이렉트를 시킨다.
    --> 즉, 이 API 엔드포인트에 GET 요청을 보내면, 사용자는 GitHub의 OAuth 인증 페이지로 이동하게 된다.

    깃허브로 로그인 버튼을 누르게 되면, formattedParams를 해당한 URL로 이동하면서 깃허브 로그인 페이지로 이동하는 것을 확인할 수 있었다!


Access Token

flow to authorize
1 . 유저들은 그들의 Github 신원을 요청하기 위해 redirect -> 우리는 'github/start'라는 URL로 유저들을 보내고, 여기서 여러 가지 parameter로 새로운 URL을 만든 다음에, 만든 github url로 유저를 redirect시키게 된다.

2 . 유저들은 Github에 의해 원래 접속했던 사이트로 다시 redirect하게 된다.
github/complete 로 redirect하게 된다. -> 사용자가 요청을 수락하게 되면, Github는 우리 사이트로 다시 redirect를 하게 되는데, 임시 code parameter를 보내주게 된다.

--> 이 임시 code parameter는 10분 후에 만료된다고 써있음.
이제 이 임시 code parameter를 access token으로 교환하는 작업을 하면 된다.
--> Github API로부터 유저 프로필 정보와 유저의 이메일을 가져올 수 있게 된다.

이제, github/complete로 접속했을 경우, 우리는 code=dsjlfsdfu와 같은 parameter를 얻게 된다.

이 parameter를 access token으로 변경하는 작업을 할 것이다.

import { notFound } from 'next/navigation';
import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const code = request.nextUrl.searchParams.get('code');
  if (!code) {
    return notFound();
  }
  const accessTokenParams = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    client_secret: process.env.GITHUB_CLIENT_SECRET!,
    code,
  }).toString();
  const accessTokenURL = `https://github.com/login/oauth/access_token?${accessTokenParams}`;
  const accessTokenResponse = await fetch(accessTokenURL, {
    method: 'POST',
    headers: {
      Accept: 'application/json',
    
  });
  const accessTokenData = await accessTokenResponse.json();
  if ('error' in accessTokenData) {
    return new Response(null, {
      status: 400,
    });
  }
  return Response.json({ accessTokenData });
}
  • export async function GET(request: NextRequest) { ... }
    이 함수는 GET 요청이 들어왔을 때 호출된다. 비동기 함수로 정의되어 있으며, GitHub OAuth 인증 과정의 일부를 처리합니다.
  • const code = request.nextUrl.searchParams.get('code') :
    request.nextUrl.searchParams.get('code') 를 사용하여, 요청 URL에서 code라는 쿼리 파라미터를 추출한다.
    --> 이 code는 사용자가 GitHub OAuth 인증 후 리디렉션되었을 때 GitHub에서 제공하는 일회용 코드이다.
  • if (!code) { return notFound(); }
    code가 존재하지 않는다면, notFound()를 호출하여 404 페이지를 반환한다
    --> 이 단계는 code가 없을 경우 요청이 유효하지 않음을 나타낸다.
  • const accessTokenParams = new URLSearchParams({ ... }).toString()
    URLSearchParams 객체를 사용하여 GitHub API에 액세스 토큰을 요청하기 위한 쿼리 파라미터를 구성한다.
    -- client_id: GitHub OAuth 애플리케이션의 클라이언트 ID.
    -- client_secret: GitHub OAuth 애플리케이션의 클라이언트 시크릿.
    -- code: 앞서 얻은 GitHub 인증 코드.
  • const accessTokenURL = https://github.com/login/oauth/access_token?${accessTokenParams}
    GitHub의 액세스 토큰 요청 URL을 생성한다. 이 URL에는 위에서 생성한 쿼리 파라미터가 포함된다.
  • const accessTokenResponse = await fetch(accessTokenURL, { ... })
    fetch API를 사용하여 GitHub에 액세스 토큰을 요청한다.
    -- method: 'POST'를 사용하여 POST 요청을 보낸다.
    -- headers: { Accept: 'application/json' }를 설정하여 JSON 형식의 응답을 받도록 지정한다.
  • const accessTokenData = await accessTokenResponse.json()
    fetch 요청에 대한 응답을 JSON 형식으로 파싱하여 accessTokenData 변수에 저장한다.
  • if ('error' in accessTokenData) { return new Response(null, { status: 400 }); }
    응답 데이터에 'error' 필드가 포함되어 있는 경우, 요청이 실패했음을 의미한다. 이때, 400 Bad Request 상태 코드를 반환한다.
  • return Response.json({ accessTokenData });
    요청이 성공하면, GitHub에서 반환된 액세스 토큰 데이터를 JSON 형식으로 클라이언트에 반환한다.

github/complete?code= 형태로 URL에 code가 포함되어있는 것을 확인할 수 있으며, return Response.json({ accessTokenData}) 로 json() 형식으로 화면에 출력되는 것을 확인할 수 있다.


Github API

위에서 github가 보내준 code를 우리 백엔드에서 access token으로 교환을 했다.
--> 이제 이 access token을 이용해서 실제로 GitHub API에 요청할 수 있게 되었다.

이제 이 정보들을 가지고 GitHub OAuth 인증 후 사용자의 프로필 정보를 가져와, 해당 사용자가 이미 데이터베이스에 있는지 확인한 다음,

사용자를 세션에 저장하고, 프로필 페이지로 리디렉션하는 로직을 구현하면 된다.
--> 새로운 사용자라면 데이터베이스에 사용자를 생성한 후 세션에 저장하고, 마찬가지로 프로필 페이지로 리디렉션한다.

 const { error, access_token } = await accessTokenResponse.json();
  if (error) {
    return new Response(null, {
      status: 400,
    });
  }
  const userProfileResponse = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${access_token}`,
    },
    cache: 'no-cache',
  });

  const { id, avatar_url, login } = await userProfileResponse.json();

  const user = await db.user.findUnique({
    where: {
      github_id: id + '',
    },
    select: {
      id: true,
    },
  });

  if (user) {
    const session = await getSession();
    session.id = user.id;
    await session.save();
    return redirect('/profile');
  }

  const newUser = await db.user.create({
    data: {
      github_id: id + '',
      avatar: avatar_url,
      username: login,
    },
    select: {
      id: true,
    },
  });
  const session = await getSession();
  session.id = newUser.id;
  await session.save();
  return redirect('/profile');
}

위의 코드에서 조금 수정하고 덧붙여주었다!

GitHub 사용자 프로필 정보 가져오기

  • access_token을 사용해 GitHub API의 /user 엔드포인트에서 사용자의 프로필 정보를 가져온다.
  • 응답에서 id(GitHub 사용자의 고유 ID), avatar_url(프로필 이미지 URL), login(GitHub 사용자 이름)을 추출한다.

사용자 정보 데이터베이스 조회

  • 데이터베이스에서 github_idGitHub에서 가져온 id와 일치하는 사용자를 조회한다.
    이미 데이터베이스에 존재하는 사용자라면, 그 사용자의 id를 선택적으로 가져온다.

기존 사용자 처리

  • 만약 사용자가 데이터베이스에 이미 존재한다면, 현재 세션에 이 사용자의 id를 저장한다.
  • 세션 정보를 저장한 후, /profile 페이지로 사용자를 리다이렉트한다.

새 사용자 생성 및 처리

  • 데이터베이스에 존재하지 않는 사용자라면, 새로운 사용자 레코드를 생성한다.
  • github_id, avatar, username을 데이터베이스에 저장하고, 새로 생성된 사용자의 id를 선택적으로 가져온다.
  • 새로운 세션에 이 사용자의 id를 저장하고, 이후 /profile 페이지로 리다이렉트하게 된다.


Code Challenge

1 . 로그인이라는 function을 만들어보기

2 . username이 여기있는 GitHub username과 같도록 생성중임.
해당 username이 이미 사용중이면 username을 누군가 사용중인지를 먼저 확인하고, 이미 사용중이라면, 랜덤 username을 준다던지, Github id에다가 username을 붙여서 두개를 합친 것을 username으로 해줄 수도 있도록

3 . https://api.github.com/user/emails 로 요청 보내기 -> 우리에게 user email을 줌 -> user의 email을 받아오기

4 . request, response 코드 분리 -> request를 보내는 곳과 response를 JSON으로 바꾸는 것을 따로 function으로 바꾸기


1 . 로그인 function 만들기

( lib 디렉토리는 프로젝트의 유틸리티 함수, 도구, API 호출 등과 같은 공통된 기능을 저장하는 데 적합 )

lib/auth.ts 에 정의한 Auth 함수는 세션에 사용자 ID를 저장하고, 사용자를 /profile 페이지로 리다이렉트하는 기능을 제공한다.

이렇게 정의해 놓으면, 회원가입 후 혹은 로그인 후에 세션에 사용자를 저장하고 리다이렉트할 때 Auth 함수를 호출하기만 하면 된다.


GitHub으로 로그인할 때 -> username이 이미 저장되어있는지 확인해보기
깃허브로 로그인을 할 때,

만약, email로 가입한 사용자가 이미 seoyeon1123 을 사용하고 있는데, 깃허브 id가 seoyeon1123 일 경우 -> 중복이 발생하게 된다.

이럴 경우 어떻게 하면 좋을 까?

먼저, 깃허브 사용자 이름이 username에 저장이 되어있는지 먼저 확인해야 한다.
만약, 저장이 안되어있다면, 사용자 이름 그대로 저장해도 되지만, 이미 사용자 이름이 username에 있다면, 깃허브 사용자 이름에 특수 기호를 붙여서 저장할 수 있도록 한번 해보도록 하겠다!

const { id, avatar_url, login } = await userProfileResponse.json();

  // GitHub ID로 사용자 조회
  const user = await db.user.findUnique({
    where: {
      github_id: id + '',
    },
    select: {
      id: true,
      username: true,
    },
  });

  if (user) {
    return Auth(user.id);
  }

  const findUser = await db.user.findUnique({
    where: {
      username: login,
    },
    select: {
      id: true,
    },
  });

  const timeStamp = Date.now();

  const newUser = await db.user.create({
    data: {
      username: findUser ? login + timeStamp : login,
      github_id: id + '',
      avatar: avatar_url,
    },
    select: {
      id: true,
    },
  });

  // 새로 생성된 사용자로 Auth 처리
  return Auth(newUser.id);
}

먼저, github_id로 user가 DB에 존재하는지 확인한다. 만약, DB에 user가 있다면, Auth(user.id)로 로그인을 시켜준다.

user가 없다면,
DB에 username이 login으로 존재하는 user가 있는지 확인한다(findUser)

만약, findUser가 있다면? login에 timeStamp를 추가해주고, findUser가 없다면, login 그대로 username에 저장되도록 해주었다.

그 후, Auth(newUser.id)로 세션에 저장하면서 로그인 처리를 해주었다.

기존에 seoyeon1123을 이메일로 회원가입을 시켜주었고,
같은 seoyeon1123을 가지고 있는 깃허브 주소로 로그인 시켜보았더니


https://api.github.com/user/emails 로 요청 보내서 user의 email 받아오기

const userEmailResponse = await fetch('https://api.github.com/user/emails', {
    headers: {
      Authorization: `Bearer ${access_token}`,
    },
    cache: 'no-cache',
  });

헤더에 Authorization을 추가하여 GitHub API의 /user/emails 엔드포인트에서 이메일 정보를 받아오고 있다.
Bearer ${access_token} 형식의 Authorization 헤더를 사용하여, GitHub에서 발급한 access_token으로 인증을 처리하고 있다.

 const emailData = await userEmailResponse.json();
  const email = emailData[0].email;

userEmailResponse.json() 을 호출해서, API 응답이 JSON 형식으로 변환되어 emailData 변수에 저장한다.
그 후, emailData 배열의 첫 번째 요소에서 email 속성을 가져왔다.
그리고, DB에 저장할 때, email 속성을 추가해주었더니 깃허브로 로그인할 때, 깃허브 회원가입 했던 email 속성이

그렇다면 username과 마찬가지로 email 중복도 확인해 주어야 한다.

 const findEmail = await db.user.findUnique({
    where: {
      email: email,
    },
    select: {
      id: true,
    },
  });

  if (findEmail) {
    return new NextResponse('Email is already in use.', {
      status: 400,
    });
  }

만약 DB에 email이 저장되어 있다면, NextResponse() 메서드를 활용해서 400 상태로 'Email is already in use' 메시지를 출력할 수 있도록 작성해 두었다.


request, response 코드 분리
github/complete 하나의 코드 안에서 Response, Request의 코드가 많이 이루어지고 있어서 분리를 시켜보려고 한다.

export async function getGitHubAccessToken(code: string) {
  const accessTokenParams = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    client_secret: process.env.GITHUB_CLIENT_SECRET!,
    code,
  }).toString();

  const accessTokenURL = `https://github.com/login/oauth/access_token?${accessTokenParams}`;

  const accessTokenResponse = await fetch(accessTokenURL, {
    method: 'POST',
    headers: {
      Accept: 'application/json',
    },
  });

  const { error, access_token } = await accessTokenResponse.json();
  if (error) {
    throw new Error('Error retrieving access token');
  }

  return access_token;
}

export async function getGitHubUserProfile(accessToken: string) {
  const userProfileResponse = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
    cache: 'no-cache',
  });

  return userProfileResponse.json();
}

export async function getGitHubUserEmail(accessToken: string) {
  const userEmailResponse = await fetch('https://api.github.com/user/emails', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
    cache: 'no-cache',
  });

  return userEmailResponse.json();
}

getGitHubAccessToken , getGitHubUserProfile , getGitHubUserEmail 를 export로 정의해 둔 뒤,

업로드중.. 이렇게 불러다가 작성해두었다.

코드를 나눠놓으면서 가독성이 더욱 좋아지고, 재사용성이 좋아진 것을 알 수 있다!


SMS Token

token생성하는 로직 구현 -> token을 검증하는 것을 해볼 것임.
twilio를 이용해서 sms을 통해 token을 user에게 보내는 것

sms login action에는 두가지 상태가 있다. --> 1 . user에게 token을 보내기 전의 상태, 이 때는 user가 우리에게 전화번호를 보내주는 때가 될 것임.

1 . 먼저 전화번호를 입력한다
-> zod를 통해 전화번호 검증을 시작한다.

2 . 전화번호 검증에 성공하면, 이전에 저장되어있던 token이 있다면 삭제한다.
(db에 저장되어있는 phone number과, 사용자가 입력한 phone number이 같을 경우에)
db.sMSToken.deleteMany({})를 통해서 phone에 저장되어있는 token을 모두 삭제한다.

3 . 이후 token을 생성한다 (cryto를 이용) crypto의 ramdonInt를 활용해서 6자리 랜덤 숫자를 생성한 후, token에 저장한다.

이후, token이 db에 존재하는지 확인한다. 만약, 존재한다면, getToken()함수를 반환해 새로운 token을 생성하고, 존재하지 않는다면, token을 반환해준다.

4 . 생성된 token을 db.sMSToken.create({})이용해 DB에 저장한다.
이때, user와 연결해야 한다.
DB에 phone number이 저장된 user가 있다면, 찾아서 sMSToken을 연결시켜주고, 없다면, 새로운 user를 생성한다.


db.sMSToken.create({}) 를 통해 sMSToken 테이블에 token과 사용자 정보를 저장한다. user 필드에서는 connectOrCreate 메서드를 사용하여 두 가지 작업을 수행한다:

  • where 조건을 사용하여 데이터베이스에서 phone 필드가 result.data와 일치하는 사용자를 찾다.
  • 만약 일치하는 사용자가 있다면, 해당 사용자와 sMSToken을 연결(connect)한다.
  • 만약 일치하는 사용자가 없다면, create 옵션을 통해 새로운 사용자를 생성한다.
    이때, username은 필수 필드이므로, crypto.randomBytes(10).toString('hex')를 사용하여 랜덤한 username을 생성하고, phone 필드에는 result.data 값을 저장한다.

정리

  • 사용자가 유효한 전화번호를 보내주면, 해당 전화번호를 가진 user와 연결된 모든 token을 삭제한 다음, token을 새로 생성함.
  • 그리고 데이터베이스에 sMSToken을 생성할 때, 방금 생성한 token을 사용해서 만들고 있음.
  • 하지만 그 token을 user에게 연결시킬 때에는 (token들은 user가 필요하기 때문),우리는 해당 user가 이미 우리 계정을 가지고 있는지 아닌지를 모름
  • connectOrCreate를 사용 -> prisma에게 sMSToken을 만들어서 이 전화번호를 가지고 있는 user가 존재한다면 연결해달라고 할 것임. 만약 존재하지 않는다면 새 user를 생성해줄 것임.
  • 그렇게 새 user를 만들면, 이 user가 새로 생성된 token에 연결될 것임.

Token Verification

사용자가 token을 받고, token을 입력하고 verify token 버튼을 눌렀을 때, 어떤 일이 일어나는지 생각해보면

사용자가 입력한 토큰이 유효한지 검증하고, 토큰이 문제가 없다면 token의 userId를 얻고(token에 연결된 user가 누구인지 알 수 있도록),

그 후 얻은 userId를 이용해서 로그인 시켜주어야 한다.
(sMSToken -> userId를 가지고 있어서 어떤 userId가 SMS Token에 연결되어 있는지 알 수 있음.)

먼저 token이 DB에 저장되어있는 token인지 확인해보아야 한다.


tokenExists() 함수를 정의한다 -> db.sMSToken,findUnique({}) 를 통해서 token이 user가 입력한 token과 일치한지를 확인하고, 만약 일치하다면 true, 일치하지 않다면 false를 return 해서 "This token not exits" 오류 메시지를 출력하도록 한다.


토큰 검증:

  • tokenSchema.safeParseAsync(token) 을 통해 입력된 token이 유효한지 검증한다.
  • 만약 result.success가 false라면, 토큰이 유효하지 않다는 것이므로 오류 메시지를 반환한다.
  • 오류가 없을 경우, result.success가 true가 되어 다음 단계로 진행한다.

토큰 조회 및 로그인:

  • 데이터베이스에서 result.data.toString()으로 변환한 토큰 값을 사용해 sMSToken을 조회한다.
  • 일치하는 토큰이 있는 경우, 해당 토큰에 연결된 userId를 가져온다.
  • Auth(token!.userId)를 호출하여 사용자 ID를 인증하고, 사용자를 로그인 상태로 만든다.

토큰 삭제 및 리다이렉트:

  • 로그인에 성공하면, 사용된 토큰을 데이터베이스에서 삭제한다.
    --> 이는 보안상의 이유로, 동일한 토큰이 다시 사용되지 않도록 하기 위함이다.
  • 토큰이 성공적으로 삭제된 후, redirect('/profile')를 호출하여 사용자를 프로필 페이지로 리다이렉트 한다.

Twilio SMS

https://console.twilio.com/auth0 링크에 들어가서 회원가입 절차를 끝나면, 이러한 정보들을 얻을 수 있다. 이 정보들을 .env 폴더에 넣어준 뒤,
npm install twilio를 통해 설치하면 된다. twilio에 .env 에 저장해둔 값을 넣어준 뒤, client.message.create({}) 에 정보들을 넣어준다.

이렇게 넣어줄때, to에는 원래 이론적으로는 result.data -> 사용자가 입력한 Phone number가 입력되어야하지만, twilio는 기본적으로 회원가입한 번호에게만 무료로 문자 제공을 해주기 때문에 이렇게 넣어두었다.

profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글