포스트

[이제와서 시작하는 Next.js 마스터하기 #9] 인증과 권한 관리 완벽 가이드

[이제와서 시작하는 Next.js 마스터하기 #9] 인증과 권한 관리 완벽 가이드

“로그인 기능 10분 만에 구현?” - NextAuth.js v5로 안전하고 쉽게!

🎯 이 글에서 배울 내용

  • NextAuth.js v5 설정
  • 이메일/비밀번호 인증
  • OAuth (Google, GitHub)
  • 역할 기반 접근 제어 (RBAC)
  • 세션 관리

예상 소요 시간: 50분


🔐 NextAuth.js v5 설정

1. 설치

1
2
npm install next-auth@beta
npm install @auth/prisma-adapter

2. 환경 변수

# .env.local
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl

# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

# GitHub OAuth
GITHUB_ID=your-github-id
GITHUB_SECRET=your-github-secret

3. Auth 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });

        if (!user || !user.hashedPassword) {
          return null;
        }

        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role;
      }
      return session;
    },
  },
  pages: {
    signIn: '/login',
    error: '/error',
  },
});

4. API Route

1
2
3
4
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

🔑 로그인 페이지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// app/login/page.js
'use client';

import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const router = useRouter();
  const [error, setError] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    const result = await signIn('credentials', {
      email: formData.get('email'),
      password: formData.get('password'),
      redirect: false,
    });

    if (result?.error) {
      setError('이메일 또는 비밀번호가 잘못되었습니다');
    } else {
      router.push('/dashboard');
      router.refresh();
    }
  }

  return (
    <div className="max-w-md mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">로그인</h1>

      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block mb-2">이메일</label>
          <input
            name="email"
            type="email"
            required
            className="w-full px-4 py-2 border rounded"
          />
        </div>

        <div>
          <label className="block mb-2">비밀번호</label>
          <input
            name="password"
            type="password"
            required
            className="w-full px-4 py-2 border rounded"
          />
        </div>

        {error && (
          <p className="text-red-600">{error}</p>
        )}

        <button
          type="submit"
          className="w-full px-4 py-2 bg-blue-600 text-white rounded"
        >
          로그인
        </button>
      </form>

      <div className="mt-8 space-y-2">
        <button
          onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
          className="w-full px-4 py-2 border rounded flex items-center justify-center gap-2"
        >
          <img src="/google.svg" className="w-5 h-5" />
          Google로 로그인
        </button>

        <button
          onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
          className="w-full px-4 py-2 border rounded flex items-center justify-center gap-2"
        >
          <img src="/github.svg" className="w-5 h-5" />
          GitHub으로 로그인
        </button>
      </div>
    </div>
  );
}

👤 회원가입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// app/actions/auth.ts
'use server';

import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';

export async function signUp(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  const name = formData.get('name') as string;

  // 이미 존재하는 사용자 확인
  const existing = await prisma.user.findUnique({
    where: { email },
  });

  if (existing) {
    return { error: '이미 존재하는 이메일입니다' };
  }

  // 비밀번호 해싱
  const hashedPassword = await bcrypt.hash(password, 10);

  // 사용자 생성
  await prisma.user.create({
    data: {
      email,
      name,
      hashedPassword,
      role: 'USER',
    },
  });

  return { success: true };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// app/signup/page.js
'use client';

import { signUp } from '@/app/actions/auth';
import { useRouter } from 'next/navigation';

export default function SignUpPage() {
  const router = useRouter();

  async function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    const result = await signUp(formData);

    if (result.success) {
      router.push('/login?registered=true');
    } else {
      alert(result.error);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto p-8 space-y-4">
      <h1 className="text-3xl font-bold mb-8">회원가입</h1>

      <input name="name" placeholder="이름" required className="w-full px-4 py-2 border rounded" />
      <input name="email" type="email" placeholder="이메일" required className="w-full px-4 py-2 border rounded" />
      <input name="password" type="password" placeholder="비밀번호" required className="w-full px-4 py-2 border rounded" />

      <button type="submit" className="w-full px-4 py-2 bg-blue-600 text-white rounded">
        가입하기
      </button>
    </form>
  );
}

🛡️ 보호된 페이지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/dashboard/page.js
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold mb-4">대시보드</h1>
      <p>환영합니다, {session.user.name}님!</p>
      <p>이메일: {session.user.email}</p>
      <p>역할: {session.user.role}</p>
    </div>
  );
}

👑 역할 기반 접근 제어 (RBAC)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/admin/page.js
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function AdminPage() {
  const session = await auth();

  if (!session || session.user.role !== 'ADMIN') {
    redirect('/unauthorized');
  }

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold mb-4">관리자 페이지</h1>
      <p>관리자만 접근 가능합니다</p>
    </div>
  );
}

🔄 로그아웃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// components/LogoutButton.js
'use client';

import { signOut } from 'next-auth/react';

export default function LogoutButton() {
  return (
    <button
      onClick={() => signOut({ callbackUrl: '/' })}
      className="px-4 py-2 bg-red-600 text-white rounded"
    >
      로그아웃
    </button>
  );
}

🎯 오늘 배운 내용 정리

  1. NextAuth.js v5
    • 설정 및 Provider
    • JWT vs Session
  2. 인증 방법
    • Credentials (이메일/비밀번호)
    • OAuth (Google, GitHub)
  3. 권한 관리
    • 역할 기반 접근 제어
    • 보호된 페이지

📚 시리즈 네비게이션


“안전한 인증은 필수입니다!” 🔐

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.