Refactor: 인증 흐름 개선 및 에러 메시지 통합
.vscode/settings.json - chatgpt 확장 자동 실행 비활성화 설정 추가 app/auth/callback/route.ts - OAuth 콜백 처리 개선: 에러 메시지 매핑 함수 사용 및 리다이렉트 로직 정리 app/auth/confirm/route.ts - 이메일 인증(토큰 검증) 라우트 신규 구현: recovery 쿠키 설정 및 안전한 리다이렉트 처리 app/forgot-password/page.tsx - UI 텍스트/플레이스홀더 정리, 메시지 렌더링 조건부 처리 app/reset-password/page.tsx - 리셋 페이지 접근/세션 검증 로직 정리 및 UI 문구/아이콘 변경 features/auth/actions.ts - 에러 처리 통합(getAuthErrorMessage 사용), 서버 액션 주석 정리 - 비밀번호 재설정 플로우 반환값을 객체로 변경하고 recovery 쿠키 삭제/로그아웃 처리 features/auth/components/reset-password-form.tsx - 클라이언트 폼: updatePassword 결과 처리 개선, 라우터 리다이렉션 및 에러 메시지 표시 개선 features/auth/constants.ts - 인증 관련 상수(에러 메시지, 코드/상태 매핑, 라우트, 검증 규칙, 쿠키 이름) 신규 추가 features/auth/errors.ts - Supabase Auth 에러를 한글 메시지로 변환하는 유틸 추가 features/auth/schemas/auth-schema.ts - zod 스키마 메시지 문구 정리 및 포맷 통일 utils/supabase/middleware.ts - 세션/쿠키 갱신 및 라우트 보호 로직 개선, recovery 쿠키 기반 리다이렉트 처리 추가 utils/supabase/server.ts - 서버용 Supabase 클라이언트 초기화 함수 추가 (쿠키 읽기/쓰기 처리)
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
"use server";
|
||||
"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";
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 함수
|
||||
@@ -139,31 +142,18 @@ function validateAuthInput(email: string, password: string): AuthError | null {
|
||||
* @param error - Supabase에서 받은 에러 메시지
|
||||
* @returns string - 한글로 번역된 에러 메시지
|
||||
*/
|
||||
function getErrorMessage(error: string): string {
|
||||
// Supabase 에러 메시지 패턴 매칭
|
||||
if (error.includes("Invalid login credentials")) {
|
||||
return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS;
|
||||
}
|
||||
if (error.includes("User already registered")) {
|
||||
return AUTH_ERROR_MESSAGES.USER_EXISTS;
|
||||
}
|
||||
if (error.includes("Password should be at least")) {
|
||||
return AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT;
|
||||
}
|
||||
if (error.includes("Email not confirmed")) {
|
||||
return AUTH_ERROR_MESSAGES.EMAIL_NOT_CONFIRMED;
|
||||
}
|
||||
if (error.toLowerCase().includes("email rate limit exceeded")) {
|
||||
return AUTH_ERROR_MESSAGES.EMAIL_RATE_LIMIT_DETAILED;
|
||||
}
|
||||
|
||||
// 알 수 없는 에러는 기본 메시지 반환
|
||||
return AUTH_ERROR_MESSAGES.DEFAULT;
|
||||
}
|
||||
// 에러 메시지는 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 라우트
|
||||
|
||||
/**
|
||||
* [로그인 액션]
|
||||
@@ -180,6 +170,7 @@ function getErrorMessage(error: string): string {
|
||||
* @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);
|
||||
|
||||
@@ -200,7 +191,7 @@ export async function login(formData: FormData) {
|
||||
|
||||
// 4. 로그인 실패 시 에러 처리
|
||||
if (error) {
|
||||
const message = getErrorMessage(error.message);
|
||||
const message = getAuthErrorMessage(error);
|
||||
return redirect(`/login?message=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
@@ -226,6 +217,7 @@ export async function login(formData: FormData) {
|
||||
* @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);
|
||||
|
||||
@@ -252,7 +244,7 @@ export async function signup(formData: FormData) {
|
||||
|
||||
// 4. 회원가입 실패 시 에러 처리
|
||||
if (error) {
|
||||
const message = getErrorMessage(error.message);
|
||||
const message = getAuthErrorMessage(error);
|
||||
return redirect(`/signup?message=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
@@ -283,6 +275,7 @@ export async function signup(formData: FormData) {
|
||||
* 3. 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
export async function signout() {
|
||||
// 호출: app/page.tsx (로그아웃 버튼의 formAction)
|
||||
const supabase = await createClient();
|
||||
|
||||
// 1. Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
|
||||
@@ -313,6 +306,7 @@ export async function signout() {
|
||||
* @param formData - 이메일이 포함된 FormData
|
||||
*/
|
||||
export async function requestPasswordReset(formData: FormData) {
|
||||
// 호출: app/forgot-password/page.tsx (비밀번호 재설정 요청 폼)
|
||||
// 1. FormData에서 이메일 추출
|
||||
const email = (formData.get("email") as string)?.trim() || "";
|
||||
|
||||
@@ -338,18 +332,8 @@ export async function requestPasswordReset(formData: FormData) {
|
||||
// 4. 에러 처리
|
||||
if (error) {
|
||||
console.error("Password reset error:", error.message);
|
||||
|
||||
// Rate limit 오류는 사용자에게 알려줌 (보안과 무관)
|
||||
if (error.message.toLowerCase().includes("rate limit")) {
|
||||
return redirect(
|
||||
`/forgot-password?message=${encodeURIComponent(
|
||||
"이메일 발송 제한을 초과했습니다. Supabase 무료 플랜은 시간당 이메일 발송 횟수가 제한됩니다. 약 1시간 후에 다시 시도해 주세요.",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 그 외 에러는 보안상 동일한 메시지 표시
|
||||
// (이메일 존재 여부를 외부에 노출하지 않음)
|
||||
const message = getAuthErrorMessage(error);
|
||||
return redirect(`/forgot-password?message=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
// 5. 성공 메시지 표시
|
||||
@@ -372,34 +356,33 @@ export async function requestPasswordReset(formData: FormData) {
|
||||
* @param formData - 새 비밀번호가 포함된 FormData
|
||||
*/
|
||||
export async function updatePassword(formData: FormData) {
|
||||
// 1. FormData에서 새 비밀번호 추출
|
||||
// 호출: features/auth/components/reset-password-form.tsx (재설정 폼)
|
||||
const password = (formData.get("password") as string) || "";
|
||||
|
||||
// 2. 비밀번호 강도 검증
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (passwordValidation) {
|
||||
return redirect(
|
||||
`/reset-password?message=${encodeURIComponent(passwordValidation.message)}`,
|
||||
);
|
||||
return { ok: false, message: passwordValidation.message };
|
||||
}
|
||||
|
||||
// 3. Supabase를 통한 비밀번호 업데이트
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password,
|
||||
});
|
||||
|
||||
// 4. 에러 처리
|
||||
if (error) {
|
||||
const message = getErrorMessage(error.message);
|
||||
return redirect(`/reset-password?message=${encodeURIComponent(message)}`);
|
||||
const message = getAuthErrorMessage(error);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
// 5. 성공 - 캐시 무효화 및 로그인 페이지로 리다이렉트
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(RECOVERY_COOKIE_NAME);
|
||||
await supabase.auth.signOut();
|
||||
revalidatePath("/", "layout");
|
||||
redirect(
|
||||
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -427,6 +410,7 @@ export async function updatePassword(formData: FormData) {
|
||||
* @param provider - OAuth 제공자 ('google' | 'kakao')
|
||||
*/
|
||||
async function signInWithProvider(provider: "google" | "kakao") {
|
||||
// 호출: 아래 signInWithGoogle / signInWithKakao에서 공통 사용
|
||||
const supabase = await createClient();
|
||||
|
||||
// 1. OAuth 인증 시작
|
||||
@@ -442,9 +426,8 @@ async function signInWithProvider(provider: "google" | "kakao") {
|
||||
// 2. 에러 처리
|
||||
if (error) {
|
||||
console.error(`[${provider} OAuth] 로그인 실패:`, error.message);
|
||||
return redirect(
|
||||
`/login?message=${encodeURIComponent(`${provider} 로그인에 실패했습니다. 다시 시도해 주세요.`)}`,
|
||||
);
|
||||
const message = getAuthErrorMessage(error);
|
||||
return redirect(`/login?message=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
// 3. OAuth 제공자 로그인 페이지로 리다이렉트
|
||||
@@ -473,6 +456,7 @@ async function signInWithProvider(provider: "google" | "kakao") {
|
||||
* 5. 메인 페이지로 이동
|
||||
*/
|
||||
export async function signInWithGoogle() {
|
||||
// 호출: features/auth/components/login-form.tsx (Google 로그인 버튼)
|
||||
return signInWithProvider("google");
|
||||
}
|
||||
|
||||
@@ -490,5 +474,6 @@ export async function signInWithGoogle() {
|
||||
* 5. 메인 페이지로 이동
|
||||
*/
|
||||
export async function signInWithKakao() {
|
||||
// 호출: features/auth/components/login-form.tsx (Kakao 로그인 버튼)
|
||||
return signInWithProvider("kakao");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user