Files
startover/apps/web/src/lib/auth.ts
T
Johngreen 5dea44046d 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)
2026-03-08 12:59:21 +09:00

292 lines
9.3 KiB
TypeScript

import NextAuth from 'next-auth';
import type { NextAuthConfig } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Kakao from 'next-auth/providers/kakao';
import { createPrismaClient } from '@relink/database';
import argon2 from 'argon2';
import type { UserRole } from '@prisma/client';
import { authConfig } from './auth.config';
const prisma = createPrismaClient();
// BigInt PK 호환: Auth.js adapter는 String ID를 기대하므로 변환
const customAdapter = {
createUser: async (data: Record<string, unknown>) => {
const emailNormalized = (data.email as string).toLowerCase().trim();
const user = await prisma.user.create({
data: {
email: data.email as string,
emailNormalized,
name: (data.name as string) || '',
primaryRole: 'CLOSING_OWNER' as UserRole,
status: 'ACTIVE',
emailVerifiedAt: data.emailVerified ? new Date() : null,
image: (data.image as string) || null,
},
});
return {
id: user.id.toString(),
email: user.email,
emailVerified: user.emailVerifiedAt,
name: user.name,
image: user.image,
};
},
getUser: async (id: string) => {
const user = await prisma.user.findUnique({ where: { id: BigInt(id) } });
if (!user) return null;
return {
id: user.id.toString(),
email: user.email,
emailVerified: user.emailVerifiedAt,
name: user.name,
image: user.image,
};
},
getUserByEmail: async (email: string) => {
const user = await prisma.user.findFirst({
where: { emailNormalized: email.toLowerCase().trim() },
});
if (!user) return null;
return {
id: user.id.toString(),
email: user.email,
emailVerified: user.emailVerifiedAt,
name: user.name,
image: user.image,
};
},
getUserByAccount: async (providerAccountId: { provider: string; providerAccountId: string }) => {
const account = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: providerAccountId.provider,
providerAccountId: providerAccountId.providerAccountId,
},
},
include: { user: true },
});
if (!account) return null;
const user = account.user;
return {
id: user.id.toString(),
email: user.email,
emailVerified: user.emailVerifiedAt,
name: user.name,
image: user.image,
};
},
linkAccount: async (data: Record<string, unknown>) => {
const account = await prisma.account.create({
data: {
userId: BigInt(data.userId as string),
type: data.type as string,
provider: data.provider as string,
providerAccountId: data.providerAccountId as string,
refresh_token: (data.refresh_token as string) ?? null,
access_token: (data.access_token as string) ?? null,
expires_at: (data.expires_at as number) ?? null,
token_type: (data.token_type as string) ?? null,
scope: (data.scope as string) ?? null,
id_token: (data.id_token as string) ?? null,
session_state: (data.session_state as string) ?? null,
},
});
return {
...data,
id: account.id.toString(),
userId: account.userId.toString(),
};
},
updateUser: async (data: Record<string, unknown>) => {
const userId = BigInt(data.id as string);
const user = await prisma.user.update({
where: { id: userId },
data: {
name: data.name as string | undefined,
email: data.email as string | undefined,
emailVerifiedAt: data.emailVerified ? new Date(data.emailVerified as string) : undefined,
image: data.image as string | undefined,
},
});
return {
id: user.id.toString(),
email: user.email,
emailVerified: user.emailVerifiedAt,
name: user.name,
image: user.image,
};
},
createVerificationToken: async (data: { identifier: string; token: string; expires: Date }) => {
const token = await prisma.verificationToken.create({
data: {
identifier: data.identifier,
token: data.token,
expires: data.expires,
},
});
return token;
},
useVerificationToken: async (params: { identifier: string; token: string }) => {
try {
const token = await prisma.verificationToken.delete({
where: {
identifier_token: {
identifier: params.identifier,
token: params.token,
},
},
});
return token;
} catch {
return null;
}
},
};
const fullConfig: NextAuthConfig = {
...authConfig,
adapter: customAdapter as unknown as NextAuthConfig['adapter'],
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: '이메일', type: 'email' },
password: { label: '비밀번호', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const email = (credentials.email as string).toLowerCase().trim();
const user = await prisma.user.findFirst({
where: { emailNormalized: email },
});
if (!user || !user.passwordHash) return null;
if (user.status === 'SUSPENDED' || user.status === 'DEACTIVATED') return null;
const isValid = await argon2.verify(user.passwordHash, credentials.password as string);
if (!isValid) return null;
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return {
id: user.id.toString(),
email: user.email,
name: user.name,
image: user.image,
};
},
}),
Kakao({
clientId: process.env.AUTH_KAKAO_CLIENT_ID,
clientSecret: process.env.AUTH_KAKAO_CLIENT_SECRET,
checks: ['state'],
// 비즈 앱 전환 전에는 이메일이 제공되지 않으므로 카카오 ID 기반 placeholder 사용
profile(profile) {
const kakaoAccount = profile.kakao_account as Record<string, unknown> | undefined;
const kakaoProfile = kakaoAccount?.profile as Record<string, string> | undefined;
return {
id: String(profile.id),
name: kakaoProfile?.nickname ?? null,
email: (kakaoAccount?.email as string) || `kakao_${profile.id}@placeholder.relink`,
image: kakaoProfile?.profile_image_url ?? null,
};
},
}),
],
callbacks: {
...authConfig.callbacks,
async jwt({ token, user }) {
if (user?.id) {
try {
const dbUser = await prisma.user.findUnique({
where: { id: BigInt(user.id) },
});
if (dbUser) {
token.publicId = dbUser.publicId;
token.primaryRole = dbUser.primaryRole;
token.userStatus = dbUser.status;
token.dbId = dbUser.id.toString();
}
} catch {
// BigInt 변환 실패 시 email fallback
const dbUser = await prisma.user.findFirst({
where: { emailNormalized: (user.email ?? '').toLowerCase().trim() },
});
if (dbUser) {
token.publicId = dbUser.publicId;
token.primaryRole = dbUser.primaryRole;
token.userStatus = dbUser.status;
token.dbId = dbUser.id.toString();
}
}
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.publicId = token.publicId as string;
session.user.primaryRole = token.primaryRole as string;
session.user.userStatus = token.userStatus as string;
session.user.dbId = token.dbId as string;
}
return session;
},
async signIn({ user, account }) {
if (account?.provider === 'kakao') {
// placeholder 이메일(@placeholder.relink)은 비즈 앱 전환 전 임시 처리
const email = user.email;
if (!email) return false;
const isPlaceholder = email.endsWith('@placeholder.relink');
const existing = !isPlaceholder
? await prisma.user.findFirst({
where: { emailNormalized: email.toLowerCase().trim() },
})
: null;
if (existing) {
if (existing.status === 'SUSPENDED' || existing.status === 'DEACTIVATED') {
return false;
}
// emailVerifiedAt + lastLoginAt 한 번에 업데이트
await prisma.user.update({
where: { id: existing.id },
data: {
emailVerifiedAt: existing.emailVerifiedAt ?? new Date(),
status: existing.status === 'PENDING_VERIFICATION' ? 'ACTIVE' : existing.status,
lastLoginAt: new Date(),
},
});
}
}
return true;
},
},
};
const nextAuth = NextAuth(fullConfig);
export const handlers: typeof nextAuth.handlers = nextAuth.handlers;
export const auth: typeof nextAuth.auth = nextAuth.auth;
export const signIn: typeof nextAuth.signIn = nextAuth.signIn;
export const signOut: typeof nextAuth.signOut = nextAuth.signOut;
declare module 'next-auth' {
interface Session {
user: {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
publicId: string;
primaryRole: string;
userStatus: string;
dbId: string;
};
}
}