[이제와서 시작하는 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>
);
}
🎯 오늘 배운 내용 정리
- NextAuth.js v5
- 설정 및 Provider
- JWT vs Session
- 인증 방법
- Credentials (이메일/비밀번호)
- OAuth (Google, GitHub)
- 권한 관리
- 역할 기반 접근 제어
- 보호된 페이지
📚 시리즈 네비게이션
“안전한 인증은 필수입니다!” 🔐
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.