Files
auto-trade/features/auth/actions.ts

419 lines
13 KiB
TypeScript
Raw Normal View History

2026-01-30 16:16:54 +09:00
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
// ========================================
// 타입 정의
// ========================================
/**
*
* /
*/
type AuthFormData = {
email: string;
password: string;
};
/**
*
* - message: 사용자에게
* - type: ( , , )
*/
type AuthError = {
message: string;
type: "validation" | "auth" | "unknown";
};
// ========================================
// 상수 정의
// ========================================
/**
*
* Supabase의
*/
const AUTH_ERROR_MESSAGES = {
INVALID_CREDENTIALS: "이메일 또는 비밀번호가 일치하지 않습니다.",
USER_EXISTS: "이미 가입된 이메일 주소입니다.",
PASSWORD_TOO_SHORT: "비밀번호는 최소 6자 이상이어야 합니다.",
PASSWORD_TOO_WEAK:
"비밀번호는 최소 6자 이상, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.",
EMAIL_RATE_LIMIT:
"이메일 발송 제한을 초과했습니다. 잠시 후 다시 시도해 주세요.",
EMAIL_NOT_CONFIRMED: "이메일 인증이 완료되지 않았습니다.",
EMPTY_FIELDS: "이메일과 비밀번호를 모두 입력해 주세요.",
INVALID_EMAIL: "올바른 이메일 형식이 아닙니다.",
EMPTY_EMAIL: "이메일을 입력해 주세요.",
PASSWORD_RESET_SENT: "비밀번호 재설정 링크를 이메일로 발송했습니다.",
PASSWORD_RESET_SUCCESS: "비밀번호가 성공적으로 변경되었습니다.",
PASSWORD_RESET_FAILED: "비밀번호 변경에 실패했습니다.",
DEFAULT: "요청을 처리하는 중 오류가 발생했습니다.",
} as const;
// ========================================
// 헬퍼 함수
// ========================================
/**
* [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. 최소 길이 체크 (6자 이상)
if (password.length < 6) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT,
type: "validation",
};
}
// 2. 숫자 포함 여부
if (!/[0-9]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// 3. 특수문자 포함 여부
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// 모든 검증 통과
return null;
}
/**
* [ ]
*
* .
*
* :
* 1. -
* 2. - '@'
* 3. - 6 (Supabase )
*
* @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 -
*/
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;
}
// 알 수 없는 에러는 기본 메시지 반환
return AUTH_ERROR_MESSAGES.DEFAULT;
}
// ========================================
// Server Actions (서버 액션)
// ========================================
/**
* [ ]
*
* / .
*
* :
* 1. FormData에서 /
* 2. ( , , )
* 3. Supabase Auth를
* 4.
* 5.
*
* @param formData - HTML form에서 FormData (, )
*/
export async function login(formData: FormData) {
// 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 = getErrorMessage(error.message);
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) {
// 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 = getErrorMessage(error.message);
return redirect(`/signup?message=${encodeURIComponent(message)}`);
}
// 5. 회원가입 성공 - 세션 생성 여부에 따라 분기 처리
if (data.session) {
// 5-1. 즉시 세션이 생성된 경우 (이메일 인증 불필요)
// → 바로 메인 페이지로 이동
revalidatePath("/", "layout");
redirect("/");
}
// 5-2. 이메일 인증이 필요한 경우
// → 로그인 페이지로 이동 + 안내 메시지
revalidatePath("/", "layout");
redirect("/login?message=회원가입이 완료되었습니다. 로그인해 주세요.");
}
/**
* [ ]
*
* .
*
* :
* 1. Supabase Auth
* 2.
* 3.
*/
export async function signout() {
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) {
// 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`,
});
// 에러가 발생해도 보안상 동일한 메시지 표시
// (이메일 존재 여부를 외부에 노출하지 않음)
if (error) {
console.error("Password reset error:", error.message);
}
// 4. 항상 성공 메시지 표시 (보안)
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) {
// 1. FormData에서 새 비밀번호 추출
const password = (formData.get("password") as string) || "";
// 2. 비밀번호 강도 검증
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return redirect(
`/reset-password?message=${encodeURIComponent(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)}`);
}
// 5. 성공 - 캐시 무효화 및 로그인 페이지로 리다이렉트
revalidatePath("/", "layout");
redirect(
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS)}`,
);
}