feat: Auth.js v5 인증 시스템 구현 - 카카오 소셜 + 이메일/비번 로그인

- Auth.js v5 (next-auth beta.30) + 커스텀 Prisma 어댑터 (BigInt PK 호환)
- 카카오 OAuth2 소셜 로그인 (PKCE 비활성화, placeholder 이메일 처리)
- 이메일/비밀번호 자격증명 로그인 (argon2 해시)
- JWT 세션 전략 + 커스텀 클레임 (publicId, primaryRole, userStatus)
- 역할 기반 미들웨어 (/admin 운영자 전용, /auth 비인증 전용)
- Prisma 스키마: Account, VerificationToken, InviteToken 모델 추가
- ConsentType enum 7개로 업데이트
- 로그인/회원가입/프로필완성/403 페이지 구현
- 운영자 초대 시스템 (admin/settings/invite)
This commit is contained in:
Johngreen
2026-03-08 12:59:21 +09:00
parent 636eaaca23
commit 5dea44046d
22 changed files with 1912 additions and 19 deletions
@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
@@ -0,0 +1,91 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createPrismaClient } from '@relink/database';
import type { ConsentType, ProfileType, UserRole } from '@prisma/client';
const prisma = createPrismaClient();
const ROLE_TO_PROFILE_TYPE: Record<string, ProfileType> = {
CLOSING_OWNER: 'CLOSING_OWNER',
FOUNDER: 'FOUNDER',
VENDOR_MANAGER: 'VENDOR_MANAGER',
};
const VALID_ROLES = ['CLOSING_OWNER', 'FOUNDER', 'VENDOR_MANAGER'];
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.dbId) {
return NextResponse.json({ error: '인증이 필요합니다' }, { status: 401 });
}
const body = await request.json();
const { role, termsOfService, privacyPolicy, marketingConsent, kakaoNotification } = body;
if (!role || !VALID_ROLES.includes(role)) {
return NextResponse.json({ error: '올바른 역할을 선택해주세요' }, { status: 400 });
}
if (!termsOfService || !privacyPolicy) {
return NextResponse.json({ error: '필수 약관에 동의해주세요' }, { status: 400 });
}
const userId = BigInt(session.user.dbId);
const existingProfile = await prisma.userProfile.findFirst({
where: { userId },
});
if (existingProfile) {
return NextResponse.json({ success: true });
}
let policyVersion = await prisma.policyVersion.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
if (!policyVersion) {
policyVersion = await prisma.policyVersion.create({
data: {
policyType: 'TERMS_OF_SERVICE',
versionCode: 'v1.0.0',
contentHash: 'initial',
effectiveFrom: new Date(),
isActive: true,
},
});
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: { primaryRole: role as UserRole },
});
await tx.userProfile.create({
data: {
userId,
profileType: ROLE_TO_PROFILE_TYPE[role]!,
},
});
const consentItems: { type: ConsentType; granted: boolean }[] = [
{ type: 'TERMS_OF_SERVICE', granted: true },
{ type: 'PRIVACY_POLICY_REQUIRED', granted: true },
{ type: 'PRIVACY_POLICY_MARKETING', granted: marketingConsent ?? false },
{ type: 'NOTIFICATION_KAKAO', granted: kakaoNotification ?? false },
];
await tx.userConsent.createMany({
data: consentItems.map((item) => ({
userId,
consentType: item.type,
policyVersionId: policyVersion!.id,
isGranted: item.granted,
grantedAt: new Date(),
})),
});
});
return NextResponse.json({ success: true });
}
@@ -0,0 +1,79 @@
import { NextResponse } from 'next/server';
import argon2 from 'argon2';
import { createPrismaClient } from '@relink/database';
const prisma = createPrismaClient();
export async function POST(request: Request) {
const { token, password, name } = await request.json();
if (!token || !password || !name) {
return NextResponse.json({ error: '필수 정보가 누락되었습니다' }, { status: 400 });
}
if (password.length < 8 || !/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
return NextResponse.json(
{ error: '비밀번호는 8자 이상, 영문+숫자를 포함해야 합니다' },
{ status: 400 },
);
}
const inviteToken = await prisma.inviteToken.findUnique({ where: { token } });
if (!inviteToken) {
return NextResponse.json({ error: '유효하지 않은 초대입니다' }, { status: 400 });
}
if (inviteToken.usedAt) {
return NextResponse.json({ error: '이미 사용된 초대입니다' }, { status: 400 });
}
if (inviteToken.expires < new Date()) {
return NextResponse.json({ error: '초대가 만료되었습니다' }, { status: 400 });
}
const emailNormalized = inviteToken.email.toLowerCase().trim();
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
if (existing) {
return NextResponse.json({ error: '이미 가입된 이메일입니다' }, { status: 400 });
}
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: inviteToken.email,
emailNormalized,
name,
passwordHash,
primaryRole: inviteToken.role,
status: 'ACTIVE',
emailVerifiedAt: new Date(),
},
});
await tx.userProfile.create({
data: {
userId: user.id,
profileType: 'OPERATOR',
},
});
await tx.inviteToken.update({
where: { id: inviteToken.id },
data: { usedAt: new Date() },
});
await tx.auditLog.create({
data: {
actorUserId: user.id,
resourceType: 'User',
resourceId: user.id.toString(),
actionType: 'OPERATOR_INVITED_ACCEPTED',
afterJson: { role: inviteToken.role, invitedBy: inviteToken.createdBy.toString() },
},
});
});
return NextResponse.json({ success: true });
}
@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createPrismaClient } from '@relink/database';
import type { UserRole } from '@prisma/client';
const prisma = createPrismaClient();
const VALID_OPERATOR_ROLES: UserRole[] = [
'OPS_MANAGER',
'SUBSIDY_OPERATOR',
'TRUST_OPERATOR',
'FINANCE_OPERATOR',
];
export async function POST(request: Request) {
const session = await auth();
if (!session?.user || session.user.primaryRole !== 'SUPER_ADMIN') {
return NextResponse.json({ error: '권한이 없습니다' }, { status: 403 });
}
const { email, role } = await request.json();
if (!email || !role) {
return NextResponse.json({ error: '이메일과 역할을 입력해주세요' }, { status: 400 });
}
if (!VALID_OPERATOR_ROLES.includes(role as UserRole)) {
return NextResponse.json({ error: '유효하지 않은 역할입니다' }, { status: 400 });
}
const emailNormalized = (email as string).toLowerCase().trim();
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
if (existing) {
return NextResponse.json({ error: '이미 가입된 이메일입니다' }, { status: 400 });
}
const existingInvite = await prisma.inviteToken.findFirst({
where: { email: emailNormalized, usedAt: null, expires: { gt: new Date() } },
});
if (existingInvite) {
return NextResponse.json({ error: '이미 발송된 초대가 있습니다' }, { status: 400 });
}
const inviteToken = await prisma.inviteToken.create({
data: {
email: emailNormalized,
role: role as UserRole,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7일
createdBy: BigInt(session.user.dbId),
},
});
// MVP: 콘솔에 초대 링크 출력
const inviteUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/auth/invite/${inviteToken.token}`;
console.log(`[운영자 초대] ${email} (${role}) → ${inviteUrl}`);
await prisma.auditLog.create({
data: {
actorUserId: BigInt(session.user.dbId),
resourceType: 'InviteToken',
resourceId: inviteToken.id.toString(),
actionType: 'OPERATOR_INVITED',
afterJson: { email: emailNormalized, role },
},
});
return NextResponse.json({ success: true });
}