[Next.js v14] 사용자 인증(Authentication)

·2024년 6월 30일
0

NextJS

목록 보기
26/26
post-thumbnail

📌 사용자 인증 - 회원가입

📖 사용자 회원가입 : 사용자 입력 추출 및 유효성 검사

💎 /components/auth-form.js

"use client";
import Link from "next/link";
import { useFormState } from "react-dom";
import { signup } from "@/action/auth";

export default function AuthForm() {
  const [formState, formAction] = useFormState(signup, {});
  return (
    <form id="auth-form" action={formAction}>
      <div>
        <img src="/images/auth-icon.jpg" alt="A lock icon" />
      </div>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" name="email" id="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" name="password" id="password" />
      </p>
      {formState.errors && (
        <ul id="form-errors">
          {Object.keys(formState.errors).map((error) => (
            <li key={error}>{formState.errors[error]}</li>
          ))}
        </ul>
      )}
      <p>
        <button type="submit">Create Account</button>
      </p>
      <p>
        <Link href="/">Login with existing account.</Link>
      </p>
    </form>
  );
}

useFormState를 이용하여 만약 formState에 errors라는 키를 가진 값이 존재한다면 화면에 렌더링

💎 /action/auth.js

"use server";

// 서버 액션은 비동기!
export async function signup(prevState, formData) {
  const email = formData.get("email");
  const password = formData.get("password");

  // validation
  let errors = {};

  if (!email.includes("@")) {
    errors.email = "이메일을 제대로 입력해주세요.";
  }

  if (password.trim().length < 8) {
    errors.password = "비밀번호는 최소 8자 이상이어야 합니다.";
  }

  if (Object.keys(errors).length > 0) {
    // 적어도 하나 이상의 키-값 쌍이 있다면..
    return {
      errors,
    };
  }
}

📖 사용자 회원가입 : 데이터베이스에 사용자 저장하기

💎 /lib/user.js

import db from "./db";
export function createUser(email, password) {
  const result = db
    .prepare("INSERT INTO users (email, password) VALUES (?, ?)")
    .run(email, password);
  return result.lastInsertRowid;
}

💎 /action/auth.js

데이터베이스에 비밀번호를 그대로 저장하면 차후에 해당 조합으로 다른 사이트에 로그인하여 유저의 정보를 빼내는 부작용이 있을 수 있다. 따라서 해시 처리를 통해 변환하여 데이터베이스에 저장해야한다.

"use server";

import { hashUserPassword } from "@/lib/hash";
import { createUser } from "@/lib/user";

// 서버 액션은 비동기!
export async function signup(prevState, formData) {
  const email = formData.get("email");
  const password = formData.get("password");

  // validation
  let errors = {};

  if (!email.includes("@")) {
    errors.email = "이메일을 제대로 입력해주세요.";
  }

  if (password.trim().length < 8) {
    errors.password = "비밀번호는 최소 8자 이상이어야 합니다.";
  }

  if (Object.keys(errors).length > 0) {
    // 적어도 하나 이상의 키-값 쌍이 있다면..
    return {
      errors,
    };
  }

  const hashedPassword = hashUserPassword(password);
  createUser(email, hashedPassword);
}

📖 사용자 회원가입 : 이메일 중복 여부 확인

DB에 이메일 정보는 UNIQUE로 설정되어있기 때문에 이메일을 중복으로 사용할 수 없다.

💎 /action/auth.js

"use server";

import { hashUserPassword } from "@/lib/hash";
import { createUser } from "@/lib/user";
import { redirect } from "next/navigation";

// 서버 액션은 비동기!
export async function signup(prevState, formData) {
  const email = formData.get("email");
  const password = formData.get("password");

  // validation
  let errors = {};

  if (!email.includes("@")) {
    errors.email = "이메일을 제대로 입력해주세요.";
  }

  if (password.trim().length < 8) {
    errors.password = "비밀번호는 최소 8자 이상이어야 합니다.";
  }

  if (Object.keys(errors).length > 0) {
    // 적어도 하나 이상의 키-값 쌍이 있다면..
    return {
      errors,
    };
  }

  const hashedPassword = hashUserPassword(password);
  try {
    createUser(email, hashedPassword);
  } catch (error) {
    //   이메일이 중복인 경우
    if (error.code === "SQLITE_CONSTRAINT_UNIQUE") {
      return { errors: { email: "이미 존재하는 이메일 계정입니다." } };
    }

    throw error; // 기타 에러
  }

  redirect("/training");
}

try~catch문을 이용해서 SQLITE에 오류가 발생했을 때 해당 코드가 유니크 키 위반과 관련된 내용이라면, formState 객체에 errors.email 을 추가한다.


📌 사용자 인증 - '/training' 접근 제한

📖 사용자 인증은 어떻게 작동할까?

💎 1단계 : 사용자가 로그인할 수 있도록 만든다.

사용자 자격 증명인 이메일과 비밀번호가 유효한지 확인하고 계정을 생성할 때 입력한 정보와 일치하는지 확인한다.

  1. 유저가 클라이언트(웹사이트)를 방문해 서버에 요청을 보낸다. 인증양식을 작성해 보낸 요청은 사용작를 생성하거나 기존 사용자롤 로그인한다.

  2. 양식을 제출하면 요청과 함께 인증 정보를 보내게 된다.

  3. 서버는 인증정보를 확인한다. 이메일/비밀번호이 유효한지, 이미 존재하는 사용자인지 혹은 사용자가 로그인을 시도했을 때 데이터베이스에서 기존 사용자들을 살펴보고 사용자가 제출한 이메일-비밀번호 조합이 유효한지 확인

  4. 사용자가 올바른 자격증명을 제공하면 인증 세션을 생성한다. (서버의 전용 테이블에 저장된 데이터베이스 항목을 말한다.)

  5. 테이블에 세션 ID를 갖게 된다. 그 ID를 일반적으로 쿠키 형식으로 사용자에게 보낸다.

  6. 즉, 로그인 요청에 대한 응답은 세션 쿠키를 포함하며 자격증명이 유효한경우 브라우저는 자동으로 세션 쿠키를 저장한다.

💎 2단계 : Authorized Access

사용자가 보낸 요청인 인증된 것이라는 것을 기억하고 보호된 리소스에 접근을 허용한다. → 보호하고있는 특정 리소스를 인증된 사용자만 접근할 수 있게 한다.

  1. 로그인해서 쿠키를 가진 사용자가 보호되어야 할 경로에 대해 요청을 보낸다.

  2. 브라우저가 웹사이트에 딸린 쿠키를 자동으로 요청에 추가한다. 따라서 세션 쿠키도 자동으로 요청에 첨부되며 서버로 전송된다.

  3. 서버에서는 세션 쿠키를 살펴보고 유효성을 검증한다. → 세션 ID가 유효하다면 데이터베이스에도 있을 것이다. 세션ID는 단순 숫자가 아니라 복잡한 문자열이여야 한다.

  4. 유효한 활성 세션 쿠키를 가진 요청을 받았다고 확인했다면 다시 요청받은 리소스(ex. 사용자가 보고자했던 페이지 내용)를 보낸다.

  5. 쿠키가 정상적이지 않거나 이미 만료된 쿠키 등 이라면 에러를 돌려보닌다.


📖 타사 인증 패키지 선택하기(Lucia)

🔗 Lucia Auth

더 직관적이고 사용하기 쉬운 서드파티 패키지이다.

npm install lucia @lucia-auth/adapter-sqlite

💎 새 Lucia 인증 인스턴스 만들기

Lucia 생성자 함수는 몇가지 인수가 필요하다. 예를 들어 adapter라는 인수는 세션을 어디에, 어떻게 저장할지 Lucia에게 알려준다.

// /lib/auth.js

import { Lucia } from "lucia";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import db from "./db";

const adapter = new BetterSqlite3Adapter(db, {
  // 구성 객체 : 이 어댑터(루시아)에게 사용자의 데이터베이스 테이블이 무엇인지 알려줌
  user: "users", // 테이블 이름
  session: "sessions", // 세션 정보는 어디에 저장하고 있는지 혹은 어디에 저장해야하는지 -> sessions 테이블
});
const lucia = new Lucia(adapter, {
  sessionCookie: {
    expires: false, // NextJS에서 Lucia를 사용할때는 expires를 false로 설정 -> 공식문서 참고
    attributes: {
      secure: process.env.NODE_ENV === "production", // 프로덕션용으로 실행하는 경우 HTTPS에만 쿠키를 설정
    },
  }, // 세션ID가 포함된 쿠키를 자동으로 생성
});

💎 세션 및 세션 쿠키 구성하기

// /lib/auth.js

import { cookies } from "next/headers";

// ...

export async function createAuthSession(userId) {
  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
}
  • createAuthSession 함수의 목적 : 특정 사용자에 대해 세션 데이터베이스에 새 세션을 생성 및 저장 → 발신 요청에 첨부된 쿠키를 설정
  • cookies() : Next.js 코드 내 어디에서나 호출할 수 있는 함수로 발신 응답에 포함된 쿠키에 접근 가능케 함. → 예를 들어, 응답에 새 쿠키를 설정하는데 사용가능하다.

💎 인증 세션 설정

// /action/auth.js

"use server";

import { createAuthSession } from "@/lib/auth";

//...

export async function signup(prevState, formData) {
  const email = formData.get("email");
  const password = formData.get("password");

  // validation ...

  const hashedPassword = hashUserPassword(password);
  try {
    const id = createUser(email, hashedPassword);
    await createAuthSession(id);
    redirect("/training");
  } catch (error) {
    if (error.code === "SQLITE_CONSTRAINT_UNIQUE") {
      return { errors: { email: "이미 존재하는 이메일 계정입니다." } };
    }

    throw error;
  }
}

해당 쿠키에 저장된 값은 세션 ID가 될 것이다.

Network 탭에서 training과 관련된 리소스를 확인해 보면 쿠키가 추가된 것을 볼 수 있다.


💎 활성 인증 세션 확인

// /lib/auth.js

// ...

export async function verifyAuth() {
  const sessionCookie = cookies().get(lucia.sessionCookieName); // 들어오는 요청으로부터 세션 쿠키 이름과 함께 쿠키를 가져온다.

  if (!sessionCookie) {
    return {
      user: null,
      session: null,
    };
  }

  const sessionId = sessionCookie.value;

  if (!sessionId) {
    return {
      user: null,
      session: null,
    };
  }

  const result = await lucia.validateSession(sessionId); // 데이터베이스를 조회하여 DB에 해당 ID를 가진 세션을 찾아 세션이 여전히 유효한지 확인

  // NextJS는 페이지 렌더링 시 쿠키를 설정하는 것을 선호하지 않지만.. Lucia 사용시 이렇게 한다.
  try {
    if (result.session && result.session.fresh) {
      // 유효한 새션이면 새로고침을 해도 세션이 활성 상태를 유지하고 갑자기 사용자의 접근이 차단되지 않도록 함
      const sessionCookie = lucia.createSessionCookie(result.session.id);
      cookies().set(
        sessionCookie.name,
        sessionCookie.value,
        sessionCookie.attributes
      ); // 기존 활성 세션을 위한 쿠키를 재생성 -> 쿠키를 연장한다.
    }

    if (!result.session) {
      // 기존 쿠키 삭제 - 유효하지 않은 세션 데이터를 포함하고 있었기 때문.
      const sessionCookie = lucia.createBlankSessionCookie();
      cookies().set(
        sessionCookie.name,
        sessionCookie.value,
        sessionCookie.attributes
      );
    }
  } catch {}

  return result;
}

verifyAuth 함수의 목적 : 들어오는 요청이 인증된 사용자의 것인지 확인


💎 인증되지 않은 액세스로부터 라우트 보호하기

// /app/training/page.js
import { verifyAuth } from "@/lib/auth";
import { getTrainings } from "@/lib/training";
import { redirect } from "next/navigation";

export default async function TrainingPage() {
  const result = await verifyAuth();

  // 로그인을 하지 않았다면 '/'로 redirect
  if (!result.user) {
    return redirect("/");
  }

  const trainingSessions = getTrainings();

  return (
    <main>
      <h1>Find your favorite activity</h1>
      <ul id="training-sessions">
        {trainingSessions.map((training) => (
          <li key={training.id}>
            <img src={`/trainings/${training.image}`} alt={training.title} />
            <div>
              <h2>{training.title}</h2>
              <p>{training.description}</p>
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

📌 사용자 인증 - 로그인

📖 쿼리 매개변수(검색 매개변수)로 인증 모드 전환하기

회원가입 페이지에서 로그인 버튼을 누르면 같은 랄우트에서 로그인 모드로 전환하도록 하고싶다.

💎 /app/page.js

import AuthForm from "@/components/auth-form";

export default async function Home({ searchParams }) {
  const formMode = searchParams.mode;
  return <AuthForm mode={formMode} />;
}

💎 /components/auth-form.js

"use client";
import Link from "next/link";
import { useFormState } from "react-dom";
import { signup } from "@/action/auth";

export default function AuthForm({ mode = "signup" }) {
  // 'login', 'signup'
  const [formState, formAction] = useFormState(signup, {});
  return (
    <form id="auth-form" action={formAction}>
      <div>
        <img src="/images/auth-icon.jpg" alt="A lock icon" />
      </div>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" name="email" id="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" name="password" id="password" />
      </p>
      {formState.errors && (
        <ul id="form-errors">
          {Object.keys(formState.errors).map((error) => (
            <li key={error}>{formState.errors[error]}</li>
          ))}
        </ul>
      )}
      <p>
        <button type="submit">
          {mode === "login" ? "로그인" : "회원가입"}
        </button>
      </p>
      <p>
        {mode === "login" && (
          <Link href="/?mode=signup">Create an Account.</Link>
        )}
        {mode === "signup" && (
          <Link href="/?mode=login">Login with existing account.</Link>
        )}
      </p>
    </form>
  );
}


📖 사용자 로그인 추가(서버 액션을 통해)

💎 /lib/user.js

export function getUserByEmail(email) {
  // 동일한 이메일을 갖는 유저.. -> login
  return db.prepare("SELECT * FROM users WHERE email = ?").get(email);
}

💎 /action/auth.js

export async function login(prevState, formData) {
  const email = formData.get("email");
  const password = formData.get("password");

  const existingUser = getUserByEmail(email);

  if (!existingUser) {
    return {
      errors: { email: "해당 이메일을 가진 사용자를 찾을 수 없습니다." },
    };
  }
  const isvalidPassword = verifyPassword(existingUser.password, password);

  if (!isvalidPassword) {
    return {
      errors: { password: "비밀번호가 옳지 않습니다. 다시 시도해주세요." },
    };
  }

  await createAuthSession(existingUser.id);
  redirect("/training");
}

📖 쿼리 매개 변수를 통해 다양한 서버 액션 트리거하기

💎 /action/auth.js

export async function auth(mode, prevState, formData) {
  if (mode === "login") {
    return login(prevState, formData);
  }
  return signup(prevState, formData);
}

이 함수를 헬퍼 함수로 사용하여 로그인/회원가입에 맞는 동작을 실행할 수 있도록 할 것이다.

💎 /components/auth-form.js

"use client";
import Link from "next/link";
import { useFormState } from "react-dom";
import { auth } from "@/action/auth";

export default function AuthForm({ mode = "signup" }) {
  // 'login', 'signup'

  // useFormState에서 위에서 작성한 헬퍼함수 auth를 사용. 이때, bind를 이용하여 mode를 같이 전달한다.
  const [formState, formAction] = useFormState(auth.bind(null, mode), {});
  return (
    <form id="auth-form" action={formAction}>
      <div>
        <img src="/images/auth-icon.jpg" alt="A lock icon" />
      </div>
      <p>
        <label htmlFor="email">Email</label>
        <input type="email" name="email" id="email" />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" name="password" id="password" />
      </p>
      {formState.errors && (
        <ul id="form-errors">
          {Object.keys(formState.errors).map((error) => (
            <li key={error}>{formState.errors[error]}</li>
          ))}
        </ul>
      )}
      <p>
        <button type="submit">
          {mode === "login" ? "로그인" : "회원가입"}
        </button>
      </p>
      <p>
        {mode === "login" && (
          <Link href="/?mode=signup">Create an Account.</Link>
        )}
        {mode === "signup" && (
          <Link href="/?mode=login">Login with existing account.</Link>
        )}
      </p>
    </form>
  );
}


📖 인증 전용 레이아웃 추가하기 - 로그인 시 헤더

사용자 로그인을 요구하는 모든 페이지를 위한 특정 레이아웃을 통해 헤더를 표현하고자 한다. → 라우트 그룹 이용

  1. /app/(auth) 생성
  2. /app/(auth)에 training 폴더 이동
  3. /app/(auth)/layout.js 생성 → 해당 라우트 그룹에 속한 모든 페이지에 적용될 레이아웃을 설정

💎 /app/(auth)/layout.js

import "../globals.css";

export const metadata = {
  title: "Next Auth",
  description: "Next.js Authentication",
};

export default function AuthRootLayout({ children }) {
  return (
    <>
      <header id="auth-header">
        <p>Welcome Back!</p>
        <form>
          <button>로그아웃</button>
        </form>
      </header>
      {children}
    </>
  );
}


📌 사용자 인증 - 로그아웃

📖 사용자 로그아웃 추가

💎 /lib/auth.js

로그아웃 시, 세션과 세션 쿠키를 종료시키기 위함이다.

export async function destroySession() {
  const { session } = await verifyAuth();
  if (!session) {
    return { error: "인증되지 않음." };
  }

  await lucia.invalidateSession(session.id); // 해당 세션의 데이터베이스 테이블에 접근하여 세션을 삭제하게 되면서 사용자가 로그인했었다는 사실을 삭제

  // 세션쿠키 삭제
  const sessionCookie = lucia.createBlankSessionCookie();
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
}

💎 /action/auth.js

export async function logout() {
  await destroySession();
  redirect("/");
}

💎 /app/(auth)/layout.js

import { logout } from "@/action/auth";
import "../globals.css";

export const metadata = {
  title: "Next Auth",
  description: "Next.js Authentication",
};

export default function AuthRootLayout({ children }) {
  return (
    <>
      <header id="auth-header">
        <p>Welcome Back!</p>
        <form action={logout}>
          <button>로그아웃</button>
        </form>
      </header>
      {children}
    </>
  );
}

로그아웃 버튼을 누른 뒤, 홈화면으로 성공적으로 리디렉션이 되었다. 로그아웃 상태에서 '/training'으로 접근을 했더니 해당 라우트가 보호가 되면서 다시 홈화면으로 리디렉션되는 것을 확인할 수 있다.

0개의 댓글

관련 채용 정보