웹 개발

Lucia 인증 연동 (Next.js, PlanetScale, Kysely, OAuth)

깨비아빠0 2024. 1. 10. 16:23
728x90
반응형

Next.js 프로젝트 스택 검토 (2023) 대로 홈페이지를 다시 만들기 위한 세 번째 단계로 Lucia를 사용하여 인증을 구현했다.

 

 

 

GitHub OAuth App 생성

GitHub OAuth 연동을 위해 https://github.com/settings/developers에서 OAuth app을 생성한다. (Create a GitHub OAuth app 참고)

 

Application name, Homepage URL을 적절히 채우고, Authorization callback URL에는 "http://localhost:3000/login/github/callback"을 입력한다.

app의 Client ID와 Secret을 다음과 같이 .env.local에 추가한다.

GITHUB_CLIENT_ID="..."
GITHUB_CLIENT_SECRET="..."

 

참고로 방금 생성한 app은 로컬 개발 시 사용하고, 웹 배포에 사용할 app은 별도로 생성하여 URL을 주소에 맞게 입력해야 한다. Client ID와 Secret도 배포할 플랫폼의 환경변수로 따로 추가한다.

 

DB 테이블 생성

인증에 필요한 테이블들을 생성한다.

PlanetScale + Kysely 데이터베이스 연동 (Next.js)에서 연동한 PlanetScale에 접속하여 다음 쿼리를 실행하였다.

CREATE TABLE auth_user (
    id VARCHAR(15) NOT NULL PRIMARY KEY,
    username VARCHAR(255) NOT NULL, # optional
    INDEX username (username)
);

CREATE TABLE user_key (
    id VARCHAR(255) NOT NULL PRIMARY KEY,
    user_id VARCHAR(15) NOT NULL,
    hashed_password VARCHAR(255),
    FOREIGN KEY (user_id) REFERENCES auth_user(id)
);

CREATE TABLE user_session (
    id VARCHAR(127) NOT NULL PRIMARY KEY,
    user_id VARCHAR(15) NOT NULL,
    active_expires BIGINT UNSIGNED NOT NULL,
    idle_expires BIGINT UNSIGNED NOT NULL,
    FOREIGN KEY (user_id) REFERENCES auth_user(id)
);
https://lucia-auth.com/database-adapters/planetscale-serverless/#mysql-schema,
https://lucia-auth.com/database-adapters/mysql2/#mysql-schema 참고

 

  • 테이블 이름은 임의로 변경 가능하다.
  • auth_user 테이블은 id 컬럼만 필수이고, 추가로 필요한 사용자 정보를 임의로 추가할 수 있다.
  • FOREIGN KEY는 필수는 아니다. PlanetScale은 현재 FOREIGN KEY가 베타 중이므로, FOREIGN KEY를 사용하려면 설정에서 활성화시켜야 한다.

 

패키지 설치

다음 명령으로 패키지를 설치한다. PlanetScale DB와 OAuth를 위한 추가 패키지도 함께 설치한다.

npm install lucia @lucia-auth/adapter-mysql @lucia-auth/oauth

 

인증 모듈

auth/lucia.ts 파일을 추가한다. 아래 코드는 다음 내용을 포함하고 있다.

  1. lucia 모듈 (auth)
  2. github OAuth 인증 모듈 (githubAuth)
  3. session 조회 도움 함수 (getPageSession)
import { lucia } from "lucia"
import { nextjs_future } from "lucia/middleware"
import { github } from "@lucia-auth/oauth/providers"
import { planetscale } from "@lucia-auth/adapter-mysql"
import { connect } from "@planetscale/database"
import { dbConfig } from "../db/kysely"
import { cache } from "react"
import * as context from "next/headers"

export const auth = lucia({
  adapter: planetscale(
    connect(dbConfig),
    {
      user: "auth_user",
      key: "user_key",
      session: "user_session",
    },
  ),
  middleware: nextjs_future(),
  env: process.env.NODE_ENV === "development" ? "DEV" : "PROD",
  sessionCookie: {
    expires: false,
  },

  getUserAttributes: (data) => {
    return {
      githubUsername: data.username
    }
  },
})

export type Auth = typeof auth

export const githubAuth = github(
  auth,
  {
    clientId: process.env.GITHUB_CLIENT_ID ?? "",
    clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
    scope: undefined,
  }
)

export const getPageSession = cache(() => {
  const authRequest = auth.handleRequest("GET", context)
  return authRequest.validate()
})
PlanetScale connect에 사용된 dbConfig는 DB 접속 정보를 담고 있는 object로서, Kysely 연동 코드에서 가져와서 사용한다.

 

Sign in 페이지

app/login/page.tsx 파일에 Sign in 페이지 코드를 추가한다.

이미 로그인된 경우에는 root로 redirect시키고, 아니면 GitHub Sign in 링크를 보여준다.

import { getPageSession } from "@/auth/lucia"
import { redirect } from "next/navigation"

const Page = async () => {
  const session = await getPageSession()
  if (session) redirect("/")
  
  return (
    <>
      <h1>Sign in</h1>
      <a href="/login/github">Sign in with GitHub</a>
    </>
  )
}

export default Page

 

인증 handler

app/login/github/route.ts에 GitHub 인증 route handler를 추가한다.

/login/github GET 요청 시 GitHub 인증 URL이 생성되어 사용자 인증에 사용된다.

import { githubAuth } from "@/auth/lucia"
import * as context from "next/headers"
import type { NextRequest } from "next/server"

export const GET = async (request: NextRequest) => {
  const [url, state] = await githubAuth.getAuthorizationUrl()

  // store state
  context.cookies().set("github_oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 60
  })

  return new Response(null, {
    status: 302,
    headers: {
      Location: url.toString()
    }
  })
};

 

인증 callback

app/login/github/callback/route.ts에 인증 callback 구현을 추가한다.

인증 결과가 전달되며, 인증 성공 시 새로운 세션이 만들어진다.

import { auth, githubAuth } from "@/auth/lucia"
import { OAuthRequestError } from "@lucia-auth/oauth"
import { cookies, headers } from "next/headers"
import type { NextRequest } from "next/server"

export const GET = async (request: NextRequest) => {
  const storedState = cookies().get("github_oauth_state")?.value
  const url = new URL(request.url)
  const state = url.searchParams.get("state")
  const code = url.searchParams.get("code")

  // validate state
  if (!storedState || !state || storedState !== state || !code) {
    return new Response(null, {
      status: 400
    })
  }
  
  try {
    const { getExistingUser, githubUser, createUser } =
      await githubAuth.validateCallback(code)

    const getUser = async () => {
      const existingUser = await getExistingUser()
      if (existingUser) return existingUser
      const user = await createUser({
        attributes: {
          username: githubUser.login
        }
      })
      return user
    }

    const user = await getUser()
    const session = await auth.createSession({
      userId: user.userId,
      attributes: {}
    })
    const authRequest = auth.handleRequest(request.method, {
      cookies,
      headers
    })
    authRequest.setSession(session)

    return new Response(null, {
      status: 302,
      headers: {
        Location: "/" // redirect to profile page
      }
    })
  } catch (e) {
    if (e instanceof OAuthRequestError) {
      // invalid code
      return new Response(null, {
        status: 400
      })
    }
    
    return new Response(null, {
      status: 500
    })
  }
}

 

프로필 표시

메인 페이지(app/page.tsx)에 사용자 정보를 표시하도록 다음과 같이 수정한다.

세션 정보가 없는 경우 로그인 페이지(/login)로 redirect시킨다.

import { getPageSession } from "@/auth/lucia"
import { redirect } from "next/navigation"
import Form from "@/components/form"

...

  return (
    ...
        <h1>Profile</h1>
        <p>User id: {session.user.userId}</p>
        <p>Username: {session.user.githubUsername}</p>
        <Form action="/api/logout">
          <input type="submit" value="Sign out" />
        </Form>
  ...

 

Form 컴포넌트

redirect를 manual 방식으로 처리하는 Form 클라이언트 컴포넌트를 추가한다. (components/form.tsx)

"use client"

import { useRouter } from "next/navigation"

const Form = ({
  children,
  action
}: {
  children: React.ReactNode
  action: string
}) => {
  const router = useRouter()
  
  return (
    <form
      action={action}
      method="post"
      onSubmit={async (e) => {
        e.preventDefault()
        const formData = new FormData(e.currentTarget)
        const response = await fetch(action, {
          method: "POST",
          body: formData,
          redirect: "manual"
        })

        if (response.status === 0) {
          // redirected
          // when using `redirect: "manual"`, response status 0 is returned
          return router.refresh()
        }
      }}
    >
      {children}
    </form>
  )
}

export default Form

 

Sign out 처리

app/api/logout/route.ts 파일을 추가하여 api/logout 요청을 처리한다.

Sign out을 위해서 Auth.invalidateSession(sessionId) 및 AuthRequest.setSession(null) 함수를 호출한다.

import { auth } from "@/auth/lucia"
import * as context from "next/headers"
import type { NextRequest } from "next/server"

export const POST = async (request: NextRequest) => {
  const authRequest = auth.handleRequest(request.method, context)

  // check if user is authenticated
  const session = await authRequest.validate()
  if (!session) {
    return new Response(null, {
      status: 401
    })
  }

  // make sure to invalidate the current session!
  await auth.invalidateSession(session.sessionId)

  // delete session cookie
  authRequest.setSession(null)
  
  return new Response(null, {
    status: 302,
    headers: {
      Location: "/login" // redirect to login page
    }
  })
}

 

인증 연동 완료

위의 작업을 모두 마치면, 아래와 같이 기본적인 인증 기능들이 추가된다.

  1. 사이트 접속 시 세션 확인, 없으면 Sign in 페이지 이동
  2. Sign in 버튼으로 GitHub 인증 페이지 이동
  3. 메인 페이지에 사용자 정보 표시
  4. Sign Out 기능

다음은 인증 완료 후 사용자정보와 Sign Out 링크가 표시된 모습이다.

(https://freeislet.vercel.app/)

 

 

 

참고: https://lucia-auth.com/guidebook/github-oauth/nextjs-app/ (Next.js App Router OAuth 연동 가이드)

 

GitHub OAuth in Next.js App Router

Learn the basic of Lucia and the OAuth integration by implementing GitHub OAuth

lucia-auth.com

https://lucia-auth.com/database-adapters/planetscale-serverless/ (PlanetScale 연동)

 

PlanetScale serverless adapter

Learn how to use the PlanetScale serverless driver with Lucia

lucia-auth.com

https://lucia-auth.com/guidebook/kysely/ (Kysely 연동)

 

Using Kysely

Learn how to use Kysely with Lucia

lucia-auth.com

 

반응형