"use server"; import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { createClient } from "@/utils/supabase/server"; import { AUTH_ERROR_MESSAGES, type AuthFormData, type AuthError, RECOVERY_COOKIE_NAME, } from "./constants"; import { getAuthErrorMessage } from "./errors"; // ======================================== // 헬퍼 함수 // ======================================== /** * [FormData 추출 헬퍼] * * FormData에서 이메일과 비밀번호를 안전하게 추출합니다. * - 이메일은 trim()으로 공백 제거 * - null/undefined 방지를 위해 기본값 "" 사용 * * @param formData - HTML form에서 전달된 FormData 객체 * @returns AuthFormData - 추출된 이메일과 비밀번호 */ function extractAuthData(formData: FormData): AuthFormData { const email = (formData.get("email") as string)?.trim() || ""; const password = (formData.get("password") as string) || ""; return { email, password }; } /** * [비밀번호 강도 검증 함수] * * 안전한 비밀번호인지 검사합니다. * * 비밀번호 정책: * - 최소 8자 이상 * - 대문자 1개 이상 포함 * - 소문자 1개 이상 포함 * - 숫자 1개 이상 포함 * - 특수문자 1개 이상 포함 (!@#$%^&*(),.?":{}|<> 등) * * @param password - 검증할 비밀번호 * @returns AuthError | null - 에러가 있으면 에러 객체, 없으면 null */ function validatePassword(password: string): AuthError | null { // 1. 최소 길이 체크 (8자 이상) if (password.length < 8) { return { message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT, type: "validation", }; } // 2. 대문자 포함 여부 if (!/[A-Z]/.test(password)) { return { message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK, type: "validation", }; } // 3. 소문자 포함 여부 if (!/[a-z]/.test(password)) { return { message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK, type: "validation", }; } // 4. 숫자 포함 여부 if (!/[0-9]/.test(password)) { return { message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK, type: "validation", }; } // 5. 특수문자 포함 여부 if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { return { message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK, type: "validation", }; } // 모든 검증 통과 return null; } /** * [입력값 검증 함수] * * 사용자가 입력한 이메일과 비밀번호의 유효성을 검사합니다. * * 검증 항목: * 1. 빈 값 체크 - 이메일 또는 비밀번호가 비어있는지 확인 * 2. 이메일 형식 - '@' 포함 여부로 간단한 형식 검증 * 3. 비밀번호 강도 - 8자 이상, 대소문자/숫자/특수문자 포함 확인 * * @param email - 사용자 이메일 * @param password - 사용자 비밀번호 * @returns AuthError | null - 에러가 있으면 에러 객체, 없으면 null */ function validateAuthInput(email: string, password: string): AuthError | null { // 1. 빈 값 체크 if (!email || !password) { return { message: AUTH_ERROR_MESSAGES.EMPTY_FIELDS, type: "validation", }; } // 2. 이메일 형식 체크 (간단한 @ 포함 여부 확인) if (!email.includes("@")) { return { message: AUTH_ERROR_MESSAGES.INVALID_EMAIL, type: "validation", }; } // 3. 비밀번호 강도 체크 const passwordValidation = validatePassword(password); if (passwordValidation) { return passwordValidation; } // 모든 검증 통과 return null; } /** * [에러 메시지 번역 헬퍼] * * Supabase의 영문 에러 메시지를 사용자 친화적인 한글로 변환합니다. * * @param error - Supabase에서 받은 에러 메시지 * @returns string - 한글로 번역된 에러 메시지 */ // 에러 메시지는 getAuthErrorMessage로 통일합니다. // ======================================== // Server Actions (서버 액션) // ======================================== // 흐름 요약 (어디서 호출되는지) // - /login: features/auth/components/login-form.tsx -> login, signInWithGoogle, signInWithKakao // - /signup: features/auth/components/signup-form.tsx -> signup // - /forgot-password: app/forgot-password/page.tsx -> requestPasswordReset // - /reset-password: features/auth/components/reset-password-form.tsx -> updatePassword // - 메인(/): app/page.tsx -> signout // - OAuth: signInWithGoogle/Kakao -> Supabase -> /auth/callback 라우트 /** * [로그인 액션] * * 사용자가 입력한 이메일/비밀번호로 로그인을 시도합니다. * * 처리 과정: * 1. FormData에서 이메일/비밀번호 추출 * 2. 입력값 유효성 검증 (빈 값, 이메일 형식, 비밀번호 길이) * 3. Supabase Auth를 통한 로그인 시도 * 4. 성공 시 메인 페이지로 리다이렉트 * 5. 실패 시 에러 메시지와 함께 로그인 페이지로 리다이렉트 * * @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함) */ export async function login(formData: FormData) { // 호출: features/auth/components/login-form.tsx (로그인 폼 action) // 1. FormData에서 이메일/비밀번호 추출 const { email, password } = extractAuthData(formData); // 2. 입력값 유효성 검증 const validationError = validateAuthInput(email, password); if (validationError) { return redirect( `/login?message=${encodeURIComponent(validationError.message)}`, ); } // 3. Supabase 클라이언트 생성 및 로그인 시도 const supabase = await createClient(); const { error } = await supabase.auth.signInWithPassword({ email, password, }); // 4. 로그인 실패 시 에러 처리 if (error) { const message = getAuthErrorMessage(error); return redirect(`/login?message=${encodeURIComponent(message)}`); } // 5. 로그인 성공 - 캐시 무효화 및 메인 페이지로 리다이렉트 // revalidatePath: Next.js 캐시를 무효화하여 최신 인증 상태 반영 revalidatePath("/", "layout"); redirect("/"); } /** * [회원가입 액션] * * 새로운 사용자를 등록합니다. * * 처리 과정: * 1. FormData에서 이메일/비밀번호 추출 * 2. 입력값 유효성 검증 * 3. Supabase Auth를 통한 회원가입 시도 * 4. 이메일 인증 리다이렉트 URL 설정 (확인 링크 클릭 시 돌아올 주소) * 5-1. 즉시 세션 생성 시: 메인 페이지로 리다이렉트 (바로 로그인됨) * 5-2. 이메일 인증 필요 시: 로그인 페이지로 리다이렉트 (안내 메시지 포함) * * @param formData - HTML form에서 전달된 FormData (이메일, 비밀번호 포함) */ export async function signup(formData: FormData) { // 호출: features/auth/components/signup-form.tsx (회원가입 폼 action) // 1. FormData에서 이메일/비밀번호 추출 const { email, password } = extractAuthData(formData); // 2. 입력값 유효성 검증 const validationError = validateAuthInput(email, password); if (validationError) { return redirect( `/signup?message=${encodeURIComponent(validationError.message)}`, ); } // 3. Supabase 클라이언트 생성 및 회원가입 시도 const supabase = await createClient(); const { data, error } = await supabase.auth.signUp({ email, password, options: { // 이메일 인증 완료 후 리다이렉트될 URL // 로컬 개발 환경: http://localhost:3001/auth/callback // 프로덕션: NEXT_PUBLIC_BASE_URL 환경 변수에 설정된 주소 emailRedirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback`, }, }); // 4. 회원가입 실패 시 에러 처리 if (error) { const message = getAuthErrorMessage(error); return redirect(`/signup?message=${encodeURIComponent(message)}`); } // 5. 회원가입 성공 - 세션 생성 여부에 따라 분기 처리 if (data.session) { // 5-1. 즉시 세션이 생성된 경우 (이메일 인증 불필요) // → 바로 메인 페이지로 이동 revalidatePath("/", "layout"); redirect("/"); } // 5-2. 이메일 인증이 필요한 경우 // → 로그인 페이지로 이동 + 안내 메시지 revalidatePath("/", "layout"); redirect( `/login?message=${encodeURIComponent("회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.")}`, ); } /** * [로그아웃 액션] * * 현재 세션을 종료하고 로그인 페이지로 이동합니다. * * 처리 과정: * 1. Supabase Auth 세션 종료 * 2. 캐시 무효화하여 인증 상태 갱신 * 3. 로그인 페이지로 리다이렉트 */ export async function signout() { // 호출: app/page.tsx (로그아웃 버튼의 formAction) const supabase = await createClient(); // 1. Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제) await supabase.auth.signOut(); // 2. Next.js 캐시 무효화 revalidatePath("/", "layout"); // 3. 로그인 페이지로 리다이렉트 redirect("/login"); } /** * [비밀번호 재설정 요청 액션] * * 사용자 이메일로 비밀번호 재설정 링크를 발송합니다. * * 보안 고려사항: * - 이메일 존재 여부와 관계없이 동일한 성공 메시지 표시 * - 이메일 열거 공격(Email Enumeration) 방지 * * 처리 과정: * 1. FormData에서 이메일 추출 * 2. 이메일 형식 검증 * 3. Supabase를 통한 재설정 링크 발송 * 4. 성공 메시지와 함께 로그인 페이지로 리다이렉트 * * @param formData - 이메일이 포함된 FormData */ export async function requestPasswordReset(formData: FormData) { // 호출: app/forgot-password/page.tsx (비밀번호 재설정 요청 폼) // 1. FormData에서 이메일 추출 const email = (formData.get("email") as string)?.trim() || ""; // 2. 이메일 검증 if (!email) { return redirect( `/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.EMPTY_EMAIL)}`, ); } if (!email.includes("@")) { return redirect( `/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.INVALID_EMAIL)}`, ); } // 3. Supabase를 통한 재설정 링크 발송 const supabase = await createClient(); const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/reset-password`, }); // 4. 에러 처리 if (error) { console.error("Password reset error:", error.message); const message = getAuthErrorMessage(error); return redirect(`/forgot-password?message=${encodeURIComponent(message)}`); } // 5. 성공 메시지 표시 redirect( `/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SENT)}`, ); } /** * [비밀번호 업데이트 액션] * * 비밀번호 재설정 링크를 통해 접근한 사용자의 비밀번호를 업데이트합니다. * * 처리 과정: * 1. FormData에서 새 비밀번호 추출 * 2. 비밀번호 길이 검증 * 3. Supabase를 통한 비밀번호 업데이트 * 4. 성공 시 로그인 페이지로 리다이렉트 * * @param formData - 새 비밀번호가 포함된 FormData */ export async function updatePassword(formData: FormData) { // 호출: features/auth/components/reset-password-form.tsx (재설정 폼) const password = (formData.get("password") as string) || ""; const passwordValidation = validatePassword(password); if (passwordValidation) { return { ok: false, message: passwordValidation.message }; } const supabase = await createClient(); const { error } = await supabase.auth.updateUser({ password: password, }); if (error) { const message = getAuthErrorMessage(error); return { ok: false, message }; } const cookieStore = await cookies(); cookieStore.delete(RECOVERY_COOKIE_NAME); await supabase.auth.signOut(); revalidatePath("/", "layout"); return { ok: true, message: AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS, }; } // ======================================== // 소셜 로그인 (OAuth) // ======================================== /** * [OAuth 로그인 헬퍼 함수] * * Google, Kakao 등의 소셜 로그인 제공자를 통해 인증을 수행합니다. * * PKCE (Proof Key for Code Exchange) 플로우: * 1. 이 함수가 signInWithOAuth를 호출하면 Supabase가 OAuth URL 반환 * 2. 사용자를 해당 OAuth 제공자(Google/Kakao) 로그인 페이지로 리다이렉트 * 3. 사용자가 로그인 및 권한 동의 * 4. OAuth 제공자가 /auth/callback?code=xxx로 리다이렉트 * 5. callback 라우트에서 code를 세션으로 교환 (exchangeCodeForSession) * 6. 메인 페이지로 최종 리다이렉트 * * 자동 회원가입: * - 첫 로그인 시 auth.users 테이블에 자동으로 사용자 생성 * - 이메일, provider, metadata(프로필 사진, 이름 등) 저장 * - 기존 사용자는 그냥 로그인 * * @param provider - OAuth 제공자 ('google' | 'kakao') */ async function signInWithProvider(provider: "google" | "kakao") { // 호출: 아래 signInWithGoogle / signInWithKakao에서 공통 사용 const supabase = await createClient(); // 1. OAuth 인증 시작 const { data, error } = await supabase.auth.signInWithOAuth({ provider, options: { // PKCE 플로우를 위한 콜백 URL // OAuth 제공자 인증 후 이 URL로 code와 함께 리다이렉트됨 redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback`, }, }); // 2. 에러 처리 if (error) { console.error(`[${provider} OAuth] 로그인 실패:`, error.message); const message = getAuthErrorMessage(error); return redirect(`/login?message=${encodeURIComponent(message)}`); } // 3. OAuth 제공자 로그인 페이지로 리다이렉트 // data.url은 Google/Kakao의 인증 페이지 URL if (data.url) { redirect(data.url); } // 4. URL이 없는 경우 (예상치 못한 상황) redirect( `/login?message=${encodeURIComponent("로그인 처리 중 오류가 발생했습니다.")}`, ); } /** * [Google 로그인 액션] * * Google 계정으로 로그인합니다. * "Google로 로그인" 버튼에서 호출됩니다. * * 처리 과정: * 1. signInWithOAuth 호출하여 Google OAuth URL 받기 * 2. Google 로그인 페이지로 리다이렉트 * 3. (사용자가 Google에서 로그인 및 권한 동의) * 4. /auth/callback으로 돌아와서 세션 생성 * 5. 메인 페이지로 이동 */ export async function signInWithGoogle() { // 호출: features/auth/components/login-form.tsx (Google 로그인 버튼) return signInWithProvider("google"); } /** * [Kakao 로그인 액션] * * Kakao 계정으로 로그인합니다. * "Kakao로 로그인" 버튼에서 호출됩니다. * * 처리 과정: * 1. signInWithOAuth 호출하여 Kakao OAuth URL 받기 * 2. Kakao 로그인 페이지로 리다이렉트 * 3. (사용자가 Kakao에서 로그인 및 권한 동의) * 4. /auth/callback으로 돌아와서 세션 생성 * 5. 메인 페이지로 이동 */ export async function signInWithKakao() { // 호출: features/auth/components/login-form.tsx (Kakao 로그인 버튼) return signInWithProvider("kakao"); }