codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›중앙 관리자 플랫폼 — 여러 도메인을 한 허브에서›5단계

5단계

OAuth 2 provider + 화이트리스트

0회 조회

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

← 4단계

AdminResourceTable 공통 컴포넌트

6단계 →

감사로그 — logAdminAction