Auth 구현
Implicit Flow vs. PKCE (Proof Key for Code Exchange) Flow
-
Implicit Flow
- 클라이언트가 인증 서버에 로그인을 요청한 후, 토큰을 클라이언트 측에 저장한다.
- URL 혹은 Request에 해당 토큰을 담아 사용한다.
- 토큰이 쉽게 탈취될 가능성이 있고 CSRF(사이트간 위조)를 방어하기 취약하다.
-
PKCE
- 클라이언트가 임의의 난수(code_verifier)와 난수를 암호화한 수(code_challenge)를 가짐
- 서버가 code_challenge를 전달받음.
- 이후 클라이언트가 엑세스 토큰을 요청할 때마다 code_verifier를 함께 보내고, code_verifier가 유효할 때에만 엑세스 토큰이 발급됨.
- 토큰이 유출되더라도 토큰이 만료되면 code_verifier가 있어야 새로운 토큰을 발급받을 수 있으며, code_verifier는 엑세스 토큰에 비해 유출될 위험이 비교적 적음.
PKCE (Proof Key for Code Exchange) Flow 구현
Google 로그인 구현
Google Cloud API Oauth 세팅
- 구글 클라우드 콘솔 에 접속한다.
- 새 프로젝트를 생성한다.
- 프로젝트-API 및 서비스-OAuth 동의 화면에 접속한다.
- '시작하기' 버튼을 누른 후 아래와 관련된 사항들을 입력하여 OAuth를 시작한다.
- '앱 이름'(로그인 시 노출될 프로젝트 명)
- '사용자 지원 이메일'(내 이메일)
- '대상'(외부)
- 이제 Supabase에 접속해서 'https://supabase.com/dashboard/project/{Project ID}/auth/providers'에 접속하여 Google을 Provider로 선택한다.
- 해당 페이지에서 'Callback URL (for OAuth)'를 확인한다.
- 다시 구글 클라우드 콘솔 프로젝트-API 및 서비스-OAuth 동의 화면에서 'OAuth 클라이언트 만들기'를 클릭하고 아래의 사항들을 입력하여 Client를 만든다.
- '애플리케이션 유형'(웹 애플리케이션)
- '승인된 JavaScript 원본'(http://localhost:3000)
- '승인된 리디렉션 URI'(https://{Project ID}.supabase.co/auth/v1/callback)
- 다시 Supabase에 접속해서 'https://supabase.com/dashboard/project/{Project ID}/auth/providers'에 아래 사항들을 입력해준다.
- Enable Sign in with Google를 활성화한다.
- 클라이언트 ID : Google OAuth 클라이언트의 ID (566..877-cuhhs...apps.googleusercontent.com)
- 클라이언트 Secret : Google OAuth 클라이언트의 보안 비밀번호 (GO...PX-fK...xQ)
- 이제 Supabase에 Google Cloud API의 OAuth Client가 등록되었다.
Route Handlers 구현
- .env 파일에
NEXT_PUBLIC_BASE_URL=http://localhost:3000
을 변수명으로 추가해준다. (해당 변수명은 배포/개발시마다 바뀔 수 있다.) - 아래와 같이
app\auth\callback\route.ts
파일을 작성한다.- code : 인증서비스제공자가 Authorization Code를 supabase 서버에 전달하면, supabase 서버는 이를 Search Params의 code라는 key에 담아서 보내준다.
- next : Next.js에서 이동할 URL을 설정할 때에는 next라는 key로 이동할 url을 Search Params에 담으면 된다.
- exchangeCodeForSession : supabase 클라이언트에서 Authorization Code를 인자로 받아, access_token을 반환받고 세션을 생성한다.
- if !error : exchange가 성공적으로 완료되면 사용자를 redirect한다. (forwardedHost는 한 어플리케이션을 여러 서버가 다룰 때에 사용하는 서버의 주소이다.(로드 밸런싱 방식))
import { NextResponse } from "next/server";
// The client you created from the Server-Side Auth instructions
import { createClient } from "@/utils/supabase/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
// if "next" is in param, use it as the redirect URL
const next = searchParams.get("next") ?? "/";
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer
const isLocalEnv = process.env.NODE_ENV === "development";
if (isLocalEnv) {
// we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
return NextResponse.redirect(`${origin}${next}`);
}
}
}
// return the user to an error page with instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}
Pages 구현
- login page : signInWithOAuth 클라이언트에 provider와 /auth/callback 주소를 전송한다.
// app/auth/login/page.tsx (로그인 페이지)
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/client";
export default function LoginPage() {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async () => {
setLoading(true);
const supabase = createClient();
const { error } = await supabase.auth.signInWithOAuth({
provider: "google", // Google OAuth 로그인
options: { redirectTo: `${window.location.origin}/auth/callback` },
});
if (error) {
router.push(
`/auth/auth-code-error?message=${encodeURIComponent(error.message)}`
);
}
setLoading(false);
};
return (
<div className="flex flex-col items-center justify-center h-screen">
<h1 className="text-2xl font-bold">로그인</h1>
<button
onClick={handleLogin}
disabled={loading}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
{loading ? "로그인 중..." : "Google 로그인"}
</button>
</div>
);
}
- auth-code-error에서는 searchParams으로 넘어온 에러 메시지를 노출시킨다.
import Link from "next/link";
// app/auth/auth-code-error/page.tsx (로그인 에러 페이지)
export default function AuthCodeErrorPage({
searchParams,
}: {
searchParams: { message?: string };
}) {
const errorMessage =
searchParams?.message || "로그인 중 문제가 발생했습니다.";
return (
<div className="flex flex-col items-center justify-center h-screen">
<h1 className="text-2xl font-bold text-red-500">인증 오류 발생</h1>
<p className="text-gray-600 mt-2">{errorMessage}</p>
<Link
href="/auth/login"
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
로그인 다시 시도하기
</Link>
</div>
);
}
Password 로그인 구현
Supabase is now compatible with Next.js 14 - supabase blogs 를 참고하여 Sign-up 액션과 Sign-in 액션을 구현하여 보자.
Redirect용 Util 함수
utils\encodedRedirect.tsx
import { redirect } from "next/navigation";
/**
* Redirects to a specified path with an encoded message as a query parameter.
* @param {('error' | 'success')} type - The type of message, either 'error' or 'success'.
* @param {string} path - The path to redirect to.
* @param {string} message - The message to be encoded and added as a query parameter.
* @returns {never} This function doesn't return as it triggers a redirect.
*/
export function encodedRedirect(
type: "error" | "success", // 메시지 타입 (에러 또는 성공)
path: string, // 리디렉션할 경로
message: string // 전달할 메시지
) {
return redirect(`${path}?${type}=${encodeURIComponent(message)}`);
}
encodeURIComponent는 JavaScript 내장 함수로, 특수 문자나 공백이 포함된 문자열을 URL-safe 형식으로 인코딩하는 역할을 합니다.
Actions
🚀 📌 코드 분석 요약
-
회원가입 (
signUpAction
)- 이메일과 비밀번호를 받아 Supabase에 회원가입 요청.
- 인증 이메일을 전송하고, 성공 또는 실패 여부에 따라 적절한 페이지로 리디렉션.
-
로그인 (
signInAction
)- 이메일과 비밀번호를 받아 Supabase에 로그인 요청.
- 로그인 실패 시
/sign-in
으로 에러 메시지를 포함하여 리디렉션. - 로그인 성공 시 보호된 페이지
/protected
로 이동.
-
비밀번호 재설정 요청 (
forgotPasswordAction
)- 이메일을 받아 비밀번호 재설정 이메일을 전송.
- 실패하면
/forgot-password
로 에러 메시지를 포함하여 리디렉션. - 성공하면 사용자가 이메일을 확인하도록 안내하는 메시지를 포함하여 리디렉션.
-
비밀번호 변경 (
resetPasswordAction
)- 사용자가 입력한 새 비밀번호를 확인 후 Supabase에 업데이트 요청.
- 비밀번호가 일치하지 않거나 요청이 실패하면 에러 메시지를 포함하여 리디렉션.
- 성공 시 비밀번호가 변경되었음을 알리는 메시지를 포함하여 리디렉션.
-
로그아웃 (
signOutAction
)- Supabase에서 세션을 삭제하고
/sign-in
페이지로 이동.
- Supabase에서 세션을 삭제하고
✔️ 모든 액션에서 encodedRedirect()
를 활용하여 성공/실패 메시지를 포함한 리디렉션을 수행하는 것이 특징! 🚀
app\auth\actions.tsx
"use server"; // Next.js의 Server Actions를 사용하도록 지정
import { encodedRedirect } from "@/utils/encodedRedirect"; // 메시지를 포함한 리디렉션 함수
import { createClient } from "@/utils/supabase/server"; // Supabase 클라이언트 생성 함수
import { headers } from "next/headers"; // 요청 헤더 가져오기
import { redirect } from "next/navigation"; // Next.js 리디렉션 함수
// 회원가입 처리 (Sign Up)
export const signUpAction = async (formData: FormData) => {
// 폼 데이터에서 이메일과 비밀번호 추출
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
const supabase = await createClient(); // Supabase 클라이언트 생성
const origin = (await headers()).get("origin"); // 현재 요청의 Origin (도메인) 가져오기
// 이메일 또는 비밀번호가 없으면 에러 메시지를 포함하여 리디렉션
if (!email || !password) {
return encodedRedirect(
"error",
"/sign-up",
"Email and password are required"
);
}
// Supabase를 사용해 회원가입 요청
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/callback`, // 이메일 확인 후 이동할 URL 설정
},
});
// 에러 발생 시 에러 메시지를 포함하여 리디렉션
if (error) {
console.error(error.code + " " + error.message);
return encodedRedirect("error", "/sign-up", error.message);
}
// 회원가입 성공 시 성공 메시지를 포함하여 리디렉션
return encodedRedirect(
"success",
"/sign-up",
"Thanks for signing up! Please check your email for a verification link."
);
};
// 로그인 처리 (Sign In)
export const signInAction = async (formData: FormData) => {
// 폼 데이터에서 이메일과 비밀번호 추출
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const supabase = await createClient(); // Supabase 클라이언트 생성
// Supabase를 사용해 로그인 요청
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
// 에러 발생 시 에러 메시지를 포함하여 리디렉션
if (error) {
return encodedRedirect("error", "/sign-in", error.message);
}
// 로그인 성공 시 보호된 페이지로 이동
return redirect("/protected");
};
// 비밀번호 재설정 요청 (Forgot Password)
export const forgotPasswordAction = async (formData: FormData) => {
// 폼 데이터에서 이메일 추출
const email = formData.get("email")?.toString();
const supabase = await createClient(); // Supabase 클라이언트 생성
const origin = (await headers()).get("origin"); // 현재 요청의 Origin (도메인) 가져오기
const callbackUrl = formData.get("callbackUrl")?.toString(); // 콜백 URL이 있는 경우 가져오기
// 이메일이 없으면 에러 메시지를 포함하여 리디렉션
if (!email) {
return encodedRedirect("error", "/forgot-password", "Email is required");
}
// Supabase를 사용해 비밀번호 재설정 이메일 전송 요청
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`, // 비밀번호 재설정 후 이동할 URL 설정
});
// 에러 발생 시 에러 메시지를 포함하여 리디렉션
if (error) {
console.error(error.message);
return encodedRedirect(
"error",
"/forgot-password",
"Could not reset password"
);
}
// 콜백 URL이 있으면 해당 URL로 리디렉션
if (callbackUrl) {
return redirect(callbackUrl);
}
// 비밀번호 재설정 이메일이 전송되었음을 알리는 메시지 포함하여 리디렉션
return encodedRedirect(
"success",
"/forgot-password",
"Check your email for a link to reset your password."
);
};
// 비밀번호 변경 처리 (Reset Password)
export const resetPasswordAction = async (formData: FormData) => {
const supabase = await createClient(); // Supabase 클라이언트 생성
// 폼 데이터에서 새 비밀번호와 확인용 비밀번호 추출
const password = formData.get("password") as string;
const confirmPassword = formData.get("confirmPassword") as string;
// 비밀번호 또는 확인용 비밀번호가 없으면 에러 메시지를 포함하여 리디렉션
if (!password || !confirmPassword) {
return encodedRedirect(
"error",
"/protected/reset-password",
"Password and confirm password are required"
);
}
// 비밀번호와 확인용 비밀번호가 일치하지 않으면 에러 메시지를 포함하여 리디렉션
if (password !== confirmPassword) {
return encodedRedirect(
"error",
"/protected/reset-password",
"Passwords do not match"
);
}
// Supabase를 사용해 비밀번호 변경 요청
const { error } = await supabase.auth.updateUser({
password: password,
});
// 에러 발생 시 에러 메시지를 포함하여 리디렉션
if (error) {
return encodedRedirect(
"error",
"/protected/reset-password",
"Password update failed"
);
}
// 비밀번호 변경 성공 시 성공 메시지를 포함하여 리디렉션
return encodedRedirect(
"success",
"/protected/reset-password",
"Password updated"
);
};
// 로그아웃 처리 (Sign Out)
export const signOutAction = async () => {
const supabase = await createClient(); // Supabase 클라이언트 생성
// Supabase를 사용해 로그아웃 요청
await supabase.auth.signOut();
// 로그아웃 후 로그인 페이지로 이동
return redirect("/sign-in");
};
해당 Server Actions는 두 가지 사용법이 있다.
- Form 태그에 action 속성에 넘겨주는 방법
<form action={signOutAction}>
<button type="submit">Sign out</button>
</form>
- Form 태그 내부의 Button 태그에 formAction 속성에 넘겨주는 방법
<form>
<button formAction={signOutAction}>Sign out</button>
</form>