5단계
OAuth 2 provider + 화이트리스트
30 분
OAuth 2 provider + 화이트리스트
관리자 앱은 외부 사용자가 가입하는 사이트가 아니므로 비밀번호 · 이메일 인증 같은 full-flow 가 필요 없습니다. "신원은 카카오 · 네이버가 증명, 허가된 이메일만 관리자" 가 가장 단순.
1. 흐름 개요
① /login 에서 [카카오로 로그인] 클릭
② GET /api/auth/kakao → Kakao 로 302
③ Kakao 에서 callback → GET /api/auth/kakao/callback
④ code → access_token → 사용자 이메일 조회
⑤ 이메일이 화이트리스트 에 있는지 확인
⑥ JWT 세션 쿠키 발급 (HTTP-only, secure, sameSite=lax)
⑦ /admin/dashboard 로 redirect
2. 화이트리스트
// src/shared/lib/auth/allowed-emails.ts
const raw = process.env.ADMIN_ALLOWED_EMAILS ?? '';
export const ALLOWED_EMAILS = new Set(
raw.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)
);
export function isAllowedEmail(email: string): boolean {
return ALLOWED_EMAILS.has(email.toLowerCase());
}
DB 테이블에 두고 관리할 수도 있지만 초기에는 .env 변수가 가볍고 배포와 함께 변경됨을 명확히 합니다.
3. Kakao provider
// src/shared/lib/auth/oauth-kakao.ts
export async function exchangeKakaoCode(code: string) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: requireEnv('KAKAO_CLIENT_ID'),
client_secret: requireEnv('KAKAO_CLIENT_SECRET'),
redirect_uri: requireEnv('KAKAO_REDIRECT_URI'),
code,
});
const tokenRes = await fetch('https://kauth.kakao.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const { access_token } = await tokenRes.json();
const userRes = await fetch('https://kapi.kakao.com/v2/user/me', {
headers: { Authorization: `Bearer ${access_token}` },
});
const user = await userRes.json();
return {
email: user.kakao_account?.email as string | undefined,
nickname: user.kakao_account?.profile?.nickname as string | undefined,
providerId: String(user.id),
};
}
Naver · Google 도 같은 틀.
4. Callback handler
// src/app/api/auth/kakao/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { exchangeKakaoCode } from '@/shared/lib/auth/oauth-kakao';
import { isAllowedEmail } from '@/shared/lib/auth/allowed-emails';
import { issueSessionCookie } from '@/shared/lib/auth/session';
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code');
if (!code) return NextResponse.redirect(new URL('/login?e=no_code', req.url));
try {
const user = await exchangeKakaoCode(code);
if (!user.email || !isAllowedEmail(user.email)) {
return NextResponse.redirect(new URL('/login?e=not_allowed', req.url));
}
const res = NextResponse.redirect(new URL('/admin/dashboard', req.url));
await issueSessionCookie(res, { email: user.email, provider: 'kakao' });
return res;
} catch (e) {
return NextResponse.redirect(new URL('/login?e=oauth_fail', req.url));
}
}
에러 메시지는 URL param ?e=... 로 전달해 /login 에서 토스트 표시.
5. JWT 세션
// src/shared/lib/auth/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
const SECRET = new TextEncoder().encode(requireEnv('JWT_SECRET_KEY'));
const EXPIRES = Number(process.env.SESSION_EXPIRES_IN ?? 86400);
export async function issueSessionCookie(
res: NextResponse,
payload: { email: string; provider: string }
) {
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${EXPIRES}s`)
.sign(SECRET);
res.cookies.set('admin_session', jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: EXPIRES,
path: '/',
});
}
export async function verifySession() {
const c = (await cookies()).get('admin_session')?.value;
if (!c) return null;
try {
const { payload } = await jwtVerify(c, SECRET);
return payload as { email: string; provider: string };
} catch { return null; }
}
jose 라이브러리는 Edge Runtime 호환.
6. AuthGuard layout
// src/app/admin/layout.tsx
import { redirect } from 'next/navigation';
import { verifySession } from '@/shared/lib/auth/session';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await verifySession();
if (!session) redirect('/login');
return <div className="flex">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>;
}
모든 /admin/** 이 이 layout 아래.
7. CSRF state 검증
Naver 는 state 파라미터 검증이 공식 권장. Kakao 도 동일 패턴 권장.
// /api/auth/kakao
const state = crypto.randomUUID();
const res = NextResponse.redirect(authUrl(state));
res.cookies.set('oauth_state', state, { httpOnly: true, maxAge: 300 });
return res;
// /api/auth/kakao/callback
const cookieState = req.cookies.get('oauth_state')?.value;
const queryState = req.nextUrl.searchParams.get('state');
if (!cookieState || cookieState !== queryState) {
return NextResponse.redirect(new URL('/login?e=csrf', req.url));
}
하고픈 말
관리자 앱에서는 신원 증명을 외부 IdP 에 위임하는 쪽이 단순하고 안전합니다. 화이트리스트는 DB 가 아닌 .env 로 두면 퇴사자 정리도 배포 한 번.
Next
- 06-audit-log