| name | supabase-auth-integrator |
| description | Supabase 인증 시스템을 React 앱에 통합. 소셜 로그인, 이메일 로그인, 세션 관리, 보호된 라우트 등 라이어 게임 사용자 인증 시스템 구축 시 사용합니다. |
Supabase Auth 통합
지침
Supabase 인증 시스템을 React 앱에 완전히 통합:
- Supabase 클라이언트 설정: 프로젝트 설정 및 환경변수 구성
- 인증 Provider 구성: React Context를 통한 전역 상태 관리
- 로그인 UI 구현: 소셜 로그인 및 이메일/비밀번호 폼
- 세션 관리: 자동 토큰 갱신 및 세션 지속성
- 보호된 라우트: 인증된 사용자만 접근 가능한 페이지
- 보안 고려사항: XSS 방지, 안전한 토큰 저장
Supabase 클라이언트 설정
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
flowType: 'pkce'
}
});
인증 Context 설정
// context/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { User, Session, AuthError } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<{ error: AuthError | null }>;
signUp: (email: string, password: string) => Promise<{ error: AuthError | null }>;
signInWithProvider: (provider: 'google' | 'github' | 'discord') => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 초기 세션 확인
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
// 인증 상태 변경 리스너
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
}
);
return () => subscription.unsubscribe();
}, []);
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error };
};
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({ email, password });
return { error };
};
const signInWithProvider = async (provider: 'google' | 'github' | 'discord') => {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) throw error;
};
const signOut = async () => {
await supabase.auth.signOut();
};
const value = {
user,
session,
loading,
signIn,
signUp,
signInWithProvider,
signOut
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
로그인 폼 컴포넌트
// components/auth/LoginForm.tsx
import React, { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/Button';
import { FcGoogle } from 'react-icons/fc';
import { FaGithub } from 'react-icons/fa';
import { FaDiscord } from 'react-icons/fa';
export const LoginForm: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { signIn, signInWithProvider } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await signIn(email, password);
if (error) {
setError(error.message);
}
setLoading(false);
};
const handleSocialLogin = async (provider: 'google' | 'github' | 'discord') => {
try {
await signInWithProvider(provider);
} catch (error) {
setError('소셜 로그인에 실패했습니다.');
}
};
return (
<div className="w-full max-w-md mx-auto">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
이메일
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
비밀번호
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
required
/>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<Button
type="submit"
disabled={loading}
className="w-full"
>
{loading ? '로그인 중...' : '로그인'}
</Button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">또는</span>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-3">
<button
onClick={() => handleSocialLogin('google')}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<FcGoogle className="h-5 w-5 mr-2" />
Google로 로그인
</button>
<button
onClick={() => handleSocialLogin('github')}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<FaGithub className="h-5 w-5 mr-2" />
GitHub으로 로그인
</button>
<button
onClick={() => handleSocialLogin('discord')}
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
>
<FaDiscord className="h-5 w-5 mr-2 text-indigo-500" />
Discord로 로그인
</button>
</div>
</div>
</div>
);
};
보호된 라우트
// components/auth/ProtectedRoute.tsx
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
interface ProtectedRouteProps {
children: React.ReactNode;
redirectTo?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
redirectTo = '/login'
}) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!user) {
return <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
};
앱 루트 설정
// App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from '@/context/AuthContext';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { HomePage } from '@/pages/HomePage';
import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard/*"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Routes>
</Router>
</AuthProvider>
);
}
export default App;
핵심 패턴
- Context 기반 상태 관리: 전역 인증 상태 공유
- 자동 세션 관리: Supabase의 자동 토큰 갱신 활용
- PKCE 흐름: 소셜 로그인 보안 강화
- 로딩 상태 처리: 사용자 경험 향상
- 에러 처리: 명확한 에러 메시지 제공
- 보호된 라우트: 인증된 사용자만 접근 제어
- 타입 안전성: TypeScript로 모든 인증 관련 타입 정의
환경변수 설정
# .env.local
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
보안 고려사항
- 안전한 토큰 저장: localStorage 대신 sessionStorage 사용
- HTTPS 필수: 프로덕션에서는 HTTPS 사용
- 환경변수 관리: 민감 정보는 서버 사이드에서 처리
- CSRF 방지: Supabase의 내장 보안 기능 활용
- 세션 타임아웃: 적절한 세션 만료 시간 설정