5dea44046d
- 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)
292 lines
9.3 KiB
TypeScript
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;
|
|
};
|
|
}
|
|
}
|