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:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"chatgpt.openOnStartup": false
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { AUTH_ERROR_MESSAGES, AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import { getAuthErrorMessage } from "@/features/auth/errors";
|
||||
|
||||
/**
|
||||
* [인증 콜백 라우트 핸들러]
|
||||
*
|
||||
* Supabase 인증 이메일(회원가입 확인, 비밀번호 재설정 등) 및 OAuth(소셜 로그인)
|
||||
* 리다이렉트될 때 호출되는 API 라우트입니다.
|
||||
* OAuth/??? ?? ?? ??
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
|
||||
// 1. URL에서 주요 직접 파라미터 및 에러 추출
|
||||
const code = searchParams.get("code");
|
||||
const next = searchParams.get("next") ?? AUTH_ROUTES.HOME;
|
||||
const error = searchParams.get("error");
|
||||
const error_code = searchParams.get("error_code");
|
||||
const error_description = searchParams.get("error_description");
|
||||
|
||||
// 2. 인증 오류가 있는 경우 (예: 구글 로그인 취소 등)
|
||||
if (error) {
|
||||
console.error("Auth callback error parameter:", {
|
||||
error,
|
||||
@@ -28,7 +24,6 @@ export async function GET(request: Request) {
|
||||
|
||||
let message: string = AUTH_ERROR_MESSAGES.DEFAULT;
|
||||
|
||||
// 에러 종류에 따른 메시지 분기
|
||||
if (error === "access_denied") {
|
||||
message = AUTH_ERROR_MESSAGES.OAUTH_ACCESS_DENIED;
|
||||
} else if (error === "server_error") {
|
||||
@@ -40,31 +35,33 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// 3. code가 있으면 세션으로 교환 (정상 플로우)
|
||||
if (code) {
|
||||
const supabase = await createClient();
|
||||
const { error: exchangeError } =
|
||||
await supabase.auth.exchangeCodeForSession(code);
|
||||
|
||||
if (!exchangeError) {
|
||||
// 세션 교환 성공 - 원래 목적지로 리다이렉트
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
const isLocalEnv = process.env.NODE_ENV === "development";
|
||||
|
||||
if (isLocalEnv) {
|
||||
return NextResponse.redirect(`${origin}${next}`);
|
||||
} else if (forwardedHost) {
|
||||
}
|
||||
|
||||
if (forwardedHost) {
|
||||
return NextResponse.redirect(`https://${forwardedHost}${next}`);
|
||||
} else {
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${origin}${next}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 세션 교환 실패 시 로그 및 에러 메시지 설정
|
||||
console.error("Auth exchange error:", exchangeError.message);
|
||||
const message = getAuthErrorMessage(exchangeError);
|
||||
return NextResponse.redirect(
|
||||
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. code가 없거나 교환 실패 시 기본 에러 페이지로 리다이렉트
|
||||
const errorMessage = encodeURIComponent(
|
||||
AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK,
|
||||
);
|
||||
|
||||
@@ -1,76 +1,84 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { AUTH_ERROR_MESSAGES } from "@/features/auth/constants";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import {
|
||||
AUTH_ERROR_MESSAGES,
|
||||
AUTH_ROUTES,
|
||||
RECOVERY_COOKIE_MAX_AGE_SECONDS,
|
||||
RECOVERY_COOKIE_NAME,
|
||||
} from "@/features/auth/constants";
|
||||
import { getAuthErrorMessage } from "@/features/auth/errors";
|
||||
import { type EmailOtpType } from "@supabase/supabase-js";
|
||||
import { redirect } from "next/navigation";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
// ========================================
|
||||
// 상수 정의
|
||||
// ========================================
|
||||
|
||||
/** 비밀번호 재설정 후 이동할 경로 */
|
||||
const RESET_PASSWORD_PATH = "/reset-password";
|
||||
|
||||
/** 인증 실패 시 리다이렉트할 경로 */
|
||||
const LOGIN_PATH = "/login";
|
||||
|
||||
// ========================================
|
||||
// 라우트 핸들러
|
||||
// ========================================
|
||||
const RESET_PASSWORD_PATH = AUTH_ROUTES.RESET_PASSWORD;
|
||||
const LOGIN_PATH = AUTH_ROUTES.LOGIN;
|
||||
|
||||
/**
|
||||
* [이메일 인증 확인 라우트]
|
||||
*
|
||||
* Supabase 이메일 템플릿의 인증 링크를 처리합니다.
|
||||
* - 회원가입 이메일 확인
|
||||
* - 비밀번호 재설정
|
||||
*
|
||||
* @example Supabase 이메일 템플릿 형식
|
||||
* {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery
|
||||
* 이메일 인증(/auth/confirm) 처리
|
||||
* - token_hash + type 검증
|
||||
* - recovery 타입일 경우 세션 쿠키 설정 후 비밀번호 재설정 페이지로 리다이렉트
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// 1) 이메일 링크에 들어있는 값 읽기
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// ========== 파라미터 추출 ==========
|
||||
// token_hash: 인증에 필요한 값
|
||||
// type: 어떤 인증인지 구분 (예: 가입, 비밀번호 재설정)
|
||||
const tokenHash = searchParams.get("token_hash");
|
||||
const type = searchParams.get("type") as EmailOtpType | null;
|
||||
const rawNext = searchParams.get("next");
|
||||
|
||||
// 보안: 외부 URL 리다이렉트 방지 (상대 경로만 허용)
|
||||
const nextPath = rawNext?.startsWith("/") ? rawNext : "/";
|
||||
// redirect_to/next: 인증 후에 이동할 주소
|
||||
const rawRedirect =
|
||||
searchParams.get("redirect_to") ?? searchParams.get("next");
|
||||
|
||||
// ========== 토큰 검증 ==========
|
||||
// 보안상 우리 사이트 안 경로(`/...`)만 허용
|
||||
const safeRedirect =
|
||||
rawRedirect && rawRedirect.startsWith("/") ? rawRedirect : null;
|
||||
|
||||
// 일반 인증이 끝난 뒤 이동할 경로
|
||||
const nextPath = safeRedirect ?? AUTH_ROUTES.HOME;
|
||||
// 비밀번호 재설정일 때 이동할 경로
|
||||
const recoveryPath = safeRedirect ?? RESET_PASSWORD_PATH;
|
||||
|
||||
// 필수 값이 없으면 로그인으로 보내고 에러를 보여줌
|
||||
if (!tokenHash || !type) {
|
||||
return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
||||
return redirectWithError(request, AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
||||
}
|
||||
|
||||
// 2) Supabase에게 "이 링크가 맞는지" 확인 요청
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
type,
|
||||
token_hash: tokenHash,
|
||||
});
|
||||
|
||||
// 확인 실패 시 이유를 알기 쉬운 메시지로 보여줌
|
||||
if (error) {
|
||||
console.error("[Auth Confirm] verifyOtp 실패:", error.message);
|
||||
return redirectWithError(AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
|
||||
console.error("[Auth Confirm] verifyOtp error:", error.message);
|
||||
const message = getAuthErrorMessage(error);
|
||||
return redirectWithError(request, message);
|
||||
}
|
||||
|
||||
// ========== 검증 성공 - 적절한 페이지로 리다이렉트 ==========
|
||||
// 3) 비밀번호 재설정이면 재설정 페이지로 보내고 쿠키를 저장
|
||||
if (type === "recovery") {
|
||||
redirect(RESET_PASSWORD_PATH);
|
||||
const response = NextResponse.redirect(new URL(recoveryPath, request.url));
|
||||
response.cookies.set(RECOVERY_COOKIE_NAME, "1", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
redirect(nextPath);
|
||||
// 4) 그 외 인증은 기본 경로로 이동
|
||||
return NextResponse.redirect(new URL(nextPath, request.url));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 에러 메시지와 함께 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
function redirectWithError(message: string): never {
|
||||
// 로그인 페이지로 보내면서 에러 메시지를 함께 전달
|
||||
function redirectWithError(request: NextRequest, message: string) {
|
||||
const encodedMessage = encodeURIComponent(message);
|
||||
redirect(`${LOGIN_PATH}?message=${encodedMessage}`);
|
||||
return NextResponse.redirect(
|
||||
new URL(`${LOGIN_PATH}?message=${encodedMessage}`, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FormMessage from "@/components/form-message";
|
||||
import FormMessage from "@/components/form-message";
|
||||
import { requestPasswordReset } from "@/features/auth/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,58 +15,46 @@ import Link from "next/link";
|
||||
/**
|
||||
* [비밀번호 찾기 페이지]
|
||||
*
|
||||
* 사용자가 이메일을 입력하면 비밀번호 재설정 링크를 이메일로 발송합니다.
|
||||
* 로그인/회원가입 페이지와 동일한 디자인 시스템 사용
|
||||
*
|
||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
|
||||
* - 이메일 입력 폼 제공
|
||||
* - 서버 액션(requestPasswordReset)과 연동
|
||||
*/
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ message: string }>;
|
||||
searchParams: Promise<{ message?: string }>;
|
||||
}) {
|
||||
// URL에서 메시지 파라미터 추출
|
||||
const { message } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
||||
{/* ========== 배경 그라디언트 ========== */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
||||
|
||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
||||
|
||||
{/* ========== 메인 콘텐츠 ========== */}
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* 에러/성공 메시지 */}
|
||||
<FormMessage message={message} />
|
||||
{message && <FormMessage message={message} />}
|
||||
|
||||
{/* ========== 비밀번호 찾기 카드 ========== */}
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
{/* 아이콘 */}
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-4xl">🔑</span>
|
||||
<span className="text-sm font-semibold">MAIL</span>
|
||||
</div>
|
||||
{/* 페이지 제목 */}
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
비밀번호 찾기
|
||||
비밀번호 재설정
|
||||
</CardTitle>
|
||||
{/* 페이지 설명 */}
|
||||
<CardDescription className="text-base">
|
||||
가입하신 이메일 주소를 입력하시면
|
||||
가입한 이메일 주소를 입력하시면 비밀번호 재설정 링크를
|
||||
보내드립니다.
|
||||
<br />
|
||||
비밀번호 재설정 링크를 보내드립니다.
|
||||
메일을 받지 못하셨다면 스팸함을 확인해 주세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 폼 영역 ========== */}
|
||||
<CardContent className="space-y-6">
|
||||
{/* 비밀번호 재설정 요청 폼 */}
|
||||
<form className="space-y-5">
|
||||
{/* ========== 이메일 입력 필드 ========== */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">
|
||||
이메일
|
||||
@@ -75,14 +63,13 @@ export default async function ForgotPasswordPage({
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
placeholder="name@example.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-11 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== 재설정 링크 발송 버튼 ========== */}
|
||||
<Button
|
||||
formAction={requestPasswordReset}
|
||||
className="h-11 w-full bg-gradient-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
@@ -91,13 +78,12 @@ export default async function ForgotPasswordPage({
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* ========== 로그인 페이지로 돌아가기 ========== */}
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
← 로그인 페이지로 돌아가기
|
||||
로그인 페이지로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import FormMessage from "@/components/form-message";
|
||||
import FormMessage from "@/components/form-message";
|
||||
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
|
||||
import {
|
||||
Card,
|
||||
@@ -13,94 +13,55 @@ import { redirect } from "next/navigation";
|
||||
/**
|
||||
* [비밀번호 재설정 페이지]
|
||||
*
|
||||
* 이메일 링크를 통해 접근한 사용자만 새 비밀번호를 설정할 수 있습니다.
|
||||
* Supabase recovery 세션 검증으로 직접 URL 접근 차단
|
||||
*
|
||||
* PKCE 플로우:
|
||||
* 1. 사용자가 비밀번호 재설정 이메일의 링크 클릭
|
||||
* 2. Supabase가 토큰 검증 후 이 페이지로 ?code=xxx 파라미터와 함께 리다이렉트
|
||||
* 3. 이 페이지에서 code를 세션으로 교환
|
||||
* 4. 유효한 세션이 있으면 비밀번호 재설정 폼 표시
|
||||
*
|
||||
* @param searchParams - URL 쿼리 파라미터 (code: PKCE 코드, message: 에러/성공 메시지)
|
||||
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
|
||||
* - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
|
||||
* - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
|
||||
*/
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ message?: string; code?: string }>;
|
||||
searchParams: Promise<{ message?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
|
||||
// 1. 이메일 링크에서 code 파라미터 확인
|
||||
// Supabase는 이메일 링크를 통해 ?code=xxx 형태로 PKCE code를 전달합니다
|
||||
if (params.code) {
|
||||
// code를 세션으로 교환
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(params.code);
|
||||
|
||||
if (error) {
|
||||
// code 교환 실패 (만료되었거나 유효하지 않음)
|
||||
console.error("Password reset code exchange error:", error.message);
|
||||
const message = encodeURIComponent(
|
||||
"비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
|
||||
);
|
||||
redirect(`/login?message=${message}`);
|
||||
}
|
||||
|
||||
// code 교환 성공 - code 없이 같은 페이지로 리다이렉트
|
||||
// (URL을 깨끗하게 유지하고 세션 쿠키가 설정된 상태로 리로드)
|
||||
redirect("/reset-password");
|
||||
}
|
||||
|
||||
// 2. 세션 확인
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// 3. 유효한 세션이 없으면 로그인 페이지로 리다이렉트
|
||||
if (!user) {
|
||||
const message = encodeURIComponent(
|
||||
"비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다.",
|
||||
"유효하지 않은 재설정 링크이거나 만료되었습니다. 다시 시도해 주세요.",
|
||||
);
|
||||
redirect(`/login?message=${message}`);
|
||||
}
|
||||
|
||||
// URL에서 메시지 파라미터 추출
|
||||
const { message } = params;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 via-white to-gray-100 px-4 py-12 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
||||
{/* ========== 배경 그라디언트 ========== */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,_var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
||||
|
||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
||||
|
||||
{/* ========== 메인 콘텐츠 ========== */}
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* 에러/성공 메시지 */}
|
||||
{message && <FormMessage message={message} />}
|
||||
|
||||
{/* ========== 비밀번호 재설정 카드 ========== */}
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
{/* 아이콘 */}
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-4xl">🔐</span>
|
||||
<span className="text-sm font-semibold">PW</span>
|
||||
</div>
|
||||
{/* 페이지 제목 */}
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
비밀번호 재설정
|
||||
</CardTitle>
|
||||
{/* 페이지 설명 */}
|
||||
<CardDescription className="text-base">
|
||||
새로운 비밀번호를 입력해 주세요.
|
||||
새 비밀번호를 입력해 주세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 폼 영역 ========== */}
|
||||
<CardContent className="space-y-6">
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { updatePassword } from "@/features/auth/actions";
|
||||
import {
|
||||
resetPasswordSchema,
|
||||
@@ -13,22 +14,14 @@ import { Label } from "@/components/ui/label";
|
||||
import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* [비밀번호 재설정 폼 클라이언트 컴포넌트 - React Hook Form 버전]
|
||||
*
|
||||
* React Hook Form과 Zod를 사용한 비밀번호 재설정 폼입니다.
|
||||
* - 타입 안전한 폼 검증
|
||||
* - 비밀번호/비밀번호 확인 일치 검증
|
||||
* - 로딩 상태 표시
|
||||
*
|
||||
* @see app/reset-password/page.tsx - 이 컴포넌트를 사용하는 페이지
|
||||
*/
|
||||
const DEFAULT_ERROR_MESSAGE =
|
||||
"알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
// ========== 로딩 상태 ==========
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [serverError, setServerError] = useState("");
|
||||
|
||||
// ========== React Hook Form 설정 ==========
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -39,11 +32,9 @@ export default function ResetPasswordForm() {
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
// 비밀번호 실시간 감시
|
||||
const password = watch("password");
|
||||
const confirmPassword = watch("confirmPassword");
|
||||
|
||||
// ========== 폼 제출 핸들러 ==========
|
||||
const onSubmit = async (data: ResetPasswordFormData) => {
|
||||
setServerError("");
|
||||
setIsLoading(true);
|
||||
@@ -52,10 +43,18 @@ export default function ResetPasswordForm() {
|
||||
const formData = new FormData();
|
||||
formData.append("password", data.password);
|
||||
|
||||
await updatePassword(formData);
|
||||
const result = await updatePassword(formData);
|
||||
|
||||
if (result?.ok) {
|
||||
const message = encodeURIComponent(result.message);
|
||||
router.replace(`/login?message=${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setServerError(result?.message || DEFAULT_ERROR_MESSAGE);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
setServerError("비밀번호 변경 중 오류가 발생했습니다.");
|
||||
setServerError(DEFAULT_ERROR_MESSAGE);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -63,14 +62,12 @@ export default function ResetPasswordForm() {
|
||||
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* ========== 서버 에러 메시지 표시 ========== */}
|
||||
{serverError && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 새 비밀번호 입력 ========== */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">
|
||||
새 비밀번호
|
||||
@@ -78,14 +75,14 @@ export default function ResetPasswordForm() {
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
placeholder="새 비밀번호를 입력해주세요"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("password")}
|
||||
className="h-11 transition-all duration-200"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
최소 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함
|
||||
8자 이상, 대문자/소문자/숫자/특수문자를 각 1개 이상 포함해야 합니다.
|
||||
</p>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
@@ -94,37 +91,33 @@ export default function ResetPasswordForm() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 비밀번호 확인 필드 ========== */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">
|
||||
비밀번호 확인
|
||||
새 비밀번호 확인
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
placeholder="새 비밀번호를 다시 입력해주세요"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("confirmPassword")}
|
||||
className="h-11 transition-all duration-200"
|
||||
/>
|
||||
{/* 비밀번호 불일치 시 실시간 피드백 */}
|
||||
{confirmPassword &&
|
||||
password !== confirmPassword &&
|
||||
!errors.confirmPassword && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
비밀번호가 일치하지 않습니다
|
||||
비밀번호가 일치하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
{/* 비밀번호 일치 시 확인 메시지 */}
|
||||
{confirmPassword &&
|
||||
password === confirmPassword &&
|
||||
!errors.confirmPassword && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
비밀번호가 일치합니다 ✓
|
||||
비밀번호가 일치합니다.
|
||||
</p>
|
||||
)}
|
||||
{/* Zod 검증 에러 */}
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
{errors.confirmPassword.message}
|
||||
@@ -132,7 +125,6 @@ export default function ResetPasswordForm() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 비밀번호 변경 버튼 ========== */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* [인증 관련 상수 정의]
|
||||
*
|
||||
* 인증 모듈 전체에서 공통으로 사용하는 상수들을 정의합니다.
|
||||
@@ -61,6 +61,107 @@ export const AUTH_ERROR_MESSAGES = {
|
||||
DEFAULT: "요청을 처리하는 중 오류가 발생했습니다.",
|
||||
} as const;
|
||||
|
||||
// ========================================
|
||||
// Supabase Auth 에러 코드 매핑
|
||||
// ========================================
|
||||
|
||||
export const AUTH_ERROR_CODE_MESSAGES = {
|
||||
anonymous_provider_disabled: "익명 로그인은 비활성화되어 있습니다.",
|
||||
bad_code_verifier: "PKCE code verifier가 일치하지 않습니다.",
|
||||
bad_json: "요청 본문이 올바른 JSON이 아닙니다.",
|
||||
bad_jwt: "Authorization 헤더의 JWT가 유효하지 않습니다.",
|
||||
bad_oauth_callback: "OAuth 콜백에 필요한 값(state)이 없습니다.",
|
||||
bad_oauth_state: "OAuth state 형식이 올바르지 않습니다.",
|
||||
captcha_failed: "CAPTCHA 검증에 실패했습니다.",
|
||||
conflict: "요청 충돌이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
email_address_invalid: "예시/테스트 도메인은 사용할 수 없습니다.",
|
||||
email_address_not_authorized:
|
||||
"기본 SMTP 사용 시 허용되지 않은 이메일 주소입니다.",
|
||||
email_conflict_identity_not_deletable:
|
||||
"이메일 충돌로 이 아이덴티티를 삭제할 수 없습니다.",
|
||||
email_exists: "이미 가입된 이메일 주소입니다.",
|
||||
email_not_confirmed: "이메일 인증이 완료되지 않았습니다.",
|
||||
email_provider_disabled: "이메일/비밀번호 가입이 비활성화되어 있습니다.",
|
||||
flow_state_expired: "로그인 흐름이 만료되었습니다. 다시 시도해주세요.",
|
||||
flow_state_not_found: "로그인 흐름을 찾을 수 없습니다. 다시 시도해주세요.",
|
||||
hook_payload_invalid_content_type:
|
||||
"훅 페이로드의 Content-Type이 올바르지 않습니다.",
|
||||
hook_payload_over_size_limit: "훅 페이로드가 최대 크기를 초과했습니다.",
|
||||
hook_timeout: "훅 요청 시간이 초과되었습니다.",
|
||||
hook_timeout_after_retry: "훅 요청 재시도 후에도 시간이 초과되었습니다.",
|
||||
identity_already_exists: "이미 연결된 아이덴티티입니다.",
|
||||
identity_not_found: "아이덴티티를 찾을 수 없습니다.",
|
||||
insufficient_aal: "추가 인증이 필요합니다.",
|
||||
invalid_credentials: "이메일 또는 비밀번호가 일치하지 않습니다.",
|
||||
invite_not_found: "초대 링크가 만료되었거나 이미 사용되었습니다.",
|
||||
manual_linking_disabled: "계정 연결 기능이 비활성화되어 있습니다.",
|
||||
mfa_challenge_expired: "MFA 인증 시간이 초과되었습니다.",
|
||||
mfa_factor_name_conflict: "MFA 요인 이름이 중복되었습니다.",
|
||||
mfa_factor_not_found: "MFA 요인을 찾을 수 없습니다.",
|
||||
mfa_ip_address_mismatch: "MFA 등록 시작/종료 IP가 일치하지 않습니다.",
|
||||
mfa_phone_enroll_not_enabled: "전화 MFA 등록이 비활성화되어 있습니다.",
|
||||
mfa_phone_verify_not_enabled: "전화 MFA 검증이 비활성화되어 있습니다.",
|
||||
mfa_totp_enroll_not_enabled: "TOTP MFA 등록이 비활성화되어 있습니다.",
|
||||
mfa_totp_verify_not_enabled: "TOTP MFA 검증이 비활성화되어 있습니다.",
|
||||
mfa_verification_failed: "MFA 검증에 실패했습니다.",
|
||||
mfa_verification_rejected: "MFA 검증이 거부되었습니다.",
|
||||
mfa_verified_factor_exists: "이미 검증된 전화 MFA가 존재합니다.",
|
||||
mfa_web_authn_enroll_not_enabled: "WebAuthn MFA 등록이 비활성화되어 있습니다.",
|
||||
mfa_web_authn_verify_not_enabled: "WebAuthn MFA 검증이 비활성화되어 있습니다.",
|
||||
no_authorization: "Authorization 헤더가 필요합니다.",
|
||||
not_admin: "관리자 권한이 없습니다.",
|
||||
oauth_provider_not_supported: "OAuth 제공자가 비활성화되어 있습니다.",
|
||||
otp_disabled: "OTP 로그인이 비활성화되어 있습니다.",
|
||||
otp_expired: "OTP가 만료되었습니다.",
|
||||
over_email_send_rate_limit: "이메일 발송 제한을 초과했습니다.",
|
||||
over_request_rate_limit: "요청 제한을 초과했습니다.",
|
||||
over_sms_send_rate_limit: "SMS 발송 제한을 초과했습니다.",
|
||||
phone_exists: "이미 가입된 전화번호입니다.",
|
||||
phone_not_confirmed: "전화번호 인증이 완료되지 않았습니다.",
|
||||
phone_provider_disabled: "전화번호 가입이 비활성화되어 있습니다.",
|
||||
provider_disabled: "OAuth 제공자가 비활성화되어 있습니다.",
|
||||
provider_email_needs_verification: "OAuth 이메일 인증이 필요합니다.",
|
||||
reauthentication_needed: "비밀번호 변경을 위해 재인증이 필요합니다.",
|
||||
reauthentication_not_valid: "재인증 코드가 유효하지 않습니다.",
|
||||
refresh_token_already_used: "세션이 만료되었습니다. 다시 로그인해주세요.",
|
||||
refresh_token_not_found: "세션을 찾을 수 없습니다.",
|
||||
request_timeout: "요청 시간이 초과되었습니다.",
|
||||
same_password: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
|
||||
saml_assertion_no_email: "SAML 응답에 이메일이 없습니다.",
|
||||
saml_assertion_no_user_id: "SAML 응답에 사용자 ID가 없습니다.",
|
||||
saml_entity_id_mismatch: "SAML 엔티티 ID가 일치하지 않습니다.",
|
||||
saml_idp_already_exists: "SAML IdP가 이미 등록되어 있습니다.",
|
||||
saml_idp_not_found: "SAML IdP를 찾을 수 없습니다.",
|
||||
saml_metadata_fetch_failed: "SAML 메타데이터를 불러오지 못했습니다.",
|
||||
saml_provider_disabled: "SAML SSO가 비활성화되어 있습니다.",
|
||||
saml_relay_state_expired: "SAML relay state가 만료되었습니다.",
|
||||
saml_relay_state_not_found: "SAML relay state를 찾을 수 없습니다.",
|
||||
session_expired: "세션이 만료되었습니다.",
|
||||
session_not_found: "세션을 찾을 수 없습니다.",
|
||||
signup_disabled: "회원가입이 비활성화되어 있습니다.",
|
||||
single_identity_not_deletable: "유일한 아이덴티티는 삭제할 수 없습니다.",
|
||||
sms_send_failed: "SMS 발송에 실패했습니다.",
|
||||
sso_domain_already_exists: "SSO 도메인이 이미 등록되어 있습니다.",
|
||||
sso_provider_not_found: "SSO 제공자를 찾을 수 없습니다.",
|
||||
too_many_enrolled_mfa_factors: "등록 가능한 MFA 요인 수를 초과했습니다.",
|
||||
unexpected_audience: "토큰 audience가 일치하지 않습니다.",
|
||||
unexpected_failure: "인증 서버 오류가 발생했습니다.",
|
||||
user_already_exists: "이미 존재하는 사용자입니다.",
|
||||
user_banned: "계정이 일시적으로 차단되었습니다.",
|
||||
user_not_found: "사용자를 찾을 수 없습니다.",
|
||||
user_sso_managed: "SSO 사용자 정보는 수정할 수 없습니다.",
|
||||
validation_failed: "요청 값 형식이 올바르지 않습니다.",
|
||||
weak_password: "비밀번호가 정책을 만족하지 않습니다.",
|
||||
} as const;
|
||||
|
||||
export const AUTH_ERROR_STATUS_MESSAGES = {
|
||||
403: "요청한 기능을 사용할 수 없습니다.",
|
||||
422: "요청을 처리할 수 없는 상태입니다.",
|
||||
429: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
|
||||
500: "인증 서버 오류가 발생했습니다.",
|
||||
501: "요청한 기능이 활성화되어 있지 않습니다.",
|
||||
} as const;
|
||||
|
||||
// ========================================
|
||||
// 라우트 경로 상수
|
||||
// ========================================
|
||||
@@ -91,6 +192,10 @@ export const PUBLIC_AUTH_PAGES = [
|
||||
AUTH_ROUTES.AUTH_CALLBACK,
|
||||
] as const;
|
||||
|
||||
// 복구 플로우 전용 쿠키 (비밀번호 재설정 화면 외 접근 차단에 사용)
|
||||
export const RECOVERY_COOKIE_NAME = "sb-recovery";
|
||||
export const RECOVERY_COOKIE_MAX_AGE_SECONDS = 10 * 60;
|
||||
|
||||
// ========================================
|
||||
// 검증 규칙 상수
|
||||
// ========================================
|
||||
|
||||
30
features/auth/errors.ts
Normal file
30
features/auth/errors.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
AUTH_ERROR_CODE_MESSAGES,
|
||||
AUTH_ERROR_MESSAGES,
|
||||
AUTH_ERROR_STATUS_MESSAGES,
|
||||
} from "./constants";
|
||||
|
||||
export type AuthApiErrorLike = {
|
||||
message?: string | null;
|
||||
code?: string | null;
|
||||
status?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Supabase Auth 에러 데이터를 인간이 읽을 수 있는 한글 메시지로 변환합니다.
|
||||
*/
|
||||
export function getAuthErrorMessage(error: AuthApiErrorLike): string {
|
||||
if (error.code && error.code in AUTH_ERROR_CODE_MESSAGES) {
|
||||
return AUTH_ERROR_CODE_MESSAGES[
|
||||
error.code as keyof typeof AUTH_ERROR_CODE_MESSAGES
|
||||
];
|
||||
}
|
||||
|
||||
if (error.status && error.status in AUTH_ERROR_STATUS_MESSAGES) {
|
||||
return AUTH_ERROR_STATUS_MESSAGES[
|
||||
error.status as keyof typeof AUTH_ERROR_STATUS_MESSAGES
|
||||
];
|
||||
}
|
||||
|
||||
return AUTH_ERROR_MESSAGES.DEFAULT;
|
||||
}
|
||||
@@ -1,93 +1,68 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "zod";
|
||||
import { PASSWORD_RULES } from "@/features/auth/constants";
|
||||
|
||||
/**
|
||||
* [비밀번호 검증 스키마]
|
||||
*
|
||||
* 비밀번호 강도 요구사항:
|
||||
* - 최소 8자 이상
|
||||
* - 대문자 1개 이상
|
||||
* - 소문자 1개 이상
|
||||
* - 숫자 1개 이상
|
||||
* - 특수문자 1개 이상
|
||||
*/
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(PASSWORD_RULES.MIN_LENGTH, {
|
||||
message: `비밀번호는 최소 ${PASSWORD_RULES.MIN_LENGTH}자 이상이어야 합니다.`,
|
||||
})
|
||||
.regex(/[A-Z]/, {
|
||||
message: "비밀번호에 대문자가 최소 1개 이상 포함되어야 합니다.",
|
||||
message: "대문자를 최소 1개 이상 포함해야 합니다.",
|
||||
})
|
||||
.regex(/[a-z]/, {
|
||||
message: "비밀번호에 소문자가 최소 1개 이상 포함되어야 합니다.",
|
||||
message: "소문자를 최소 1개 이상 포함해야 합니다.",
|
||||
})
|
||||
.regex(/[0-9]/, {
|
||||
message: "비밀번호에 숫자가 최소 1개 이상 포함되어야 합니다.",
|
||||
message: "숫자를 최소 1개 이상 포함해야 합니다.",
|
||||
})
|
||||
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
|
||||
message: "비밀번호에 특수문자가 최소 1개 이상 포함되어야 합니다.",
|
||||
message: "특수문자를 최소 1개 이상 포함해야 합니다.",
|
||||
});
|
||||
|
||||
/**
|
||||
* [회원가입 폼 스키마]
|
||||
*
|
||||
* 회원가입 시 필요한 필드 검증
|
||||
*/
|
||||
export const signupSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "이메일을 입력해주세요." })
|
||||
.email({ message: "올바른 이메일 형식이 아닙니다." }),
|
||||
.min(1, { message: "이메일을 입력해 주세요." })
|
||||
.email({ message: "유효한 이메일 형식이 아닙니다." }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, { message: "비밀번호 확인을 입력해주세요." }),
|
||||
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "비밀번호가 일치하지 않습니다.",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
/**
|
||||
* [비밀번호 재설정 폼 스키마]
|
||||
*/
|
||||
export const resetPasswordSchema = z
|
||||
.object({
|
||||
password: passwordSchema,
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, { message: "비밀번호 확인을 입력해주세요." }),
|
||||
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "비밀번호가 일치하지 않습니다.",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
/**
|
||||
* [로그인 폼 스키마]
|
||||
*/
|
||||
export const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "이메일을 입력해주세요." })
|
||||
.email({ message: "올바른 이메일 형식이 아닙니다." }),
|
||||
password: z.string().min(1, { message: "비밀번호를 입력해주세요." }),
|
||||
.min(1, { message: "이메일을 입력해 주세요." })
|
||||
.email({ message: "유효한 이메일 형식이 아닙니다." }),
|
||||
password: z.string().min(1, { message: "비밀번호를 입력해 주세요." }),
|
||||
rememberMe: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* [비밀번호 찾기 폼 스키마]
|
||||
*/
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { message: "이메일을 입력해주세요." })
|
||||
.email({ message: "올바른 이메일 형식이 아닙니다." }),
|
||||
.min(1, { message: "이메일을 입력해 주세요." })
|
||||
.email({ message: "유효한 이메일 형식이 아닙니다." }),
|
||||
});
|
||||
|
||||
// TypeScript 타입 추론
|
||||
export type SignupFormData = z.infer<typeof signupSchema>;
|
||||
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
||||
export type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { PUBLIC_AUTH_PAGES, AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import {
|
||||
PUBLIC_AUTH_PAGES,
|
||||
AUTH_ROUTES,
|
||||
RECOVERY_COOKIE_NAME,
|
||||
} from "@/features/auth/constants";
|
||||
|
||||
/**
|
||||
* [미들웨어용 세션 업데이트 및 라우트 보호 함수]
|
||||
*
|
||||
* 모든 페이지 요청이 서버에 도달하기 전에 가장 먼저 실행됩니다.
|
||||
*
|
||||
* 주요 기능:
|
||||
* 1. 만료된 로그인 토큰 자동 갱신 (Refresh)
|
||||
* 2. 인증 상태에 따른 라우트 보호
|
||||
* 서버 사이드 인증 상태를 관리하고 보호된 라우트를 처리합니다.
|
||||
*/
|
||||
export async function updateSession(request: NextRequest) {
|
||||
// ========== 초기 응답 생성 ==========
|
||||
let supabaseResponse = NextResponse.next({ request });
|
||||
|
||||
// ========== Supabase 클라이언트 생성 ==========
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
@@ -25,13 +21,10 @@ export async function updateSession(request: NextRequest) {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
// 요청 객체에 쿠키 업데이트
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value),
|
||||
);
|
||||
// 응답 객체 재생성
|
||||
supabaseResponse = NextResponse.next({ request });
|
||||
// 응답에 쿠키 설정
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options),
|
||||
);
|
||||
@@ -40,26 +33,45 @@ export async function updateSession(request: NextRequest) {
|
||||
},
|
||||
);
|
||||
|
||||
// ========== 사용자 인증 정보 확인 ==========
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
const recoveryCookie = request.cookies.get(RECOVERY_COOKIE_NAME)?.value;
|
||||
|
||||
if (recoveryCookie && !user) {
|
||||
const response = NextResponse.redirect(
|
||||
new URL(AUTH_ROUTES.LOGIN, request.url),
|
||||
);
|
||||
response.cookies.delete(RECOVERY_COOKIE_NAME);
|
||||
return response;
|
||||
}
|
||||
|
||||
const isRecoveryRoute =
|
||||
pathname.startsWith(AUTH_ROUTES.RESET_PASSWORD) ||
|
||||
pathname.startsWith(AUTH_ROUTES.AUTH_CONFIRM);
|
||||
|
||||
if (recoveryCookie && !isRecoveryRoute) {
|
||||
return NextResponse.redirect(
|
||||
new URL(AUTH_ROUTES.RESET_PASSWORD, request.url),
|
||||
);
|
||||
}
|
||||
|
||||
const isAuthPage = PUBLIC_AUTH_PAGES.some((page) =>
|
||||
pathname.startsWith(page),
|
||||
);
|
||||
|
||||
// ========== 라우트 보호 ==========
|
||||
|
||||
// 비로그인 사용자 → 보호된 페이지 접근 시 로그인으로 리다이렉트
|
||||
if (!user && !isAuthPage) {
|
||||
return NextResponse.redirect(new URL(AUTH_ROUTES.LOGIN, request.url));
|
||||
}
|
||||
|
||||
// 로그인 사용자 → 인증 페이지 접근 시 홈으로 리다이렉트
|
||||
// 단, 비밀번호 재설정 페이지는 예외
|
||||
if (user && isAuthPage && pathname !== AUTH_ROUTES.RESET_PASSWORD) {
|
||||
if (
|
||||
user &&
|
||||
isAuthPage &&
|
||||
pathname !== AUTH_ROUTES.RESET_PASSWORD &&
|
||||
!recoveryCookie
|
||||
) {
|
||||
return NextResponse.redirect(new URL(AUTH_ROUTES.HOME, request.url));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,35 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* [서버 컴포넌트용 Supabase 클라이언트 생성 함수]
|
||||
*
|
||||
* 이 함수는 Next.js의 SSR(서버 사이드 렌더링) 환경에서 Supabase에 접근할 때 사용합니다.
|
||||
* 서버 컴포넌트, 서버 액션(Server Actions), 라우트 핸들러(Route Handlers)에서 호출됩니다.
|
||||
* 서버용 Supabase 클라이언트를 생성합니다.
|
||||
* 쿠키 읽기/쓰기 권한이 있어 SSR에 적합합니다.
|
||||
*/
|
||||
export async function createClient() {
|
||||
// Next.js의 쿠키 저장소에 접근합니다. (await 필수)
|
||||
const cookieStore = await cookies();
|
||||
|
||||
/**
|
||||
* createServerClient: 서버 환경에서 안전하게 Supabase 클라이언트를 생성합니다.
|
||||
* 첫 번째 인자: Supabase 프로젝트 URL
|
||||
* 두 번째 인자: Supabase 익명(Anon) 키 (공개되어도 안전한 키)
|
||||
* 세 번째 인자: 쿠키 제어 옵션
|
||||
*/
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
// 1. Supabase가 쿠키를 읽어야 할 때 실행됩니다.
|
||||
// 현재 요청(Request)에 있는 모든 쿠키를 가져와서 Supabase에 전달합니다.
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
|
||||
// 2. Supabase가 쿠키를 새로 써야 할 때(로그인, 로그아웃, 토큰 갱신 등) 실행됩니다.
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
// Supabase가 요청한 쿠키들을 하나씩 브라우저에 저장하도록 설정합니다.
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
cookieStore.set(name, value, options),
|
||||
);
|
||||
} catch {
|
||||
// [주의] 이 부분은 '서버 컴포넌트'에서 쿠키를 쓰려고 할 때 발생하는 에러를 무시하기 위함입니다.
|
||||
// Next.js 규칙상 '서버 컴포넌트'는 렌더링 중에 쿠키를 직접 쓸 수 없습니다.
|
||||
// 대신 미들웨어(middleware)가 토큰 갱신을 담당하므로 여기서는 에러를 무시해도 안전합니다.
|
||||
// Server Components에서는 쿠키를 설정할 수 없습니다.
|
||||
// 읽기 전용 에러가 발생합니다.
|
||||
/**
|
||||
* 서버 사이드 인증 상태를 관리하고 보호된 라우트를 처리합니다.
|
||||
*/
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user