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:
2026-02-05 09:38:42 +09:00
parent edcfa2a837
commit 22ced3a6ae
12 changed files with 342 additions and 300 deletions

View File

@@ -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");
}