2025-12-20 13:43:37 +09:00
|
|
|
# MNG → DEV 자동 로그인 기능 (React 구현 가이드)
|
|
|
|
|
|
|
|
|
|
## 개요
|
|
|
|
|
|
|
|
|
|
MNG 관리자가 사용자 목록에서 "DEV 접속" 버튼을 클릭하면, 해당 사용자로 자동 로그인되어 DEV 사이트로 이동하는 기능.
|
|
|
|
|
|
|
|
|
|
## 아키텍처
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
|
|
|
│ MNG │ │ API │ │ React │
|
|
|
|
|
│ (관리자) │ │ (토큰검증) │ │ (프론트) │
|
|
|
|
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
|
|
|
|
│ │ │
|
|
|
|
|
1. DEV접속 클릭 │ │
|
|
|
|
|
│ │ │
|
|
|
|
|
2. 토큰생성 ─────────────┼─── DB 저장 ───────┤
|
|
|
|
|
│ │ (login_tokens) │
|
|
|
|
|
3. URL반환 │ │
|
|
|
|
|
│ │ │
|
|
|
|
|
4. 새창열기 ─────────────┼───────────────────┼─→ /auto-login?token=xxx
|
|
|
|
|
│ │ │
|
|
|
|
|
│ 5. 토큰검증 ←────────────┤
|
|
|
|
|
│ │ │
|
|
|
|
|
│ 6. access_token 발급 ────┤
|
|
|
|
|
│ │ │
|
|
|
|
|
│ 7. 토큰삭제 (1회용) │
|
|
|
|
|
│ │ │
|
|
|
|
|
│ │ 8. 로그인 완료 → 대시보드
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## 환경별 URL
|
|
|
|
|
|
|
|
|
|
| 환경 | MNG | DEV |
|
|
|
|
|
|------|-----|-----|
|
|
|
|
|
| 로컬 | https://mng.sam.kr | https://dev.sam.kr |
|
2026-02-24 22:40:20 +09:00
|
|
|
| 개발 | https://admin.codebridge-x.com | https://dev.codebridge-x.com |
|
2025-12-20 13:43:37 +09:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## React 구현 작업
|
|
|
|
|
|
|
|
|
|
### 1. 신규 파일: `/auto-login` 페이지
|
|
|
|
|
|
|
|
|
|
**경로:** `src/app/[locale]/auto-login/page.tsx`
|
|
|
|
|
|
|
|
|
|
```tsx
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
|
|
|
import { transformApiMenusToMenuItems } from "@/lib/utils/menuTransform";
|
|
|
|
|
|
|
|
|
|
export default function AutoLoginPage() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const searchParams = useSearchParams();
|
|
|
|
|
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
|
|
|
|
const [message, setMessage] = useState("자동 로그인 처리 중...");
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const token = searchParams.get("token");
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
setStatus("error");
|
|
|
|
|
setMessage("유효하지 않은 접근입니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const performAutoLogin = async () => {
|
|
|
|
|
try {
|
|
|
|
|
// 1. 기존 세션 클리어 (기존 로그인 사용자 로그아웃)
|
|
|
|
|
localStorage.removeItem("user");
|
|
|
|
|
|
|
|
|
|
// 기존 쿠키도 클리어하기 위해 logout API 호출 (선택사항)
|
|
|
|
|
try {
|
|
|
|
|
await fetch("/api/auth/logout", { method: "POST" });
|
|
|
|
|
} catch {
|
|
|
|
|
// 로그아웃 실패해도 계속 진행
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 토큰 로그인 API 호출
|
|
|
|
|
const response = await fetch("/api/auth/token-login", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ token }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorData = await response.json();
|
|
|
|
|
throw new Error(errorData.error || "토큰이 만료되었거나 유효하지 않습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
// 3. 사용자 정보 저장 (기존 로그인과 동일한 형식)
|
|
|
|
|
const transformedMenus = transformApiMenusToMenuItems(data.menus || []);
|
|
|
|
|
|
|
|
|
|
const userData = {
|
|
|
|
|
name: data.user?.name,
|
|
|
|
|
position: data.roles?.[0]?.description || "사용자",
|
|
|
|
|
userId: data.user?.user_id,
|
|
|
|
|
menu: transformedMenus,
|
|
|
|
|
roles: data.roles || [],
|
|
|
|
|
tenant: data.tenant || {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
localStorage.setItem("user", JSON.stringify(userData));
|
|
|
|
|
|
|
|
|
|
setStatus("success");
|
|
|
|
|
setMessage("로그인 성공! 대시보드로 이동합니다...");
|
|
|
|
|
|
|
|
|
|
// 4. 대시보드로 리다이렉트
|
|
|
|
|
setTimeout(() => router.push("/dashboard"), 500);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Auto login failed:", error);
|
|
|
|
|
setStatus("error");
|
|
|
|
|
setMessage(error instanceof Error ? error.message : "로그인에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
performAutoLogin();
|
|
|
|
|
}, [searchParams, router]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
|
|
|
<div className="text-center p-8">
|
|
|
|
|
{status === "loading" && (
|
|
|
|
|
<div className="animate-spin h-12 w-12 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4" />
|
|
|
|
|
)}
|
|
|
|
|
{status === "success" && (
|
|
|
|
|
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{status === "error" && (
|
|
|
|
|
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
|
|
|
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<p className={`text-lg ${status === "error" ? "text-destructive" : "text-muted-foreground"}`}>
|
|
|
|
|
{message}
|
|
|
|
|
</p>
|
|
|
|
|
{status === "error" && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => router.push("/login")}
|
|
|
|
|
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
|
|
|
|
>
|
|
|
|
|
로그인 페이지로 이동
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 2. 신규 파일: 토큰 로그인 API Route
|
|
|
|
|
|
|
|
|
|
**경로:** `src/app/api/auth/token-login/route.ts`
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
import { NextResponse } from "next/server";
|
|
|
|
|
import type { NextRequest } from "next/server";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 토큰 자동 로그인 API
|
|
|
|
|
*
|
|
|
|
|
* MNG 관리자가 생성한 One-Time Token으로 자동 로그인 처리
|
|
|
|
|
*
|
|
|
|
|
* 흐름:
|
|
|
|
|
* 1. token 파라미터 검증
|
|
|
|
|
* 2. PHP API /v1/auth/token-login 호출
|
|
|
|
|
* 3. access_token을 HttpOnly 쿠키로 저장
|
|
|
|
|
* 4. 사용자 정보 반환
|
|
|
|
|
*/
|
|
|
|
|
export async function POST(request: NextRequest) {
|
|
|
|
|
try {
|
|
|
|
|
const { token } = await request.json();
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
{ error: "Token is required" },
|
|
|
|
|
{ status: 400 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PHP 백엔드 호출
|
|
|
|
|
const backendResponse = await fetch(
|
|
|
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/token-login`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
"X-API-KEY": process.env.NEXT_PUBLIC_API_KEY || "",
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ token }),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!backendResponse.ok) {
|
|
|
|
|
const errorText = await backendResponse.text();
|
|
|
|
|
console.error("Token login backend error:", errorText);
|
|
|
|
|
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
{ error: "Invalid or expired token" },
|
|
|
|
|
{ status: 401 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await backendResponse.json();
|
|
|
|
|
|
|
|
|
|
// HttpOnly 쿠키 설정 (기존 login/route.ts와 동일)
|
|
|
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
|
|
|
|
|
|
|
|
const accessTokenCookie = [
|
|
|
|
|
`access_token=${data.access_token}`,
|
|
|
|
|
"HttpOnly",
|
|
|
|
|
...(isProduction ? ["Secure"] : []),
|
|
|
|
|
"SameSite=Lax",
|
|
|
|
|
"Path=/",
|
|
|
|
|
`Max-Age=${data.expires_in || 7200}`,
|
|
|
|
|
].join("; ");
|
|
|
|
|
|
|
|
|
|
const refreshTokenCookie = [
|
|
|
|
|
`refresh_token=${data.refresh_token}`,
|
|
|
|
|
"HttpOnly",
|
|
|
|
|
...(isProduction ? ["Secure"] : []),
|
|
|
|
|
"SameSite=Lax",
|
|
|
|
|
"Path=/",
|
|
|
|
|
"Max-Age=604800",
|
|
|
|
|
].join("; ");
|
|
|
|
|
|
|
|
|
|
console.log("✅ Token login successful - tokens stored in HttpOnly cookies");
|
|
|
|
|
|
|
|
|
|
const response = NextResponse.json({
|
|
|
|
|
message: data.message,
|
|
|
|
|
user: data.user,
|
|
|
|
|
tenant: data.tenant,
|
|
|
|
|
menus: data.menus,
|
|
|
|
|
roles: data.roles,
|
|
|
|
|
expires_in: data.expires_in,
|
|
|
|
|
expires_at: data.expires_at,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
response.headers.append("Set-Cookie", accessTokenCookie);
|
|
|
|
|
response.headers.append("Set-Cookie", refreshTokenCookie);
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Token login error:", error);
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
{ error: "Internal server error" },
|
|
|
|
|
{ status: 500 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 3. 수정 파일: auth-config.ts
|
|
|
|
|
|
|
|
|
|
**경로:** `src/lib/api/auth/auth-config.ts`
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// guestOnlyRoutes 배열에 /auto-login 추가
|
|
|
|
|
guestOnlyRoutes: [
|
|
|
|
|
'/login',
|
|
|
|
|
'/forgot-password',
|
|
|
|
|
'/auto-login', // 추가
|
|
|
|
|
],
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
### 4. 수정 파일: middleware.ts (확인 필요)
|
|
|
|
|
|
|
|
|
|
`/auto-login` 경로가 인증 없이 접근 가능한지 확인 필요.
|
|
|
|
|
`guestOnlyRoutes`에 추가되면 자동으로 처리될 수 있음.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## API 엔드포인트 (백엔드)
|
|
|
|
|
|
|
|
|
|
### POST /api/v1/auth/token-login
|
|
|
|
|
|
|
|
|
|
**Request:**
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"token": "abc123..." // 64자 랜덤 문자열
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Response (성공):**
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"message": "로그인 성공",
|
|
|
|
|
"access_token": "1|xxx...",
|
|
|
|
|
"refresh_token": "2|yyy...",
|
|
|
|
|
"token_type": "Bearer",
|
|
|
|
|
"expires_in": 7200,
|
|
|
|
|
"expires_at": "2025-12-20 15:00:00",
|
|
|
|
|
"user": {
|
|
|
|
|
"id": 1,
|
|
|
|
|
"user_id": "testuser",
|
|
|
|
|
"name": "테스트 사용자",
|
|
|
|
|
"email": "test@example.com"
|
|
|
|
|
},
|
|
|
|
|
"tenant": { ... },
|
|
|
|
|
"menus": [ ... ],
|
|
|
|
|
"roles": [ ... ]
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Response (실패):**
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"error": "Invalid or expired token"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 보안 고려사항
|
|
|
|
|
|
|
|
|
|
1. **토큰 1회 사용**: 사용 후 즉시 삭제
|
|
|
|
|
2. **토큰 만료**: 5분 (생성 후)
|
|
|
|
|
3. **HTTPS 필수**: 토큰이 URL에 노출되므로
|
|
|
|
|
4. **관리자 권한**: MNG 로그인한 관리자만 토큰 생성 가능
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 테스트 시나리오
|
|
|
|
|
|
|
|
|
|
1. MNG에서 사용자 목록 → "DEV 접속" 버튼 클릭
|
|
|
|
|
2. 새 창에서 DEV 사이트 열림
|
|
|
|
|
3. 자동 로그인 처리 (로딩 표시)
|
|
|
|
|
4. 성공 시 대시보드로 이동
|
|
|
|
|
5. 실패 시 에러 메시지 + 로그인 페이지 이동 버튼
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 작성일
|
|
|
|
|
|
|
|
|
|
2025-12-20
|