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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user