본문 바로가기
Web Study/Next.js

코딩애플 - 로그인 / 회원기능

by 쿠리의일상 2023. 7. 2.

NextAuth 로 소셜로그인 구현하기

1. 아이디/비밀번호 관리가 필요 없음

2. 코드 짤 것이 없음

github 소셜로그인

1. 깃헙 측에 permission 얻기

settings > developer settings > oauth app

url쪽에 현재 개발 중인 url을 가져다가 써준다. 이때, 해당 url 이 이미 있다면(다른 어플을 만들때 사용했다든가) 그걸 그대로 사용해줘야 에러를 만나지 않을 수 있음

 

만들면 나오는 클라이언트ID와 비밀번호를 가지고 있어야 유저 정보를 달라고 요청이 가능하다.

 

2. next-auth 설치하기

npm install next-auth@4.21.1

3. next-auth 세팅하기

pages > api > auth 폴더 생성
해당 폴더 안에 [...nextauth].js 라는 이름으로 파일을 생성

https://next-auth.js.org/

 

NextAuth.js

Authentication for Next.js

next-auth.js.org

 

import NextAuth from "next-auth";
import GithubProvider from "next-auth/providers/github";

export const authOptions = {
  providers: [
  	// 깃헙 소셜로그인
    GithubProvider({
      clientId: 'Github에서 발급받은ID',
      clientSecret: 'Github에서 발급받은Secret',
    }),
    // 다른 소셜로그인은 공식 홈페이지 확인 필요
  ],
  secret : 'jwt생성시쓰는암호'
};
export default NextAuth(authOptions);
위와 같은 코드로 세팅해준다.

위에서 깃헙 Oauth app 으로 생성해준 id와 pw 를 넣어주고 (.env 처리)

secret 의 경우 토큰 만들 때 쓰는 암호이므로 직접 지어준다.

 

4. 로그인 버튼 만들기

로그인 버튼에 직접 코드를 작성해줄 필요 없이, next-auth 에서 제공하는 함수를 사용해준다.

signIn() 이라는 함수이며, 이 함수가 실행되면 이미 준비 되어 있는 로그인 페이지로 자동 이동하게 처리되어 있다.

이때 서버 컴포넌트에선 자바스크립트 기능을 쓸 수 없으므로, 로그인 버튼은 클라이언트 컴포넌트로 따로 빼줘야 한다!

 

'use client'

import React from 'react'
import { signIn } from 'next-auth/react'

export default function LoginBtn() {
  return (
    <button onClick={() => {
      signIn()
    }}>로그인</button>
  )
}

next-auth/react 에서 signIn 을 가져와야 하며, 로그아웃을 하려면 signOut 함수를 가져오면 된다.

이와 같은 페이지가 뜨면 성공

처음 로그인 하는 거면 자동 가입 처리가 된다. 

로그인이 되었다면 유저 정보는

  • 서버 컴포넌트나 서버 기능(API) 안에서 => await getServerSession() 을 사용해준다.
    • getServerSession 의 파라미터로는 [...nextauth].js 의 authOptions 를 넣어줘야 한다.
import { getServerSession } from 'next-auth'
import { authOptions } from "......../[...nextauth]"

// ...
await getServerSession(authOptions);

getServerSession() 함수는 리턴값으로 아래와 같이 회원 정보를 반환해준다.

이름과 이메일, 깃헙 프로필 정보를 제공해준다.

그렇다면 해당 정보를 통해 깃헙 로그아웃도 구현이 가능할 것이다.

  {userInfo === null 
    ? <LoginBtn /> 
    : <>
        <LogOutBtn />
        <span> {userInfo.user.name}</span>
      </>
  }

 

JWT (Json Web Token)

Next-auth 라이브러리를 사용하면 기본적으로 모든 방식은 JWT 이다.

유저 세션 데이터를 DB에 저장해두지 않고 JWT만 유저에게 보낸 뒤
유저가 로그인이 필요한 페이지 방문 시 제출한 JWT만 검사하는 방식인 것이다.

 

DB adapter 란?

session 방식으로 회원기능을 만들고 싶을 때

  1. 첫 로그인 시 자동 회원 가입 처리 -> DB에 보관
  2. 로그인 시 DB에 세션 정보 보관
  3. 현재 로그인된 유저정보가 필요하면 DB에서 조회

 

DB adapter 설치

npm install @next-auth/mongodb-adapter

DB adapter 세팅

// [...nextauth].js
import { connectDB } from "@/utils/database";
import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import NextAuth from "next-auth";
import GithubProvider from "next-auth/providers/github";

export const authOptions = {
  providers: [
    GithubProvider({
      clientId: process.env.REACT_APP_GITHUB_ID,
      clientSecret: process.env.REACT_APP_GITHUB_PW,
    }),
  ],
  secret : '',
  adapter : MongoDBAdapter(connectDB)
};
export default NextAuth(authOptions);

[...nextauto].js 파일 안에 adapter 속성을 추가해준다. MongoDBAdapter() 는 방금 설치했던

@next-auth/mongodb-adapter 에서 가져올 수 있다.

MongoDBAdapter() 의 파라미터에는 몽고DB를 연결해줬던 그 파일을 넣어주면 된다.

혹, 몽고DB가 싫다면 다른 DB Adapter 를 찾아서 사용해주면 된다. (ex. redis 는 하드가 아닌 램에 저장해줘서 빠른 속도가 가능함, 세션 구현할 때 많이 사용됨)

 

위처럼 세팅까지 마치고 로그인 해보면

몽고디비에 해당 db와 컬렉션이 생긴다.

  1. sessions : 현재 로그인된 유저 세션 정보 저장용
  2. accounts : 가입된 유저의 계정 정보(여러 방식의 회원가입을 하는 경우)
  3. users : 가입된 유저 정보

sessions

accounts 와 users 의 정보는 거의 유사하나 분리된 이유는, 하나의 유저가 여러 계정(여러 방식의 회원가입으로)을 가질 수 있기 때문이다.
accounts 의 provider 로 확인 가능함

accounts
users

users 의 email 은 유일한 값이므로 이 값으로 회원의 중복 확인을 해줄 수 있다.

 

임의로 설정된 test DB 말고 직접 디비를 선택해주고 싶다면?

database.js 쪽에 작성했던 url 의 ? 앞에 디비명을 넣어준다. 아래에서 위치를 대략적으로 확인할수 있다.(옵션 쿼리스트링 이전임)

url = mongodb://admin:<pw>@ac-i3fbai1-shard-00-00.s2bhe9x.mongodb.net:27017,ac-i3fbai1-shard-00-01.s2bhe9x.mongodb.net:27017,ac-i3fbai1-shard-00-02.s2bhe9x.mongodb.net:27017/<여기에 db명을 기재>?ssl=true&replicaSet=atlas-ek39km-shard-0&authSource=admin&retryWrites=true&w=majority

 


회원 기능과 게시글 기능을 합치기

삭제 요청을 예시로 들면, 요청자와 글쓴이가 같아야 삭제가 가능하게 해줘야 한다.

그럼 글작성에도 글쓴이 정보가 들어가야 한다.

 

전에 만들었던 /api/new 부분에 글작성 시 코드를 마이그레이션 해주자.

글쓴이를 식별할 수 있는 가장 간단한 방법은 이메일 정보이다.

해당 이메일 정보는 글을 쓸 때 유저가 직접 보내게끔 하면 위변조 될 수 있기 때문에, 직접 서버측에서 getServerSession() 을 사용하여 저장한 정보를 가져와준다.

다만 서버 컴포넌트에서 사용했던 파라미터와는 달리 서버 api 에선 파라미터가 추가적으로 들어가줘야 한다.

import { getServerSession } from 'next-auth';
import { authOptions } from '../auth/[...nextauth]';

// ...
let session = await getServerSession(request, response, authOptions);

위의 정보를 가져와서 새로운 글을 써줄 때마다 추가해준다

 

let newPost = {
    title : request.body.title,
    content : request.body.content,
    author : session.user.email,
}

 

수정 버튼과 삭제 버튼이 글쓴이가 아닌 경우에 안보이게 하기

나의 경우엔 그냥 컴포넌트상에서 삼항 연산자를 사용해줬는데

{session && session.user.email === el.author.email 
  ? <Link href={'/modify/' + el._id} prefetch={false}> ✏️ </Link>
  : null}

 

코딩애플 님은 서버쪽에서 처리해주셨으므로 서버에도 처리해주기로 했다.

원리는 똑같음.. 그저 서버냐 아니냐의 차이

export default async function handler(requset, response) {
  const client = await connectDB;
  let session = await getServerSession(requset, response, authOptions);
  if(!session) return;

  if(requset.method == 'POST') {
    const findPost = await client.db('forum').collection('post').findOne({
      _id : new ObjectId(requset.body.id)
    })
    if(session.user.email === findPost.author.email) {
      let result = await client.db('forum').collection('post').deleteOne({
        _id : new ObjectId(requset.body.id),
      });

      // 삭제를 시도할 때 그 결과값을 반환해준다. deletedCount 가 1이면 성공, 0이면 실패
      if(result.deletedCount === 1) {
        return response.redirect(302, '/list');
      } else if(result.deletedCount === 0) {
        return response.redirect(500, '/list');
      }
    }
  }
}

 

 

아이디와 비번으로 로그인 구현하기

Nextauth 라이브러리 설정에서 Credentials provider 속성을 추가해주면, 세션 방식 말고 강제로 JWT 방식만 사용하도록 세팅되어 있다(디폴트)

세션 방식으로 아이디/비번으로 로그인 구현하는 방법은 제외하고 기본값인 아이디/비번+JWT 방식으로 구현해본다.

 

  1. 회원가입 페이지에서 유저의 아이디/비번을 서버로 제출
  2. 서버는 그걸 DB에 저장

 

회원가입 페이지를 form 태그로 간단히 만들어준 다음, 서버측에서 받아줄 api 를 만들어준다.

export default async function handler(requset, response) {
  if(requset.method == 'POST') {
    const client = await connectDB;
    
    await client.db('forum').collection('user_cred').insertOne(requset.body);
  }
}

간단하게는 insertOne 으로 넣어주면 되는데 여기서 request.body 값에 비밀번호는 암호화가 필요하다.

 

비밀번호 암호화를 위한 라이브러리 bcrypt

npm install bcrypt
import bcrypt from 'bcrypt';

//...

const hashPw = await bcrypt.hash(requset.body.password, 10);
requset.body.password = hashPw;

bcrypt 라이브러리를 가져와서 hash() 라는 메서드의 파라미터에 암호화해줄 값을 넣어주면 암호화된 값이 반환된다.

두번째 파라미터는

이제 암호화된 비밀번호로 가입처리를 완료해주면 된다.

 

Next-auth 의 credentialsProvider 속성을 추가하여 아이디/비번으로 회원가입 설정하기

import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from 'bcrypt';

//...
// authOptions의 providers 안에 추가
CredentialsProvider({
      //1. 로그인페이지 폼 자동생성해주는 코드 
      name: "credentials",
        credentials: {
          email: { label: "email", type: "text" },
          password: { label: "password", type: "password" },
      },

      //2. 로그인요청시 실행되는코드
      //직접 DB에서 아이디,비번 비교하고 
      //아이디,비번 맞으면 return 결과, 틀리면 return null 해야함
      async authorize(credentials) {
        let db = (await connectDB).db('forum');
        let user = await db.collection('user_cred').findOne({email : credentials.email})
        if (!user) {
          console.log('해당 이메일은 없음');
          return null
        }
        const pwcheck = await bcrypt.compare(credentials.password, user.password);
        if (!pwcheck) {
          console.log('비번틀림');
          return null
        }
        return user
      }
    })
//Provider 바깥부분에 추가
//3. jwt 써놔야 잘됩니다 + jwt 만료일설정
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60 //30일
  },


  callbacks: {
    //4. jwt 만들 때 실행되는 코드 
    //user변수는 DB의 유저정보담겨있고 token.user에 뭐 저장하면 jwt에 들어갑니다.
    jwt: async ({ token, user }) => {
      if (user) {
        token.user = {};
        token.user.name = user.name
        token.user.email = user.email
      }
      return token;
    },
    //5. 유저 세션이 조회될 때 마다 실행되는 코드
    session: async ({ session, token }) => {
      session.user = token.user;  
      return session;
    },
  },

authOptions 안에 위와같은 설정을 추가해주면, 로그인을 클릭했을 때 아래와 같은 페이지를 볼 수 있다.

회원가입 폼도 만들어준 뒤 간단하게 서버를 만들어주고 몽고디비의 컬렉션에 담아준다.

export default async function handler(requset, response) {
  if(requset.method == 'POST') {
    const client = await connectDB;
    if(requset.body.name === '' || requset.body.email === '' || requset.body.password === '') return;

    const duplicated = await client.db('forum').collection('user_cred').findOne({
      email : requset.body.email,
    });
    if(duplicated !== null) {
      return response.status(302).json('이미 가입된 이메일입니다!');
    }

    const hashPw = await bcrypt.hash(requset.body.password, 10);
    requset.body.password = hashPw;
    await client.db('forum').collection('user_cred').insertOne(requset.body);
    
    return response.redirect(302, '/list');
  }
}

그러면, 정상적으로 로그인이 가능하다.

 

+ 추가 JWT 사용 시 refresh token 사용하기

next-auth 5버전부터 가능하다고 한다. (나의 경우 강의를 따라가기 위해 4.21.1을 다운 받아서 안됨)

https://auth-docs-git-feat-nextjs-auth-authjs.vercel.app/guides/basics/refresh-token-rotation

토큰의 유효기간이 너무 비정상적으로 길어지면 만약에 정보가 털렸을 때 대응할 방법이 없어지므로

보통 토큰의 유효기간을 30분 같이 짧게 유지한다. 하지만 유효기간이 짧으면 유저에게 재로그인을 요청해야하므로

불편할 수밖에 없는데 이때 유효기간이 지나면 새로운 유효기간을 자동으로 발급해주는 방법을 refresh token 이라고 한다.

사실 refresh token 도 쿠키 같은 곳에서 저장하므로 도난당하기 쉽지만 해당 토큰을 만들어둘 때 DB에도 저장하기 때문에
서버측에서 대응이 가능해진다.

결국 DB를 사용하므로 세션방식과 유사해지지만 세션보다 DB조회가 적다는 장점이 존재함

 

깃헙 OAuth 사용 시 자동으로 제공되는 refresh token

새롭게 OAuth app 을 생성해줘야 하지만, 이 방식을 사용하면 깃헙 로그인 시 자동으로 access token 과 refresh token 을 발급해준다.

settings > developer settings > github apps 로 새로운 앱을 만들어준다.

 

Expire user ~

Requset user ~

두 항목을 체크해준다. 그럼 OAuth app 과 동일하게 client id 와 비번을 발급해줄 것임

 

export const authOptions = {
  providers: [
    GithubProvider({
      clientId: 'Github에서 발급받은ID',
      clientSecret: 'Github에서 발급받은Secret',
    }),
  ],

  //기간설정은 무시됨, github은 access token 유효기간 8시간, refresh token 유효기간 6개월 
  jwt : {
    maxAge: 60,
  },
  callbacks: {
    // JWT 사용할 때마다 실행됨, return 오른쪽에 뭐 적으면 그걸 JWT로 만들어서 유저에게 보내줌
    async jwt({ token, account, user }) {
      console.log('account', account);
      console.log('user', user);
      console.log('token', token);

      // 1. 첫 JWT 토큰 만들어주기 (첫 로그인시에만 실행)
      if (account && user) {
      	// jwt() 안에서 return 한 데이터는 자동으로 JWT으로 만들어주고 유저에게 보내준다.
        return {
          accessToken: account.access_token,
          refreshToken: account.refresh_token, 
          accessTokenExpires: account.expires_at,
          user,
        }
      }

      // 2. 남은 시간이 임박한 경우 access token 재발급하기 
      let 남은시간 = token.accessTokenExpires - Math.round(Date.now() / 1000)
      if (남은시간 < (60 * 60 * 8 - 10) ) {  
        console.log('유효기간 얼마안남음')
        let 새로운JWT = await refreshAccessToken(token) // 3. 깃헙에게 재발급해달라고 조르기 
        console.log('새로운 JWT : ', 새로운JWT)
        return 새로운JWT
      } else {
        return token
      }
    },

    //getServerSession 실행시 토큰에 있던 어떤 정보 뽑아서 컴포넌트로 보내줄지 결정가능 
    async session({ session, token }) {
      session.user = token.user
      session.accessToken = token.accessToken
      session.accessTokenExpires = token.accessTokenExpires
      session.error = token.error
      return session
    },
  },
  secret : 'password1234',
}

async function refreshAccessToken(token) {
    //1. access token 재발급해달라고 POST요청
    const url = 'https://github.com/login/oauth/access_token'
    const params = {
      grant_type: 'refresh_token',
      refresh_token: token.refreshToken,
      client_id: 'Github에서 발급받은ID',
      client_secret: 'Github에서 발급받은SECRET',
    }

	// fetch 로 토큰보내면 복잡해지므로 axios 로 토큰을 보낸다. npm install axios
    const res = await axios.post(url, null, { params : params })
    const refreshedTokens = await res.data
    if (res.status !== 200) {
      console.log('실패', refreshedTokens)
    }

    //2. 재발급한거 출력해보기 
    console.log('토큰 재발급한거 : ')
    console.log(refreshedTokens) 

    //3. 이걸로 새로운 토큰 만들어서 return 해주기, 쿼리스트링 형태에서 데이터를 추출하려면 URLSearchParams() 에 넣고 get() 으로 뽑아줘야 한다.
    let data = new URLSearchParams(refreshedTokens);
    if (data.get('error') == null){
      return {
        ...token,
        accessToken: data.get('access_token'),
        accessTokenExpires:
          Math.round(Date.now() / 1000) + Number(data.get('expires_in')),
        refreshToken: data.get('refresh_token')
      }
    } else {
      return token
    }
} 

export default NextAuth(authOptions)

 

refreshAccessToken() 이 실행되어 토큰이 재발급되지만 JWT에 갱신이 안되는 이슈가 있음

nextjs 13버전의 app폴더에선 쿠키 조작이 어려워서 생긴 버그라서 아직은 사용이 불가하다고 한다!