feat(auth): 카카오 로그인/가입 추가 (kakao_id 매칭 + 추가정보 입력 플로우)

- user_info.kakao_id 컬럼 + 부분 unique 인덱스 (010 마이그레이션)
- OAuth 인가/콜백/완료 3-step 플로우, state CSRF + pending JWT 사용
- 신규 사용자는 /signup/kakao 에서 업체정보 입력 후 가입, 동일 이메일 일반
  가입자가 카카오 로그인 시 자동으로 kakao_id 연결
- 비즈 앱 미인증 환경에서도 동작하도록 이메일 입력 필드 조건부 노출
- 운영 전환 체크리스트(docs/KAKAO_LOGIN_CHECKLIST.md) 동봉

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-04-29 22:05:44 +09:00
parent defd358e20
commit 92ad098351
12 changed files with 976 additions and 7 deletions
+16
View File
@@ -0,0 +1,16 @@
-- 010_kakao_login.sql
-- 카카오 OAuth 로그인/가입 지원
-- 1) user_info.kakao_id 컬럼 추가 (카카오 user.id, 정수형이지만 64자 문자열로 저장)
-- 2) kakao_id 부분 unique 인덱스 (NULL 다수 허용, 값이 있을 때만 unique)
BEGIN;
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS kakao_id VARCHAR(64);
COMMENT ON COLUMN user_info.kakao_id IS '카카오 로그인 연동 ID (Kakao user.id). NULL = 미연동';
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_info_kakao_id
ON user_info (kakao_id)
WHERE kakao_id IS NOT NULL;
COMMIT;
+155
View File
@@ -0,0 +1,155 @@
# 카카오 로그인 — 운영 전환 체크리스트
테스트 환경에서 카카오 로그인/가입 기능 동작 확인 완료.
운영 배포 전후로 처리할 작업을 단계별로 정리.
---
## 1. 운영 DB 마이그레이션
운영 DB 에 `user_info.kakao_id` 컬럼 + 부분 unique 인덱스 추가.
```bash
# A. 자동 배포 스크립트 사용 (권장 — 멱등)
# deploy.sh 가 매 배포마다 migrate:momo 자동 실행
docker compose -f docker-compose.prod.yml exec -T momo-erp npm run migrate:momo
# B. 010 만 수동 실행 (009 가 admin 비번 초기화하는 게 부담스러우면)
psql "<운영 DATABASE_URL>" -f db/migrations/010_kakao_login.sql
```
### 검증
```sql
-- 컬럼 존재 확인
SELECT column_name FROM information_schema.columns
WHERE table_name = 'user_info' AND column_name = 'kakao_id';
-- 부분 unique 인덱스 확인
SELECT indexdef FROM pg_indexes
WHERE indexname = 'idx_user_info_kakao_id';
```
---
## 2. 카카오 앱 명의 이전 (모모유통)
**현재 상태**: 개발자(혜진) 개인 카카오 계정 명의로 등록된 테스트 앱.
**목표 상태**: (주)모모유통 명의 카카오 계정으로 등록된 운영 앱 + 비즈 앱 인증.
### 모모유통 측에서 진행
- [ ] 회사 대표 이메일로 카카오 계정 생성 (예: `momo8443@daum.net`)
- [ ] https://developers.kakao.com 가입
- [ ] **애플리케이션 추가**
- 앱 이름: `MOMO 유통 ERP` (또는 적절한 이름)
- 사업자명: `(주)모모유통`
- [ ] **비즈니스 → 비즈 앱 전환 → 사업자등록증 업로드 → 검수 신청** (1~3 영업일 소요)
- [ ] 검수 통과 후:
- **카카오 로그인 → 동의항목**에서 `카카오계정(이메일)`**필수 동의** 로 활성화
- [ ] **앱 → 멤버** 메뉴에서 개발자(혜진) 카카오 계정을 팀원으로 초대
### 신규 앱에서 등록할 것
- [ ] **카카오 로그인 → 활성화 ON**
- [ ] **앱 → 플랫폼 키 → 카카오 로그인 리다이렉트 URI 등록**
- `http://localhost:3000/api/auth/kakao/callback` (개발용)
- `https://momotogether.com/api/auth/kakao/callback` (운영용)
- [ ] **클라이언트 시크릿 활성화 + 코드 발급** (보안 권장)
- [ ] **동의항목**: 닉네임 필수 동의 + 이메일 필수 동의 (비즈 검수 통과 후)
---
## 3. 운영 환경변수 설정
운영 서버의 `.env.production` 에 카카오 환경변수 3개 추가.
```bash
# 운영 서버 SSH 접속
cd /deploy/source # 또는 ~/momo-erp/source
# .env.production 끝에 추가
cat >> .env.production <<'EOF'
KAKAO_REST_API_KEY=<신규 앱의 REST API 키>
KAKAO_CLIENT_SECRET=<신규 앱의 Client Secret>
KAKAO_REDIRECT_URI=https://momotogether.com/api/auth/kakao/callback
EOF
# 컨테이너 재시작 (env_file 다시 로드)
docker compose -f docker-compose.prod.yml up -d --force-recreate momo-erp
```
---
## 4. 노출된 키 폐기 (보안)
테스트 단계에서 채팅·로그에 노출된 키들. 운영 시작 전 카카오 콘솔에서 **재발급** 후 새 값으로 운영 적용.
대상:
- 테스트용 REST API 키: `1e7825...`
- 테스트용 Client Secret: `jva60F8UfxZtDFI...`
운영 앱은 신규 발급 키만 쓰므로, 테스트 앱은 **삭제하거나 그대로 두어도 무방** (사용 안 하면 그만).
> ⚠ 운영 앱 키는 이번에는 **채팅/이슈/PR 본문에 절대 붙여넣지 말 것**. `.env.production` 에 직접 SSH 붙여 넣기.
---
## 5. 배포 후 동작 확인
### 신규 가입 (kakao_id 미보유)
1. `https://momotogether.com/login` → "카카오로 시작하기"
2. 카카오 동의 화면 (이메일·닉네임)
3.`/signup/kakao` 추가정보 입력
- 비즈 인증 후라면 이메일 자동 채워짐 (read-only)
- 비즈 인증 전이면 이메일도 직접 입력
4. 업체명·연락처·주소 입력 → 가입 완료 → `/m/dashboard`
### 재방문 로그인 (kakao_id 매칭)
1. 같은 카카오 계정으로 "카카오로 시작하기"
2. 동의 화면 건너뜀 (카카오 SSO + 이전 동의 기록)
3. → 즉시 `/m/dashboard` 진입
### DB 검증
```sql
SELECT user_id, user_name, email, kakao_id, regdate
FROM user_info
WHERE kakao_id IS NOT NULL
ORDER BY regdate DESC
LIMIT 10;
```
---
## 6. 자동 동작 (코드 변경 불필요)
- 비즈 앱 인증 통과 → 카카오에서 이메일 자동 수신 → `/signup/kakao` 페이지가 이메일 read-only 모드로 자동 전환 ([src/app/(auth)/signup/kakao/page.tsx](../src/app/(auth)/signup/kakao/page.tsx) 의 `needEmailInput` 분기)
- 동일 이메일 일반 가입자가 카카오 로그인 시도 → 자동으로 `kakao_id` 연결 후 로그인 ([src/app/api/auth/kakao/callback/route.ts](../src/app/api/auth/kakao/callback/route.ts))
---
## 코드 변경 요약 (참고)
| 파일 | 역할 |
|------|------|
| [db/migrations/010_kakao_login.sql](../db/migrations/010_kakao_login.sql) | `user_info.kakao_id` 컬럼 + unique 인덱스 |
| [src/lib/kakao-auth.ts](../src/lib/kakao-auth.ts) | 카카오 OAuth 헬퍼 (인가 URL · 토큰 교환 · 프로필 · DB 조회/INSERT) |
| [src/app/api/auth/kakao/start/route.ts](../src/app/api/auth/kakao/start/route.ts) | 인가 URL 리다이렉트 + state CSRF 쿠키 |
| [src/app/api/auth/kakao/callback/route.ts](../src/app/api/auth/kakao/callback/route.ts) | 콜백 — kakao_id 매칭 / 동일 이메일 자동 연결 / 신규 분기 |
| [src/app/api/auth/kakao/pending/route.ts](../src/app/api/auth/kakao/pending/route.ts) | 가입 페이지용 pending JWT 검증 |
| [src/app/api/auth/kakao/complete/route.ts](../src/app/api/auth/kakao/complete/route.ts) | 가입 완료 — INSERT + 세션 |
| [src/app/(auth)/signup/kakao/page.tsx](../src/app/(auth)/signup/kakao/page.tsx) | 추가정보 입력 페이지 |
| [src/app/(auth)/login/page.tsx](../src/app/(auth)/login/page.tsx) | 카카오 버튼 + `?kakao_error=...` 토스트 |
| [src/app/(auth)/signup/page.tsx](../src/app/(auth)/signup/page.tsx) | 카카오 버튼 |
| [src/middleware.ts](../src/middleware.ts) | `/api/auth/kakao` public path 추가 |
### 보안 설계
- **CSRF**: 인가 요청 `state` 파라미터에 16바이트 nonce, httpOnly 쿠키와 대조
- **임시 가입 정보**: jose JWT (NEXTAUTH_SECRET 서명, 10분 만료) — 클라이언트 위조 불가
- **자동 계정 연결**: 카카오 OAuth 가 이메일 소유 검증을 보장하는 경우에만
- **일반 로그인 차단**: 카카오 가입자는 `user_password=''``verifyMomoCredentials` 가 거부
+2 -5
View File
@@ -2180,7 +2180,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2190,7 +2189,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3451,7 +3450,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -7033,7 +7031,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
@@ -8079,7 +8076,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+36 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, FormEvent } from "react";
import { useState, useEffect, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin } from "lucide-react";
@@ -13,6 +13,16 @@ export default function LoginPage() {
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const url = new URL(window.location.href);
const err = url.searchParams.get("kakao_error");
if (err) {
Swal.fire({ icon: "error", title: "카카오 로그인 실패", text: err });
url.searchParams.delete("kakao_error");
window.history.replaceState({}, "", url.toString());
}
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -194,6 +204,20 @@ export default function LoginPage() {
</button>
</form>
<div className="my-6 flex items-center gap-3 text-[11px] text-slate-400 tracking-wider">
<span className="flex-1 h-px bg-slate-200" />
OR
<span className="flex-1 h-px bg-slate-200" />
</div>
<a
href="/api/auth/kakao/start"
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-[#FEE500] hover:bg-[#FDD835] text-[#3C1E1E] text-sm font-bold tracking-wide shadow-sm hover:shadow-md transition-all"
>
<KakaoIcon />
</a>
<div className="mt-8 text-center">
<p className="text-sm text-slate-500">
?{" "}
@@ -213,3 +237,14 @@ export default function LoginPage() {
</div>
);
}
function KakaoIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 1.5C4.58 1.5 1 4.32 1 7.8c0 2.27 1.52 4.27 3.81 5.4l-.96 3.5c-.08.31.27.55.54.39L8.6 14.7c.13.01.27.02.4.02 4.42 0 8-2.82 8-6.92S13.42 1.5 9 1.5z"
fill="#3C1E1E"
/>
</svg>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
import { Mail, Building2, User as UserIcon, Phone, FileText, MapPin, ArrowRight } from "lucide-react";
interface Pending {
email: string; // 카카오에서 받았으면 채워짐, 아니면 ""
nickname: string;
}
export default function KakaoSignupPage() {
const router = useRouter();
const [pending, setPending] = useState<Pending | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
const [form, setForm] = useState({
email: "",
companyName: "",
ceoName: "",
bizNo: "",
phone: "",
address: "",
});
const [loading, setLoading] = useState(false);
// 카카오에서 이메일을 못 받았으면 사용자가 직접 입력해야 함
const needEmailInput = pending !== null && !pending.email;
useEffect(() => {
fetch("/api/auth/kakao/pending", { credentials: "same-origin" })
.then(async (res) => {
const j = await res.json();
if (!res.ok || !j.success) {
setPendingError(j.message || "카카오 인증 세션이 만료되었습니다.");
return;
}
setPending({ email: j.email || "", nickname: j.nickname || "" });
setForm((f) => ({ ...f, companyName: j.nickname || "" }));
})
.catch(() => setPendingError("서버 오류가 발생했습니다."));
}, []);
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm({ ...form, [k]: e.target.value });
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!form.companyName || !form.phone || !form.address) {
Swal.fire({ icon: "warning", title: "필수 항목을 입력하세요.", text: "업체명·연락처·주소는 필수입니다." });
return;
}
if (needEmailInput) {
if (!form.email) {
Swal.fire({ icon: "warning", title: "이메일을 입력하세요." });
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
Swal.fire({ icon: "warning", title: "유효한 이메일 형식이 아닙니다." });
return;
}
}
setLoading(true);
try {
const res = await fetch("/api/auth/kakao/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (data.success) {
await Swal.fire({
icon: "success",
title: "가입이 완료되었습니다",
text: "이제 발주를 시작하실 수 있습니다.",
confirmButtonColor: "#0f766e",
});
router.push("/m/dashboard");
} else {
Swal.fire({ icon: "error", title: "가입 실패", text: data.message });
}
} catch {
Swal.fire({ icon: "error", title: "서버 오류", text: "잠시 후 다시 시도하세요." });
} finally {
setLoading(false);
}
};
if (pendingError) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-6">
<div className="w-full max-w-md text-center bg-white rounded-2xl shadow-sm p-10">
<h1 className="text-xl font-bold text-slate-900 mb-3"> </h1>
<p className="text-sm text-slate-500 mb-6">{pendingError}</p>
<Link
href="/login"
className="inline-flex items-center gap-2 h-11 px-5 rounded-xl bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 transition"
>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
<div className="relative lg:flex-1 lg:min-h-screen overflow-hidden bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-10 py-16 lg:py-0 flex flex-col justify-center">
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
backgroundImage:
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%)",
}}
/>
<div className="relative z-10">
<Link href="/" className="inline-flex items-center gap-2.5 mb-12 hover:opacity-80 transition">
<img src="/momo-icon.svg" alt="" className="w-9 h-9" />
<span className="text-white/95 text-sm font-bold tracking-widest">MOMO DISTRIBUTION</span>
</Link>
<h2 className="text-white text-4xl font-bold mb-4 tracking-tight">
<br />
<span className="text-emerald-200"> </span>
</h2>
<p className="text-emerald-100/80 leading-relaxed max-w-md">
. .
</p>
</div>
</div>
<div className="lg:flex-1 flex items-center justify-center px-6 py-12 lg:py-16 bg-slate-50">
<div className="w-full max-w-md">
<div className="mb-8">
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
<span className="w-6 h-[2px] bg-emerald-600" />
KAKAO SIGN UP
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-2"> </h1>
<p className="text-slate-500 text-sm"> .</p>
</div>
{pending && pending.email && (
<div className="mb-6 p-4 rounded-xl bg-yellow-50 border border-yellow-200">
<div className="flex items-center gap-2 text-[12px] text-yellow-900 font-semibold mb-1">
<Mail size={14} />
</div>
<div className="text-sm text-slate-800 font-medium">{pending.email}</div>
{pending.nickname && (
<div className="text-[12px] text-slate-500 mt-0.5">{pending.nickname} </div>
)}
</div>
)}
{pending && !pending.email && pending.nickname && (
<div className="mb-6 p-4 rounded-xl bg-yellow-50 border border-yellow-200">
<div className="flex items-center gap-2 text-[12px] text-yellow-900 font-semibold mb-1">
</div>
<div className="text-sm text-slate-800 font-medium">{pending.nickname} </div>
<div className="text-[12px] text-slate-500 mt-1">
.
</div>
</div>
)}
<form onSubmit={submit} className="space-y-4">
{needEmailInput && (
<Field
icon={<Mail size={16} />}
label="이메일 *"
type="email"
value={form.email}
onChange={set("email")}
placeholder="you@company.com"
autoFocus
/>
)}
<Field icon={<Building2 size={16} />} label="업체명 *" value={form.companyName} onChange={set("companyName")} placeholder="(주)거래처 또는 매장명" autoFocus={!needEmailInput} />
<div className="grid grid-cols-2 gap-3">
<Field icon={<UserIcon size={16} />} label="대표자" value={form.ceoName} onChange={set("ceoName")} placeholder="홍길동" />
<Field icon={<Phone size={16} />} label="연락처 *" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" />
</div>
<Field icon={<MapPin size={16} />} label="주소 *" value={form.address} onChange={set("address")} placeholder="배송지 주소를 입력하세요" />
<Field icon={<FileText size={16} />} label="사업자등록번호" value={form.bizNo} onChange={set("bizNo")} placeholder="000-00-00000 (선택)" />
<button
type="submit"
disabled={loading || !pending}
className="group relative w-full h-12 mt-2 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white text-sm font-bold tracking-wide shadow-lg shadow-emerald-600/25 hover:shadow-emerald-600/40 hover:-translate-y-0.5 active:translate-y-0 transition-all disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<>
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ArrowRight size={16} className="group-hover:translate-x-0.5 transition-transform" />
</>
)}
</button>
</form>
</div>
</div>
</div>
);
}
function Field(props: {
icon: React.ReactNode;
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
type?: string;
placeholder?: string;
autoFocus?: boolean;
}) {
return (
<div>
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">{props.label}</label>
<div className="relative group">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition">
{props.icon}
</span>
<input
type={props.type ?? "text"}
value={props.value}
onChange={props.onChange}
placeholder={props.placeholder}
autoFocus={props.autoFocus}
className="w-full h-11 pl-11 pr-4 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
/>
</div>
</div>
);
}
+25
View File
@@ -167,12 +167,37 @@ export default function SignupPage() {
<Link href="/" className="underline hover:text-slate-600"> </Link> .
</p>
</form>
<div className="my-6 flex items-center gap-3 text-[11px] text-slate-400 tracking-wider">
<span className="flex-1 h-px bg-slate-200" />
OR
<span className="flex-1 h-px bg-slate-200" />
</div>
<a
href="/api/auth/kakao/start"
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-[#FEE500] hover:bg-[#FDD835] text-[#3C1E1E] text-sm font-bold tracking-wide shadow-sm hover:shadow-md transition-all"
>
<KakaoIcon />
</a>
</div>
</div>
</div>
);
}
function KakaoIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 1.5C4.58 1.5 1 4.32 1 7.8c0 2.27 1.52 4.27 3.81 5.4l-.96 3.5c-.08.31.27.55.54.39L8.6 14.7c.13.01.27.02.4.02 4.42 0 8-2.82 8-6.92S13.42 1.5 9 1.5z"
fill="#3C1E1E"
/>
</svg>
);
}
function Field(props: {
icon: React.ReactNode;
label: string;
+137
View File
@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SignJWT } from "jose";
import {
exchangeCodeForToken,
fetchKakaoProfile,
findMomoUserByKakaoId,
findUserInfoForKakaoLink,
linkKakaoIdToUser,
} from "@/lib/kakao-auth";
import { findMomoUserByEmail } from "@/lib/momo-auth";
import { createSession } from "@/lib/session";
import type { User } from "@/types";
const STATE_COOKIE = "kakao-oauth-state";
const PENDING_COOKIE = "kakao-signup-pending";
const PENDING_TTL_SEC = 10 * 60;
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "fito-plm-default-secret"
);
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams;
const code = sp.get("code");
const state = sp.get("state");
const err = sp.get("error");
// 사용자가 카카오 동의 화면에서 취소
if (err) {
return redirectWithError(request, "카카오 로그인이 취소되었습니다.");
}
if (!code || !state) {
return redirectWithError(request, "잘못된 요청입니다.");
}
const cookieStore = await cookies();
const savedState = cookieStore.get(STATE_COOKIE)?.value;
cookieStore.delete(STATE_COOKIE);
if (!savedState || savedState !== state) {
return redirectWithError(request, "카카오 인증 세션이 만료되었습니다. 다시 시도해주세요.");
}
let profile;
try {
const token = await exchangeCodeForToken(code);
profile = await fetchKakaoProfile(token);
} catch (e) {
console.error("[kakao/callback]", e);
return redirectWithError(request, "카카오 인증 처리 중 오류가 발생했습니다.");
}
// 1) kakao_id 로 기존 연동 사용자 조회 → 즉시 로그인
const linked = await findMomoUserByKakaoId(profile.kakaoId);
if (linked) {
await createSession(toSessionUser(linked));
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}
// 2) 카카오 이메일과 일치하는 기존 일반 가입자가 있으면 자동 연결 후 로그인
// (카카오 OAuth 가 이메일 소유를 검증했으므로 안전)
if (profile.email) {
const candidate = await findUserInfoForKakaoLink(profile.email);
if (candidate && !candidate.existingKakaoId) {
await linkKakaoIdToUser(candidate.userId, profile.kakaoId);
const fresh = await findMomoUserByEmail(profile.email);
if (fresh) {
await createSession(toSessionUser(fresh));
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}
}
if (candidate && candidate.existingKakaoId && candidate.existingKakaoId !== profile.kakaoId) {
return redirectWithError(request, "해당 이메일은 다른 카카오 계정에 연결되어 있습니다.");
}
}
// 3) 신규 사용자 — 카카오 정보를 단기 JWT 쿠키에 담아 추가정보 입력 페이지로
// email 은 카카오에서 받았으면 채워두고, 못 받았으면 빈 문자열 (가입 페이지에서 직접 입력받음)
const pendingToken = await new SignJWT({
purpose: "kakao-signup",
kakaoId: profile.kakaoId,
email: profile.email ?? "",
nickname: profile.nickname ?? "",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${PENDING_TTL_SEC}s`)
.sign(SECRET);
cookieStore.set(PENDING_COOKIE, pendingToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: PENDING_TTL_SEC,
path: "/",
});
return NextResponse.redirect(new URL("/signup/kakao", request.url));
}
function redirectWithError(request: NextRequest, message: string): NextResponse {
const url = new URL("/login", request.url);
url.searchParams.set("kakao_error", message);
return NextResponse.redirect(url);
}
function toSessionUser(u: {
objid: string;
email: string;
companyName: string;
phone: string;
role: "USER" | "ADMIN";
isAdmin: boolean;
}): User {
return {
sabun: "",
userId: u.email,
userName: u.companyName,
userNameEng: "",
userNameCn: "",
deptCode: "",
deptName: "",
positionCode: "",
positionName: "",
email: u.email,
tel: "",
cellPhone: u.phone,
userType: "MOMO",
userTypeName: u.role === "ADMIN" ? "관리자" : "거래처",
authName: u.role,
partnerCd: "",
isAdmin: u.isAdmin,
role: u.role,
objid: u.objid,
companyName: u.companyName,
};
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { signupKakaoUser } from "@/lib/kakao-auth";
import { findMomoUserByEmail } from "@/lib/momo-auth";
import { createSession } from "@/lib/session";
import type { User } from "@/types";
const PENDING_COOKIE = "kakao-signup-pending";
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "fito-plm-default-secret"
);
interface PendingPayload {
purpose: string;
kakaoId: string;
email: string;
nickname?: string;
}
export async function POST(request: NextRequest) {
let body: Record<string, string>;
try {
body = await request.json();
} catch {
return NextResponse.json({ success: false, message: "잘못된 요청입니다." }, { status: 400 });
}
const { email: bodyEmail, companyName, ceoName, bizNo, phone, address } = body;
if (!companyName || !phone || !address) {
return NextResponse.json(
{ success: false, message: "업체명·연락처·주소는 필수입니다." },
{ status: 400 }
);
}
const cookieStore = await cookies();
const token = cookieStore.get(PENDING_COOKIE)?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: "카카오 인증 세션이 없습니다. 다시 로그인해주세요." },
{ status: 401 }
);
}
let payload: PendingPayload;
try {
const { payload: p } = await jwtVerify(token, SECRET);
payload = p as unknown as PendingPayload;
if (payload.purpose !== "kakao-signup") throw new Error("invalid purpose");
} catch {
cookieStore.delete(PENDING_COOKIE);
return NextResponse.json(
{ success: false, message: "세션이 만료되었습니다. 카카오 로그인을 다시 시도해주세요." },
{ status: 401 }
);
}
// 이메일 결정: 카카오에서 받았으면 그걸로(신뢰), 아니면 사용자가 입력한 값
// 사용자 입력값을 받을 때 형식 검증 (카카오 검증 이메일은 형식 보장됨)
const finalEmail = (payload.email || bodyEmail || "").trim().toLowerCase();
if (!finalEmail) {
return NextResponse.json(
{ success: false, message: "이메일을 입력해주세요." },
{ status: 400 }
);
}
if (!payload.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(finalEmail)) {
return NextResponse.json(
{ success: false, message: "유효한 이메일 형식이 아닙니다." },
{ status: 400 }
);
}
// 가입 직전 이메일 중복 체크 — 사용자 입력 이메일이 다른 사람 계정과 충돌하면 거부
const dup = await findMomoUserByEmail(finalEmail);
if (dup) {
cookieStore.delete(PENDING_COOKIE);
return NextResponse.json(
{ success: false, message: "이미 가입된 이메일입니다. 다른 이메일을 입력하거나 일반 로그인을 이용하세요." },
{ status: 409 }
);
}
let user;
try {
user = await signupKakaoUser({
kakaoId: payload.kakaoId,
extra: {
email: finalEmail,
companyName,
ceoName,
bizNo,
phone,
address,
},
});
} catch (e) {
console.error("[kakao/complete]", e);
return NextResponse.json(
{ success: false, message: "가입 처리 중 오류가 발생했습니다." },
{ status: 500 }
);
}
cookieStore.delete(PENDING_COOKIE);
const sessionUser: User = {
sabun: "",
userId: user.email,
userName: user.companyName,
userNameEng: "",
userNameCn: "",
deptCode: "",
deptName: "",
positionCode: "",
positionName: "",
email: user.email,
tel: "",
cellPhone: user.phone,
userType: "MOMO",
userTypeName: user.role === "ADMIN" ? "관리자" : "거래처",
authName: user.role,
partnerCd: "",
isAdmin: user.isAdmin,
role: user.role,
objid: user.objid,
companyName: user.companyName,
};
await createSession(sessionUser);
return NextResponse.json({ success: true, user: sessionUser });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
const PENDING_COOKIE = "kakao-signup-pending";
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "fito-plm-default-secret"
);
// 추가정보 입력 페이지에서 호출 — 이메일/닉네임 미리보기용
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get(PENDING_COOKIE)?.value;
if (!token) {
return NextResponse.json({ success: false, message: "카카오 인증 세션이 없습니다." }, { status: 401 });
}
try {
const { payload } = await jwtVerify(token, SECRET);
if (payload.purpose !== "kakao-signup") {
return NextResponse.json({ success: false, message: "유효하지 않은 세션입니다." }, { status: 401 });
}
return NextResponse.json({
success: true,
email: payload.email,
nickname: payload.nickname,
});
} catch {
return NextResponse.json({ success: false, message: "세션이 만료되었습니다. 다시 로그인해주세요." }, { status: 401 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import crypto from "node:crypto";
import { buildAuthorizeUrl } from "@/lib/kakao-auth";
const STATE_COOKIE = "kakao-oauth-state";
export async function GET() {
if (!process.env.KAKAO_REST_API_KEY || !process.env.KAKAO_REDIRECT_URI) {
return NextResponse.json(
{ success: false, message: "카카오 로그인이 구성되지 않았습니다. 관리자에게 문의하세요." },
{ status: 503 }
);
}
const state = crypto.randomBytes(16).toString("hex");
const cookieStore = await cookies();
cookieStore.set(STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 5 * 60,
path: "/",
});
return NextResponse.redirect(buildAuthorizeUrl(state));
}
+176
View File
@@ -0,0 +1,176 @@
// 카카오 OAuth 2.0 (Authorization Code grant) 헬퍼
// docs: https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
import { queryOne, execute } from "./db";
import type { MomoUser } from "./momo-auth";
const KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize";
const KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
const KAKAO_USER_URL = "https://kapi.kakao.com/v2/user/me";
export interface KakaoProfile {
kakaoId: string;
email: string | null;
nickname: string | null;
}
export interface KakaoSignupExtra {
email: string; // 카카오에서 받았거나 사용자가 직접 입력한 값. user_id 로도 사용
companyName: string;
ceoName?: string;
bizNo?: string;
phone: string;
address: string;
}
function requireEnv(key: string): string {
const v = process.env[key];
if (!v) throw new Error(`환경변수 ${key} 가 설정되지 않았습니다.`);
return v;
}
export function buildAuthorizeUrl(state: string): string {
// scope 를 명시하지 않으면 카카오 콘솔의 "동의항목" 에 활성화된 항목만 자동으로 요청됨.
// 비즈 앱 미인증 상태에서 account_email 같이 권한 없는 scope 를 강제하면 KOE205 발생.
const params = new URLSearchParams({
response_type: "code",
client_id: requireEnv("KAKAO_REST_API_KEY"),
redirect_uri: requireEnv("KAKAO_REDIRECT_URI"),
state,
});
return `${KAKAO_AUTH_URL}?${params.toString()}`;
}
export async function exchangeCodeForToken(code: string): Promise<string> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: requireEnv("KAKAO_REST_API_KEY"),
redirect_uri: requireEnv("KAKAO_REDIRECT_URI"),
code,
});
const secret = process.env.KAKAO_CLIENT_SECRET;
if (secret) body.set("client_secret", secret);
const res = await fetch(KAKAO_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" },
body,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`카카오 토큰 교환 실패: ${res.status} ${txt}`);
}
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("카카오 access_token 미수신");
return json.access_token;
}
export async function fetchKakaoProfile(accessToken: string): Promise<KakaoProfile> {
const res = await fetch(KAKAO_USER_URL, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`카카오 사용자 조회 실패: ${res.status} ${txt}`);
}
const j = (await res.json()) as {
id: number | string;
kakao_account?: { email?: string; profile?: { nickname?: string } };
properties?: { nickname?: string };
};
const acc = j.kakao_account;
return {
kakaoId: String(j.id),
email: acc?.email?.toLowerCase() ?? null,
nickname: acc?.profile?.nickname ?? j.properties?.nickname ?? null,
};
}
// kakao_id 로 기존 연동 사용자 조회
export async function findMomoUserByKakaoId(kakaoId: string): Promise<MomoUser | null> {
const row = await queryOne<Record<string, unknown>>(
`SELECT user_id AS "USER_ID", user_name AS "USER_NAME",
user_name_eng AS "USER_NAME_ENG",
email AS "EMAIL", cell_phone AS "CELL_PHONE", tel AS "TEL",
user_type AS "USER_TYPE", status AS "STATUS",
biz_no AS "BIZ_NO", ceo_name AS "CEO_NAME",
address AS "ADDRESS"
FROM user_info
WHERE kakao_id = $1
LIMIT 1`,
[kakaoId]
);
if (!row) return null;
return rowToMomoUser(row);
}
// 이메일로 기존 일반 가입자 조회 (카카오 자동 연결용)
export async function findUserInfoForKakaoLink(
email: string
): Promise<{ userId: string; existingKakaoId: string | null } | null> {
const row = await queryOne<{ user_id: string; kakao_id: string | null }>(
`SELECT user_id, kakao_id
FROM user_info
WHERE LOWER(email) = LOWER($1) OR LOWER(user_id) = LOWER($1)
LIMIT 1`,
[email]
);
if (!row) return null;
return { userId: row.user_id, existingKakaoId: row.kakao_id };
}
export async function linkKakaoIdToUser(userId: string, kakaoId: string): Promise<void> {
await execute(`UPDATE user_info SET kakao_id = $1 WHERE user_id = $2`, [kakaoId, userId]);
}
// 카카오 가입자 생성 — 추가정보 입력 후 호출
// user_password = '' (빈 문자열) → 기존 verifyMomoCredentials 가 거부 → 일반 로그인 차단
// email 은 KakaoSignupExtra.email 에서 가져옴 (카카오 동의항목으로 받았거나 사용자가 직접 입력)
export async function signupKakaoUser(args: {
kakaoId: string;
extra: KakaoSignupExtra;
}): Promise<MomoUser> {
const email = args.extra.email.trim().toLowerCase();
// user_id 는 이메일로 통일 (기존 momo 가입과 동일 패턴)
await execute(
`INSERT INTO user_info
(user_id, user_password, user_name, email, cell_phone,
user_type, user_type_name, biz_no, ceo_name, address, status, kakao_id, regdate)
VALUES ($1, '', $2, $1, $3, 'C', '거래처', $4, $5, $6, 'active', $7, NOW())`,
[
email,
args.extra.companyName.trim(),
args.extra.phone.trim(),
args.extra.bizNo?.trim() ?? "",
args.extra.ceoName?.trim() ?? "",
args.extra.address.trim(),
args.kakaoId,
]
);
const fresh = await findMomoUserByKakaoId(args.kakaoId);
if (!fresh) throw new Error("카카오 가입 후 사용자 조회 실패");
return fresh;
}
function rowToMomoUser(r: Record<string, unknown>): MomoUser {
const userType = String(r.USER_TYPE || "").toUpperCase();
const role: "USER" | "ADMIN" = userType === "A" ? "ADMIN" : "USER";
const userId = (r.USER_ID as string) || "";
const email = (r.EMAIL as string) || userId;
const companyName = (r.USER_NAME as string) || "";
return {
objid: userId,
email,
companyName,
ceoName: (r.CEO_NAME as string) || (r.USER_NAME_ENG as string) || "",
bizNo: (r.BIZ_NO as string) || "",
phone: (r.CELL_PHONE as string) || (r.TEL as string) || "",
address: (r.ADDRESS as string) || "",
role,
status: (r.STATUS as string) || "active",
userId,
userName: companyName,
isAdmin: role === "ADMIN",
};
}
+2 -1
View File
@@ -11,6 +11,7 @@ export function middleware(request: NextRequest) {
"/api/auth/login",
"/api/auth/signup",
"/api/auth/mobile-login",
"/api/auth/kakao",
"/api/deploy/webhook",
"/_next",
"/favicon.ico",
@@ -25,7 +26,7 @@ export function middleware(request: NextRequest) {
}
return NextResponse.next();
}
// 로그인/가입 페이지도 세션 있으면 대시보드로
// 로그인/가입 페이지도 세션 있으면 대시보드로 (단 /signup/kakao 는 카카오 인증 직후 진입하므로 제외)
if (pathname === "/login" || pathname === "/signup") {
if (request.cookies.get("plm-session")) {
return NextResponse.redirect(new URL("/m/dashboard", request.url));