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,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 });
}