+ );
+}
+
+
+===== /Users/a456/Desktop/quipuWebProject/backoffice/frontend/src/App.js =====
+import "./App.css";
+import React from "react";
+import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
+import RecruitDB from "./page/recruitDB";
+import Login from "./page/login";
+import AuthCallback from "./page/AuthCallback";
+import RequireAuth from "./components/RequireAuth";
+import { AuthProvider } from "./auth/AuthProvider";
+import { Toaster } from "react-hot-toast";
+
+function App() {
+ return (
+
+
+
+
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+
+
+ );
+}
+
+export default App;
+
+
+===== /Users/a456/Desktop/quipuWebProject/backoffice/frontend/src/api/logout_api.jsx =====
+import http from "../auth/authClient";
+
+export const logout = async () => {
+ const response = await http.post("/bo/auth/logout");
+ return response;
+};
+
+
+===== /Users/a456/Desktop/quipuWebProject/backoffice/frontend/src/api/recruitDB_api.jsx =====
+import http from "../auth/authClient";
+
+export const fetchGeneralData = async () => {
+ const response = await http.get("/bo/data/joinquipu_general", {
+ headers: { accept: "application/json" },
+ });
+ return response.data;
+};
+
+export const fetchDevData = async () => {
+ const response = await http.get("/bo/data/joinquipu_dev", {
+ headers: { accept: "application/json" },
+ });
+ return response.data;
+};
+
+export const fetchMemberData = async () => {
+ const response = await http.get("/bo/member", {
+ headers: { accept: "application/json" },
+ });
+ return response.data;
+};
+
+const getPdf = async (filename) => {
+ try {
+ const response = await http.get(`/bo/member/pdf/${filename}`, {
+ headers: { accept: "application/json" },
+ responseType: "blob",
+ });
+ return response;
+ } catch (err) {
+ if (err.response && err.response.status === 404) {
+ return { status: 404 };
+ }
+ throw err;
+ }
+};
+
+const downloadPdf = (filename, blob) => {
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ window.URL.revokeObjectURL(url);
+};
+
+export const fetchAndSavePortfolio = async (filename) => {
+ try {
+ const response = await getPdf(filename);
+ if (response.status === 200) {
+ downloadPdf(filename, response.data);
+ } else if (response.status === 404) {
+ alert("파일이 존재하지 않습니다.");
+ }
+ } catch (_error) {
+ alert("서버 에러");
+ }
+};
+
+export const recruitStateCheck = async () => {
+ const response = await http.get("/bo/feature/recruit", {
+ headers: { accept: "application/json" },
+ });
+ return response;
+};
+
+export const recruitStateChange = async () => {
+ const response = await http.patch(
+ "/bo/feature/recruit",
+ {},
+ {
+ headers: { accept: "application/json" },
+ }
+ );
+ return response;
+};
+
+
+===== /Users/a456/Desktop/quipuWebProject/backoffice/backend/package.json =====
+{
+ "name": "backoffice",
+ "version": "0.0.1",
+ "description": "",
+ "main": "src/app.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "start": "nodemon src/app.js"
+ },
+ "author": "ejjem",
+ "license": "MIT",
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.758.0",
+ "@aws-sdk/s3-request-presigner": "^3.758.0",
+ "cookie-parser": "^1.4.6",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "express-rate-limit": "^7.4.0",
+ "fs": "^0.0.1-security",
+ "helmet": "^7.1.0",
+ "hpp": "^0.2.3",
+ "ioredis": "^5.7.0",
+ "jsonwebtoken": "^9.0.2",
+ "mongoose": "^8.13.0",
+ "morgan": "^1.10.0",
+ "multer": "^1.4.5-lts.1",
+ "nodemon": "^3.1.4",
+ "passport": "^0.7.0",
+ "passport-google-oauth20": "^2.0.0",
+ "process": "^0.11.10",
+ "rate-limit-redis": "^4.3.0",
+ "swagger-ui-express": "^5.0.1",
+ "winston": "^3.19.0"
+ }
+}
+
+
+===== /Users/a456/Desktop/quipuWebProject/backoffice/feature/01-bo-auth-appendix.md =====
+# 01. BO Auth 기술 상세 Appendix
+
+## 문서 관계 / 소스 오브 트루스
+
+- 이 Appendix는 기술 구현 기준 문서다.
+- 기존 [01-bo-auth.md](./01-bo-auth.md)의 기술 상세(데이터 모델, 인증/토큰, 권한 검사, 인덱스, 에러 코드)는 이 Appendix 기준으로 대체한다.
+- PM 의사결정/일정/리스크는 [01-bo-auth-plan.md](./01-bo-auth.md)를 기준으로 본다.
+
+## A) 목표 아키텍처 요약
+
+- 인증: Google OAuth
+- API 인증 유지: Authorization Bearer Token
+- DB: MongoDB
+- 권한: `perm` 비트마스크
+- 계정 온보딩: 초대 링크 기반
+
+## B) 데이터 모델
+
+- IP 개인정보 최소화 정책은 `admin_audit_logs`, `refresh_tokens` 모두에 동일 적용한다(원문 IP 미저장, `ipHash` 사용).
+
+## B.1 `admin_users`
+
+- `email` (unique, required)
+- `googleSub` (unique, nullable)
+- `name` (nullable)
+- `pictureUrl` (nullable)
+- `perm` (number, default `READ`)
+- `isSuperAdmin` (boolean, default false)
+- `isActive` (boolean, default true)
+- `lastLoginAt`
+- `createdAt`, `updatedAt`
+
+## B.2 `admin_invites`
+
+- `email` (required)
+- `perm` (number, default `READ`)
+- `tokenHash` (unique, required)
+- `expiresAt` (required)
+- `status` (`pending`, `used`, `expired`, `revoked`)
+- `invitedBy` (required)
+- `usedByUserId` (nullable)
+- `createdAt`, `updatedAt`
+
+## B.3 `admin_audit_logs`
+
+- `actorUserId`
+- `targetUserId`
+- `action`
+- `before`, `after`
+- `ipHash` (원문 IP 저장 금지, `HMAC-SHA256(ip, SERVER_SECRET_SALT)` 적용)
+- `userAgent`
+- `createdAt`
+
+## B.4 `refresh_tokens`
+
+- `userId` (required)
+- `tokenHash` (unique, required)
+- `expiresAt` (required)
+- `revoked` (boolean, default false)
+- `revokedAt` (nullable)
+- `deviceInfo`
+ - `userAgent` (raw string)
+ - `os` (parsed)
+ - `browser` (parsed)
+ - `ipHash` (선택, 원문 IP 저장 금지 권장)
+ - 알고리즘: `HMAC-SHA256(ip, SERVER_SECRET_SALT)`
+ - 운영 정책: `SERVER_SECRET_SALT`는 운영 중 교체 금지
+ - 주의: secret 변경 시 기존 `ipHash` 비교/조회가 불가능해짐
+ - 조회/비교: 세션 목록/필터링은 원문 IP가 아닌 `ipHash` 기준으로 처리
+ - 비상 교체 절차:
+ - 트리거: secret 유출이 확인/강하게 의심되는 경우
+ - 조치: `refresh_tokens` 전체 revoke + 전체 재로그인 유도 공지
+ - 영향: 기존 `ipHash` 기반 조회/필터링 일시 불가
+ - 기록: 교체 시각/사유/조치 내역을 운영 로그 및 감사 로그에 기록
+ - `lastSeenAt` (선택)
+- `createdAt`, `updatedAt`
+
+## B.5 `auth_codes` (OAuth callback code exchange)
+
+- `codeHash` (unique, required)
+- `userId` (required)
+- `expiresAt` (required, short-lived)
+- `usedAt` (nullable, 1회 교환 후 즉시 마킹)
+- `createdAt`, `updatedAt`
+- 운영 원칙:
+ - 원문 code 저장 금지, `codeHash`만 저장
+ - 매우 짧은 만료(예: 30초) + 1회 사용 원칙
+ - `expiresAt` TTL 인덱스로 자동 정리
+
+## C) 권한 모델
+
+```ts
+enum Permission {
+ READ = 1 << 0,
+ WRITE_ACTIVITY = 1 << 1,
+ WRITE_RECRUIT_FORM = 1 << 2,
+ WRITE_CLUB_INFO = 1 << 3,
+}
+```
+
+권한 체크:
+
+- `(perm & WRITE_ACTIVITY) !== 0`
+- `(perm & WRITE_RECRUIT_FORM) !== 0`
+- `(perm & WRITE_CLUB_INFO) !== 0`
+- `(perm & REQUIRED_MASK) === REQUIRED_MASK`
+
+운영 UI:
+
+- 내부 저장은 비트마스크
+- 화면은 라벨(`read/all`, `write/activity`, `write/recruit-form`, `write/club-info`, `write/all`)로 표시
+- `write/all`은 별도 비트를 두지 않고 `WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO` OR 합산값으로 해석한다.
+- UI 역변환 규칙: `(perm & (WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO)) === (WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO)`이면 `write/all`로 표시하고, 아니면 보유한 개별 write 라벨만 표시한다.
+
+비트 관리 규칙:
+
+- 신규 권한은 `2^n` 값만 사용
+- 기존 비트 값 변경 금지
+- 중앙 enum 파일에서만 권한 정의
+- 32bit 초과 시 64bit/bigint 확장 정책 적용
+
+## D) 인증/토큰 정책
+
+## D.1 Access Token
+
+- 만료: 10~15분(최대 15분)
+- 저장: 메모리 저장(브라우저 새로고침 시 초기화되는 런타임 메모리; 예: 앱 전역 state/in-memory store)
+- `localStorage/sessionStorage` 저장 금지
+- `jti` claim 포함(필요 시 replay 탐지 확장)
+
+## D.2 Refresh Token
+
+- 만료: 14일
+- 저장: `httpOnly + Secure + SameSite` cookie 권장
+- rotation + revoke 적용
+- 다중 디바이스 분리 관리
+- FE는 refresh token 기반 silent refresh로 페이지 reload 시 세션을 복구한다.
+- 도메인 정책(확정):
+ - Option A 채택: 리버스 프록시로 FE/BE 요청 경로를 same-site로 정렬
+ - FE는 동일 사이트 경로(예: `/api`)로 호출하고 프록시가 BE로 전달
+ - refresh cookie는 same-site 기준으로 처리해 Safari ITP 영향 구간을 최소화
+ - `SameSite=Lax` 이상을 기본으로 하고 운영 환경은 `Secure` 필수 적용
+- 잔여 리스크 및 대응:
+ - 프록시 misconfiguration으로 cross-site 전송이 남는 경우 Safari에서 silent refresh 실패 가능
+ - Phase 1 QA에서 Safari refresh 실패가 확인되면 프록시 규칙 우선 수정, 인프라 제약으로 불가 시 BFF 패턴 전환
+
+rotation atomic 처리:
+
+- 동시 refresh 요청 중복 방지:
+ - FE 1차 방어(필수): 탭 내부 싱글턴 Promise lock으로 refresh 중복 호출을 차단한다.
+ - FE 2차 방어(권장): `BroadcastChannel('auth')`로 탭 간 신규 access token을 공유한다.
+ - BE 안전망(권장): `revokedAt` 기준 grace window(5~10초) 정책을 적용한다.
+ - grace window 이내 재사용: 경쟁 조건으로 판단(`REFRESH_TOKEN_REVOKED`) 후 재로그인 강제 없이 복구 경로 안내
+ - grace window 초과 재사용: 탈취 의심으로 판단(`REFRESH_TOKEN_REUSE_DETECTED`) 후 전체 refresh token revoke
+
+1. `revoked=false` 조건으로 토큰 조회/전환
+2. 기존 토큰 revoke
+3. 새 refresh token 저장
+4. 새 access token 발급
+5. 2~4 중 하나라도 실패 시 전체 롤백(트랜잭션 정책은 `N) 트랜잭션 정책` 참조)
+
+reuse detection:
+
+- revoke된 refresh token 재사용 시 `REFRESH_TOKEN_REUSE_DETECTED`
+- 보안 이벤트 기록
+- 해당 사용자 전체 refresh token 강제 revoke
+
+## D.3 CSRF 경계
+
+- 일반 Bearer API는 CSRF 영향 낮음
+- refresh 엔드포인트는 cookie 사용으로 CSRF 방어 필수
+- refresh 엔드포인트 CSRF 방어:
+ - 요청 시 커스텀 헤더(`X-Requested-With: XMLHttpRequest`) 필수 포함
+ - 서버는 해당 헤더 부재 시 요청 거부 (`401`)
+ - CORS preflight 의존 방식이므로 `Access-Control-Allow-Origin` 설정 엄격 유지 필수
+ - 허용 도메인은 운영 allowlist로 관리하며(예: `BO_ALLOWED_ORIGINS`), 와일드카드(`*`) 사용 금지
+ - 추가 검증: `Origin`(필수) 및 `Referer`(보조) 값을 allowlist와 대조하고 불일치 시 거부
+- 한계 및 보완:
+ - `X-Requested-With` 단독 방식은 same-origin/프록시 환경에서 우회 가능성이 있으므로 단독 방어로 간주하지 않는다.
+ - 따라서 custom header + origin/referer 검증을 기본 세트로 적용한다.
+- refresh 요청 통과 조건(체크리스트):
+ - `X-Requested-With: XMLHttpRequest` 헤더 존재
+ - `Origin`이 `BO_ALLOWED_ORIGINS` allowlist와 일치
+ - `Referer`(존재 시)가 `BO_ALLOWED_ORIGINS` allowlist와 일치
+ - 위 조건을 모두 만족할 때만 통과, 하나라도 불일치하면 `401` 거부
+
+## D.4 OAuth Callback -> AuthCode -> Token Exchange 플로우
+
+- 목적:
+ - OAuth callback 직후 access/refresh token 원문을 URL query/hash에 직접 노출하지 않기 위함
+ - SPA 라우팅 환경에서 브라우저 히스토리/로그/리퍼러를 통한 토큰 유출 위험 완화
+- 처리 순서:
+ 1. `GET /bo/auth/google/callback` 성공 시 서버는 short-lived 1회용 code를 발급한다.
+ 2. 서버는 `auth_codes`에 `codeHash`만 저장하고, FE로는 원문 code만 전달한다(redirect query).
+ 3. FE는 즉시 `POST /bo/auth/token-exchange`로 code를 교환한다.
+ 4. 서버는 `codeHash + usedAt:null + expiresAt>now` 조건으로 원자적으로 1회 소비 처리 후 access/refresh token을 발급한다.
+- 보안 원칙:
+ - code는 재사용 불가(atomic consume)
+ - code 만료는 매우 짧게 유지(예: 30초)
+ - 실패/만료/재사용 요청은 `401 UNAUTHORIZED`로 처리
+
+## E) Google OAuth 보안
+
+- `state` 검증
+- `email_verified` 검증
+- ID Token `issuer` 검증
+- ID Token `audience(client_id)` 검증
+- 구현 메모:
+ - callback 단계에서 Google token info 검증을 통해 `issuer/audience`를 명시적으로 확인한다.
+ - `audience !== GOOGLE_CLIENT_ID` 또는 비정상 `issuer`는 인증 실패로 처리한다.
+- 필요 시 `hd`/도메인 제한
+- 이메일 비교는 `trim + lowercase` 정규화 후 수행
+
+## F) 초대 플로우
+
+1. 슈퍼어드민 이메일 입력
+2. 이메일 전용 초대 링크 생성(만료 기본 2일)
+3. 사용자 링크 접속 후 Google 로그인
+4. 정규화된 이메일 일치 시 승인
+5. `googleSub` 바인딩
+6. 초대 상태 `used` 처리
+
+보안:
+
+- 토큰 엔트로피 최소 128bit
+- 토큰 원문 저장 금지, `tokenHash`만 저장
+- 1회 사용 후 즉시 폐기
+- 재발급 시 기존 활성 토큰 revoke
+- 초대 검증 API rate limit 적용
+- 만료 정책:
+ - 기본 만료는 2일
+ - 허용 범위: 최소 1시간(3600초) ~ 최대 7일(604800초)
+ - 하드코딩 금지, 운영 설정값으로 변경 가능
+ - 슈퍼어드민은 허용 범위 내에서 초대 생성 시 만료값을 조정할 수 있다.
+ - 허용 범위를 벗어난 요청은 `400`으로 거부한다.
+- 권한 초기값/지정:
+ - 초대 생성 시 기본 권한은 `READ`를 부여
+ - 슈퍼어드민은 초대 생성 시 추가 write 권한(`WRITE_ACTIVITY`, `WRITE_RECRUIT_FORM`, `WRITE_CLUB_INFO`)을 사전 지정할 수 있다.
+
+초대 생성 API 스펙(`POST /bo/admin/invites`):
+
+- body:
+ - `email` (required, 1개)
+ - `perm` (optional, 기본값 `READ`)
+ - `expiresInSec` (optional, 기본값 172800, 허용 범위 3600~604800)
+- 검증:
+ - `perm`에 `READ` 미포함 요청 시 서버에서 `READ`를 강제 포함
+ - 범위 외 `expiresInSec` 요청은 `400`으로 거부
+
+초대 폐기 API 스펙(`PATCH /bo/admin/invites/:id/revoke`):
+
+- 대상이 `pending` 상태일 때만 `revoked`로 전환 가능
+- `pending`이 아닌 초대 폐기 요청은 `409`로 거부
+
+초대 재발급 API 스펙(`POST /bo/admin/invites/:id/reissue`):
+
+- 기존 초대를 기준으로 새 토큰을 재발급한다.
+- 재발급 시 해당 이메일의 기존 `pending` 초대는 모두 `revoked` 처리한다.
+- 본문에서 `perm`/`labels`가 없으면 기존 초대의 `perm`을 승계한다.
+
+동시성:
+
+- `status=pending` 조건에서만 수락 허용
+- 트랜잭션 내부에서 `pending -> used` 전환 후 바인딩
+- 동시 요청은 1건만 성공
+
+충돌 처리:
+
+- `googleSub`가 이미 다른 계정에 바인딩된 경우 `GOOGLE_SUB_CONFLICT` 반환
+
+## G) 슈퍼어드민 정책
+
+- 런타임 판단 기준: DB `isSuperAdmin` 단일 기준
+- 서버 시작 시 `SUPER_ADMIN_EMAIL` bootstrap 보정
+- 운영 중 env 자동 덮어쓰기 금지
+- 최소 1개 이상 비상 super admin 계정 유지(활성 상태)
+- Google OAuth 장애 시 임시 로컬 인증 fallback 경로를 통해 비상 계정만 로그인 허용
+- 로컬 인증 fallback 경로는 상시 개방하지 않고 장애 대응 시에만 운영 플래그로 활성화
+- 예외 전환 규칙: Phase 1 전환 기간에는 기존 로그인 fallback 경로를 임시 활성화로 유지하고, Phase 3 완료 후에는 비상 플래그 기반 fallback만 허용한다.
+- env/DB mismatch:
+ - 기본: 경고 + 운영 알림
+ - strict mode: fail-fast
+
+## H) DB 재검증/권한 검사
+
+라우트 레벨:
+
+- 모든 보호 API 1차 권한 검사 미들웨어 적용
+
+서비스 레벨:
+
+- 민감 작업 2차 DB 재검증
+
+필수 DB 재검증 대상:
+
+- 권한 변경 API
+- 초대 생성/검증/수락 API
+- 계정 활성/비활성 API
+- 모든 write API
+- `/bo/auth/me` (화면 기준 정보 최신화)
+
+성능 기준:
+
+- `userId` 단건 조회 중심
+- write/admin API low QPS 가정
+- 필요 시 Redis 캐시 확장
+- `/bo/auth/me`는 매 요청 DB 재검증을 기본 정책으로 한다.
+- 근거: `/bo/auth/me`는 관리자 화면 권한/상태 렌더링의 기준 엔드포인트이므로 stale 권한 노출 방지를 위해 성능보다 최신 권한 일관성을 우선한다.
+
+## I) Rate Limiting
+
+대상:
+
+- OAuth 시작
+- OAuth callback
+- 초대 검증 전 단계
+- 초대 검증 API
+- refresh API
+
+초기값 및 조정 범위:
+
+| 대상 | 초기값 | 조정 범위 | 기준 |
+| --- | --- | --- | --- |
+| OAuth callback | IP당 10 req/min | 5~20 | 로그인 실패율 모니터링 후 |
+| refresh API | 사용자당 30 req/min | 20~60 | 다중 디바이스 환경 고려 |
+| invite 검증 API | IP당 10 req/min | 5~20 | 초대 남용 시 강화 |
+
+초기값은 백오피스 내부 사용자 규모 기준으로 설정한다. 운영 중 rate limit 초과 알림이 반복되거나 브루트포스 징후 발생 시 하향 조정하고, 정상 사용자 차단 이슈 발생 시 상향 조정한다. 조정 이력은 감사 로그에 준하여 기록한다.
+구현 전제: 분산 환경 일관성을 위해 Redis 기반 rate limiter를 기본으로 사용한다(단일 인스턴스 개발 환경은 in-memory fallback 허용).
+refresh 동시성 제어는 Redis 분산 락을 기본으로 하고, Redis 미연결/미설치 환경에서는 in-memory 락으로 fallback한다.
+
+## J) 감사 로그/모니터링
+
+감사 로그:
+
+- append-only
+- 수정/삭제 API 제공 금지
+- 필요 시 해시 체인/외부 저장 이중 적재 확장
+- retention: 180일
+
+모니터링:
+
+- 권한 변경
+- super admin 이벤트
+- rate limit 반복 초과
+- 로그인 실패 반복
+
+## K) 에러 코드 카탈로그
+
+공통:
+
+- `UNAUTHORIZED` (`401`)
+- `CSRF_BLOCKED` (`401`)
+- `FORBIDDEN` (`403`)
+- `TOO_MANY_REQUESTS` (`429`)
+- `INTERNAL_ERROR` (`500`)
+
+초대/인증:
+
+- `INVITE_EXPIRED` (`410`)
+- `INVITE_ALREADY_USED` (`409`)
+- `INVITE_REVOKED` (`410`)
+- `INVITE_EMAIL_MISMATCH` (`422`)
+- `GOOGLE_SUB_CONFLICT` (`409`)
+- `OAUTH_STATE_INVALID` (`400`)
+- `EMAIL_NOT_VERIFIED` (`403`)
+
+토큰:
+
+- `REFRESH_TOKEN_INVALID` (`401`)
+- `REFRESH_TOKEN_REVOKED` (`401`): 이미 revoke된 토큰 사용(grace window 이내 경쟁 조건 포함)
+- `REFRESH_TOKEN_EXPIRED` (`401`)
+- `REFRESH_TOKEN_REUSE_DETECTED` (`401`): revoke된 토큰의 grace window 초과 재사용(탈취 의심, 전체 토큰 강제 revoke)
+
+권한/계정:
+
+- `ACCOUNT_INACTIVE` (`403`)
+- `PERMISSION_DENIED` (`403`)
+- `SUPER_ADMIN_REQUIRED` (`403`)
+
+## L) 필수 인덱스
+
+`admin_users`:
+
+- `email` unique
+- `googleSub` unique
+
+`admin_invites`:
+
+- `tokenHash` unique
+- `email + status` 복합 인덱스
+- `expiresAt` TTL 인덱스(정책 적용 시)
+
+`admin_audit_logs`:
+
+- `actorUserId`
+- `createdAt`
+- 필요 시 `action + createdAt` 복합 인덱스
+
+`refresh_tokens`:
+
+- `tokenHash` unique
+- `userId + revoked + revokedAt` 복합 인덱스 (grace window 판정 최적화)
+- `expiresAt` TTL 인덱스(정책 적용 시)
+
+## M) 구현 순서 (개발자 기준)
+
+1. [Phase 1] MongoDB 컬렉션/인덱스 생성 (`admin_users`, `admin_invites`, `admin_audit_logs`, `refresh_tokens`)
+2. [Phase 1] Google OAuth 검증 로직 구현(`state`, `email_verified`, `issuer`, `audience`)
+3. [Phase 1] 토큰 발급/검증 로직 구현(access/refresh, rotation, revoke)
+4. [Phase 1] 기존 `passport-local` 및 세션 직렬화/역직렬화 전략 제거
+5. [Phase 1] refresh reuse detection 구현 및 보안 이벤트 연계(배치 근거: rotation/revoke와 분리 불가한 동일 인증 경로)
+6. [Phase 2] 권한 enum/비트마스크 유틸 구현(라벨 <-> 비트 변환 레이어 포함, `write/all` OR 합산/역변환 규칙 포함)
+7. [Phase 2] 초대 생성/검증/수락/재발급/폐기 구현
+8. [Phase 2] `googleSub` 바인딩 충돌 정책 구현(`GOOGLE_SUB_CONFLICT`)
+9. [Phase 2] 라우트 미들웨어 + 서비스 DB 재검증 적용
+10. [Phase 2] `/bo/auth/me` 최신 상태 정책 및 권한 반영 검증
+11. [Phase 2] FE 라우트 가드/`useCan()` 훅/silent refresh 실패 처리 구현(`R) FE 구현 기준` 반영)
+12. [Phase 3] 감사 로그 적재/조회 및 append-only 정책 적용
+13. [Phase 3] rate limiting 및 에러 코드 매핑 적용
+14. [Phase 3] 통합 테스트 및 운영 점검
+
+## N) 트랜잭션 정책 (MongoDB)
+
+아래 시나리오는 반드시 트랜잭션으로 처리한다.
+
+- 초대 수락: `invite(pending->used)` + `googleSub 바인딩` + `admin_users.perm 반영`
+- 권한 변경: `admin_users.perm 변경` + `admin_audit_logs 기록`
+- super-admin 변경: `admin_users.isSuperAdmin 변경` + `admin_audit_logs 기록`
+- 계정 비활성화: `isActive=false` + `refresh_tokens revoke` + `audit 기록`
+- refresh rotation: `old refresh revoke` + `new refresh 발급` (atomic 전환)
+
+트랜잭션 원칙:
+
+- 실패 시 전체 롤백
+- 감사 로그 쓰기 실패도 롤백 조건
+- 동시성 경합 지점은 조건부 갱신(`revoked=false`, `status=pending`)으로 원자성 보장
+- 운영 영향: 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음(보안 무결성 우선 정책)
+- 운영 대응: 장애 알림 즉시 전파, 복구 전까지 읽기 중심 운영 모드 유지, 복구 후 변경 작업 재개
+
+## O) 기존 데이터 마이그레이션 전략
+
+목표:
+
+- 기존 관리자 계정을 신규 인증/권한 체계로 안전하게 전환한다.
+
+적용 시점:
+
+- Phase 1 완료 후, Phase 2 시작 직전 실행
+- 실제 운영 관리자 계정 수는 Phase 1 완료 시점에 확정해 본 섹션에 기록한다.
+- 예상 소요:
+ - 관리자 계정 30개 기준 반나절~1일
+ - 관리자 계정 100개 기준 1~2일(초대 수락 속도에 따라 변동)
+
+기존 계정 처리 원칙:
+
+- `googleSub`가 없는 기존 계정은 초대 플로우 재온보딩을 기본 경로로 사용
+- 운영상 즉시 전환이 필요한 계정은 제한적으로 마이그레이션 스크립트 사용 가능(슈퍼어드민 승인 필수)
+- 비밀번호 로그인 데이터는 신규 인증 기준에서 참조하지 않는다.
+
+실행 절차:
+
+1. 마이그레이션 대상 계정 목록 확정(활성 계정 기준)
+2. 기존 권한 -> 신규 `perm` 매핑 테이블 확정
+3. 계정별 초대 발송 또는 승인된 스크립트 전환 수행
+4. 전환 완료 계정의 `googleSub` 바인딩 및 로그인 검증
+5. 권한 대조 리포트 생성(기존 권한 vs 신규 `perm`)
+
+검증 기준:
+
+- 마이그레이션 전/후 활성 관리자 계정 수 일치
+- 권한 매핑 불일치 0건
+- 전환 대상 계정의 Google 로그인 성공 확인
+
+롤백 기준 및 절차:
+
+- 롤백 임계치:
+ - 권한 매핑 불일치 1건 이상 발생 시 즉시 중단
+ - 전환 대상 계정 로그인 실패율 5% 초과 시 즉시 중단
+- 임계치 초과 시 신규 적용 중단
+- 영향 계정에 대해 기존 운영 경로(임시 fallback)로 즉시 복귀
+- 원인 수정 후 재실행
+
+## P) 테스트 전략
+
+단위 테스트:
+
+- [Phase 1] OAuth 검증: `state` 불일치, `email_verified=false`, `issuer/audience` 실패
+- [Phase 1] 토큰: 발급/검증/만료/revoke/rotation/reuse detection
+- [Phase 2] 권한: `perm` 비트 연산, 복합 마스크 검사, 경계값 검증
+
+통합 테스트:
+
+- [Phase 1] refresh rotation atomic 보장(동시 요청 포함)
+- [Phase 1] 탭 내부 동시 401 상황에서 refresh API 1회만 호출되는지 검증(싱글턴 Promise lock)
+- [Phase 3] 감사 로그 append-only(수정/삭제 거부)
+- [Phase 2] 권한 변경 후 `/bo/auth/me` 최신 상태 반영
+
+E2E 테스트:
+
+- [Phase 2] 초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여
+- [Phase 2] 예외 시나리오: 초대 만료/중복 수락/이메일 불일치/`GOOGLE_SUB_CONFLICT`
+
+Safari silent refresh QA(Phase 1 필수):
+
+- 환경:
+ - 스테이징 FE/BE를 운영과 동일한 cross-domain으로 배포해 검증
+ - 브라우저/디바이스: macOS Safari, iOS Safari
+- 시나리오:
+ - [Phase 1] access token 만료 후 자동 refresh 성공 및 원 요청 재시도 성공
+ - [Phase 1] 페이지 새로고침 후 `/auth/refresh` 기반 세션 복구 성공
+ - [Phase 1] Safari cross-site tracking 활성화 상태에서 refresh cookie 전달 여부 확인
+ - [Phase 1] 다중 탭 동시 refresh 시 reuse detection 오탐 여부 확인
+- 판정/조치:
+ - 시나리오 1,2 통과 시 Phase 1 인증 기준 충족
+ - 아래 조건 중 하나라도 충족하면 Safari cross-origin refresh 실패로 판정하고 BFF 패턴 전환 착수:
+ - 시나리오 1 실패(access token 만료 후 자동 refresh 미동작 또는 재시도 API 실패)
+ - 시나리오 2 실패(새로고침 후 세션 복구 실패)
+ - 시나리오 3에서 refresh 요청에 cookie 미첨부 확인
+ - reuse detection 오탐 발생 시 동시 요청 제어(debounce/lock) 적용 후 재검증
+ - grace window(5~10초) 내 재사용은 `REVOKED`로 처리되고, window 초과 재사용만 `REUSE_DETECTED`로 처리되는지 검증
+
+완료 기준:
+
+- [Phase 1] 인증/OAuth/토큰 테스트 통과 후 Phase 2 진입
+- [Phase 2] 권한/초대 E2E 테스트 통과 후 Phase 3 진입
+- [Phase 3] 감사로그/rate limit/에러코드 회귀 테스트 통과 후 릴리스
+
+## Q) 기존 API 권한 매핑 (비트마스크 기준)
+
+라벨-비트 기준:
+
+- `read/all` -> `READ`
+- `write/activity` -> `WRITE_ACTIVITY`
+- `write/recruit-form` -> `WRITE_RECRUIT_FORM`
+- `write/club-info` -> `WRITE_CLUB_INFO`
+- `write/all` -> `WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO`
+
+라우트 매핑:
+
+| API | 필요 권한(라벨) | 비트마스크 조건 |
+| --- | --- | --- |
+| `GET /bo/member` | `read/all` | `(perm & READ) !== 0` |
+| `GET /bo/member/pdf/:filename` | `read/all` | `(perm & READ) !== 0` |
+| `POST /bo/semina` | `write/activity` 또는 `write/all` | `(perm & WRITE_ACTIVITY) !== 0` |
+| `GET /bo/feature/recruit` | `read/all` | `(perm & READ) !== 0` |
+| `PATCH /bo/feature/recruit` | `write/recruit-form` 또는 `write/all` | `(perm & WRITE_RECRUIT_FORM) !== 0` |
+| `GET /bo/feature/club-info` | `read/all` | `(perm & READ) !== 0` (`신규 구현 필요`) |
+| `PATCH /bo/feature/club-info` | `write/club-info` 또는 `write/all` | `(perm & WRITE_CLUB_INFO) !== 0` (`신규 구현 필요`) |
+| `GET /bo/admin/users` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+| `POST /bo/admin/invites` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+| `PATCH /bo/admin/users/:id/perm` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+| `PATCH /bo/admin/users/:id/super-admin` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`, 감사 로그 필수) |
+| `PATCH /bo/admin/users/:id/active` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+
+## R) FE 구현 기준
+
+- 앱 부팅:
+ - 앱 시작 시 `GET /bo/auth/me`를 호출해 사용자/권한 정보를 전역 상태(store)로 초기화한다.
+ - 실패(`401/403`) 시 인증 상태를 비로그인으로 전환하고 로그인 화면으로 라우팅한다.
+- 라우트 가드:
+ - 보호 라우트 진입 전 `isAuthenticated`와 `perm/isSuperAdmin`을 검사한다.
+ - 가드 실패 시 권한 안내 페이지 또는 로그인 페이지로 리다이렉트한다.
+- 권한 기반 UI 제어:
+ - `useCan()` 훅 또는 `Can` 컴포넌트로 버튼/CTA 렌더링을 제어한다.
+ - `write/all` 표시는 C 섹션의 UI 역변환 규칙을 동일하게 사용한다.
+- silent refresh 실패 처리:
+ - `/auth/refresh` 실패 시 access token/사용자 상태를 즉시 초기화한다.
+ - 현재 페이지에서 재시도 루프 없이 로그인 페이지로 단일 리다이렉트한다.
+ - 필요 시 `reason=session_expired` 쿼리로 사용자 안내 문구를 노출한다.
+
+## S) API 응답 포맷 (`GET /bo/auth/me`)
+
+- 응답 원칙:
+ - `perm`(number)을 권한 판정의 기준값으로 사용한다.
+ - `permLabels`는 UI 편의를 위한 파생 필드로 제공한다.
+ - `permLabels`는 서버가 C 섹션의 UI 역변환 규칙(`write/all` 합산 규칙 포함)을 적용해 생성하며, FE는 이를 그대로 표시한다.
+ - `isSuperAdmin`는 super-admin 전용 UI/기능 노출 제어에 사용한다.
+
+응답 예시:
+
+```json
+{
+ "id": "1234567890abcde",
+ "email": "admin@uos.ac.kr",
+ "name": "홍길동",
+ "isSuperAdmin": false,
+ "isActive": true,
+ "perm": 3,
+ "permLabels": ["read/all", "write/activity"]
+}
+```
+
+
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 416a871..5a78818 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,34 +1,36 @@
{
- "name": "backoffice-backend",
+ "name": "backoffice",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "backoffice-backend",
+ "name": "backoffice",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
- "bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
- "express-session": "^1.18.0",
+ "express-rate-limit": "^7.4.0",
"fs": "^0.0.1-security",
"helmet": "^7.1.0",
"hpp": "^0.2.3",
- "mongoose": "^8.12.2",
+ "ioredis": "^5.7.0",
+ "jsonwebtoken": "^9.0.2",
+ "mongoose": "^8.13.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"nodemon": "^3.1.4",
"passport": "^0.7.0",
- "passport-local": "^1.0.0",
+ "passport-google-oauth20": "^2.0.0",
"process": "^0.11.10",
+ "rate-limit-redis": "^4.3.0",
"swagger-ui-express": "^5.0.1",
- "winston": "^3.17.0"
+ "winston": "^3.19.0"
}
},
"node_modules/@aws-crypto/crc32": {
@@ -935,24 +937,11 @@
"kuler": "^2.0.0"
}
},
- "node_modules/@mapbox/node-pre-gyp": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
- "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
- "dependencies": {
- "detect-libc": "^2.0.0",
- "https-proxy-agent": "^5.0.0",
- "make-dir": "^3.1.0",
- "node-fetch": "^2.6.7",
- "nopt": "^5.0.0",
- "npmlog": "^5.0.1",
- "rimraf": "^3.0.2",
- "semver": "^7.3.5",
- "tar": "^6.1.11"
- },
- "bin": {
- "node-pre-gyp": "bin/node-pre-gyp"
- }
+ "node_modules/@ioredis/commands": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
+ "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
+ "license": "MIT"
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
@@ -970,19 +959,6 @@
"hasInstallScript": true,
"license": "Apache-2.0"
},
- "node_modules/@smithy/abort-controller": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz",
- "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/@smithy/chunked-blob-reader": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz",
@@ -1009,9 +985,9 @@
}
},
"node_modules/@smithy/config-resolver": {
- "version": "4.4.11",
- "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz",
- "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==",
+ "version": "4.4.13",
+ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz",
+ "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.12",
@@ -1026,9 +1002,9 @@
}
},
"node_modules/@smithy/core": {
- "version": "3.23.11",
- "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz",
- "integrity": "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==",
+ "version": "3.23.13",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz",
+ "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/protocol-http": "^5.3.12",
@@ -1037,7 +1013,7 @@
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-middleware": "^4.2.12",
- "@smithy/util-stream": "^4.5.19",
+ "@smithy/util-stream": "^4.5.21",
"@smithy/util-utf8": "^4.2.2",
"@smithy/uuid": "^1.1.2",
"tslib": "^2.6.2"
@@ -1246,13 +1222,13 @@
}
},
"node_modules/@smithy/middleware-endpoint": {
- "version": "4.4.25",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz",
- "integrity": "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==",
+ "version": "4.4.28",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz",
+ "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.11",
- "@smithy/middleware-serde": "^4.2.14",
+ "@smithy/core": "^3.23.13",
+ "@smithy/middleware-serde": "^4.2.16",
"@smithy/node-config-provider": "^4.3.12",
"@smithy/shared-ini-file-loader": "^4.4.7",
"@smithy/types": "^4.13.1",
@@ -1265,18 +1241,18 @@
}
},
"node_modules/@smithy/middleware-retry": {
- "version": "4.4.42",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz",
- "integrity": "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==",
+ "version": "4.4.46",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz",
+ "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.12",
"@smithy/protocol-http": "^5.3.12",
"@smithy/service-error-classification": "^4.2.12",
- "@smithy/smithy-client": "^4.12.5",
+ "@smithy/smithy-client": "^4.12.8",
"@smithy/types": "^4.13.1",
"@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.12",
+ "@smithy/util-retry": "^4.2.13",
"@smithy/uuid": "^1.1.2",
"tslib": "^2.6.2"
},
@@ -1285,12 +1261,12 @@
}
},
"node_modules/@smithy/middleware-serde": {
- "version": "4.2.14",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz",
- "integrity": "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==",
+ "version": "4.2.16",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz",
+ "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.11",
+ "@smithy/core": "^3.23.13",
"@smithy/protocol-http": "^5.3.12",
"@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
@@ -1328,12 +1304,11 @@
}
},
"node_modules/@smithy/node-http-handler": {
- "version": "4.4.16",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz",
- "integrity": "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==",
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz",
+ "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/abort-controller": "^4.2.12",
"@smithy/protocol-http": "^5.3.12",
"@smithy/querystring-builder": "^4.2.12",
"@smithy/types": "^4.13.1",
@@ -1441,17 +1416,17 @@
}
},
"node_modules/@smithy/smithy-client": {
- "version": "4.12.5",
- "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz",
- "integrity": "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==",
+ "version": "4.12.8",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz",
+ "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.23.11",
- "@smithy/middleware-endpoint": "^4.4.25",
+ "@smithy/core": "^3.23.13",
+ "@smithy/middleware-endpoint": "^4.4.28",
"@smithy/middleware-stack": "^4.2.12",
"@smithy/protocol-http": "^5.3.12",
"@smithy/types": "^4.13.1",
- "@smithy/util-stream": "^4.5.19",
+ "@smithy/util-stream": "^4.5.21",
"tslib": "^2.6.2"
},
"engines": {
@@ -1548,13 +1523,13 @@
}
},
"node_modules/@smithy/util-defaults-mode-browser": {
- "version": "4.3.41",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz",
- "integrity": "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==",
+ "version": "4.3.44",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz",
+ "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/property-provider": "^4.2.12",
- "@smithy/smithy-client": "^4.12.5",
+ "@smithy/smithy-client": "^4.12.8",
"@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
@@ -1563,16 +1538,16 @@
}
},
"node_modules/@smithy/util-defaults-mode-node": {
- "version": "4.2.44",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz",
- "integrity": "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==",
+ "version": "4.2.48",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz",
+ "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/config-resolver": "^4.4.11",
+ "@smithy/config-resolver": "^4.4.13",
"@smithy/credential-provider-imds": "^4.2.12",
"@smithy/node-config-provider": "^4.3.12",
"@smithy/property-provider": "^4.2.12",
- "@smithy/smithy-client": "^4.12.5",
+ "@smithy/smithy-client": "^4.12.8",
"@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
@@ -1620,9 +1595,9 @@
}
},
"node_modules/@smithy/util-retry": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz",
- "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==",
+ "version": "4.2.13",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz",
+ "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/service-error-classification": "^4.2.12",
@@ -1634,13 +1609,13 @@
}
},
"node_modules/@smithy/util-stream": {
- "version": "4.5.19",
- "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz",
- "integrity": "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==",
+ "version": "4.5.21",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz",
+ "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/node-http-handler": "^4.4.16",
+ "@smithy/node-http-handler": "^4.5.1",
"@smithy/types": "^4.13.1",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-buffer-from": "^4.2.2",
@@ -1678,12 +1653,11 @@
}
},
"node_modules/@smithy/util-waiter": {
- "version": "4.2.13",
- "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz",
- "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==",
+ "version": "4.2.14",
+ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.14.tgz",
+ "integrity": "sha512-2zqq5o/oizvMaFUlNiTyZ7dbgYv1a893aGut2uaxtbzTx/VYYnRxWzDHuD/ftgcw94ffenua+ZNLrbqwUYE+Bg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/abort-controller": "^4.2.12",
"@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
@@ -1734,11 +1708,6 @@
"@types/webidl-conversions": "*"
}
},
- "node_modules/abbrev": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
- "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
- },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1751,46 +1720,6 @@
"node": ">= 0.6"
}
},
- "node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "dependencies": {
- "debug": "4"
- },
- "engines": {
- "node": ">= 6.0.0"
- }
- },
- "node_modules/agent-base/node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/agent-base/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1808,37 +1737,6 @@
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
- "node_modules/aproba": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
- "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ=="
- },
- "node_modules/are-we-there-yet": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
- "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
- "deprecated": "This package is no longer supported.",
- "dependencies": {
- "delegates": "^1.0.0",
- "readable-stream": "^3.6.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/are-we-there-yet/node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -1855,6 +1753,15 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/base64url": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
+ "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -1871,19 +1778,6 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
- "node_modules/bcrypt": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
- "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
- "hasInstallScript": true,
- "dependencies": {
- "@mapbox/node-pre-gyp": "^1.0.11",
- "node-addon-api": "^5.0.0"
- },
- "engines": {
- "node": ">= 10.0.0"
- }
- },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1953,6 +1847,11 @@
"node": ">=16.20.1"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2027,12 +1926,13 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/chownr": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
- "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
"engines": {
- "node": ">=10"
+ "node": ">=0.10.0"
}
},
"node_modules/color": {
@@ -2048,19 +1948,19 @@
"node": ">=18"
}
},
- "node_modules/color-convert": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
- "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
+ "node_modules/color-string": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
+ "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
- "node": ">=14.6"
+ "node": ">=18"
}
},
- "node_modules/color-name": {
+ "node_modules/color-string/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
@@ -2069,24 +1969,25 @@
"node": ">=12.20"
}
},
- "node_modules/color-string": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
- "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
+ "node_modules/color/node_modules/color-convert": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
+ "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
- "node": ">=18"
+ "node": ">=14.6"
}
},
- "node_modules/color-support": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
- "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
- "bin": {
- "color-support": "bin.js"
+ "node_modules/color/node_modules/color-name": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
+ "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
}
},
"node_modules/concat-map": {
@@ -2108,11 +2009,6 @@
"typedarray": "^0.0.6"
}
},
- "node_modules/console-control-strings": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
- "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="
- },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2182,10 +2078,14 @@
"ms": "2.0.0"
}
},
- "node_modules/delegates": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
- "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
},
"node_modules/depd": {
"version": "2.0.0",
@@ -2204,14 +2104,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
- "node_modules/detect-libc": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
- "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -2236,16 +2128,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
- "node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
- },
"node_modules/enabled": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
@@ -2260,29 +2155,6 @@
"node": ">= 0.8"
}
},
- "node_modules/encoding": {
- "version": "0.1.13",
- "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
- "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
- "optional": true,
- "peer": true,
- "dependencies": {
- "iconv-lite": "^0.6.2"
- }
- },
- "node_modules/encoding/node_modules/iconv-lite": {
- "version": "0.6.3",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "optional": true,
- "peer": true,
- "dependencies": {
- "safer-buffer": ">= 2.1.2 < 3.0.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2368,29 +2240,21 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/express-session": {
- "version": "1.18.1",
- "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
- "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
- "dependencies": {
- "cookie": "0.7.2",
- "cookie-signature": "1.0.7",
- "debug": "2.6.9",
- "depd": "~2.0.0",
- "on-headers": "~1.0.2",
- "parseurl": "~1.3.3",
- "safe-buffer": "5.2.1",
- "uid-safe": "~2.1.5"
- },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
"engines": {
- "node": ">= 0.8.0"
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
}
},
- "node_modules/express-session/node_modules/cookie-signature": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
- "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
- },
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
@@ -2484,22 +2348,19 @@
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
"license": "ISC"
},
- "node_modules/fs-minipass": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
- "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "dependencies": {
- "minipass": "^3.0.0"
- },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
"engines": {
- "node": ">= 8"
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2508,26 +2369,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/gauge": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
- "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
- "deprecated": "This package is no longer supported.",
- "dependencies": {
- "aproba": "^1.0.3 || ^2.0.0",
- "color-support": "^1.1.2",
- "console-control-strings": "^1.0.0",
- "has-unicode": "^2.0.1",
- "object-assign": "^4.1.1",
- "signal-exit": "^3.0.0",
- "string-width": "^4.2.3",
- "strip-ansi": "^6.0.1",
- "wide-align": "^1.1.2"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -2563,26 +2404,6 @@
"node": ">= 0.4"
}
},
- "node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "deprecated": "Glob versions prior to v9 are no longer supported",
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -2616,11 +2437,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-unicode": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
- "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
- },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2667,39 +2483,6 @@
"node": ">= 0.8"
}
},
- "node_modules/https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "dependencies": {
- "agent-base": "6",
- "debug": "4"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/https-proxy-agent/node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/https-proxy-agent/node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
- },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -2716,35 +2499,58 @@
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="
},
- "node_modules/inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
- "dependencies": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
- "node_modules/ip-address": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
- "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
- "optional": true,
- "peer": true,
+ "node_modules/ioredis": {
+ "version": "5.10.1",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
+ "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
+ "license": "MIT",
"dependencies": {
- "jsbn": "1.1.0",
- "sprintf-js": "^1.1.3"
+ "@ioredis/commands": "1.5.1",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
},
"engines": {
- "node": ">= 12"
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
}
},
+ "node_modules/ioredis/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ioredis/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2772,14 +2578,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2816,13 +2614,54 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
- "node_modules/jsbn": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
- "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
- "optional": true,
- "peer": true
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
},
"node_modules/kareem": {
"version": "2.6.3",
@@ -2844,6 +2683,60 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/logform": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@@ -2867,28 +2760,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
- "node_modules/make-dir": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
- "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
- "dependencies": {
- "semver": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/make-dir/node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2976,29 +2847,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/minipass": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
- "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/minizlib": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
- "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "dependencies": {
- "minipass": "^3.0.0",
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -3228,30 +3076,6 @@
"node": ">= 0.6"
}
},
- "node_modules/node-addon-api": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
- "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
- },
- "node_modules/node-fetch": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
- "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
- "dependencies": {
- "whatwg-url": "^5.0.0"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
"node_modules/nodemon": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@@ -3319,20 +3143,6 @@
"node": ">=4"
}
},
- "node_modules/nopt": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
- "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
- "dependencies": {
- "abbrev": "1"
- },
- "bin": {
- "nopt": "bin/nopt.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3341,17 +3151,11 @@
"node": ">=0.10.0"
}
},
- "node_modules/npmlog": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
- "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
- "deprecated": "This package is no longer supported.",
- "dependencies": {
- "are-we-there-yet": "^2.0.0",
- "console-control-strings": "^1.1.0",
- "gauge": "^3.0.0",
- "set-blocking": "^2.0.0"
- }
+ "node_modules/oauth": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
+ "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
+ "license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -3391,14 +3195,6 @@
"node": ">= 0.8"
}
},
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dependencies": {
- "wrappy": "1"
- }
- },
"node_modules/one-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
@@ -3433,17 +3229,38 @@
"url": "https://github.com/sponsors/jaredhanson"
}
},
- "node_modules/passport-local": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
- "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==",
+ "node_modules/passport-google-oauth20": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
+ "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
+ "license": "MIT",
"dependencies": {
- "passport-strategy": "1.x.x"
+ "passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
+ "node_modules/passport-oauth2": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
+ "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
+ "license": "MIT",
+ "dependencies": {
+ "base64url": "3.x.x",
+ "oauth": "0.10.x",
+ "passport-strategy": "1.x.x",
+ "uid2": "0.0.x",
+ "utils-merge": "1.x.x"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jaredhanson"
+ }
+ },
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@@ -3452,14 +3269,6 @@
"node": ">= 0.4.0"
}
},
- "node_modules/path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -3533,14 +3342,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/random-bytes": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
- "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -3549,6 +3350,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/rate-limit-redis": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz",
+ "integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "peerDependencies": {
+ "express-rate-limit": ">= 6"
+ }
+ },
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
@@ -3593,19 +3406,25 @@
"node": ">=8.10.0"
}
},
- "node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
"dependencies": {
- "glob": "^7.1.3"
+ "redis-errors": "^1.0.0"
},
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
+ "engines": {
+ "node": ">=4"
}
},
"node_modules/safe-buffer": {
@@ -3702,11 +3521,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/set-blocking": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
- "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
- },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -3786,11 +3600,6 @@
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
"license": "MIT"
},
- "node_modules/signal-exit": {
- "version": "3.0.7",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
- "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
- },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -3802,32 +3611,6 @@
"node": ">=10"
}
},
- "node_modules/smart-buffer": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
- "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">= 6.0.0",
- "npm": ">= 3.0.0"
- }
- },
- "node_modules/socks": {
- "version": "2.8.4",
- "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
- "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
- "optional": true,
- "peer": true,
- "dependencies": {
- "ip-address": "^9.0.5",
- "smart-buffer": "^4.2.0"
- },
- "engines": {
- "node": ">= 10.0.0",
- "npm": ">= 3.0.0"
- }
- },
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
@@ -3837,13 +3620,6 @@
"memory-pager": "^1.0.2"
}
},
- "node_modules/sprintf-js": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
- "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
- "optional": true,
- "peer": true
- },
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -3853,6 +3629,12 @@
"node": "*"
}
},
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -3882,30 +3664,6 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
- "node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
@@ -3940,41 +3698,6 @@
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
- "node_modules/tar": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
- "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
- "dependencies": {
- "chownr": "^2.0.0",
- "fs-minipass": "^2.0.0",
- "minipass": "^5.0.0",
- "minizlib": "^2.1.1",
- "mkdirp": "^1.0.3",
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/tar/node_modules/minipass": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
- "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/tar/node_modules/mkdirp": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "bin": {
- "mkdirp": "bin/cmd.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -4008,11 +3731,6 @@
"nodetouch": "bin/nodetouch.js"
}
},
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
- },
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
@@ -4045,16 +3763,11 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
- "node_modules/uid-safe": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
- "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
- "dependencies": {
- "random-bytes": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
+ "node_modules/uid2": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
+ "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
+ "license": "MIT"
},
"node_modules/undefsafe": {
"version": "2.0.5",
@@ -4090,28 +3803,6 @@
"node": ">= 0.8"
}
},
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
- },
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
- "node_modules/wide-align": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
- "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
- "dependencies": {
- "string-width": "^1.0.2 || 2 || 3 || 4"
- }
- },
"node_modules/winston": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
@@ -4176,11 +3867,6 @@
"node": ">= 6"
}
},
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
- },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -4188,11 +3874,6 @@
"engines": {
"node": ">=0.4"
}
- },
- "node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}
diff --git a/backend/package.json b/backend/package.json
index 250286e..1d97b07 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,35 +1,36 @@
{
- "name": "backoffice-backend",
+ "name": "backoffice",
"version": "0.0.1",
"description": "",
"main": "src/app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
- "start": "node src/app.js",
- "dev": "nodemon src/app.js"
+ "start": "nodemon src/app.js"
},
"author": "ejjem",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@aws-sdk/s3-request-presigner": "^3.758.0",
- "bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
- "express-session": "^1.18.0",
+ "express-rate-limit": "^7.4.0",
"fs": "^0.0.1-security",
"helmet": "^7.1.0",
"hpp": "^0.2.3",
- "mongoose": "^8.12.2",
+ "ioredis": "^5.7.0",
+ "jsonwebtoken": "^9.0.2",
+ "mongoose": "^8.13.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"nodemon": "^3.1.4",
"passport": "^0.7.0",
- "passport-local": "^1.0.0",
+ "passport-google-oauth20": "^2.0.0",
"process": "^0.11.10",
+ "rate-limit-redis": "^4.3.0",
"swagger-ui-express": "^5.0.1",
- "winston": "^3.17.0"
+ "winston": "^3.19.0"
}
}
diff --git a/backend/src/app.js b/backend/src/app.js
index 8a8cd36..72cc967 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -1,134 +1,89 @@
const express = require("express");
-const app = express();
const morgan = require("morgan");
const winston = require("winston");
-
const cookieParser = require("cookie-parser");
const path = require("path");
-const session = require("express-session");
-const passport = require("passport");
const helmet = require("helmet");
const hpp = require("hpp");
const cors = require("cors");
+const passport = require("passport");
require("dotenv").config({ path: path.resolve(__dirname, "../.env") });
-const NODE_ENV = process.env.NODE_ENV;
-console.log(`NODE_ENV = ${NODE_ENV}`);
-const PORT = process.env.PORT;
-const passportConfig = require("../src/passport");
-passportConfig();
+const app = express();
+const NODE_ENV = process.env.NODE_ENV || "development";
+const PORT = process.env.PORT || 4000;
+
+[
+ "COOKIE_SECRET",
+ "ACCESS_TOKEN_SECRET",
+ "BO_BACKEND_URL",
+ "BO_FRONTEND_URL",
+ "BO_ALLOWED_ORIGINS",
+ "MONGO_URI",
+ "GOOGLE_CLIENT_ID",
+ "GOOGLE_CLIENT_SECRET",
+ "SERVER_SECRET_SALT",
+].forEach((k) => {
+ if (!process.env[k]) throw new Error(`${k} is required`);
+});
-const { connectDB } = require("../src/models");
-const loginRouter = require("../src/routes/login");
-const memberRouter = require("../src/routes/member");
-const seminaRouter = require("../src/routes/semina");
-const featureRouter = require("../src/routes/feature");
+const { connectMongo } = require("./config/mongo");
+const { bootstrapSuperAdmin } = require("./services/superAdminBootstrap");
+const { getRedisMode } = require("./services/redisClient");
+const passportConfig = require("./passport");
-const isProdOrTest = NODE_ENV === "production" || NODE_ENV === "test";
-const PORT_NUMBER = Number(PORT) || 3001;
+const memberRouter = require("./routes/member");
+const seminaRouter = require("./routes/semina");
+const featureRouter = require("./routes/feature");
+const boAuthRouter = require("./routes/boAuth");
+const boAdminUsersRouter = require("./routes/boAdminUsers");
+const boAdminInvitesRouter = require("./routes/boAdminInvites");
-const SESSION_SECRET = process.env.COOKIE_SECRET;
-if (!SESSION_SECRET && isProdOrTest) {
- throw new Error("COOKIE_SECRET 환경변수가 필요합니다.");
-}
-const safeSessionSecret = SESSION_SECRET || "dev-only-cookie-secret";
-
-const sessionOption = {
- resave: false,
- saveUninitialized: false,
- secret: safeSessionSecret,
- cookie: {
- maxAge: 1000 * 60 * 60 * 2,
- httpOnly: true,
- secure: isProdOrTest,
- ...(isProdOrTest && { sameSite: "None" }),
- },
- ...(isProdOrTest && { proxy: true }),
-};
-
-app.use(cookieParser(safeSessionSecret));
-app.use(session(sessionOption));
-app.use(passport.initialize());
-app.use(passport.session());
+const swaggerUi = require("swagger-ui-express");
+const swaggerDocument = require("./swagger.json");
+
+// CORS와 CSRF(boAuth.js)가 동일 상수를 공유한다. 파싱 로직은 config/allowedOrigins.js에서 관리한다.
+const allowedOrigins = require("./config/allowedOrigins");
+
+app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(express.json());
+app.use(express.urlencoded({ extended: false }));
+app.use(passport.initialize());
-if (process.env.NODE_ENV === "development") {
- app.use(
- cors({
- origin: process.env.CLIENT_ORIGIN_DEV,
- methods: ["GET", "POST", "OPTIONS", "DELETE", "PATCH"],
- credentials: true,
- })
- );
+app.use(
+ cors({
+ origin: allowedOrigins,
+ methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
+ credentials: true,
+ })
+);
+
+app.use(
+ helmet({
+ contentSecurityPolicy: false,
+ frameguard: { action: "deny" },
+ referrerPolicy: { policy: "strict-origin-when-cross-origin" },
+ noSniff: true,
+ })
+);
+
+if (NODE_ENV === "development") {
app.use(morgan("dev"));
- app.use(express.urlencoded({ extended: false }));
} else {
- app.use(
- cors({
- origin: process.env.CLIENT_ORIGIN,
- methods: ["GET", "POST", "PATCH", "OPTIONS"],
- credentials: true,
- })
- );
app.enable("trust proxy");
app.use(morgan("combined"));
app.use(hpp());
- app.use(express.urlencoded({ extended: false }));
- app.use(
- helmet.contentSecurityPolicy({
- directives: {
- defaultSrc: ["'none'"],
- scriptSrc: ["'none'"],
- styleSrc: ["'none'"],
- frameSrc: ["'none'"],
- },
- })
- );
- app.use(helmet.frameguard({ action: "deny" }));
- app.use(helmet.noSniff());
- app.use(helmet.dnsPrefetchControl({ allow: false }));
- app.use(helmet.hidePoweredBy());
- app.use(helmet.referrerPolicy({ policy: "strict-origin-when-cross-origin" }));
-}
-
-const swaggerUi = require("swagger-ui-express");
-const swaggerDocument = require("./swagger.json");
-
-async function startServer() {
- try {
- await connectDB();
- console.log("[LOG] MongoDB 연결 성공");
-
- app.listen(PORT_NUMBER, () => {
- console.log(`PORT: ${PORT_NUMBER}`);
- console.log(`swagger: http://localhost:${PORT_NUMBER}/api-docs`);
- console.log(`server: http://localhost:${PORT_NUMBER}`);
- });
- } catch (err) {
- console.error("DB 연결 실패:", err);
- process.exit(1);
- }
}
-startServer();
-
-app.get("/", (req, res) => {
- res.status(200).json({ message: "backoffice backend is running" });
-});
-
-app.get("/health", (req, res) => {
- res.status(200).json({ status: "ok" });
-});
-
-app.use("/bo/auth", loginRouter);
+app.use("/bo/auth", boAuthRouter);
+app.use("/bo/admin/users", boAdminUsersRouter);
+app.use("/bo/admin/invites", boAdminInvitesRouter);
app.use("/bo/member", memberRouter);
app.use("/bo/semina", seminaRouter);
app.use("/bo/feature", featureRouter);
-// 하위호환: 구버전 프론트가 /feature/* 를 호출하는 경우 지원
-app.use("/feature", featureRouter);
-if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
+if (NODE_ENV === "development" || NODE_ENV === "test") {
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}
@@ -138,15 +93,37 @@ const logger = winston.createLogger({
transports: [new winston.transports.File({ filename: "error.log" })],
});
-app.use((err, req, res, next) => {
- if (process.env.NODE_ENV === "development") {
- console.log("[ERROR] error handler 동작");
+app.use((err, _req, res, _next) => {
+ if (NODE_ENV === "development" || NODE_ENV === "test") {
console.error(err.stack || err);
} else {
logger.error(err.message || "Unexpected error");
}
res.status(err.status || 500).json({
- error: { message: "Internal Server Error" },
+ code: "INTERNAL_ERROR",
+ message: "Internal Server Error",
});
});
+
+async function startServer() {
+ try {
+ passportConfig();
+
+ await connectMongo();
+ await bootstrapSuperAdmin();
+
+ const rateLimiterMode = getRedisMode();
+ console.log(`[LOG] Rate limiter mode: ${rateLimiterMode}`);
+
+ app.listen(PORT, () => {
+ console.log(`PORT: ${PORT}`);
+ console.log(`server: http://localhost:${PORT}`);
+ });
+ } catch (err) {
+ console.error("Server start failed:", err);
+ process.exit(1);
+ }
+}
+
+startServer();
diff --git a/backend/src/config/allowedOrigins.js b/backend/src/config/allowedOrigins.js
new file mode 100644
index 0000000..dcd4833
--- /dev/null
+++ b/backend/src/config/allowedOrigins.js
@@ -0,0 +1,9 @@
+// BO_ALLOWED_ORIGINS는 서버 기동 시 한 번만 파싱하여 모듈 싱글턴으로 캐싱한다.
+// CORS(app.js)와 CSRF(boAuth.js) 두 곳에서 동일 env를 각자 파싱하는 중복을 제거하고,
+// 환경변수 이름이나 파싱 로직 변경 시 이 파일 한 곳만 수정하면 되도록 한다.
+const ALLOWED_ORIGINS = (process.env.BO_ALLOWED_ORIGINS || "")
+ .split(",")
+ .map((v) => v.trim())
+ .filter(Boolean);
+
+module.exports = ALLOWED_ORIGINS;
diff --git a/backend/src/config/mongo.js b/backend/src/config/mongo.js
new file mode 100644
index 0000000..d01c876
--- /dev/null
+++ b/backend/src/config/mongo.js
@@ -0,0 +1,14 @@
+const mongoose = require("mongoose");
+
+async function connectMongo() {
+ const uri = process.env.MONGO_URI;
+ if (!uri) throw new Error("MONGO_URI is required");
+
+ await mongoose.connect(uri, {
+ dbName: process.env.MONGO_DB_NAME || "quipu_backoffice",
+ });
+
+ console.log("[LOG] MongoDB connected");
+}
+
+module.exports = { connectMongo };
diff --git a/backend/src/config/permissions.js b/backend/src/config/permissions.js
new file mode 100644
index 0000000..6576566
--- /dev/null
+++ b/backend/src/config/permissions.js
@@ -0,0 +1,13 @@
+const Permission = {
+ READ: 1 << 0,
+ WRITE_ACTIVITY: 1 << 1,
+ WRITE_RECRUIT_FORM: 1 << 2,
+ WRITE_CLUB_INFO: 1 << 3,
+};
+
+const WRITE_ALL_MASK =
+ Permission.WRITE_ACTIVITY |
+ Permission.WRITE_RECRUIT_FORM |
+ Permission.WRITE_CLUB_INFO;
+
+module.exports = { Permission, WRITE_ALL_MASK };
diff --git a/backend/src/controllers/auth.js b/backend/src/controllers/auth.js
index 038004a..5c7d51e 100644
--- a/backend/src/controllers/auth.js
+++ b/backend/src/controllers/auth.js
@@ -1,37 +1,4 @@
-const passport = require('passport');
-
-exports.login = (req, res, next) => {
- passport.authenticate('local', (authError, user, info) => {
- if (authError) {
- console.error(authError);
- return next(authError);
- }
- if (!user) {
- return res
- .status(401)
- .send(`${info.message}`);
- }
- return req.login(user, (loginError) => {
- if (loginError) {
- console.error(loginError);
- return next(loginError);
- }
- return res
- .status(200)
- .send('로그인 성공');
- })
- })(req, res, next);
-}
-
-exports.logout = (req, res) => {
- req.logout((err) => {
- if (err) {
- return res
- .status(500)
- .json({message: '로그아웃 중 오류 발생'});
- }
- res
- .status(200)
- .json({message: '로그아웃 완료'});
- });
-}
+// DEPRECATED: passport-local 기반 로그인/로그아웃 컨트롤러.
+// Google OAuth + Authorization Bearer Token 방식으로 전환 완료.
+// 이 파일은 더 이상 사용되지 않으며 삭제 예정입니다.
+// 인증 관련 처리는 src/routes/boAuth.js 를 참조하세요.
diff --git a/backend/src/controllers/getdata.js b/backend/src/controllers/getdata.js
index 744e744..3f98b66 100644
--- a/backend/src/controllers/getdata.js
+++ b/backend/src/controllers/getdata.js
@@ -1,20 +1,22 @@
const getData = (model) => async (req, res) => {
- try {
- const data = await model.find({}).lean();
+ try {
+ const data = await model.find({}).lean();
+ const rows = data.map(({ _id, __v, ...rest }) => rest);
- const indexedData = data.map((item, index) => {
- const { _id, ...rest } = item;
- return {
- index: index + 1,
- ...rest,
- };
- });
+ const indexedData = rows.map((item, index) => ({
+ index: index + 1,
+ ...item
+ }));
- res.status(200).json(indexedData);
- } catch (err) {
- console.log(err);
- res.status(500).send("Server Error");
- }
+ res
+ .status(200)
+ .json(indexedData);
+ } catch (err) {
+ console.log(err);
+ res
+ .status(500)
+ .send('Server Error');
+ }
};
module.exports = getData;
diff --git a/backend/src/controllers/recruit.js b/backend/src/controllers/recruit.js
index ff1e5dc..be7205d 100644
--- a/backend/src/controllers/recruit.js
+++ b/backend/src/controllers/recruit.js
@@ -1,4 +1,4 @@
-const { Feature } = require("../models");
+const { Feature } = require("../models/mongo");
const checkRecruit = async (req, res) => {
try {
@@ -18,30 +18,19 @@ const checkRecruit = async (req, res) => {
const changeRecruit = async (req, res) => {
try {
- const recruit = await Feature.findOne({ feature_name: "recruit" }).lean();
+ const recruit = await Feature.findOne({ feature_name: "recruit" });
if (!recruit) {
return res
.status(404)
.json({ message: "해당 feature를 찾을 수 없습니다." });
}
- const nextState = !recruit.is_enabled;
- const updated = await Feature.findOneAndUpdate(
- { feature_name: "recruit" },
- { $set: { is_enabled: nextState } },
- { new: true }
- ).lean();
-
- if (!updated) {
- console.error("[ERROR] 업데이트 실패: recruit 상태 변경되지 않음.");
- return res
- .status(500)
- .json({ message: "recruit 상태 변경에 실패했습니다." });
- }
+ recruit.is_enabled = !recruit.is_enabled;
+ await recruit.save();
res.status(200).json({
message: "recruit 상태가 변경.",
- is_enabled: updated.is_enabled,
+ is_enabled: recruit.is_enabled,
});
} catch (err) {
console.error("[ERROR] changeRecruit 실행 중 오류 발생:", err);
diff --git a/backend/src/controllers/uploadtoR2.js b/backend/src/controllers/uploadtoR2.js
index b479ba1..c087db8 100644
--- a/backend/src/controllers/uploadtoR2.js
+++ b/backend/src/controllers/uploadtoR2.js
@@ -1,99 +1,98 @@
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
-const multer = require("multer");
+const multer = require ("multer");
const dotenv = require("dotenv");
const path = require("path");
-const { Semina, File } = require("../models");
+const { Semina, File } = require("../models/mongo");
dotenv.config({ path: path.resolve(__dirname, "../.env") });
+// FE로부터 data셋이 들어옴.
+// data 셋의 형태: json + img or png + pdf
+// json: seminas table에 저장, img or png + pdf: R2에 저장
+// img or png + pdf는 R2에 저장 이후 url을 files table에 저장.
+
+
+// Cloudflare R2 클라이언트 설정
const r2 = new S3Client({
- region: "auto",
- endpoint: process.env.R2_ENDPOINT,
+ region: "auto", // R2는 region 개념 없음
+ endpoint: process.env.R2_ENDPOINT, // Cloudflare R2 엔드포인트
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY,
secretAccessKey: process.env.R2_SECRET_KEY,
},
});
+// `multer` 설정 (파일을 메모리에 저장)
const storage = multer.memoryStorage();
-const upload = multer({ storage }).array("files", 10);
+const upload = multer({ storage }).array("files", 10); // 최대 10개 파일 업로드
+// 대용량 파일이 없다고 가정, memory에 담았다가 바로 R2에 저장하는 방식 사용
+// 추후 대용량 파일을 다룬다면, multer.diskStorage()를 사용해 디스크에 저장하고, 리사이징 후 R2에 업로드하는 방식 추가 필요
+
+// JSON + 파일 업로드 컨트롤러
const uploadHandler = async (req, res) => {
try {
- console.log("[LOG] 요청 수신: JSON + 파일 업로드");
-
- const { speaker, topic, detail, resources, presentation_date } = req.body;
- console.log("[LOG] JSON 데이터 확인:", req.body);
-
- const lastSemina = await Semina.findOne({}).sort({ semina_id: -1 }).lean();
- const nextSeminaId = (lastSemina?.semina_id || 0) + 1;
-
- const seminaRecord = await Semina.create({
- semina_id: nextSeminaId,
- speaker,
- topic,
- detail,
- resources,
- presentation_date,
- });
-
- console.log(
- `[LOG] Semina 데이터 저장 완료 (id: ${seminaRecord.semina_id}, speaker: ${seminaRecord.speaker}), topic: ${seminaRecord.topic}`
- );
-
- if (!req.files || req.files.length === 0) {
- console.log("[ERROR] 업로드할 파일이 존재하지 않음");
- return res.status(400).send("업로드할 파일이 없습니다.");
- }
-
- const formatDateToYYMMDD = (date) => {
- const year = String(date.getFullYear()).slice(2);
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- return `${year}${month}${day}`;
- };
-
- const uploadResults = await Promise.all(
- req.files.map(async (file, index) => {
- const fileKey = `${formatDateToYYMMDD(new Date(seminaRecord.presentation_date))}-${seminaRecord.speaker}-${index + 1}${path.extname(file.originalname)}`;
- const params = {
- Bucket: process.env.R2_BUCKET_NAME,
- Key: fileKey,
- Body: file.buffer,
- ContentType: file.mimetype,
- };
- await r2.send(new PutObjectCommand(params));
- console.log(`[LOG] 파일 업로드 성공: ${fileKey}`);
+ console.log("[LOG] 요청 수신: JSON + 파일 업로드");
+
+ // JSON 데이터 추출 및 `Semina` 테이블 저장
+ const { speaker, topic, detail, resources, presentation_date } = req.body; // FE에서 보낸 JSON 데이터
+ console.log("[LOG] JSON 데이터 확인:", req.body);
+
+ const seminaRecord = await Semina.create({ speaker, topic, detail, resources, presentation_date });
+ console.log(`[LOG] Semina 데이터 저장 완료 (id: ${seminaRecord._id}, speaker: ${seminaRecord.speaker}), topic: ${seminaRecord.topic}`);
+
+ // 파일 업로드 (Cloudflare R2)
+ if (!req.files || req.files.length === 0) {
+ console.log('[ERROR] 업로드할 파일이 존재하지 않음');
+ return res.status(400).send("업로드할 파일이 없습니다.");
+ }
+
+ const formatDateToYYMMDD = (date) => {
+ const year = String(date.getFullYear()).slice(2);
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}${month}${day}`;
+ };
- return { filename: fileKey };
- })
+ const uploadResults = await Promise.all(
+ req.files.map(async (file, index) => { //index 추가 (파일명만 변경)
+ const fileKey = `${formatDateToYYMMDD(new Date(seminaRecord.presentation_date))}-${seminaRecord.speaker}-${index + 1}${path.extname(file.originalname)}`; // 🎯 파일명에 index 추가
+ const params = {
+ Bucket: process.env.R2_BUCKET_NAME,
+ Key: fileKey,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ };
+ await r2.send(new PutObjectCommand(params));
+ console.log(`[LOG] 파일 업로드 성공: ${fileKey}`);
+
+ return { filename: fileKey }; // DB에는 index 저장 X, 파일명만 클라이언트에 반환
+ })
);
console.log(`[LOG] File 데이터 저장 완료 (총 ${uploadResults.length}개)`);
- const lastFile = await File.findOne({}).sort({ file_id: -1 }).lean();
- let nextFileId = (lastFile?.file_id || 0) + 1;
-
- const filePayload = uploadResults.map((file) => {
- const currentId = nextFileId;
- nextFileId += 1;
- return {
- file_id: currentId,
- semina_id: seminaRecord.semina_id,
- file_name: file.filename,
- };
- });
-
- const fileRecords = await File.insertMany(filePayload);
- console.log(`[LOG] File 데이터 저장 완료 (총 ${fileRecords.length}개)`);
+ // `File` 테이블에 저장
+ const fileRecords = await Promise.all(
+ uploadResults.map(async (file) => {
+ return await File.create({
+ semina_id: seminaRecord._id, // Semina와 연결
+ file_name: file.filename,
+ });
+ })
+ );
+ console.log(`[LOG] File 데이터 저장 완료 (총 ${fileRecords.length}개)`);
+
+ // 클라이언트 응답
+ res.status(200).json({
+ message: "데이터 저장 및 파일 업로드 성공",
+ semina: seminaRecord,
+ files: fileRecords,
+ });
- res.status(200).json({
- message: "데이터 저장 및 파일 업로드 성공",
- semina: seminaRecord,
- files: fileRecords,
- });
} catch (error) {
- console.error("[ERROR] 업로드 실패:", error);
- res.status(500).send("서버 오류");
+ console.error("[ERROR] 업로드 실패:", error);
+ res.status(500).send("서버 오류");
}
};
+// 컨트롤러 내보내기
module.exports = { upload, uploadHandler };
diff --git a/backend/src/middlewares/boAuth.js b/backend/src/middlewares/boAuth.js
new file mode 100644
index 0000000..9b94d3b
--- /dev/null
+++ b/backend/src/middlewares/boAuth.js
@@ -0,0 +1,60 @@
+const { AdminUser } = require("../models/admin");
+const { verifyAccessToken } = require("../utils/tokenUtil");
+
+function extractBearer(req) {
+ const h = req.headers.authorization || "";
+ if (!h.startsWith("Bearer ")) return null;
+ return h.slice(7);
+}
+
+async function requireAuth(req, res, next) {
+ try {
+ const token = extractBearer(req);
+ if (!token) return res.status(401).json({ code: "UNAUTHORIZED" });
+
+ const payload = verifyAccessToken(token);
+ const user = await AdminUser.findById(payload.sub).lean();
+ if (!user) return res.status(401).json({ code: "UNAUTHORIZED" });
+ if (!user.isActive) return res.status(403).json({ code: "ACCOUNT_INACTIVE" });
+
+ req.auth = {
+ userId: String(user._id),
+ perm: user.perm,
+ isSuperAdmin: user.isSuperAdmin,
+ };
+ // 전체 user 객체를 부착해 downstream 핸들러(예: /me)의 이중 DB 조회를 방지한다.
+ req.adminUser = user;
+
+ next();
+ } catch (err) {
+ // JWT 오류(서명 불일치, 만료, 형식 오류)는 인증 실패(401)로 처리한다.
+ // 그 외(DB 연결 오류 등) 예상치 못한 에러는 전역 에러 핸들러로 위임해 500으로 응답한다.
+ // JWT 오류 외에 CastError도 401로 처리한다.
+ // payload.sub가 유효하지 않은 ObjectId인 경우 findById에서 CastError가 발생하며,
+ // 이는 비정상 토큰으로 인한 인증 실패이므로 500 대신 401을 반환한다.
+ const isJwtError =
+ err.name === "JsonWebTokenError" ||
+ err.name === "TokenExpiredError" ||
+ err.name === "NotBeforeError" ||
+ err.name === "CastError";
+ if (isJwtError) return res.status(401).json({ code: "UNAUTHORIZED" });
+ return next(err);
+ }
+}
+
+function requirePerm(mask) {
+ return (req, res, next) => {
+ // requireAuth 없이 단독 사용 시 req.auth가 undefined이면 TypeError가 발생한다.
+ // requirePerm은 항상 requireAuth 뒤에 체인해야 하지만, 실수를 방지하기 위해 방어 가드를 둔다.
+ if (!req.auth) return res.status(401).json({ code: "UNAUTHORIZED" });
+ if ((req.auth.perm & mask) === mask) return next();
+ return res.status(403).json({ code: "PERMISSION_DENIED" });
+ };
+}
+
+function requireSuperAdmin(req, res, next) {
+ if (req.auth.isSuperAdmin) return next();
+ return res.status(403).json({ code: "SUPER_ADMIN_REQUIRED" });
+}
+
+module.exports = { requireAuth, requirePerm, requireSuperAdmin };
diff --git a/backend/src/models/Feature.js b/backend/src/models/Feature.js
deleted file mode 100644
index 9c5dcdd..0000000
--- a/backend/src/models/Feature.js
+++ /dev/null
@@ -1,15 +0,0 @@
-const mongoose = require("mongoose");
-
-const featureSchema = new mongoose.Schema(
- {
- feature_name: { type: String, required: true, unique: true, trim: true },
- is_enabled: { type: Boolean, required: true },
- },
- {
- versionKey: false,
- timestamps: true,
- collection: "features",
- }
-);
-
-module.exports = mongoose.model("Feature", featureSchema);
diff --git a/backend/src/models/File.js b/backend/src/models/File.js
deleted file mode 100644
index 2a80d10..0000000
--- a/backend/src/models/File.js
+++ /dev/null
@@ -1,16 +0,0 @@
-const mongoose = require("mongoose");
-
-const fileSchema = new mongoose.Schema(
- {
- file_id: { type: Number, required: true, unique: true, index: true },
- file_name: { type: String, required: true, trim: true },
- semina_id: { type: Number, required: true, index: true },
- },
- {
- versionKey: false,
- timestamps: true,
- collection: "files",
- }
-);
-
-module.exports = mongoose.model("File", fileSchema);
diff --git a/backend/src/models/Member.js b/backend/src/models/Member.js
deleted file mode 100644
index 1934db8..0000000
--- a/backend/src/models/Member.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const mongoose = require("mongoose");
-
-const memberSchema = new mongoose.Schema(
- {
- name: { type: String, required: true, trim: true },
- grade: { type: Number, required: true },
- student_id: { type: String, required: true, unique: true, trim: true },
- major: { type: String, required: true, trim: true },
- phone_number: { type: String, required: true, trim: true },
- semina: { type: Boolean, required: true },
- dev: { type: Boolean, required: true },
- study: { type: Boolean, required: true },
- external: { type: Boolean, required: true },
- motivation_semina: { type: String, default: null },
- field_dev: { type: String, default: null },
- motivation_study: { type: String, default: null },
- motivation_external: { type: String, default: null },
- portfolio_pdf: { type: String, default: null },
- github_profile: { type: String, default: null },
- },
- {
- versionKey: false,
- timestamps: true,
- collection: "members",
- }
-);
-
-module.exports = mongoose.model("Member", memberSchema);
diff --git a/backend/src/models/Semina.js b/backend/src/models/Semina.js
deleted file mode 100644
index ff6df41..0000000
--- a/backend/src/models/Semina.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const mongoose = require("mongoose");
-
-const seminaSchema = new mongoose.Schema(
- {
- semina_id: { type: Number, required: true, unique: true, index: true },
- speaker: { type: String, required: true, trim: true },
- topic: { type: String, required: true, trim: true },
- detail: { type: String, required: true },
- resources: { type: String, default: null },
- presentation_date: { type: Date, required: true },
- },
- {
- versionKey: false,
- timestamps: true,
- collection: "seminas",
- }
-);
-
-module.exports = mongoose.model("Semina", seminaSchema);
diff --git a/backend/src/models/admin/AdminAuditLog.js b/backend/src/models/admin/AdminAuditLog.js
new file mode 100644
index 0000000..784b74c
--- /dev/null
+++ b/backend/src/models/admin/AdminAuditLog.js
@@ -0,0 +1,40 @@
+const mongoose = require("mongoose");
+
+const adminAuditLogSchema = new mongoose.Schema(
+ {
+ actorUserId: { type: mongoose.Schema.Types.ObjectId, ref: "AdminUser" },
+ targetUserId: { type: mongoose.Schema.Types.ObjectId, ref: "AdminUser" },
+ action: { type: String, required: true },
+ before: { type: mongoose.Schema.Types.Mixed },
+ after: { type: mongoose.Schema.Types.Mixed },
+ ipHash: { type: String },
+ userAgent: { type: String },
+ },
+ { timestamps: { createdAt: true, updatedAt: false } }
+);
+
+adminAuditLogSchema.index({ actorUserId: 1, createdAt: -1 });
+adminAuditLogSchema.index({ action: 1, createdAt: -1 });
+adminAuditLogSchema.index({ createdAt: 1 }, { expireAfterSeconds: 180 * 24 * 60 * 60 });
+
+function denyMutation(next) {
+ next(new Error("ADMIN_AUDIT_LOG_APPEND_ONLY"));
+}
+
+// Enforce append-only policy at model level.
+// save()는 신규 document(isNew=true)일 때 INSERT, 기존 document일 때 UPDATE를 실행한다.
+// 기존 document 수정 시도를 차단해 append-only 정책의 우회 경로를 완전히 막는다.
+adminAuditLogSchema.pre("save", function (next) {
+ if (!this.isNew) return denyMutation(next);
+ next();
+});
+adminAuditLogSchema.pre("updateOne", denyMutation);
+adminAuditLogSchema.pre("updateMany", denyMutation);
+adminAuditLogSchema.pre("findOneAndUpdate", denyMutation);
+adminAuditLogSchema.pre("replaceOne", denyMutation);
+adminAuditLogSchema.pre("deleteOne", denyMutation);
+adminAuditLogSchema.pre("deleteMany", denyMutation);
+adminAuditLogSchema.pre("findOneAndDelete", denyMutation);
+adminAuditLogSchema.pre("findOneAndRemove", denyMutation);
+
+module.exports = mongoose.model("AdminAuditLog", adminAuditLogSchema);
diff --git a/backend/src/models/admin/AdminInvite.js b/backend/src/models/admin/AdminInvite.js
new file mode 100644
index 0000000..f962abe
--- /dev/null
+++ b/backend/src/models/admin/AdminInvite.js
@@ -0,0 +1,24 @@
+const mongoose = require("mongoose");
+const { Permission } = require("../../config/permissions");
+
+const adminInviteSchema = new mongoose.Schema(
+ {
+ email: { type: String, required: true, lowercase: true, trim: true },
+ perm: { type: Number, required: true, default: Permission.READ },
+ tokenHash: { type: String, required: true, unique: true },
+ expiresAt: { type: Date, required: true },
+ status: {
+ type: String,
+ enum: ["pending", "used", "expired", "revoked"],
+ default: "pending",
+ },
+ invitedBy: { type: mongoose.Schema.Types.ObjectId, required: true, ref: "AdminUser" },
+ usedByUserId: { type: mongoose.Schema.Types.ObjectId, ref: "AdminUser" },
+ },
+ { timestamps: true }
+);
+
+adminInviteSchema.index({ email: 1, status: 1 });
+adminInviteSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
+
+module.exports = mongoose.model("AdminInvite", adminInviteSchema);
diff --git a/backend/src/models/admin/AdminUser.js b/backend/src/models/admin/AdminUser.js
new file mode 100644
index 0000000..6a3ebfa
--- /dev/null
+++ b/backend/src/models/admin/AdminUser.js
@@ -0,0 +1,18 @@
+const mongoose = require("mongoose");
+const { Permission } = require("../../config/permissions");
+
+const adminUserSchema = new mongoose.Schema(
+ {
+ email: { type: String, required: true, unique: true, lowercase: true, trim: true },
+ googleSub: { type: String, unique: true, sparse: true },
+ name: { type: String },
+ pictureUrl: { type: String },
+ perm: { type: Number, required: true, default: Permission.READ },
+ isSuperAdmin: { type: Boolean, default: false },
+ isActive: { type: Boolean, default: true },
+ lastLoginAt: { type: Date },
+ },
+ { timestamps: true }
+);
+
+module.exports = mongoose.model("AdminUser", adminUserSchema);
diff --git a/backend/src/models/admin/AuthCode.js b/backend/src/models/admin/AuthCode.js
new file mode 100644
index 0000000..1e35f55
--- /dev/null
+++ b/backend/src/models/admin/AuthCode.js
@@ -0,0 +1,15 @@
+const mongoose = require("mongoose");
+
+const authCodeSchema = new mongoose.Schema(
+ {
+ codeHash: { type: String, required: true, unique: true },
+ userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: "AdminUser" },
+ expiresAt: { type: Date, required: true },
+ usedAt: { type: Date, default: null },
+ },
+ { timestamps: true }
+);
+
+authCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
+
+module.exports = mongoose.model("AuthCode", authCodeSchema);
diff --git a/backend/src/models/admin/RefreshToken.js b/backend/src/models/admin/RefreshToken.js
new file mode 100644
index 0000000..348b648
--- /dev/null
+++ b/backend/src/models/admin/RefreshToken.js
@@ -0,0 +1,25 @@
+const mongoose = require("mongoose");
+
+const refreshTokenSchema = new mongoose.Schema(
+ {
+ userId: { type: mongoose.Schema.Types.ObjectId, required: true, ref: "AdminUser" },
+ tokenHash: { type: String, required: true, unique: true },
+ expiresAt: { type: Date, required: true },
+ revoked: { type: Boolean, default: false },
+ revokedAt: { type: Date },
+ replacedByTokenId: { type: mongoose.Schema.Types.ObjectId, ref: "RefreshToken" },
+ deviceInfo: {
+ userAgent: String,
+ os: String,
+ browser: String,
+ ipHash: String,
+ lastSeenAt: Date,
+ },
+ },
+ { timestamps: true }
+);
+
+refreshTokenSchema.index({ userId: 1, revoked: 1, revokedAt: 1 });
+refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
+
+module.exports = mongoose.model("RefreshToken", refreshTokenSchema);
diff --git a/backend/src/models/admin/index.js b/backend/src/models/admin/index.js
new file mode 100644
index 0000000..c04f671
--- /dev/null
+++ b/backend/src/models/admin/index.js
@@ -0,0 +1,13 @@
+const AdminUser = require("./AdminUser");
+const AdminInvite = require("./AdminInvite");
+const RefreshToken = require("./RefreshToken");
+const AdminAuditLog = require("./AdminAuditLog");
+const AuthCode = require("./AuthCode");
+
+module.exports = {
+ AdminUser,
+ AdminInvite,
+ RefreshToken,
+ AdminAuditLog,
+ AuthCode,
+};
diff --git a/backend/src/models/index.js b/backend/src/models/index.js
deleted file mode 100644
index 3b77eeb..0000000
--- a/backend/src/models/index.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const mongoose = require("mongoose");
-const config = require("../config/config");
-
-const Member = require("./Member");
-const Semina = require("./Semina");
-const File = require("./File");
-const Feature = require("./Feature");
-
-async function connectDB() {
- if (!config?.mongodbUri) {
- throw new Error("MONGODB_URI(또는 환경별 URI)가 설정되지 않았습니다.");
- }
-
- await mongoose.connect(config.mongodbUri, {
- dbName: config.dbName || undefined,
- serverSelectionTimeoutMS: 10000,
- });
-}
-
-module.exports = {
- mongoose,
- connectDB,
- Member,
- Semina,
- File,
- Feature,
-};
diff --git a/backend/src/models/mongo/Feature.js b/backend/src/models/mongo/Feature.js
new file mode 100644
index 0000000..65e8905
--- /dev/null
+++ b/backend/src/models/mongo/Feature.js
@@ -0,0 +1,12 @@
+const mongoose = require("mongoose");
+
+const featureSchema = new mongoose.Schema(
+ {
+ feature_name: { type: String, required: true, unique: true, trim: true },
+ is_enabled: { type: Boolean, default: false },
+ },
+ { timestamps: true, collection: "features" }
+);
+
+module.exports = mongoose.model("FeatureMongo", featureSchema);
+
diff --git a/backend/src/models/mongo/File.js b/backend/src/models/mongo/File.js
new file mode 100644
index 0000000..b8e72ef
--- /dev/null
+++ b/backend/src/models/mongo/File.js
@@ -0,0 +1,12 @@
+const mongoose = require("mongoose");
+
+const fileSchema = new mongoose.Schema(
+ {
+ semina_id: { type: mongoose.Schema.Types.ObjectId, required: true, ref: "SeminaMongo" },
+ file_name: { type: String, required: true },
+ },
+ { timestamps: true, collection: "files" }
+);
+
+module.exports = mongoose.model("FileMongo", fileSchema);
+
diff --git a/backend/src/models/mongo/Member.js b/backend/src/models/mongo/Member.js
new file mode 100644
index 0000000..3af29e5
--- /dev/null
+++ b/backend/src/models/mongo/Member.js
@@ -0,0 +1,23 @@
+const mongoose = require("mongoose");
+
+const memberSchema = new mongoose.Schema(
+ {
+ generation: { type: String },
+ username: { type: String },
+ student_id: { type: String },
+ major: { type: String },
+ email: { type: String },
+ activity: { type: Boolean },
+ dev: { type: Boolean },
+ game: { type: Boolean },
+ design: { type: Boolean },
+ blog: { type: String },
+ github: { type: String },
+ portfolio: { type: String },
+ etc: { type: String },
+ },
+ { timestamps: true, collection: "members" }
+);
+
+module.exports = mongoose.model("MemberMongo", memberSchema);
+
diff --git a/backend/src/models/mongo/Semina.js b/backend/src/models/mongo/Semina.js
new file mode 100644
index 0000000..c7da706
--- /dev/null
+++ b/backend/src/models/mongo/Semina.js
@@ -0,0 +1,15 @@
+const mongoose = require("mongoose");
+
+const seminaSchema = new mongoose.Schema(
+ {
+ speaker: { type: String, required: true },
+ topic: { type: String, required: true },
+ detail: { type: String },
+ resources: { type: String },
+ presentation_date: { type: Date, required: true },
+ },
+ { timestamps: true, collection: "semina" }
+);
+
+module.exports = mongoose.model("SeminaMongo", seminaSchema);
+
diff --git a/backend/src/models/mongo/index.js b/backend/src/models/mongo/index.js
new file mode 100644
index 0000000..50182a9
--- /dev/null
+++ b/backend/src/models/mongo/index.js
@@ -0,0 +1,12 @@
+const Member = require("./Member");
+const Feature = require("./Feature");
+const Semina = require("./Semina");
+const File = require("./File");
+
+module.exports = {
+ Member,
+ Feature,
+ Semina,
+ File,
+};
+
diff --git a/backend/src/passport/googleStrategy.js b/backend/src/passport/googleStrategy.js
new file mode 100644
index 0000000..7df1437
--- /dev/null
+++ b/backend/src/passport/googleStrategy.js
@@ -0,0 +1,179 @@
+const passport = require("passport");
+const { Strategy: GoogleStrategy } = require("passport-google-oauth20");
+const mongoose = require("mongoose");
+const { AdminUser, AdminInvite } = require("../models/admin");
+const { Permission } = require("../config/permissions");
+const { sha256 } = require("../utils/cryptoUtil");
+const { writeAuditLog } = require("../services/auditLogService");
+
+async function verifyGoogleTokenClaims(accessToken) {
+ if (!accessToken) return false;
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ try {
+ const res = await fetch(
+ `https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(accessToken)}`,
+ { signal: controller.signal }
+ );
+ if (!res.ok) return false;
+
+ const payload = await res.json();
+ const aud = payload.aud || payload.azp || payload.issued_to;
+ const iss = payload.iss || payload.issuer;
+ const validAud = aud === process.env.GOOGLE_CLIENT_ID;
+ const validIss =
+ !iss || iss === "accounts.google.com" || iss === "https://accounts.google.com";
+ return validAud && validIss;
+ } catch (_e) {
+ return false;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+module.exports = function configureGoogleStrategy() {
+ passport.use(
+ new GoogleStrategy(
+ {
+ clientID: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ callbackURL: `${process.env.BO_BACKEND_URL}/bo/auth/google/callback`,
+ passReqToCallback: true,
+ },
+ async (req, accessToken, _refreshToken, profile, done) => {
+ const email = (profile.emails?.[0]?.value || "").trim().toLowerCase();
+ const emailVerified =
+ profile._json?.email_verified === true || profile.emails?.[0]?.verified === true;
+ const googleSub = profile.id;
+ const name = profile.displayName || "";
+ const pictureUrl = profile.photos?.[0]?.value || "";
+
+ try {
+ const oauthClaimsValid = await verifyGoogleTokenClaims(accessToken);
+ if (!oauthClaimsValid) return done(null, false, { message: "OAUTH_CLAIMS_INVALID" });
+ if (!emailVerified) return done(null, false, { message: "EMAIL_NOT_VERIFIED" });
+
+ let user = await AdminUser.findOne({ googleSub });
+ if (user) {
+ if (!user.isActive) return done(null, false, { message: "ACCOUNT_INACTIVE" });
+ // lastLoginAt 갱신은 /google/callback에서 AuthCode.create() 성공 후에 수행한다.
+ // 여기서 save()하면 AuthCode 생성 실패 시에도 lastLoginAt이 갱신되는 불일치가 발생한다.
+ return done(null, user);
+ }
+
+ const inviteRawToken = req.signedCookies.bo_invite_token;
+ if (!inviteRawToken) return done(null, false, { message: "INVITE_REQUIRED" });
+ const inviteHash = sha256(inviteRawToken);
+
+ // 트랜잭션 외부에서 이메일 중복 여부만 사전 확인 (서브 충돌 조기 탐지)
+ const existingByEmail = await AdminUser.findOne({ email });
+ if (existingByEmail?.googleSub && existingByEmail.googleSub !== googleSub) {
+ return done(null, false, { message: "GOOGLE_SUB_CONFLICT" });
+ }
+
+ // 이메일 불일치를 INVITE_INVALID와 구분하기 위해 트랜잭션 진입 전에 사전 확인한다.
+ // tokenHash + status + expiresAt 조건으로만 조회해 invite 존재 여부를 먼저 파악하고,
+ // email이 다른 경우 INVITE_EMAIL_MISMATCH를 반환해 "올바른 계정으로 재시도" 안내를 표시한다.
+ // 트랜잭션 내 findOneAndUpdate는 email 조건을 포함해 원자성을 여전히 보장한다.
+ const preCheckInvite = await AdminInvite.findOne({
+ tokenHash: inviteHash,
+ status: "pending",
+ expiresAt: { $gt: new Date() },
+ });
+ if (!preCheckInvite) {
+ return done(null, false, { message: "INVITE_INVALID" });
+ }
+ if (preCheckInvite.email !== email) {
+ return done(null, false, { message: "INVITE_EMAIL_MISMATCH" });
+ }
+
+ const session = await mongoose.startSession();
+ let createdUser;
+ try {
+ await session.withTransaction(async () => {
+ // N섹션 동시성 방어: findOneAndUpdate의 { status: "pending" } 조건으로
+ // 복수 요청이 동시에 진입해도 단 한 요청만 invite를 "used"로 전환할 수 있다.
+ const invite = await AdminInvite.findOneAndUpdate(
+ {
+ tokenHash: inviteHash,
+ status: "pending",
+ expiresAt: { $gt: new Date() },
+ email,
+ },
+ { $set: { status: "used" } },
+ { session, new: false }
+ );
+ if (!invite) throw new Error("INVITE_INVALID");
+
+ let targetUser = existingByEmail;
+ if (targetUser) {
+ // 트랜잭션 진입 후 session 내에서 재조회하여 세션 일관성 보장
+ targetUser = await AdminUser.findById(targetUser._id).session(session);
+ if (!targetUser) throw new Error("INVITE_INVALID");
+ targetUser.googleSub = googleSub;
+ targetUser.name = name;
+ targetUser.pictureUrl = pictureUrl;
+ targetUser.perm = invite.perm | Permission.READ;
+ targetUser.isActive = true;
+ targetUser.lastLoginAt = new Date();
+ await targetUser.save({ session });
+ createdUser = targetUser;
+ } else {
+ createdUser = await AdminUser.create(
+ [
+ {
+ email,
+ googleSub,
+ name,
+ pictureUrl,
+ perm: invite.perm | Permission.READ,
+ isActive: true,
+ lastLoginAt: new Date(),
+ },
+ ],
+ { session }
+ ).then((arr) => arr[0]);
+ }
+
+ await AdminInvite.updateOne(
+ { _id: invite._id },
+ { $set: { usedByUserId: createdUser._id } },
+ { session }
+ );
+
+ await writeAuditLog({
+ actorUserId: createdUser._id,
+ targetUserId: createdUser._id,
+ action: "ADMIN_INVITE_ACCEPTED",
+ after: {
+ email,
+ perm: createdUser.perm,
+ inviteId: String(invite._id),
+ },
+ req,
+ session,
+ });
+ });
+ } catch (txErr) {
+ if (txErr.message === "INVITE_INVALID") {
+ return done(null, false, { message: "INVITE_INVALID" });
+ }
+ // MongoDB E11000: email 또는 googleSub unique constraint 위반.
+ // 동시 요청이 같은 이메일/googleSub로 create를 시도할 때 발생할 수 있다.
+ if (txErr.code === 11000) {
+ return done(null, false, { message: "GOOGLE_SUB_CONFLICT" });
+ }
+ throw txErr;
+ } finally {
+ await session.endSession();
+ }
+
+ return done(null, createdUser);
+ } catch (err) {
+ return done(err);
+ }
+ }
+ )
+ );
+};
diff --git a/backend/src/passport/index.js b/backend/src/passport/index.js
index e1173f4..2fb367f 100644
--- a/backend/src/passport/index.js
+++ b/backend/src/passport/index.js
@@ -1,17 +1,5 @@
-const passport = require('passport');
-const local = require('./localStrategy');
+const configureGoogleStrategy = require("./googleStrategy");
module.exports = () => {
- passport.serializeUser( (user, done) => {
- done(null, user.username);
- });
-
- passport.deserializeUser( (username, done) => {
- if (username === 'admin'){
- done(null, {username: 'admin'});
- } else {
- done(new Error('unauthorized'));
- }
- });
- local();
+ configureGoogleStrategy();
};
diff --git a/backend/src/passport/localStrategy.js b/backend/src/passport/localStrategy.js
index 114f078..7079007 100644
--- a/backend/src/passport/localStrategy.js
+++ b/backend/src/passport/localStrategy.js
@@ -1,39 +1,4 @@
-const passport = require('passport');
-const bcrypt = require('bcrypt');
-const dotenv = require('dotenv');
-const path = require('path');
-dotenv.config({
- path: path.resolve(__dirname, "../.env")
-});
-
-const {Strategy: LocalStrategy} = require('passport-local');
-
-module.exports = () => {
- passport.use(new LocalStrategy({
- usernameField: 'username', //req.body.username
- passwordField: 'password', //req.body.password
- passReqToCallback: false
- }, async (_username, password, done) => { //username은 실제로 사용 x
- try {
- if (!process.env.PASSWORD) {
- throw new Error('NO process.env.PASSWORD');
- }
-
- const rawHash = String(process.env.PASSWORD).trim();
- const normalizedHash = rawHash
- .replace(/^"|"$/g, '')
- .replace(/^'|'$/g, '')
- .replace(/^\$2y\$/, '$2b$');
-
- const result = await bcrypt.compare(String(password), normalizedHash);
- if (result) {
- done(null, {username: 'admin'}); //user.username = 'admin'
- } else {
- done(null, false, {message: '비밀번호가 틀림'});
- }
- } catch (error) {
- console.log(error);
- done(error);
- }
- }));
-};
+// DEPRECATED: passport-local 전략.
+// Google OAuth 전략(googleStrategy.js)으로 전환 완료.
+// 이 파일은 더 이상 사용되지 않으며 삭제 예정입니다.
+// passport/index.js 에서 이미 호출하지 않습니다.
diff --git a/backend/src/routes/boAdminInvites.js b/backend/src/routes/boAdminInvites.js
new file mode 100644
index 0000000..971ac2f
--- /dev/null
+++ b/backend/src/routes/boAdminInvites.js
@@ -0,0 +1,303 @@
+const express = require("express");
+const mongoose = require("mongoose");
+const { requireAuth, requireSuperAdmin } = require("../middlewares/boAuth");
+const { Permission, WRITE_ALL_MASK } = require("../config/permissions");
+
+// 유효한 perm 비트마스크 최대값: READ | 모든 WRITE 권한
+const MAX_VALID_PERM = Permission.READ | WRITE_ALL_MASK;
+const { labelsToPerm, VALID_PERM_LABELS } = require("../utils/permLabels");
+const { randomToken, sha256 } = require("../utils/cryptoUtil");
+const { AdminInvite, AdminUser } = require("../models/admin");
+const { writeAuditLog } = require("../services/auditLogService");
+
+const router = express.Router();
+const INVITE_STATUSES = new Set(["pending", "used", "expired", "revoked"]);
+
+// RFC 5322 기반 간이 이메일 형식 검증
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
+
+router.get("/", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ try {
+ const query = {};
+ if (typeof req.query?.status === "string" && INVITE_STATUSES.has(req.query.status)) {
+ query.status = req.query.status;
+ }
+
+ const page = Math.max(1, parseInt(req.query.page) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
+ const skip = (page - 1) * limit;
+
+ const [invites, total] = await Promise.all([
+ AdminInvite.find(query)
+ .select("email perm status expiresAt invitedBy usedByUserId createdAt updatedAt")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit)
+ .lean(),
+ AdminInvite.countDocuments(query),
+ ]);
+
+ return res.json({
+ data: invites,
+ pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
+ });
+ } catch (err) {
+ return next(err);
+ }
+});
+
+router.post("/", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ const { email, perm, labels = ["read/all"], expiresInSec = 172800 } = req.body || {};
+ const normEmail = String(email || "").trim().toLowerCase();
+ if (!normEmail || !EMAIL_RE.test(normEmail)) {
+ return res.status(400).json({ code: "INVALID_INPUT" });
+ }
+ if (expiresInSec < 3600 || expiresInSec > 604800) {
+ return res.status(400).json({ code: "INVALID_EXPIRES" });
+ }
+
+ let invitePerm;
+ if (Number.isInteger(perm) && perm >= 0) {
+ // 유효 비트마스크 범위를 초과한 값은 거부한다.
+ if (perm > MAX_VALID_PERM) {
+ return res.status(400).json({ code: "INVALID_PERM", message: `perm must be between 0 and ${MAX_VALID_PERM}` });
+ }
+ invitePerm = perm | Permission.READ;
+ } else {
+ // labels 유효성 검사: boAdminUsers PATCH /:id/perm과 동일한 정책 적용
+ if (!Array.isArray(labels)) {
+ return res.status(400).json({ code: "INVALID_LABELS", message: "labels must be an array" });
+ }
+ const knownLabels = labels.filter((l) => VALID_PERM_LABELS.includes(l));
+ if (knownLabels.length === 0) {
+ return res.status(400).json({
+ code: "INVALID_LABELS",
+ message: `labels must include at least one of: ${VALID_PERM_LABELS.join(", ")}`,
+ });
+ }
+ invitePerm = labelsToPerm(labels) | Permission.READ;
+ }
+
+ const token = randomToken(32);
+ const tokenHash = sha256(token);
+
+ // 기존 pending 초대 revoke + 새 초대 생성 + 감사 로그를 트랜잭션으로 묶어
+ // reissue 라우트와 동일한 원자성을 보장한다.
+ let session;
+ let invite;
+ try {
+ session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ // 이미 Google 계정이 바인딩된 활성 사용자에게는 초대가 불필요하다.
+ // 초대 링크를 보내도 해당 사용자는 이미 googleSub로 직접 로그인하므로 초대 플로우를 타지 않는다.
+ const alreadyOnboarded = await AdminUser.findOne(
+ { email: normEmail, googleSub: { $ne: null }, isActive: true },
+ null,
+ { session }
+ ).lean();
+ if (alreadyOnboarded) {
+ const e = new Error("USER_ALREADY_ONBOARDED");
+ e.status = 409;
+ throw e;
+ }
+
+ await AdminInvite.updateMany(
+ { email: normEmail, status: "pending" },
+ { $set: { status: "revoked" } },
+ { session }
+ );
+
+ invite = await AdminInvite.create(
+ [
+ {
+ email: normEmail,
+ perm: invitePerm,
+ tokenHash,
+ expiresAt: new Date(Date.now() + expiresInSec * 1000),
+ invitedBy: req.auth.userId,
+ },
+ ],
+ { session }
+ ).then((arr) => arr[0]);
+
+ await writeAuditLog({
+ actorUserId: req.auth.userId,
+ action: "ADMIN_INVITE_CREATED",
+ after: { inviteId: String(invite._id), email: normEmail, perm: invitePerm, expiresInSec },
+ req,
+ session,
+ });
+ });
+ } catch (e) {
+ if (e.message === "USER_ALREADY_ONBOARDED") {
+ return res.status(409).json({ code: "USER_ALREADY_ONBOARDED" });
+ }
+ return next(e);
+ } finally {
+ await session?.endSession();
+ }
+
+ return res.status(201).json({
+ inviteUrl: `${process.env.BO_BACKEND_URL}/bo/auth/invite/${token}`,
+ });
+});
+
+router.patch("/:id/revoke", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ if (!mongoose.isValidObjectId(req.params.id)) {
+ return res.status(404).json({ code: "NOT_FOUND" });
+ }
+ let session;
+ try {
+ session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const invite = await AdminInvite.findById(req.params.id).session(session);
+ if (!invite) {
+ const e = new Error("NOT_FOUND");
+ e.status = 404;
+ throw e;
+ }
+ if (invite.status !== "pending") {
+ const e = new Error("INVITE_NOT_PENDING");
+ e.status = 409;
+ throw e;
+ }
+
+ invite.status = "revoked";
+ await invite.save({ session });
+
+ await writeAuditLog({
+ actorUserId: req.auth.userId,
+ action: "ADMIN_INVITE_REVOKED",
+ after: { inviteId: String(invite._id), email: invite.email },
+ req,
+ session,
+ });
+ });
+ } catch (e) {
+ if (e.message === "NOT_FOUND") return res.status(404).json({ code: "NOT_FOUND" });
+ if (e.message === "INVITE_NOT_PENDING") return res.status(409).json({ code: "INVITE_NOT_PENDING" });
+ return next(e);
+ } finally {
+ await session?.endSession();
+ }
+
+ return res.json({ ok: true });
+});
+
+router.post("/:id/reissue", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ if (!mongoose.isValidObjectId(req.params.id)) {
+ return res.status(404).json({ code: "NOT_FOUND" });
+ }
+ const { perm, labels, expiresInSec = 172800 } = req.body || {};
+ if (expiresInSec < 3600 || expiresInSec > 604800) {
+ return res.status(400).json({ code: "INVALID_EXPIRES" });
+ }
+
+ // labels가 명시된 경우 유효성 검사: POST /와 동일한 정책 적용
+ // labels.length > 0 가드를 제거해 빈 배열도 POST /와 동일하게 400으로 거부한다.
+ // 빈 배열이 통과되면 labelsToPerm([]) = READ-only로 조용히 강등되는 불일치가 발생한다.
+ if (labels !== undefined && !Number.isInteger(perm)) {
+ if (!Array.isArray(labels)) {
+ return res.status(400).json({ code: "INVALID_LABELS", message: "labels must be an array" });
+ }
+ const knownLabels = labels.filter((l) => VALID_PERM_LABELS.includes(l));
+ if (knownLabels.length === 0) {
+ return res.status(400).json({
+ code: "INVALID_LABELS",
+ message: `labels must include at least one of: ${VALID_PERM_LABELS.join(", ")}`,
+ });
+ }
+ }
+
+ let session;
+ let inviteUrl;
+ try {
+ session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const baseInvite = await AdminInvite.findById(req.params.id).session(session);
+ if (!baseInvite) {
+ const e = new Error("NOT_FOUND");
+ e.status = 404;
+ throw e;
+ }
+
+ // POST /와 동일하게 이미 온보딩된 사용자에게 재발급하지 않는다.
+ const alreadyOnboarded = await AdminUser.findOne(
+ { email: baseInvite.email, googleSub: { $ne: null }, isActive: true },
+ null,
+ { session }
+ ).lean();
+ if (alreadyOnboarded) {
+ const e = new Error("USER_ALREADY_ONBOARDED");
+ e.status = 409;
+ throw e;
+ }
+
+ await AdminInvite.updateMany(
+ { email: baseInvite.email, status: "pending" },
+ { $set: { status: "revoked" } },
+ { session }
+ );
+
+ let invitePerm;
+ if (Number.isInteger(perm) && perm >= 0) {
+ if (perm > MAX_VALID_PERM) {
+ const e = new Error("INVALID_PERM");
+ e.status = 400;
+ throw e;
+ }
+ invitePerm = perm | Permission.READ;
+ } else if (Array.isArray(labels)) {
+ invitePerm = labelsToPerm(labels) | Permission.READ;
+ } else {
+ invitePerm = baseInvite.perm | Permission.READ;
+ }
+
+ const token = randomToken(32);
+ const tokenHash = sha256(token);
+ const created = await AdminInvite.create(
+ [
+ {
+ email: baseInvite.email,
+ perm: invitePerm,
+ tokenHash,
+ expiresAt: new Date(Date.now() + expiresInSec * 1000),
+ invitedBy: req.auth.userId,
+ },
+ ],
+ { session }
+ ).then((arr) => arr[0]);
+
+ await writeAuditLog({
+ actorUserId: req.auth.userId,
+ action: "ADMIN_INVITE_REISSUED",
+ after: {
+ sourceInviteId: String(baseInvite._id),
+ newInviteId: String(created._id),
+ email: created.email,
+ perm: created.perm,
+ expiresInSec,
+ },
+ req,
+ session,
+ });
+
+ inviteUrl = `${process.env.BO_BACKEND_URL}/bo/auth/invite/${token}`;
+ });
+ } catch (e) {
+ if (e.message === "NOT_FOUND") return res.status(404).json({ code: "NOT_FOUND" });
+ if (e.message === "USER_ALREADY_ONBOARDED") {
+ return res.status(409).json({ code: "USER_ALREADY_ONBOARDED" });
+ }
+ if (e.message === "INVALID_PERM") {
+ return res.status(400).json({ code: "INVALID_PERM", message: `perm must be between 0 and ${MAX_VALID_PERM}` });
+ }
+ return next(e);
+ } finally {
+ await session?.endSession();
+ }
+
+ return res.status(201).json({ inviteUrl });
+});
+
+module.exports = router;
diff --git a/backend/src/routes/boAdminUsers.js b/backend/src/routes/boAdminUsers.js
new file mode 100644
index 0000000..b74b955
--- /dev/null
+++ b/backend/src/routes/boAdminUsers.js
@@ -0,0 +1,219 @@
+const express = require("express");
+const mongoose = require("mongoose");
+const { requireAuth, requireSuperAdmin } = require("../middlewares/boAuth");
+const { Permission } = require("../config/permissions");
+const { labelsToPerm, VALID_PERM_LABELS } = require("../utils/permLabels");
+const { AdminUser, RefreshToken } = require("../models/admin");
+const { writeAuditLog } = require("../services/auditLogService");
+
+const router = express.Router();
+
+router.get("/", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ try {
+ const page = Math.max(1, parseInt(req.query.page) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20));
+ const skip = (page - 1) * limit;
+
+ const [users, total] = await Promise.all([
+ AdminUser.find({})
+ .select("email name perm isSuperAdmin isActive lastLoginAt createdAt")
+ .sort({ createdAt: -1 })
+ .skip(skip)
+ .limit(limit)
+ .lean(),
+ AdminUser.countDocuments({}),
+ ]);
+
+ return res.json({
+ data: users,
+ pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
+ });
+ } catch (err) {
+ return next(err);
+ }
+});
+
+router.patch("/:id/perm", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ if (!mongoose.isValidObjectId(req.params.id)) {
+ return res.status(404).json({ code: "NOT_FOUND" });
+ }
+ const { labels } = req.body || {};
+
+ // 배열 타입 및 최소 1개 이상의 유효한 label 포함 여부를 검증한다.
+ // 빈 배열이거나 알 수 없는 label만 포함된 경우 조용히 READ만 남기는 대신 명시적 에러를 반환한다.
+ if (!Array.isArray(labels)) {
+ return res.status(400).json({ code: "INVALID_LABELS", message: "labels must be an array" });
+ }
+ const knownLabels = labels.filter((l) => VALID_PERM_LABELS.includes(l));
+ if (knownLabels.length === 0) {
+ return res
+ .status(400)
+ .json({ code: "INVALID_LABELS", message: `labels must include at least one of: ${VALID_PERM_LABELS.join(", ")}` });
+ }
+
+ let session;
+ try {
+ session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const target = await AdminUser.findById(req.params.id).session(session);
+ if (!target) {
+ const e = new Error("NOT_FOUND");
+ e.status = 404;
+ throw e;
+ }
+
+ const before = { perm: target.perm };
+ target.perm = labelsToPerm(labels) | Permission.READ;
+ await target.save({ session });
+
+ await writeAuditLog({
+ actorUserId: req.auth.userId,
+ targetUserId: target._id,
+ action: "ADMIN_PERM_CHANGED",
+ before,
+ after: { perm: target.perm },
+ req,
+ session,
+ });
+ });
+ } catch (e) {
+ if (e.message === "NOT_FOUND") return res.status(404).json({ code: "NOT_FOUND" });
+ return next(e);
+ } finally {
+ await session?.endSession();
+ }
+
+ return res.json({ ok: true });
+});
+
+router.patch("/:id/active", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ if (!mongoose.isValidObjectId(req.params.id)) {
+ return res.status(404).json({ code: "NOT_FOUND" });
+ }
+ const { isActive } = req.body || {};
+ // boolean 타입 명시 검증: body에 isActive가 없거나 boolean이 아니면 !!isActive = false로 평가되어
+ // 의도치 않은 계정 비활성화가 발생할 수 있다. 명시적으로 boolean만 허용한다.
+ if (typeof isActive !== "boolean") {
+ return res.status(400).json({ code: "INVALID_INPUT", message: "isActive must be a boolean" });
+ }
+ let session;
+ try {
+ session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const target = await AdminUser.findById(req.params.id).session(session);
+ if (!target) {
+ const e = new Error("NOT_FOUND");
+ e.status = 404;
+ throw e;
+ }
+
+ // 자기 자신을 비활성화하면 즉시 로그인 불가가 되므로 방지한다.
+ // (superAdminBootstrap이 서버 재시작 시 복구하지만 그 전까지는 잠긴 상태가 됨)
+ if (String(target._id) === req.auth.userId && isActive === false) {
+ const e = new Error("SELF_DEACTIVATION_FORBIDDEN");
+ e.status = 400;
+ throw e;
+ }
+
+ const before = { isActive: target.isActive };
+ target.isActive = !!isActive;
+ await target.save({ session });
+
+ if (!target.isActive) {
+ await RefreshToken.updateMany(
+ { userId: target._id, revoked: false },
+ { $set: { revoked: true, revokedAt: new Date() } },
+ { session }
+ );
+ }
+
+ await writeAuditLog({
+ actorUserId: req.auth.userId,
+ targetUserId: target._id,
+ action: "ADMIN_ACTIVE_CHANGED",
+ before,
+ after: { isActive: target.isActive },
+ req,
+ session,
+ });
+ });
+ } catch (e) {
+ if (e.message === "NOT_FOUND") return res.status(404).json({ code: "NOT_FOUND" });
+ if (e.message === "SELF_DEACTIVATION_FORBIDDEN") {
+ return res.status(400).json({ code: "SELF_DEACTIVATION_FORBIDDEN" });
+ }
+ return next(e);
+ } finally {
+ await session?.endSession();
+ }
+
+ return res.json({ ok: true });
+});
+
+router.patch("/:id/super-admin", requireAuth, requireSuperAdmin, async (req, res, next) => {
+ if (!mongoose.isValidObjectId(req.params.id)) {
+ return res.status(404).json({ code: "NOT_FOUND" });
+ }
+ const { isSuperAdmin } = req.body || {};
+ // isActive와 동일하게 boolean 타입 명시 검증: body 누락 시 !!undefined = false로 평가되어
+ // 의도치 않은 super admin 강등이 발생할 수 있다.
+ if (typeof isSuperAdmin !== "boolean") {
+ return res.status(400).json({ code: "INVALID_INPUT", message: "isSuperAdmin must be a boolean" });
+ }
+ let session;
+ try {
+ session = await mongoose.startSession();
+ await session.withTransaction(async () => {
+ const target = await AdminUser.findById(req.params.id).session(session);
+ if (!target) {
+ const e = new Error("NOT_FOUND");
+ e.status = 404;
+ throw e;
+ }
+
+ if (String(target._id) === req.auth.userId && isSuperAdmin === false) {
+ const e = new Error("SELF_SUPER_ADMIN_DOWNGRADE_FORBIDDEN");
+ e.status = 400;
+ throw e;
+ }
+
+ if (target.isSuperAdmin && isSuperAdmin === false) {
+ const count = await AdminUser.countDocuments({ isSuperAdmin: true }).session(session);
+ if (count <= 1) {
+ const e = new Error("LAST_SUPER_ADMIN_FORBIDDEN");
+ e.status = 400;
+ throw e;
+ }
+ }
+
+ const before = { isSuperAdmin: target.isSuperAdmin };
+ target.isSuperAdmin = !!isSuperAdmin;
+ await target.save({ session });
+
+ await writeAuditLog({
+ actorUserId: req.auth.userId,
+ targetUserId: target._id,
+ action: "ADMIN_SUPER_ADMIN_CHANGED",
+ before,
+ after: { isSuperAdmin: target.isSuperAdmin },
+ req,
+ session,
+ });
+ });
+ } catch (e) {
+ if (e.message === "NOT_FOUND") return res.status(404).json({ code: "NOT_FOUND" });
+ if (e.message === "SELF_SUPER_ADMIN_DOWNGRADE_FORBIDDEN") {
+ return res.status(400).json({ code: "SELF_SUPER_ADMIN_DOWNGRADE_FORBIDDEN" });
+ }
+ if (e.message === "LAST_SUPER_ADMIN_FORBIDDEN") {
+ return res.status(400).json({ code: "LAST_SUPER_ADMIN_FORBIDDEN" });
+ }
+ return next(e);
+ } finally {
+ await session?.endSession();
+ }
+
+ return res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/backend/src/routes/boAuth.js b/backend/src/routes/boAuth.js
new file mode 100644
index 0000000..b312310
--- /dev/null
+++ b/backend/src/routes/boAuth.js
@@ -0,0 +1,526 @@
+const express = require("express");
+const passport = require("passport");
+const mongoose = require("mongoose");
+const { randomToken, sha256 } = require("../utils/cryptoUtil");
+const { signAccessToken } = require("../utils/tokenUtil");
+const { permToLabels } = require("../utils/permLabels");
+const { toIpHash } = require("../utils/ipHash");
+const { AdminUser, RefreshToken, AuthCode, AdminInvite } = require("../models/admin");
+const { requireAuth } = require("../middlewares/boAuth");
+const { writeAuditLog } = require("../services/auditLogService");
+const { createRateLimiter } = require("../services/rateLimiterFactory");
+const { getRedisClient } = require("../services/redisClient");
+const ALLOWED_ORIGINS = require("../config/allowedOrigins");
+
+const router = express.Router();
+
+const REFRESH_GRACE_WINDOW_MS = Number(process.env.REFRESH_GRACE_WINDOW_MS || 10000);
+const refreshLocks = new Map(); // in-process dedupe; distributed lock is attempted via Redis.
+
+const oauthStartLimiter = createRateLimiter({
+ windowMs: 60 * 1000,
+ max: 10,
+});
+
+const tokenExchangeLimiter = createRateLimiter({
+ windowMs: 60 * 1000,
+ max: 5,
+});
+
+const oauthCallbackLimiter = createRateLimiter({
+ windowMs: 60 * 1000,
+ max: 10,
+});
+
+const refreshLimiter = createRateLimiter({
+ windowMs: 60 * 1000,
+ max: 30,
+ keyGenerator: (req) => {
+ const rt = req.cookies?.bo_rt;
+ if (rt) return `rt:${sha256(rt)}`;
+ return `ip:${req.ip}`;
+ },
+});
+
+const inviteLimiter = createRateLimiter({
+ windowMs: 60 * 1000,
+ max: 10,
+});
+
+/**
+ * 분산 락 획득 결과를 status 객체로 반환한다.
+ * - { status: 'no-redis' } : Redis 미연결 → in-memory fallback으로 진행
+ * - { status: 'acquired', redis, lockRedisKey, lockValue } : 락 획득 성공
+ * - { status: 'contention' } : 다른 프로세스가 이미 락 보유 → REFRESH_TOKEN_REVOKED 반환해야 함
+ * - { status: 'error' } : Redis 통신 오류 → in-memory fallback으로 진행 (오류를 경합으로 오분류하지 않음)
+ */
+async function acquireDistributedRefreshLock(lockKey) {
+ const redis = getRedisClient();
+ if (!redis) return { status: "no-redis" };
+
+ try {
+ const lockValue = randomToken(16);
+ const lockRedisKey = `bo:refresh-lock:${lockKey}`;
+ const ok = await redis.set(lockRedisKey, lockValue, "PX", 15000, "NX");
+ if (ok !== "OK") return { status: "contention" };
+ return { status: "acquired", redis, lockRedisKey, lockValue };
+ } catch (_e) {
+ // Redis 통신 오류: 경합이 아니므로 REVOKED 처리하지 않고 in-memory fallback으로 넘긴다.
+ return { status: "error" };
+ }
+}
+
+async function releaseDistributedRefreshLock(lockResult) {
+ if (!lockResult || lockResult.status !== "acquired") return;
+ // Delete lock only if value matches (avoid deleting another owner's lock).
+ const lua = `
+if redis.call("get", KEYS[1]) == ARGV[1] then
+ return redis.call("del", KEYS[1])
+else
+ return 0
+end
+`;
+ try {
+ await lockResult.redis.eval(lua, 1, lockResult.lockRedisKey, lockResult.lockValue);
+ } catch (_e) {}
+}
+
+function parseDeviceInfo(userAgentRaw) {
+ const ua = userAgentRaw || "";
+ let os = "unknown";
+ if (/iphone|ipad|ios/i.test(ua)) os = "ios";
+ else if (/android/i.test(ua)) os = "android";
+ else if (/windows/i.test(ua)) os = "windows";
+ else if (/mac os x|macintosh/i.test(ua)) os = "macos";
+ else if (/linux/i.test(ua)) os = "linux";
+
+ let browser = "unknown";
+ if (/edg\//i.test(ua)) browser = "edge";
+ else if (/chrome\//i.test(ua) && !/edg\//i.test(ua)) browser = "chrome";
+ else if (/safari\//i.test(ua) && !/chrome\//i.test(ua)) browser = "safari";
+ else if (/firefox\//i.test(ua)) browser = "firefox";
+
+ return { os, browser };
+}
+
+function checkRefreshCsrf(req) {
+ const xrw = req.headers["x-requested-with"];
+ const origin = req.headers.origin || "";
+ const referer = req.headers.referer || "";
+ const allowed = ALLOWED_ORIGINS;
+
+ if (xrw !== "XMLHttpRequest") return false;
+ if (!allowed.includes(origin)) return false;
+ if (referer && !allowed.some((o) => referer.startsWith(o))) return false;
+ return true;
+}
+
+function setRefreshCookie(res, rawToken) {
+ res.cookie("bo_rt", rawToken, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/bo/auth/refresh",
+ maxAge: 14 * 24 * 60 * 60 * 1000,
+ });
+}
+
+function setOAuthStateCookie(res, state) {
+ res.cookie("bo_oauth_state", state, {
+ signed: true,
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/bo/auth/google/callback",
+ maxAge: 5 * 60 * 1000,
+ });
+}
+
+function clearOAuthStateCookie(res) {
+ res.clearCookie("bo_oauth_state", { path: "/bo/auth/google/callback" });
+}
+
+function verifyOAuthState(req, res, next) {
+ const cookieState = req.signedCookies?.bo_oauth_state;
+ const queryState = typeof req.query?.state === "string" ? req.query.state : "";
+ if (!cookieState || !queryState || cookieState !== queryState) {
+ clearOAuthStateCookie(res);
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=oauth_state_invalid`);
+ }
+ clearOAuthStateCookie(res);
+ next();
+}
+
+router.get("/google", oauthStartLimiter, (req, res, next) => {
+ const state = randomToken(16);
+ setOAuthStateCookie(res, state);
+ passport.authenticate("google", {
+ session: false,
+ state,
+ scope: ["openid", "email", "profile"],
+ })(req, res, next);
+});
+
+router.get(
+ "/google/callback",
+ oauthCallbackLimiter,
+ verifyOAuthState,
+ // 커스텀 콜백: info.message를 소문자화해서 reason으로 전달한다.
+ // failureRedirect를 사용하면 info가 무시되어 모든 실패가 oauth_failed로 통합되는 문제가 있다.
+ (req, res, next) => {
+ passport.authenticate("google", { session: false }, (err, user, info) => {
+ if (err) return next(err);
+ if (!user) {
+ const reason = info?.message
+ ? info.message.toLowerCase()
+ : "oauth_failed";
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=${reason}`);
+ }
+ req.user = user;
+ next();
+ })(req, res, next);
+ },
+ async (req, res, next) => {
+ try {
+ const user = req.user;
+ res.clearCookie("bo_invite_token", { path: "/" });
+
+ const code = randomToken(16);
+ await AuthCode.create({
+ codeHash: sha256(code),
+ userId: user._id,
+ expiresAt: new Date(Date.now() + 30 * 1000),
+ });
+
+ // AuthCode 생성 성공 후 lastLoginAt을 갱신한다.
+ // googleStrategy에서 미리 save()하면 AuthCode 생성 실패 시에도 lastLoginAt이 찍히는
+ // 불일치가 발생한다. best-effort: 갱신 실패가 로그인 플로우를 중단하지 않도록 한다.
+ AdminUser.updateOne({ _id: user._id }, { $set: { lastLoginAt: new Date() } }).catch(() => {});
+
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/auth/callback?code=${code}`);
+ } catch (err) {
+ return next(err);
+ }
+ }
+);
+
+router.post("/token-exchange", tokenExchangeLimiter, async (req, res, next) => {
+ try {
+ const { code } = req.body || {};
+ if (!code) return res.status(401).json({ code: "UNAUTHORIZED" });
+
+ const doc = await AuthCode.findOneAndUpdate(
+ {
+ codeHash: sha256(code),
+ usedAt: null,
+ expiresAt: { $gt: new Date() },
+ },
+ { $set: { usedAt: new Date() } },
+ { new: true }
+ );
+ if (!doc) return res.status(401).json({ code: "UNAUTHORIZED" });
+
+ // AuthCode 발급 후 계정이 비활성화된 케이스를 방어한다.
+ // 30초 윈도우 내의 타이밍 이슈지만, 비활성 계정으로 RefreshToken이 발급되는
+ // 데이터 불일치를 막기 위해 명시적으로 검증한다.
+ const tokenUser = await AdminUser.findById(doc.userId).lean();
+ if (!tokenUser || !tokenUser.isActive) {
+ return res.status(401).json({ code: "UNAUTHORIZED" });
+ }
+
+ const rt = randomToken(32);
+ const tokenHash = sha256(rt);
+ const userAgent = req.headers["user-agent"] || "";
+ const parsed = parseDeviceInfo(userAgent);
+ await RefreshToken.create({
+ userId: doc.userId,
+ tokenHash,
+ expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
+ deviceInfo: {
+ userAgent,
+ os: parsed.os,
+ browser: parsed.browser,
+ ipHash: toIpHash(
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || ""
+ ),
+ lastSeenAt: new Date(),
+ },
+ });
+
+ setRefreshCookie(res, rt);
+ const accessToken = signAccessToken(doc.userId);
+
+ // J섹션: 로그인 완료(토큰 발급) 이벤트를 감사 로그에 기록한다.
+ // best-effort: 로그 쓰기 실패가 로그인 자체를 막지 않도록 await하지 않는다.
+ writeAuditLog({
+ actorUserId: doc.userId,
+ targetUserId: doc.userId,
+ action: "ADMIN_LOGIN",
+ after: {
+ os: parsed.os,
+ browser: parsed.browser,
+ },
+ req,
+ }).catch(() => {});
+
+ return res.json({ accessToken });
+ } catch (err) {
+ return next(err);
+ }
+});
+
+router.get("/invite/:token", inviteLimiter, async (req, res, next) => {
+ try {
+ const token = req.params.token;
+ const invite = await AdminInvite.findOne({ tokenHash: sha256(token) });
+ if (!invite) {
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=invite_invalid`);
+ }
+ if (invite.expiresAt < new Date()) {
+ if (invite.status === "pending") {
+ // best-effort: status 업데이트 실패가 redirect를 막지 않도록 한다.
+ // 유효성 판단은 expiresAt 비교로 이미 완료됐으며, status는 정보성 기록이다.
+ invite.status = "expired";
+ invite.save().catch(() => {});
+ }
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=invite_expired`);
+ }
+ if (invite.status === "used") {
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=invite_used`);
+ }
+ if (invite.status === "revoked") {
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=invite_revoked`);
+ }
+ if (invite.status !== "pending") {
+ return res.redirect(`${process.env.BO_FRONTEND_URL}/?reason=invite_invalid`);
+ }
+
+ res.cookie("bo_invite_token", token, {
+ signed: true,
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 10 * 60 * 1000,
+ });
+
+ return res.redirect(`${process.env.BO_BACKEND_URL}/bo/auth/google`);
+ } catch (err) {
+ return next(err);
+ }
+});
+
+// tokenHash: 호출자(/refresh 핸들러)가 이미 sha256(rawRt)로 계산한 lockKey와 동일한 값.
+// 이중 계산을 피하기 위해 외부에서 전달받는다.
+async function rotateRefresh(rawRt, tokenHash, req) {
+ const now = Date.now();
+ const session = await mongoose.startSession();
+ let newRawRt;
+ let userId;
+ try {
+ await session.withTransaction(async () => {
+ // Atomic lock by tokenHash: only one request can revoke+own this token.
+ const lockedOld = await RefreshToken.findOneAndUpdate(
+ {
+ tokenHash,
+ revoked: false,
+ expiresAt: { $gt: new Date(now) },
+ },
+ { $set: { revoked: true, revokedAt: new Date() } },
+ { session, new: true }
+ );
+ if (!lockedOld) throw new Error("REFRESH_TOKEN_RACE_LOST");
+
+ const user = await AdminUser.findById(lockedOld.userId).session(session);
+ if (!user || !user.isActive) throw new Error("ACCOUNT_INACTIVE");
+
+ newRawRt = randomToken(32);
+ const newHash = sha256(newRawRt);
+ const userAgent = req.headers["user-agent"] || "";
+ const parsed = parseDeviceInfo(userAgent);
+
+ const created = await RefreshToken.create(
+ [
+ {
+ userId: lockedOld.userId,
+ tokenHash: newHash,
+ expiresAt: new Date(now + 14 * 24 * 60 * 60 * 1000),
+ deviceInfo: {
+ userAgent,
+ os: parsed.os,
+ browser: parsed.browser,
+ ipHash: toIpHash(
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || ""
+ ),
+ lastSeenAt: new Date(),
+ },
+ },
+ ],
+ { session }
+ ).then((arr) => arr[0]);
+
+ await RefreshToken.updateOne(
+ { _id: lockedOld._id },
+ { $set: { replacedByTokenId: created._id } },
+ { session }
+ );
+
+ userId = String(lockedOld.userId);
+ });
+ } catch (e) {
+ if (e.message === "ACCOUNT_INACTIVE") return { error: "ACCOUNT_INACTIVE" };
+ if (e.message === "REFRESH_TOKEN_RACE_LOST") {
+ // No token issuance happened. Safely classify current token state.
+ const current = await RefreshToken.findOne({ tokenHash });
+ if (!current) return { error: "REFRESH_TOKEN_INVALID" };
+ if (current.expiresAt.getTime() < now) return { error: "REFRESH_TOKEN_EXPIRED" };
+ if (!current.revoked) return { error: "REFRESH_TOKEN_INVALID" };
+
+ // revokedAt이 없는 비정상 document는 grace window 판단 자체가 불가능하다.
+ // `|| 0`으로 fallback하면 elapsed가 수십억 ms가 되어 REUSE_DETECTED가 오탐되고
+ // 해당 유저의 모든 세션이 일괄 revoke되는 false positive가 발생한다.
+ // 데이터 손상 케이스이므로 REVOKED로 처리해 현재 요청만 거부한다.
+ if (!current.revokedAt) return { error: "REFRESH_TOKEN_REVOKED" };
+ const elapsed = now - current.revokedAt.getTime();
+ if (elapsed <= REFRESH_GRACE_WINDOW_MS) return { error: "REFRESH_TOKEN_REVOKED" };
+
+ // N섹션: "감사 로그 쓰기 실패도 롤백 조건" — 전체 revoke와 audit log를 새 트랜잭션으로 묶는다.
+ // 기존 session은 이미 withTransaction 종료 후 catch에 진입한 상태이므로 새 세션을 시작한다.
+ const reuseSession = await mongoose.startSession();
+ try {
+ await reuseSession.withTransaction(async () => {
+ await RefreshToken.updateMany(
+ { userId: current.userId, revoked: false },
+ { $set: { revoked: true, revokedAt: new Date() } },
+ { session: reuseSession }
+ );
+ await writeAuditLog({
+ actorUserId: current.userId,
+ targetUserId: current.userId,
+ action: "REFRESH_TOKEN_REUSE_DETECTED",
+ before: { tokenId: String(current._id) },
+ after: { revokedAllTokens: true },
+ req,
+ session: reuseSession,
+ });
+ });
+ } finally {
+ await reuseSession.endSession();
+ }
+ return { error: "REFRESH_TOKEN_REUSE_DETECTED" };
+ }
+ throw e;
+ } finally {
+ await session.endSession();
+ }
+
+ return { accessToken: signAccessToken(userId), refreshToken: newRawRt };
+}
+
+router.post("/refresh", refreshLimiter, async (req, res, next) => {
+ if (!checkRefreshCsrf(req)) return res.status(401).json({ code: "CSRF_BLOCKED" });
+
+ const rawRt = req.cookies.bo_rt;
+ if (!rawRt) return res.status(401).json({ code: "REFRESH_TOKEN_INVALID" });
+
+ const lockKey = sha256(rawRt);
+
+ // 1차 방어(in-process): 동일 프로세스 내에서 동일 토큰으로 이미 rotation 중이면 그 결과를 기다린다.
+ if (refreshLocks.has(lockKey)) {
+ try {
+ const result = await refreshLocks.get(lockKey);
+ if (result.error) return res.status(401).json({ code: result.error });
+ setRefreshCookie(res, result.refreshToken);
+ return res.json({ accessToken: result.accessToken });
+ } catch (err) {
+ // rotateRefresh에서 예상된 에러는 { error } 객체로 반환된다.
+ // Promise rejection까지 도달하면 서버 오류이므로 전역 핸들러로 위임한다.
+ return next(err);
+ }
+ }
+
+ // 2차 방어(distributed): Redis 분산 락으로 다른 인스턴스와의 경합을 차단한다.
+ // - contention: 다른 프로세스가 이미 락 보유 → REVOKED 반환
+ // - error / no-redis: Redis 오류 또는 미연결 → in-memory 락만으로 진행 (fallback)
+ const lockResult = await acquireDistributedRefreshLock(lockKey);
+ if (lockResult.status === "contention") {
+ return res.status(401).json({ code: "REFRESH_TOKEN_REVOKED" });
+ }
+
+ const p = rotateRefresh(rawRt, lockKey, req).finally(async () => {
+ refreshLocks.delete(lockKey);
+ await releaseDistributedRefreshLock(lockResult);
+ });
+ refreshLocks.set(lockKey, p);
+
+ try {
+ const result = await p;
+ if (result.error) return res.status(401).json({ code: result.error });
+
+ setRefreshCookie(res, result.refreshToken);
+ return res.json({ accessToken: result.accessToken });
+ } catch (err) {
+ // rotateRefresh의 예상된 에러는 { error } 객체로 반환된다.
+ // 여기까지 도달하면 DB 오류 등 서버 문제이므로 전역 핸들러로 위임한다.
+ return next(err);
+ }
+});
+
+router.post("/logout", async (req, res, next) => {
+ // Intentionally allow logout without requireAuth:
+ // access token can be expired while refresh cookie still needs revocation.
+ const rawRt = req.cookies.bo_rt;
+ if (rawRt) {
+ // N섹션: revoke + 감사 로그를 트랜잭션으로 묶어 원자성 보장.
+ // 감사 로그 쓰기 실패 시 revoke도 롤백되어 데이터 불일치를 방지한다.
+ let logoutSession;
+ try {
+ logoutSession = await mongoose.startSession();
+ await logoutSession.withTransaction(async () => {
+ const token = await RefreshToken.findOneAndUpdate(
+ { tokenHash: sha256(rawRt), revoked: false },
+ { $set: { revoked: true, revokedAt: new Date() } },
+ { session: logoutSession, new: false }
+ );
+ if (token) {
+ await writeAuditLog({
+ actorUserId: token.userId,
+ targetUserId: token.userId,
+ action: "ADMIN_LOGOUT",
+ after: { tokenId: String(token._id) },
+ req,
+ session: logoutSession,
+ });
+ }
+ });
+ } catch (err) {
+ await logoutSession?.endSession();
+ // DB 실패 여부와 무관하게 쿠키를 삭제한다.
+ // 토큰을 DB에서 revoke하지 못했더라도 브라우저에서 쿠키를 지워
+ // 이후 refresh 요청이 발생하지 않도록 best-effort로 처리한다.
+ res.clearCookie("bo_rt", { path: "/bo/auth/refresh" });
+ return next(err);
+ }
+ await logoutSession.endSession();
+ }
+ res.clearCookie("bo_rt", { path: "/bo/auth/refresh" });
+ return res.status(200).json({ ok: true });
+});
+
+router.get("/me", requireAuth, (req, res) => {
+ // requireAuth에서 이미 DB 조회 후 req.adminUser에 부착했으므로 재조회하지 않는다.
+ // 명세 H섹션: /me는 매 요청 DB 재검증을 기본 정책으로 하며,
+ // requireAuth 미들웨어가 매 요청마다 DB에서 user를 조회하므로 이 정책을 만족한다.
+ const user = req.adminUser;
+ return res.json({
+ id: String(user._id),
+ email: user.email,
+ name: user.name,
+ isSuperAdmin: user.isSuperAdmin,
+ isActive: user.isActive,
+ perm: user.perm,
+ permLabels: permToLabels(user.perm),
+ });
+});
+
+module.exports = router;
diff --git a/backend/src/routes/feature.js b/backend/src/routes/feature.js
index ba4c1a2..d649e55 100644
--- a/backend/src/routes/feature.js
+++ b/backend/src/routes/feature.js
@@ -1,11 +1,29 @@
const express = require("express");
-const { isLoggedIn } = require("../middlewares");
const { checkRecruit, changeRecruit } = require("../controllers/recruit");
+const { requireAuth, requirePerm } = require("../middlewares/boAuth");
+const { Permission } = require("../config/permissions");
+
const router = express.Router();
-// GET /recruit
-router.get("/recruit", isLoggedIn, checkRecruit);
-// PATCH /recruit
-router.patch("/recruit", isLoggedIn, changeRecruit);
+router.get("/recruit", requireAuth, requirePerm(Permission.READ), checkRecruit);
+router.patch(
+ "/recruit",
+ requireAuth,
+ requirePerm(Permission.WRITE_RECRUIT_FORM),
+ changeRecruit
+);
+
+router.get("/club-info", requireAuth, requirePerm(Permission.READ), (_req, res) => {
+ return res.status(501).json({ code: "NOT_IMPLEMENTED" });
+});
+
+router.patch(
+ "/club-info",
+ requireAuth,
+ requirePerm(Permission.WRITE_CLUB_INFO),
+ (_req, res) => {
+ return res.status(501).json({ code: "NOT_IMPLEMENTED" });
+ }
+);
module.exports = router;
diff --git a/backend/src/routes/member.js b/backend/src/routes/member.js
index 9e9f994..bfcdb25 100644
--- a/backend/src/routes/member.js
+++ b/backend/src/routes/member.js
@@ -1,31 +1,13 @@
const express = require("express");
-const { Member } = require("../models");
-const { isLoggedIn } = require("../middlewares");
+const { Member } = require("../models/mongo");
const getData = require("../controllers/getdata");
const getPDF = require("../controllers/getpdf");
+const { requireAuth, requirePerm } = require("../middlewares/boAuth");
+const { Permission } = require("../config/permissions");
const router = express.Router();
-// GET /bo/member
-router.get(
- "/",
- (req, res, next) => {
- console.log(`[LOG] GET /bo/member 요청`);
- next(); // 다음 미들웨어 또는 컨트롤러로 이동
- },
- isLoggedIn,
- getData(Member)
-);
-
-// GET /bo/member/pdf/{filename}
-router.get(
- "/pdf/:filename",
- (req, res, next) => {
- console.log(`[LOG] GET /bo/member/pdf/${req.params.filename} 요청`);
- next();
- },
- isLoggedIn,
- getPDF
-);
+router.get("/", requireAuth, requirePerm(Permission.READ), getData(Member));
+router.get("/pdf/:filename", requireAuth, requirePerm(Permission.READ), getPDF);
module.exports = router;
diff --git a/backend/src/routes/semina.js b/backend/src/routes/semina.js
index d15b220..0976280 100644
--- a/backend/src/routes/semina.js
+++ b/backend/src/routes/semina.js
@@ -1,13 +1,10 @@
-const express = require('express');
-const {isLoggedIn} = require('../middlewares');
-const { upload, uploadHandler } = require('../controllers/uploadtoR2');
+const express = require("express");
+const { upload, uploadHandler } = require("../controllers/uploadtoR2");
+const { requireAuth, requirePerm } = require("../middlewares/boAuth");
+const { Permission } = require("../config/permissions");
const router = express.Router();
-// GET /bo/semina₩
-router.post('/', (req, res, next) => {
- console.log(`[LOG] POST /bo/semina 요청`);
- next(); // 다음 미들웨어 또는 컨트롤러로 이동
-}, isLoggedIn, upload, uploadHandler);
+router.post("/", requireAuth, requirePerm(Permission.WRITE_ACTIVITY), upload, uploadHandler);
module.exports = router;
diff --git a/backend/src/services/auditLogService.js b/backend/src/services/auditLogService.js
new file mode 100644
index 0000000..81c9dd2
--- /dev/null
+++ b/backend/src/services/auditLogService.js
@@ -0,0 +1,37 @@
+const { AdminAuditLog } = require("../models/admin");
+const { toIpHash } = require("../utils/ipHash");
+
+async function writeAuditLog({
+ actorUserId,
+ targetUserId = null,
+ action,
+ before = null,
+ after = null,
+ req,
+ session = null,
+}) {
+ const ip =
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
+ req.socket?.remoteAddress ||
+ "";
+ const ipHash = toIpHash(ip);
+
+ const payload = {
+ actorUserId,
+ targetUserId,
+ action,
+ before,
+ after,
+ ipHash,
+ userAgent: req.headers["user-agent"] || "",
+ };
+
+ if (session) {
+ await AdminAuditLog.create([payload], { session });
+ return;
+ }
+
+ await AdminAuditLog.create(payload);
+}
+
+module.exports = { writeAuditLog };
diff --git a/backend/src/services/rateLimiterFactory.js b/backend/src/services/rateLimiterFactory.js
new file mode 100644
index 0000000..82a3d42
--- /dev/null
+++ b/backend/src/services/rateLimiterFactory.js
@@ -0,0 +1,55 @@
+const rateLimit = require("express-rate-limit");
+const { getRedisClient } = require("./redisClient");
+
+let RedisStore = null;
+let warnedMissingStore = false;
+
+function getRedisStoreCtor() {
+ if (RedisStore) return RedisStore;
+ try {
+ ({ RedisStore } = require("rate-limit-redis"));
+ return RedisStore;
+ } catch (_e) {
+ if (!warnedMissingStore) {
+ warnedMissingStore = true;
+ console.warn(
+ "[WARN] rate-limit-redis is not installed. Falling back to in-memory rate limiter."
+ );
+ }
+ return null;
+ }
+}
+
+function createRateLimiter({
+ windowMs,
+ max,
+ keyGenerator,
+ code = "TOO_MANY_REQUESTS",
+ message = "Too Many Requests",
+}) {
+ const redis = getRedisClient();
+ const Ctor = redis ? getRedisStoreCtor() : null;
+
+ const base = {
+ windowMs,
+ max,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator,
+ handler: (_req, res) => {
+ return res.status(429).json({ code, message });
+ },
+ };
+
+ if (!redis || !Ctor) return rateLimit(base);
+
+ return rateLimit({
+ ...base,
+ store: new Ctor({
+ sendCommand: (...args) => redis.call(...args),
+ }),
+ });
+}
+
+module.exports = { createRateLimiter };
+
diff --git a/backend/src/services/redisClient.js b/backend/src/services/redisClient.js
new file mode 100644
index 0000000..40c78d0
--- /dev/null
+++ b/backend/src/services/redisClient.js
@@ -0,0 +1,45 @@
+let redisClient = null;
+let warnedMissingModule = false;
+
+function getRedisClient() {
+ if (redisClient) return redisClient;
+
+ const redisUrl = process.env.REDIS_URL;
+ if (!redisUrl) return null;
+
+ let Redis;
+ try {
+ // Optional dependency: production can enable Redis; dev can run without it.
+ ({ default: Redis } = require("ioredis"));
+ } catch (_e) {
+ if (!warnedMissingModule) {
+ warnedMissingModule = true;
+ console.warn("[WARN] REDIS_URL is set but ioredis is not installed. Falling back to in-memory mode.");
+ }
+ return null;
+ }
+
+ redisClient = new Redis(redisUrl, {
+ lazyConnect: true,
+ maxRetriesPerRequest: 1,
+ enableReadyCheck: true,
+ });
+
+ redisClient.on("error", (err) => {
+ console.error("[ERROR] Redis client error:", err?.message || err);
+ });
+
+ redisClient.connect().catch((err) => {
+ console.error("[ERROR] Redis connect failed:", err?.message || err);
+ });
+
+ return redisClient;
+}
+
+function getRedisMode() {
+ if (!process.env.REDIS_URL) return "in-memory";
+ const client = getRedisClient();
+ return client ? "redis" : "in-memory";
+}
+
+module.exports = { getRedisClient, getRedisMode };
diff --git a/backend/src/services/superAdminBootstrap.js b/backend/src/services/superAdminBootstrap.js
new file mode 100644
index 0000000..79b5e3f
--- /dev/null
+++ b/backend/src/services/superAdminBootstrap.js
@@ -0,0 +1,45 @@
+const { AdminUser } = require("../models/admin");
+const { Permission, WRITE_ALL_MASK } = require("../config/permissions");
+
+async function bootstrapSuperAdmin() {
+ const email = (process.env.SUPER_ADMIN_EMAIL || "").trim().toLowerCase();
+ if (!email) return;
+
+ const superPerm = Permission.READ | WRITE_ALL_MASK;
+
+ let user = await AdminUser.findOne({ email });
+ if (!user) {
+ await AdminUser.create({
+ email,
+ perm: superPerm,
+ isSuperAdmin: true,
+ isActive: true,
+ });
+ console.log("[LOG] super admin bootstrapped");
+ return;
+ }
+
+ // Intentional bootstrap policy:
+ // SUPER_ADMIN_EMAIL is always enforced with max admin permissions.
+ // Manual perm downgrades or deactivations are reverted on next server bootstrap.
+ const needsUpdate = !user.isSuperAdmin || user.perm !== superPerm || !user.isActive;
+ if (needsUpdate) {
+ const prevPerm = user.perm;
+ const prevIsSuperAdmin = user.isSuperAdmin;
+ const prevIsActive = user.isActive;
+ user.isSuperAdmin = true;
+ user.perm = superPerm;
+ user.isActive = true;
+ await user.save();
+ // G섹션: DB 값이 환경변수 정책과 불일치할 경우 경고 로그를 남겨야 한다.
+ console.warn(
+ `[WARN] super admin mismatch detected for ${email}. ` +
+ `isSuperAdmin: ${prevIsSuperAdmin} → true, ` +
+ `perm: ${prevPerm} → ${superPerm}, ` +
+ `isActive: ${prevIsActive} → true. ` +
+ `Auto-corrected by bootstrap. 운영 알림 필요.`
+ );
+ }
+}
+
+module.exports = { bootstrapSuperAdmin };
diff --git a/backend/src/utils/cryptoUtil.js b/backend/src/utils/cryptoUtil.js
new file mode 100644
index 0000000..e353274
--- /dev/null
+++ b/backend/src/utils/cryptoUtil.js
@@ -0,0 +1,15 @@
+const crypto = require("crypto");
+
+function sha256(value) {
+ return crypto.createHash("sha256").update(String(value)).digest("hex");
+}
+
+function randomToken(bytes = 32) {
+ return crypto.randomBytes(bytes).toString("hex");
+}
+
+function hmacSha256(value, secret) {
+ return crypto.createHmac("sha256", secret).update(String(value)).digest("hex");
+}
+
+module.exports = { sha256, randomToken, hmacSha256 };
diff --git a/backend/src/utils/ipHash.js b/backend/src/utils/ipHash.js
new file mode 100644
index 0000000..ae948a2
--- /dev/null
+++ b/backend/src/utils/ipHash.js
@@ -0,0 +1,10 @@
+const { hmacSha256 } = require("./cryptoUtil");
+
+function toIpHash(ip) {
+ const salt = process.env.SERVER_SECRET_SALT;
+ if (!salt) return null;
+ if (!ip) return null;
+ return hmacSha256(ip, salt);
+}
+
+module.exports = { toIpHash };
diff --git a/backend/src/utils/permLabels.js b/backend/src/utils/permLabels.js
new file mode 100644
index 0000000..3f6a8ba
--- /dev/null
+++ b/backend/src/utils/permLabels.js
@@ -0,0 +1,40 @@
+const { Permission, WRITE_ALL_MASK } = require("../config/permissions");
+
+// 유효한 permission label 목록. PATCH /:id/perm validation에서 참조한다.
+const VALID_PERM_LABELS = [
+ "read/all",
+ "write/activity",
+ "write/recruit-form",
+ "write/club-info",
+ "write/all",
+];
+
+function permToLabels(perm) {
+ const labels = [];
+ if (perm & Permission.READ) labels.push("read/all");
+
+ const hasAllWrite = (perm & WRITE_ALL_MASK) === WRITE_ALL_MASK;
+ if (hasAllWrite) {
+ labels.push("write/all");
+ return labels;
+ }
+
+ if (perm & Permission.WRITE_ACTIVITY) labels.push("write/activity");
+ if (perm & Permission.WRITE_RECRUIT_FORM) labels.push("write/recruit-form");
+ if (perm & Permission.WRITE_CLUB_INFO) labels.push("write/club-info");
+
+ return labels;
+}
+
+function labelsToPerm(labels = []) {
+ let perm = 0;
+ if (labels.includes("read/all")) perm |= Permission.READ;
+ if (labels.includes("write/activity")) perm |= Permission.WRITE_ACTIVITY;
+ if (labels.includes("write/recruit-form")) perm |= Permission.WRITE_RECRUIT_FORM;
+ if (labels.includes("write/club-info")) perm |= Permission.WRITE_CLUB_INFO;
+ if (labels.includes("write/all")) perm |= WRITE_ALL_MASK;
+ if ((perm & Permission.READ) === 0) perm |= Permission.READ;
+ return perm;
+}
+
+module.exports = { permToLabels, labelsToPerm, VALID_PERM_LABELS };
diff --git a/backend/src/utils/tokenUtil.js b/backend/src/utils/tokenUtil.js
new file mode 100644
index 0000000..a5f4a7f
--- /dev/null
+++ b/backend/src/utils/tokenUtil.js
@@ -0,0 +1,16 @@
+const jwt = require("jsonwebtoken");
+const crypto = require("crypto");
+
+function signAccessToken(userId) {
+ return jwt.sign(
+ { sub: String(userId), jti: crypto.randomUUID() },
+ process.env.ACCESS_TOKEN_SECRET,
+ { expiresIn: "15m" }
+ );
+}
+
+function verifyAccessToken(token) {
+ return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
+}
+
+module.exports = { signAccessToken, verifyAccessToken };
diff --git a/feature/01-bo-auth-appendix.md b/feature/01-bo-auth-appendix.md
new file mode 100644
index 0000000..5325869
--- /dev/null
+++ b/feature/01-bo-auth-appendix.md
@@ -0,0 +1,592 @@
+# 01. BO Auth 기술 상세 Appendix
+
+## 문서 관계 / 소스 오브 트루스
+
+- 이 Appendix는 기술 구현 기준 문서다.
+- 기존 [01-bo-auth.md](./01-bo-auth.md)의 기술 상세(데이터 모델, 인증/토큰, 권한 검사, 인덱스, 에러 코드)는 이 Appendix 기준으로 대체한다.
+- PM 의사결정/일정/리스크는 [01-bo-auth-plan.md](./01-bo-auth.md)를 기준으로 본다.
+
+## A) 목표 아키텍처 요약
+
+- 인증: Google OAuth
+- API 인증 유지: Authorization Bearer Token
+- DB: MongoDB
+- 권한: `perm` 비트마스크
+- 계정 온보딩: 초대 링크 기반
+
+## B) 데이터 모델
+
+- IP 개인정보 최소화 정책은 `admin_audit_logs`, `refresh_tokens` 모두에 동일 적용한다(원문 IP 미저장, `ipHash` 사용).
+
+## B.1 `admin_users`
+
+- `email` (unique, required)
+- `googleSub` (unique, nullable)
+- `name` (nullable)
+- `pictureUrl` (nullable)
+- `perm` (number, default `READ`)
+- `isSuperAdmin` (boolean, default false)
+- `isActive` (boolean, default true)
+- `lastLoginAt`
+- `createdAt`, `updatedAt`
+
+## B.2 `admin_invites`
+
+- `email` (required)
+- `perm` (number, default `READ`)
+- `tokenHash` (unique, required)
+- `expiresAt` (required)
+- `status` (`pending`, `used`, `expired`, `revoked`)
+- `invitedBy` (required)
+- `usedByUserId` (nullable)
+- `createdAt`, `updatedAt`
+
+## B.3 `admin_audit_logs`
+
+- `actorUserId`
+- `targetUserId`
+- `action`
+- `before`, `after`
+- `ipHash` (원문 IP 저장 금지, `HMAC-SHA256(ip, SERVER_SECRET_SALT)` 적용)
+- `userAgent`
+- `createdAt`
+
+## B.4 `refresh_tokens`
+
+- `userId` (required)
+- `tokenHash` (unique, required)
+- `expiresAt` (required)
+- `revoked` (boolean, default false)
+- `revokedAt` (nullable)
+- `deviceInfo`
+ - `userAgent` (raw string)
+ - `os` (parsed)
+ - `browser` (parsed)
+ - `ipHash` (선택, 원문 IP 저장 금지 권장)
+ - 알고리즘: `HMAC-SHA256(ip, SERVER_SECRET_SALT)`
+ - 운영 정책: `SERVER_SECRET_SALT`는 운영 중 교체 금지
+ - 주의: secret 변경 시 기존 `ipHash` 비교/조회가 불가능해짐
+ - 조회/비교: 세션 목록/필터링은 원문 IP가 아닌 `ipHash` 기준으로 처리
+ - 비상 교체 절차:
+ - 트리거: secret 유출이 확인/강하게 의심되는 경우
+ - 조치: `refresh_tokens` 전체 revoke + 전체 재로그인 유도 공지
+ - 영향: 기존 `ipHash` 기반 조회/필터링 일시 불가
+ - 기록: 교체 시각/사유/조치 내역을 운영 로그 및 감사 로그에 기록
+ - `lastSeenAt` (선택)
+- `createdAt`, `updatedAt`
+
+## B.5 `auth_codes` (OAuth callback code exchange)
+
+- `codeHash` (unique, required)
+- `userId` (required)
+- `expiresAt` (required, short-lived)
+- `usedAt` (nullable, 1회 교환 후 즉시 마킹)
+- `createdAt`, `updatedAt`
+- 운영 원칙:
+ - 원문 code 저장 금지, `codeHash`만 저장
+ - 매우 짧은 만료(예: 30초) + 1회 사용 원칙
+ - `expiresAt` TTL 인덱스로 자동 정리
+
+## C) 권한 모델
+
+```ts
+enum Permission {
+ READ = 1 << 0,
+ WRITE_ACTIVITY = 1 << 1,
+ WRITE_RECRUIT_FORM = 1 << 2,
+ WRITE_CLUB_INFO = 1 << 3,
+}
+```
+
+권한 체크:
+
+- `(perm & WRITE_ACTIVITY) !== 0`
+- `(perm & WRITE_RECRUIT_FORM) !== 0`
+- `(perm & WRITE_CLUB_INFO) !== 0`
+- `(perm & REQUIRED_MASK) === REQUIRED_MASK`
+
+운영 UI:
+
+- 내부 저장은 비트마스크
+- 화면은 라벨(`read/all`, `write/activity`, `write/recruit-form`, `write/club-info`, `write/all`)로 표시
+- `write/all`은 별도 비트를 두지 않고 `WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO` OR 합산값으로 해석한다.
+- UI 역변환 규칙: `(perm & (WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO)) === (WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO)`이면 `write/all`로 표시하고, 아니면 보유한 개별 write 라벨만 표시한다.
+
+비트 관리 규칙:
+
+- 신규 권한은 `2^n` 값만 사용
+- 기존 비트 값 변경 금지
+- 중앙 enum 파일에서만 권한 정의
+- 32bit 초과 시 64bit/bigint 확장 정책 적용
+
+## D) 인증/토큰 정책
+
+## D.1 Access Token
+
+- 만료: 10~15분(최대 15분)
+- 저장: 메모리 저장(브라우저 새로고침 시 초기화되는 런타임 메모리; 예: 앱 전역 state/in-memory store)
+- `localStorage/sessionStorage` 저장 금지
+- `jti` claim 포함(필요 시 replay 탐지 확장)
+
+## D.2 Refresh Token
+
+- 만료: 14일
+- 저장: `httpOnly + Secure + SameSite` cookie 권장
+- rotation + revoke 적용
+- 다중 디바이스 분리 관리
+- FE는 refresh token 기반 silent refresh로 페이지 reload 시 세션을 복구한다.
+- 도메인 정책(확정):
+ - Option A 채택: 리버스 프록시로 FE/BE 요청 경로를 same-site로 정렬
+ - FE는 동일 사이트 경로(예: `/api`)로 호출하고 프록시가 BE로 전달
+ - refresh cookie는 same-site 기준으로 처리해 Safari ITP 영향 구간을 최소화
+ - `SameSite=Lax` 이상을 기본으로 하고 운영 환경은 `Secure` 필수 적용
+- 잔여 리스크 및 대응:
+ - 프록시 misconfiguration으로 cross-site 전송이 남는 경우 Safari에서 silent refresh 실패 가능
+ - Phase 1 QA에서 Safari refresh 실패가 확인되면 프록시 규칙 우선 수정, 인프라 제약으로 불가 시 BFF 패턴 전환
+
+rotation atomic 처리:
+
+- 동시 refresh 요청 중복 방지:
+ - FE 1차 방어(필수): 탭 내부 싱글턴 Promise lock으로 refresh 중복 호출을 차단한다.
+ - FE 2차 방어(권장): `BroadcastChannel('auth')`로 탭 간 신규 access token을 공유한다.
+ - BE 안전망(권장): `revokedAt` 기준 grace window(5~10초) 정책을 적용한다.
+ - grace window 이내 재사용: 경쟁 조건으로 판단(`REFRESH_TOKEN_REVOKED`) 후 재로그인 강제 없이 복구 경로 안내
+ - grace window 초과 재사용: 탈취 의심으로 판단(`REFRESH_TOKEN_REUSE_DETECTED`) 후 전체 refresh token revoke
+
+1. `revoked=false` 조건으로 토큰 조회/전환
+2. 기존 토큰 revoke
+3. 새 refresh token 저장
+4. 새 access token 발급
+5. 2~4 중 하나라도 실패 시 전체 롤백(트랜잭션 정책은 `N) 트랜잭션 정책` 참조)
+
+reuse detection:
+
+- revoke된 refresh token 재사용 시 `REFRESH_TOKEN_REUSE_DETECTED`
+- 보안 이벤트 기록
+- 해당 사용자 전체 refresh token 강제 revoke
+
+## D.3 CSRF 경계
+
+- 일반 Bearer API는 CSRF 영향 낮음
+- refresh 엔드포인트는 cookie 사용으로 CSRF 방어 필수
+- refresh 엔드포인트 CSRF 방어:
+ - 요청 시 커스텀 헤더(`X-Requested-With: XMLHttpRequest`) 필수 포함
+ - 서버는 해당 헤더 부재 시 요청 거부 (`401`)
+ - CORS preflight 의존 방식이므로 `Access-Control-Allow-Origin` 설정 엄격 유지 필수
+ - 허용 도메인은 운영 allowlist로 관리하며(예: `BO_ALLOWED_ORIGINS`), 와일드카드(`*`) 사용 금지
+ - 추가 검증: `Origin`(필수) 및 `Referer`(보조) 값을 allowlist와 대조하고 불일치 시 거부
+- 한계 및 보완:
+ - `X-Requested-With` 단독 방식은 same-origin/프록시 환경에서 우회 가능성이 있으므로 단독 방어로 간주하지 않는다.
+ - 따라서 custom header + origin/referer 검증을 기본 세트로 적용한다.
+- refresh 요청 통과 조건(체크리스트):
+ - `X-Requested-With: XMLHttpRequest` 헤더 존재
+ - `Origin`이 `BO_ALLOWED_ORIGINS` allowlist와 일치
+ - `Referer`(존재 시)가 `BO_ALLOWED_ORIGINS` allowlist와 일치
+ - 위 조건을 모두 만족할 때만 통과, 하나라도 불일치하면 `401` 거부
+
+## D.4 OAuth Callback -> AuthCode -> Token Exchange 플로우
+
+- 목적:
+ - OAuth callback 직후 access/refresh token 원문을 URL query/hash에 직접 노출하지 않기 위함
+ - SPA 라우팅 환경에서 브라우저 히스토리/로그/리퍼러를 통한 토큰 유출 위험 완화
+- 처리 순서:
+ 1. `GET /bo/auth/google/callback` 성공 시 서버는 short-lived 1회용 code를 발급한다.
+ 2. 서버는 `auth_codes`에 `codeHash`만 저장하고, FE로는 원문 code만 전달한다(redirect query).
+ 3. FE는 즉시 `POST /bo/auth/token-exchange`로 code를 교환한다.
+ 4. 서버는 `codeHash + usedAt:null + expiresAt>now` 조건으로 원자적으로 1회 소비 처리 후 access/refresh token을 발급한다.
+- 보안 원칙:
+ - code는 재사용 불가(atomic consume)
+ - code 만료는 매우 짧게 유지(예: 30초)
+ - 실패/만료/재사용 요청은 `401 UNAUTHORIZED`로 처리
+
+## E) Google OAuth 보안
+
+- `state` 검증
+- `email_verified` 검증
+- ID Token `issuer` 검증
+- ID Token `audience(client_id)` 검증
+- 구현 메모:
+ - callback 단계에서 Google token info 검증을 통해 `issuer/audience`를 명시적으로 확인한다.
+ - `audience !== GOOGLE_CLIENT_ID` 또는 비정상 `issuer`는 인증 실패로 처리한다.
+- 필요 시 `hd`/도메인 제한
+- 이메일 비교는 `trim + lowercase` 정규화 후 수행
+
+## F) 초대 플로우
+
+1. 슈퍼어드민 이메일 입력
+2. 이메일 전용 초대 링크 생성(만료 기본 2일)
+3. 사용자 링크 접속 후 Google 로그인
+4. 정규화된 이메일 일치 시 승인
+5. `googleSub` 바인딩
+6. 초대 상태 `used` 처리
+
+보안:
+
+- 토큰 엔트로피 최소 128bit
+- 토큰 원문 저장 금지, `tokenHash`만 저장
+- 1회 사용 후 즉시 폐기
+- 재발급 시 기존 활성 토큰 revoke
+- 초대 검증 API rate limit 적용
+- 만료 정책:
+ - 기본 만료는 2일
+ - 허용 범위: 최소 1시간(3600초) ~ 최대 7일(604800초)
+ - 하드코딩 금지, 운영 설정값으로 변경 가능
+ - 슈퍼어드민은 허용 범위 내에서 초대 생성 시 만료값을 조정할 수 있다.
+ - 허용 범위를 벗어난 요청은 `400`으로 거부한다.
+- 권한 초기값/지정:
+ - 초대 생성 시 기본 권한은 `READ`를 부여
+ - 슈퍼어드민은 초대 생성 시 추가 write 권한(`WRITE_ACTIVITY`, `WRITE_RECRUIT_FORM`, `WRITE_CLUB_INFO`)을 사전 지정할 수 있다.
+
+초대 생성 API 스펙(`POST /bo/admin/invites`):
+
+- body:
+ - `email` (required, 1개)
+ - `perm` (optional, 기본값 `READ`)
+ - `expiresInSec` (optional, 기본값 172800, 허용 범위 3600~604800)
+- 검증:
+ - `perm`에 `READ` 미포함 요청 시 서버에서 `READ`를 강제 포함
+ - 범위 외 `expiresInSec` 요청은 `400`으로 거부
+
+초대 폐기 API 스펙(`PATCH /bo/admin/invites/:id/revoke`):
+
+- 대상이 `pending` 상태일 때만 `revoked`로 전환 가능
+- `pending`이 아닌 초대 폐기 요청은 `409`로 거부
+
+초대 재발급 API 스펙(`POST /bo/admin/invites/:id/reissue`):
+
+- 기존 초대를 기준으로 새 토큰을 재발급한다.
+- 재발급 시 해당 이메일의 기존 `pending` 초대는 모두 `revoked` 처리한다.
+- 본문에서 `perm`/`labels`가 없으면 기존 초대의 `perm`을 승계한다.
+
+동시성:
+
+- `status=pending` 조건에서만 수락 허용
+- 트랜잭션 내부에서 `pending -> used` 전환 후 바인딩
+- 동시 요청은 1건만 성공
+
+충돌 처리:
+
+- `googleSub`가 이미 다른 계정에 바인딩된 경우 `GOOGLE_SUB_CONFLICT` 반환
+
+## G) 슈퍼어드민 정책
+
+- 런타임 판단 기준: DB `isSuperAdmin` 단일 기준
+- 서버 시작 시 `SUPER_ADMIN_EMAIL` bootstrap 보정
+- 운영 중 env 자동 덮어쓰기 금지
+- 최소 1개 이상 비상 super admin 계정 유지(활성 상태)
+- Google OAuth 장애 시 임시 로컬 인증 fallback 경로를 통해 비상 계정만 로그인 허용
+- 로컬 인증 fallback 경로는 상시 개방하지 않고 장애 대응 시에만 운영 플래그로 활성화
+- 예외 전환 규칙: Phase 1 전환 기간에는 기존 로그인 fallback 경로를 임시 활성화로 유지하고, Phase 3 완료 후에는 비상 플래그 기반 fallback만 허용한다.
+- env/DB mismatch:
+ - 기본: 경고 + 운영 알림
+ - strict mode: fail-fast
+
+## H) DB 재검증/권한 검사
+
+라우트 레벨:
+
+- 모든 보호 API 1차 권한 검사 미들웨어 적용
+
+서비스 레벨:
+
+- 민감 작업 2차 DB 재검증
+
+필수 DB 재검증 대상:
+
+- 권한 변경 API
+- 초대 생성/검증/수락 API
+- 계정 활성/비활성 API
+- 모든 write API
+- `/bo/auth/me` (화면 기준 정보 최신화)
+
+성능 기준:
+
+- `userId` 단건 조회 중심
+- write/admin API low QPS 가정
+- 필요 시 Redis 캐시 확장
+- `/bo/auth/me`는 매 요청 DB 재검증을 기본 정책으로 한다.
+- 근거: `/bo/auth/me`는 관리자 화면 권한/상태 렌더링의 기준 엔드포인트이므로 stale 권한 노출 방지를 위해 성능보다 최신 권한 일관성을 우선한다.
+
+## I) Rate Limiting
+
+대상:
+
+- OAuth 시작
+- OAuth callback
+- 초대 검증 전 단계
+- 초대 검증 API
+- refresh API
+
+초기값 및 조정 범위:
+
+| 대상 | 초기값 | 조정 범위 | 기준 |
+| --- | --- | --- | --- |
+| OAuth callback | IP당 10 req/min | 5~20 | 로그인 실패율 모니터링 후 |
+| refresh API | 사용자당 30 req/min | 20~60 | 다중 디바이스 환경 고려 |
+| invite 검증 API | IP당 10 req/min | 5~20 | 초대 남용 시 강화 |
+
+초기값은 백오피스 내부 사용자 규모 기준으로 설정한다. 운영 중 rate limit 초과 알림이 반복되거나 브루트포스 징후 발생 시 하향 조정하고, 정상 사용자 차단 이슈 발생 시 상향 조정한다. 조정 이력은 감사 로그에 준하여 기록한다.
+구현 전제: 분산 환경 일관성을 위해 Redis 기반 rate limiter를 기본으로 사용한다(단일 인스턴스 개발 환경은 in-memory fallback 허용).
+refresh 동시성 제어는 Redis 분산 락을 기본으로 하고, Redis 미연결/미설치 환경에서는 in-memory 락으로 fallback한다.
+
+## J) 감사 로그/모니터링
+
+감사 로그:
+
+- append-only
+- 수정/삭제 API 제공 금지
+- 필요 시 해시 체인/외부 저장 이중 적재 확장
+- retention: 180일
+
+모니터링:
+
+- 권한 변경
+- super admin 이벤트
+- rate limit 반복 초과
+- 로그인 실패 반복
+
+## K) 에러 코드 카탈로그
+
+공통:
+
+- `UNAUTHORIZED` (`401`)
+- `CSRF_BLOCKED` (`401`)
+- `FORBIDDEN` (`403`)
+- `TOO_MANY_REQUESTS` (`429`)
+- `INTERNAL_ERROR` (`500`)
+
+초대/인증:
+
+- `INVITE_EXPIRED` (`410`)
+- `INVITE_ALREADY_USED` (`409`)
+- `INVITE_REVOKED` (`410`)
+- `INVITE_EMAIL_MISMATCH` (`422`)
+- `GOOGLE_SUB_CONFLICT` (`409`)
+- `OAUTH_STATE_INVALID` (`400`)
+- `EMAIL_NOT_VERIFIED` (`403`)
+
+토큰:
+
+- `REFRESH_TOKEN_INVALID` (`401`)
+- `REFRESH_TOKEN_REVOKED` (`401`): 이미 revoke된 토큰 사용(grace window 이내 경쟁 조건 포함)
+- `REFRESH_TOKEN_EXPIRED` (`401`)
+- `REFRESH_TOKEN_REUSE_DETECTED` (`401`): revoke된 토큰의 grace window 초과 재사용(탈취 의심, 전체 토큰 강제 revoke)
+
+권한/계정:
+
+- `ACCOUNT_INACTIVE` (`403`)
+- `PERMISSION_DENIED` (`403`)
+- `SUPER_ADMIN_REQUIRED` (`403`)
+
+## L) 필수 인덱스
+
+`admin_users`:
+
+- `email` unique
+- `googleSub` unique
+
+`admin_invites`:
+
+- `tokenHash` unique
+- `email + status` 복합 인덱스
+- `expiresAt` TTL 인덱스(정책 적용 시)
+
+`admin_audit_logs`:
+
+- `actorUserId`
+- `createdAt`
+- 필요 시 `action + createdAt` 복합 인덱스
+
+`refresh_tokens`:
+
+- `tokenHash` unique
+- `userId + revoked + revokedAt` 복합 인덱스 (grace window 판정 최적화)
+- `expiresAt` TTL 인덱스(정책 적용 시)
+
+## M) 구현 순서 (개발자 기준)
+
+1. [Phase 1] MongoDB 컬렉션/인덱스 생성 (`admin_users`, `admin_invites`, `admin_audit_logs`, `refresh_tokens`)
+2. [Phase 1] Google OAuth 검증 로직 구현(`state`, `email_verified`, `issuer`, `audience`)
+3. [Phase 1] 토큰 발급/검증 로직 구현(access/refresh, rotation, revoke)
+4. [Phase 1] 기존 `passport-local` 및 세션 직렬화/역직렬화 전략 제거
+5. [Phase 1] refresh reuse detection 구현 및 보안 이벤트 연계(배치 근거: rotation/revoke와 분리 불가한 동일 인증 경로)
+6. [Phase 2] 권한 enum/비트마스크 유틸 구현(라벨 <-> 비트 변환 레이어 포함, `write/all` OR 합산/역변환 규칙 포함)
+7. [Phase 2] 초대 생성/검증/수락/재발급/폐기 구현
+8. [Phase 2] `googleSub` 바인딩 충돌 정책 구현(`GOOGLE_SUB_CONFLICT`)
+9. [Phase 2] 라우트 미들웨어 + 서비스 DB 재검증 적용
+10. [Phase 2] `/bo/auth/me` 최신 상태 정책 및 권한 반영 검증
+11. [Phase 2] FE 라우트 가드/`useCan()` 훅/silent refresh 실패 처리 구현(`R) FE 구현 기준` 반영)
+12. [Phase 3] 감사 로그 적재/조회 및 append-only 정책 적용
+13. [Phase 3] rate limiting 및 에러 코드 매핑 적용
+14. [Phase 3] 통합 테스트 및 운영 점검
+
+## N) 트랜잭션 정책 (MongoDB)
+
+아래 시나리오는 반드시 트랜잭션으로 처리한다.
+
+- 초대 수락: `invite(pending->used)` + `googleSub 바인딩` + `admin_users.perm 반영`
+- 권한 변경: `admin_users.perm 변경` + `admin_audit_logs 기록`
+- super-admin 변경: `admin_users.isSuperAdmin 변경` + `admin_audit_logs 기록`
+- 계정 비활성화: `isActive=false` + `refresh_tokens revoke` + `audit 기록`
+- refresh rotation: `old refresh revoke` + `new refresh 발급` (atomic 전환)
+
+트랜잭션 원칙:
+
+- 실패 시 전체 롤백
+- 감사 로그 쓰기 실패도 롤백 조건
+- 동시성 경합 지점은 조건부 갱신(`revoked=false`, `status=pending`)으로 원자성 보장
+- 운영 영향: 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음(보안 무결성 우선 정책)
+- 운영 대응: 장애 알림 즉시 전파, 복구 전까지 읽기 중심 운영 모드 유지, 복구 후 변경 작업 재개
+
+## O) 기존 데이터 마이그레이션 전략
+
+목표:
+
+- 기존 관리자 계정을 신규 인증/권한 체계로 안전하게 전환한다.
+
+적용 시점:
+
+- Phase 1 완료 후, Phase 2 시작 직전 실행
+- 실제 운영 관리자 계정 수는 Phase 1 완료 시점에 확정해 본 섹션에 기록한다.
+- 예상 소요:
+ - 관리자 계정 30개 기준 반나절~1일
+ - 관리자 계정 100개 기준 1~2일(초대 수락 속도에 따라 변동)
+
+기존 계정 처리 원칙:
+
+- `googleSub`가 없는 기존 계정은 초대 플로우 재온보딩을 기본 경로로 사용
+- 운영상 즉시 전환이 필요한 계정은 제한적으로 마이그레이션 스크립트 사용 가능(슈퍼어드민 승인 필수)
+- 비밀번호 로그인 데이터는 신규 인증 기준에서 참조하지 않는다.
+
+실행 절차:
+
+1. 마이그레이션 대상 계정 목록 확정(활성 계정 기준)
+2. 기존 권한 -> 신규 `perm` 매핑 테이블 확정
+3. 계정별 초대 발송 또는 승인된 스크립트 전환 수행
+4. 전환 완료 계정의 `googleSub` 바인딩 및 로그인 검증
+5. 권한 대조 리포트 생성(기존 권한 vs 신규 `perm`)
+
+검증 기준:
+
+- 마이그레이션 전/후 활성 관리자 계정 수 일치
+- 권한 매핑 불일치 0건
+- 전환 대상 계정의 Google 로그인 성공 확인
+
+롤백 기준 및 절차:
+
+- 롤백 임계치:
+ - 권한 매핑 불일치 1건 이상 발생 시 즉시 중단
+ - 전환 대상 계정 로그인 실패율 5% 초과 시 즉시 중단
+- 임계치 초과 시 신규 적용 중단
+- 영향 계정에 대해 기존 운영 경로(임시 fallback)로 즉시 복귀
+- 원인 수정 후 재실행
+
+## P) 테스트 전략
+
+단위 테스트:
+
+- [Phase 1] OAuth 검증: `state` 불일치, `email_verified=false`, `issuer/audience` 실패
+- [Phase 1] 토큰: 발급/검증/만료/revoke/rotation/reuse detection
+- [Phase 2] 권한: `perm` 비트 연산, 복합 마스크 검사, 경계값 검증
+
+통합 테스트:
+
+- [Phase 1] refresh rotation atomic 보장(동시 요청 포함)
+- [Phase 1] 탭 내부 동시 401 상황에서 refresh API 1회만 호출되는지 검증(싱글턴 Promise lock)
+- [Phase 3] 감사 로그 append-only(수정/삭제 거부)
+- [Phase 2] 권한 변경 후 `/bo/auth/me` 최신 상태 반영
+
+E2E 테스트:
+
+- [Phase 2] 초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여
+- [Phase 2] 예외 시나리오: 초대 만료/중복 수락/이메일 불일치/`GOOGLE_SUB_CONFLICT`
+
+Safari silent refresh QA(Phase 1 필수):
+
+- 환경:
+ - 스테이징 FE/BE를 운영과 동일한 cross-domain으로 배포해 검증
+ - 브라우저/디바이스: macOS Safari, iOS Safari
+- 시나리오:
+ - [Phase 1] access token 만료 후 자동 refresh 성공 및 원 요청 재시도 성공
+ - [Phase 1] 페이지 새로고침 후 `/auth/refresh` 기반 세션 복구 성공
+ - [Phase 1] Safari cross-site tracking 활성화 상태에서 refresh cookie 전달 여부 확인
+ - [Phase 1] 다중 탭 동시 refresh 시 reuse detection 오탐 여부 확인
+- 판정/조치:
+ - 시나리오 1,2 통과 시 Phase 1 인증 기준 충족
+ - 아래 조건 중 하나라도 충족하면 Safari cross-origin refresh 실패로 판정하고 BFF 패턴 전환 착수:
+ - 시나리오 1 실패(access token 만료 후 자동 refresh 미동작 또는 재시도 API 실패)
+ - 시나리오 2 실패(새로고침 후 세션 복구 실패)
+ - 시나리오 3에서 refresh 요청에 cookie 미첨부 확인
+ - reuse detection 오탐 발생 시 동시 요청 제어(debounce/lock) 적용 후 재검증
+ - grace window(5~10초) 내 재사용은 `REVOKED`로 처리되고, window 초과 재사용만 `REUSE_DETECTED`로 처리되는지 검증
+
+완료 기준:
+
+- [Phase 1] 인증/OAuth/토큰 테스트 통과 후 Phase 2 진입
+- [Phase 2] 권한/초대 E2E 테스트 통과 후 Phase 3 진입
+- [Phase 3] 감사로그/rate limit/에러코드 회귀 테스트 통과 후 릴리스
+
+## Q) 기존 API 권한 매핑 (비트마스크 기준)
+
+라벨-비트 기준:
+
+- `read/all` -> `READ`
+- `write/activity` -> `WRITE_ACTIVITY`
+- `write/recruit-form` -> `WRITE_RECRUIT_FORM`
+- `write/club-info` -> `WRITE_CLUB_INFO`
+- `write/all` -> `WRITE_ACTIVITY | WRITE_RECRUIT_FORM | WRITE_CLUB_INFO`
+
+라우트 매핑:
+
+| API | 필요 권한(라벨) | 비트마스크 조건 |
+| --- | --- | --- |
+| `GET /bo/member` | `read/all` | `(perm & READ) !== 0` |
+| `GET /bo/member/pdf/:filename` | `read/all` | `(perm & READ) !== 0` |
+| `POST /bo/semina` | `write/activity` 또는 `write/all` | `(perm & WRITE_ACTIVITY) !== 0` |
+| `GET /bo/feature/recruit` | `read/all` | `(perm & READ) !== 0` |
+| `PATCH /bo/feature/recruit` | `write/recruit-form` 또는 `write/all` | `(perm & WRITE_RECRUIT_FORM) !== 0` |
+| `GET /bo/feature/club-info` | `read/all` | `(perm & READ) !== 0` (`신규 구현 필요`) |
+| `PATCH /bo/feature/club-info` | `write/club-info` 또는 `write/all` | `(perm & WRITE_CLUB_INFO) !== 0` (`신규 구현 필요`) |
+| `GET /bo/admin/users` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+| `POST /bo/admin/invites` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+| `PATCH /bo/admin/users/:id/perm` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+| `PATCH /bo/admin/users/:id/super-admin` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`, 감사 로그 필수) |
+| `PATCH /bo/admin/users/:id/active` | super-admin only | `isSuperAdmin === true` (`신규 구현 필요`) |
+
+## R) FE 구현 기준
+
+- 앱 부팅:
+ - 앱 시작 시 `GET /bo/auth/me`를 호출해 사용자/권한 정보를 전역 상태(store)로 초기화한다.
+ - 실패(`401/403`) 시 인증 상태를 비로그인으로 전환하고 로그인 화면으로 라우팅한다.
+- 라우트 가드:
+ - 보호 라우트 진입 전 `isAuthenticated`와 `perm/isSuperAdmin`을 검사한다.
+ - 가드 실패 시 권한 안내 페이지 또는 로그인 페이지로 리다이렉트한다.
+- 권한 기반 UI 제어:
+ - `useCan()` 훅 또는 `Can` 컴포넌트로 버튼/CTA 렌더링을 제어한다.
+ - `write/all` 표시는 C 섹션의 UI 역변환 규칙을 동일하게 사용한다.
+- silent refresh 실패 처리:
+ - `/auth/refresh` 실패 시 access token/사용자 상태를 즉시 초기화한다.
+ - 현재 페이지에서 재시도 루프 없이 로그인 페이지로 단일 리다이렉트한다.
+ - 필요 시 `reason=session_expired` 쿼리로 사용자 안내 문구를 노출한다.
+
+## S) API 응답 포맷 (`GET /bo/auth/me`)
+
+- 응답 원칙:
+ - `perm`(number)을 권한 판정의 기준값으로 사용한다.
+ - `permLabels`는 UI 편의를 위한 파생 필드로 제공한다.
+ - `permLabels`는 서버가 C 섹션의 UI 역변환 규칙(`write/all` 합산 규칙 포함)을 적용해 생성하며, FE는 이를 그대로 표시한다.
+ - `isSuperAdmin`는 super-admin 전용 UI/기능 노출 제어에 사용한다.
+
+응답 예시:
+
+```json
+{
+ "id": "1234567890abcde",
+ "email": "admin@uos.ac.kr",
+ "name": "홍길동",
+ "isSuperAdmin": false,
+ "isActive": true,
+ "perm": 3,
+ "permLabels": ["read/all", "write/activity"]
+}
+```
diff --git a/feature/01-bo-auth-plan.md b/feature/01-bo-auth-plan.md
new file mode 100644
index 0000000..31d9a20
--- /dev/null
+++ b/feature/01-bo-auth-plan.md
@@ -0,0 +1,184 @@
+# 01. BO Auth 실행 명세서 (PM용)
+
+## Executive Summary
+
+- Safari 인증 이슈와 권한 모델 확장성 문제를 동시에 해결한다.
+- 인증은 Google OAuth + Authorization 토큰 기반으로 전환한다.
+- 권한은 확장 가능한 구조로 단순화하고, 슈퍼어드민 운영 통제를 강화한다.
+- 2주 내 단계적 전환과 rollback 전략으로 도입 리스크를 관리한다.
+
+## 0) 문서 목적
+
+본 문서는 백오피스 인증/권한 체계 고도화의 PM 의사결정용 실행 계획이다.
+기술 구현 세부는 별도 Appendix 문서로 분리한다.
+
+- 기술 Appendix: [01-bo-auth-appendix.md](./01-bo-auth-appendix.md)
+
+## 1) Expected Impact
+
+- Safari의 third-party cookie 제한 이슈를 제거하기 위해 cookie 기반 인증에서 Authorization header 기반 인증으로 전환, 인증 실패율 감소(특히 Safari 환경)
+- 권한 관련 운영 이슈 감소(CS/운영 문의 티켓 기준)
+- 관리자 계정 생성/권한 변경 작업 시간 감소
+- 계정/권한 보안 사고 리스크 감소
+- 정량 목표/측정 기준은 `9) Success Metrics`를 따른다.
+- baseline은 개편 전 최근 7일 서버 로그인 API 로그를 기준으로 산정한다.
+
+## 2) Risk if not implemented
+
+- Safari 인증 이슈가 지속된다.
+- 권한 확장 시 기술 부채가 누적된다.
+- 계정/보안 운영 비용이 계속 증가한다.
+
+## 3) Scope (2주)
+
+전체 담당:
+
+- 백엔드/프론트: 엄준식
+
+### Phase 1 (3/24~3/27, 4일): 인증 전환
+
+- Google OAuth + Authorization 토큰 기반 로그인 전환
+- 크로스 도메인 안정화
+- 기존 로그인 fallback 유지
+
+완료 기준:
+
+- Safari 포함 주요 브라우저 로그인 성공
+- 인증 성공률 99% 이상 (QA + staging 로그 기준)
+- Safari 환경에서 access token 만료 후 refresh 기반 세션 복구(silent refresh) 성공
+- 로그인 상태 새로고침(F5) 및 신규 탭 보호 경로 직접 접근 시 `bootstrapAuth -> /auth/refresh -> /bo/auth/me` 세션 복구 흐름 성공
+- Safari cross-domain refresh 검증은 Appendix `P) 테스트 전략`의 QA 시나리오 기준으로 판정
+
+### Phase 2 (3/28~4/2, 5일): 권한 모델 전환
+
+- 권한 모델 전환(확장 가능한 구조)
+- 관리자 권한 관리 UX 정리
+- `/bo/auth/me` 최신 상태 기준 확정
+- 리스크:
+ - 권한 모델 전환 시 기존 권한 불일치 가능성
+- 대응:
+ - 병행 검증 기간: Phase 2 전체 기간(3/28~4/2)
+ - 검증 방법: 신규 권한 모델 결과와 기존 권한 기준 결과를 관리자 계정 전체 대상 대조
+ - 완료 판단: QA 환경에서 권한 오검증 0건 확인 후 Phase 3 진입
+ - 불일치 발생 시: 신규 모델 적용 즉시 중단 -> 원인 분석 -> 수정 후 재검증
+
+완료 기준:
+
+- 권한 오검증 0건
+- 권한 변경 즉시 반영 정책 동작
+- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과
+
+### Phase 3 (4/3~4/5, 4일): 감사/보안 고도화
+
+- 감사 로그/보안 이벤트 모니터링
+- 보안 정책 마감(rate limit, 토큰 운영, 경보 연계)
+- 릴리스 점검 및 운영 인수
+
+완료 기준:
+
+- 보안 체크리스트 충족(Appendix `D`, `I`, `J`, `K`, `P` 기준)
+- 운영 대시보드/알림 체계 확인
+- 기존 세션 로그인 경로 제거 완료(rollback 비상 경로 제외)
+
+## 4) 설계 선택 근거 (요약)
+
+- 인증 방식 전환(세션 쿠키 -> Authorization header):
+ - FE/BE 크로스 도메인 환경에서 Safari third-party cookie 제한 이슈를 줄이기 위한 선택
+ - Access Token은 header로 전달해 인증 안정성을 확보하고, refresh는 별도 보안 정책으로 관리
+- 슈퍼어드민 판단 기준 단일화:
+ - 서버 시작 시 `SUPER_ADMIN_EMAIL`로 DB를 bootstrap(보정)하되
+ - 런타임 권한 판단은 항상 DB `isSuperAdmin`만 사용
+- 권한 모델 전환(라벨 조인 -> `perm` 비트마스크):
+ - 권한 라벨 증가 시 조인 복잡도를 줄이고 확장성을 확보
+ - 내부 저장은 비트마스크, 운영 UI는 기존 라벨 형태를 유지해 가독성 보전
+- 데이터 저장소 전환(MySQL/Sequelize -> MongoDB):
+ - 메인/백오피스 DB 스택을 단일화해 운영 복잡도와 이중 관리 비용을 낮춤
+ - 문서/구현 기준은 MongoDB 모델을 소스 오브 트루스로 사용
+
+## 5) Prerequisites / Dependencies
+
+Phase 1 시작 게이팅(필수, Day 1 전 완료):
+
+- Google Cloud OAuth 설정(Client ID/Secret, Redirect URI)
+- FE/BE 도메인/CORS/TLS 확정
+- Safari 대응 인증 경로 확정: Option A(리버스 프록시 기반 same-site 정렬) 적용
+ - FE 도메인에서 `/api` 경로를 BE로 프록시해 refresh cookie를 same-site로 처리
+- fail-fast 환경변수 확정 및 주입:
+ - `COOKIE_SECRET`, `ACCESS_TOKEN_SECRET`, `BO_BACKEND_URL`, `BO_FRONTEND_URL`, `BO_ALLOWED_ORIGINS`, `MONGO_URI`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `SERVER_SECRET_SALT`
+
+준비 권장 항목(병행 진행 가능):
+
+- 운영 알림 채널(이메일/슬랙) 준비
+- 비상 계정 운영 정책 승인
+- IP 개인정보 최소화 정책 확정
+ - 원문 IP 저장 대신 `ipHash(HMAC-SHA256 + 고정 서버 secret salt)` 사용
+ - 운영 중 secret 교체 금지(교체 시 기존 비교/조회 불가)
+
+## 6) 운영 UX 변화
+
+Before/After:
+
+- 로그인
+ - Before: 아이디/비밀번호 입력 및 세션 쿠키 기반 로그인(브라우저/크로스도메인 이슈 존재)
+ - After: Google 로그인 버튼 1개 + Authorization 기반 인증
+- 계정 생성
+ - Before: 수동 계정 생성/전달 중심
+ - After: 슈퍼어드민이 이메일 기반 초대 링크 발급 후 사용자 온보딩(초대 시 권한 사전 지정 가능)
+- 권한 변경
+ - Before: 권한 기준/변경 이력 가시성이 낮음
+ - After: 라벨 기반 권한 UI + 권한 변경 이력 확인
+- 초대 만료 관리
+ - Before: 만료 정책 운영 기준 불명확
+ - After: 기본 2일 정책을 기준으로 초대 만료값을 관리(슈퍼어드민 권한 범위 내 조정 가능)
+
+초대 플로우 상세:
+
+1. 슈퍼어드민이 이메일 1개를 입력해 해당 이메일 전용 초대 링크 1개를 생성한다.
+2. 초대 시 기본 권한은 읽기 전용(`read/all`)이며, 슈퍼어드민이 필요 시 write 권한을 사전 지정할 수 있다.
+3. 초대 링크는 만료 시간(기본 2일)을 포함하며, 만료/재발급 상태를 운영 화면에서 확인한다.
+4. 사용자가 링크 접속 후 Google 로그인하면, 로그인 이메일과 초대 이메일 일치 여부를 검증한다.
+5. 일치 시 `googleSub`를 계정에 바인딩하고 이후 로그인 식별 기준은 `googleSub`로 고정한다.
+
+## 7) Rollback Strategy
+
+- Phase 1 동안 기존 세션 로그인 경로를 임시 fallback으로 유지
+- 로그인 실패율 > 2% 또는 OAuth 오류율 급증 시 OAuth 경로 비활성화 후 기존 로그인으로 즉시 복구
+- Phase 3 완료 기준 충족 후 기존 로그인 경로 제거(rollback 비상 경로 제외)
+
+## 8) 비상 운영 절차
+
+- 판단 주체: PM + 온콜 백엔드 리드
+- 절차:
+ 1. 장애 판단(로그인 실패율/에러율 기준)
+ 2. 공지(운영 채널/사용자 안내)
+ 3. fallback 적용
+ 4. 원인 해결 후 OAuth 재활성화
+- 운영 리스크 공유:
+ - 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음
+ - 이 경우 읽기 중심 운영 모드를 유지하고 로그 저장소 복구 후 변경 작업을 재개
+- 사후 조치: 장애 리포트 및 재발 방지 백로그 등록
+
+## 9) Success Metrics
+
+- 인증 성공률 99.9% 이상 (서버 로그인 API 로그 기준)
+- 인증 실패율 baseline 대비 50% 이상 감소 (개편 전 7일 평균 대비)
+- 권한 관련 CS 티켓 0건 (Phase 3 완료 후 2주 기준)
+- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과
+- Staging 환경에서 운영자 계정 1개 이상 초대 플로우 온보딩 완료
+- 슈퍼어드민 권한 변경 시 대상 계정에 즉시 반영됨을 검증 완료
+
+## 10) 리소스 리스크 및 대응
+
+- 리스크: 단일 개발자 의존 구조
+- 대응:
+ - Phase 단위 배포로 리스크 분산
+ - 각 Phase 완료 시 중간 검증/승인
+ - Phase 2 지연 시 관리자 권한 변경 UI를 임시 제외하고, 슈퍼어드민 API/DB 운영 절차로 권한 변경을 대체해 기능 우선 배포
+ - 개발자 이탈/병가 발생 시 즉시 백엔드/프론트 대체 담당자를 지정하고, Phase 1(인증 안정화) 범위 우선으로 축소 운영
+
+## 11) 수용 기준 (PM 관점)
+
+- 2주 마일스톤 내 Phase 1~3 완료
+- 인증/권한/운영 절차가 문서 기준으로 인수 가능
+- 롤백/비상 대응 절차가 실제 운영 가능 상태
+- 기술 상세 항목은 Appendix 기준으로 구현/검증 완료
diff --git a/feature/01-bo-auth.md b/feature/01-bo-auth.md
new file mode 100644
index 0000000..ccf381a
--- /dev/null
+++ b/feature/01-bo-auth.md
@@ -0,0 +1,175 @@
+# 01. BO Auth 실행 명세서
+
+## Executive Summary
+
+- Safari 인증 이슈와 권한 모델 확장성 문제를 동시에 해결한다.
+- 인증은 Google OAuth + Authorization 토큰 기반으로 전환한다.
+- 권한은 확장 가능한 구조로 단순화하고, 슈퍼어드민 운영 통제를 강화한다.
+- 2주 내 단계적 전환과 rollback 전략으로 도입 리스크를 관리한다.
+
+## 0) 문서 목적
+
+본 문서는 백오피스 인증/권한 체계 고도화의 실행 계획이다.
+기술 구현 세부는 별도 Appendix 문서로 분리한다.
+
+- 기술 Appendix: [01-bo-auth-appendix.md](./01-bo-auth-appendix.md)
+
+## 1) Expected Impact
+
+- Safari의 third-party cookie 제한 이슈를 제거하기 위해 cookie 기반 인증에서 Authorization header 기반 인증으로 전환, 인증 실패율 감소(특히 Safari 환경)
+- 권한 관련 운영 이슈 감소(CS/운영 문의 티켓 기준)
+- 관리자 계정 생성/권한 변경 작업 시간 감소
+- 계정/권한 보안 사고 리스크 감소
+- 정량 목표/측정 기준은 `9) Success Metrics`를 따른다.
+- baseline은 개편 전 최근 7일 서버 로그인 API 로그를 기준으로 산정한다.
+
+## 2) Risk if not implemented
+
+- Safari 인증 이슈가 지속된다.
+- 권한 확장 시 기술 부채가 누적된다.
+- 계정/보안 운영 비용이 계속 증가한다.
+
+### Phase 1 (3/24~3/27, 4일): 인증 전환
+
+- Google OAuth + Authorization 토큰 기반 로그인 전환
+- 크로스 도메인 안정화
+- 기존 로그인 fallback 유지
+
+완료 기준:
+
+- Safari 포함 주요 브라우저 로그인 성공
+- 인증 성공률 99% 이상 (QA + staging 로그 기준)
+- Safari 환경에서 access token 만료 후 refresh 기반 세션 복구(silent refresh) 성공
+- Safari cross-domain refresh 검증은 Appendix `P) 테스트 전략`의 QA 시나리오 기준으로 판정
+
+### Phase 2 (3/28~4/2, 5일): 권한 모델 전환
+
+- 권한 모델 전환(확장 가능한 구조)
+- 관리자 권한 관리 UX 정리
+- `/bo/auth/me` 최신 상태 기준 확정
+- 리스크:
+ - 권한 모델 전환 시 기존 권한 불일치 가능성
+- 대응:
+ - 병행 검증 기간: Phase 2 전체 기간(3/28~4/2)
+ - 검증 방법: 신규 권한 모델 결과와 기존 권한 기준 결과를 관리자 계정 전체 대상 대조
+ - 완료 판단: QA 환경에서 권한 오검증 0건 확인 후 Phase 3 진입
+ - 불일치 발생 시: 신규 모델 적용 즉시 중단 -> 원인 분석 -> 수정 후 재검증
+
+완료 기준:
+
+- 권한 오검증 0건
+- 권한 변경 즉시 반영 정책 동작
+- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과
+
+### Phase 3 (4/3~4/5, 4일): 감사/보안 고도화
+
+- 감사 로그/보안 이벤트 모니터링
+- 보안 정책 마감(rate limit, 토큰 운영, 경보 연계)
+- 릴리스 점검 및 운영 인수
+
+완료 기준:
+
+- 보안 체크리스트 충족(Appendix `D`, `I`, `J`, `K`, `P` 기준)
+- 운영 대시보드/알림 체계 확인
+- 기존 세션 로그인 경로 제거 완료(rollback 비상 경로 제외)
+
+## 4) 설계 선택 근거 (요약)
+
+- 인증 방식 전환(세션 쿠키 -> Authorization header):
+ - FE/BE 크로스 도메인 환경에서 Safari third-party cookie 제한 이슈를 줄이기 위한 선택
+ - Access Token은 header로 전달해 인증 안정성을 확보하고, refresh는 별도 보안 정책으로 관리
+- 슈퍼어드민 판단 기준 단일화:
+ - 서버 시작 시 `SUPER_ADMIN_EMAIL`로 DB를 bootstrap(보정)하되
+ - 런타임 권한 판단은 항상 DB `isSuperAdmin`만 사용
+- 권한 모델 전환(라벨 조인 -> `perm` 비트마스크):
+ - 권한 라벨 증가 시 조인 복잡도를 줄이고 확장성을 확보
+ - 내부 저장은 비트마스크, 운영 UI는 기존 라벨 형태를 유지해 가독성 보전
+- 데이터 저장소 전환(MySQL/Sequelize -> MongoDB):
+ - 메인/백오피스 DB 스택을 단일화해 운영 복잡도와 이중 관리 비용을 낮춤
+ - 문서/구현 기준은 MongoDB 모델을 소스 오브 트루스로 사용
+
+## 5) Prerequisites / Dependencies
+
+Phase 1 시작 게이팅(필수, Day 1 전 완료):
+
+- Google Cloud OAuth 설정(Client ID/Secret, Redirect URI)
+- FE/BE 도메인/CORS/TLS 확정
+- Safari 대응 인증 경로 확정: Option A(리버스 프록시 기반 same-site 정렬) 적용
+ - FE 도메인에서 `/api` 경로를 BE로 프록시해 refresh cookie를 same-site로 처리
+
+준비 권장 항목(병행 진행 가능):
+
+- 운영 알림 채널(이메일/슬랙) 준비
+- 비상 계정 운영 정책 승인
+- IP 개인정보 최소화 정책 확정
+ - 원문 IP 저장 대신 `ipHash(HMAC-SHA256 + 고정 서버 secret salt)` 사용
+ - 운영 중 secret 교체 금지(교체 시 기존 비교/조회 불가)
+
+## 6) 운영 UX 변화
+
+Before/After:
+
+- 로그인
+ - Before: 아이디/비밀번호 입력 및 세션 쿠키 기반 로그인(브라우저/크로스도메인 이슈 존재)
+ - After: Google 로그인 버튼 1개 + Authorization 기반 인증
+- 계정 생성
+ - Before: 수동 계정 생성/전달 중심
+ - After: 슈퍼어드민이 이메일 기반 초대 링크 발급 후 사용자 온보딩(초대 시 권한 사전 지정 가능)
+- 권한 변경
+ - Before: 권한 기준/변경 이력 가시성이 낮음
+ - After: 라벨 기반 권한 UI + 권한 변경 이력 확인
+- 초대 만료 관리
+ - Before: 만료 정책 운영 기준 불명확
+ - After: 기본 2일 정책을 기준으로 초대 만료값을 관리(슈퍼어드민 권한 범위 내 조정 가능)
+
+초대 플로우 상세:
+
+1. 슈퍼어드민이 이메일 1개를 입력해 해당 이메일 전용 초대 링크 1개를 생성한다.
+2. 초대 시 기본 권한은 읽기 전용(`read/all`)이며, 슈퍼어드민이 필요 시 write 권한을 사전 지정할 수 있다.
+3. 초대 링크는 만료 시간(기본 2일)을 포함하며, 만료/재발급 상태를 운영 화면에서 확인한다.
+4. 사용자가 링크 접속 후 Google 로그인하면, 로그인 이메일과 초대 이메일 일치 여부를 검증한다.
+5. 일치 시 `googleSub`를 계정에 바인딩하고 이후 로그인 식별 기준은 `googleSub`로 고정한다.
+
+## 7) Rollback Strategy
+
+- Phase 1 동안 기존 세션 로그인 경로를 임시 fallback으로 유지
+- 로그인 실패율 > 2% 또는 OAuth 오류율 급증 시 OAuth 경로 비활성화 후 기존 로그인으로 즉시 복구
+- Phase 3 완료 기준 충족 후 기존 로그인 경로 제거(rollback 비상 경로 제외)
+
+## 8) 비상 운영 절차
+
+- 판단 주체: PM + 온콜 백엔드 리드
+- 절차:
+ 1. 장애 판단(로그인 실패율/에러율 기준)
+ 2. 공지(운영 채널/사용자 안내)
+ 3. fallback 적용
+ 4. 원인 해결 후 OAuth 재활성화
+- 운영 리스크 공유:
+ - 감사 로그 저장소 장애 시 권한 변경/계정 상태 변경 API가 일시 중단될 수 있음
+ - 이 경우 읽기 중심 운영 모드를 유지하고 로그 저장소 복구 후 변경 작업을 재개
+- 사후 조치: 장애 리포트 및 재발 방지 백로그 등록
+
+## 9) Success Metrics
+
+- 인증 성공률 99.9% 이상 (서버 로그인 API 로그 기준)
+- 인증 실패율 baseline 대비 50% 이상 감소 (개편 전 7일 평균 대비)
+- 권한 관련 CS 티켓 0건 (Phase 3 완료 후 2주 기준)
+- 초대 플로우 E2E(초대 생성 -> 링크 접속 -> Google 로그인 -> 권한 부여) QA 통과
+- Staging 환경에서 운영자 계정 1개 이상 초대 플로우 온보딩 완료
+- 슈퍼어드민 권한 변경 시 대상 계정에 즉시 반영됨을 검증 완료
+
+## 10) 리소스 리스크 및 대응
+
+- 리스크: 단일 개발자 의존 구조
+- 대응:
+ - Phase 단위 배포로 리스크 분산
+ - 각 Phase 완료 시 중간 검증/승인
+ - Phase 2 지연 시 관리자 권한 변경 UI를 임시 제외하고, 슈퍼어드민 API/DB 운영 절차로 권한 변경을 대체해 기능 우선 배포
+ - 개발자 이탈/병가 발생 시 즉시 백엔드/프론트 대체 담당자를 지정하고, Phase 1(인증 안정화) 범위 우선으로 축소 운영
+
+## 11) 수용 기준
+
+- 2주 마일스톤 내 Phase 1~3 완료
+- 인증/권한/운영 절차가 문서 기준으로 인수 가능
+- 롤백/비상 대응 절차가 실제 운영 가능 상태
+- 기술 상세 항목은 Appendix 기준으로 구현/검증 완료
diff --git a/feature/02-activity-cms.md b/feature/02-activity-cms.md
new file mode 100644
index 0000000..6214c57
--- /dev/null
+++ b/feature/02-activity-cms.md
@@ -0,0 +1,417 @@
+# 02. Activity 동적 구성 기능 도입 (Activity CMS)
+
+> **📜 문서 수정 이력 (Changelog)**
+> **[2026-03-23 수정 사항]**
+> - **Orphaned Image (미사용 이미지) 방지 정책 추가:** 게시물 작성 중도 취소 시 클라이언트 측 즉각적인 클라우드 스토리지 비우기 및 주기적 잉여 이미지 일괄 제거(Cron) 정책 (5.1.2 항목) 반영
+>
+> **[2026-03-22 수정 사항]**
+> - **용어 및 계층 구조 명확화:** `Type(종류)` > `Field(속성)` > `Item(콘텐츠)` 3단계 구조로 용어 통일
+> - **속성명 변경:** 렌더링용 입력 양식임을 명확히 하기 위해 `Field`항목에 들어 있던 `type`을 `inputType`으로 변경
+> - **불변 식별자(Immutable Identifier) 도입:** 값이 변경될 소지가 있는 `name` 대신, 식별 고유 키값인 `typeId`, `fieldId`를 사용하도록 변경
+> - **시스템 아키텍처 명시:** 백오피스와 메인 웹 서버가 서로 API(구독) 통신하는 방식이 아닌, 동일한 MongoDB를 각각 직접 조회하는 Shared DB(Direct Connection) 구조 채택 및 역할 명시 (3.3 및 3.4 항목 도입)
+
+## 1. 개요 및 목적
+기존 메인 웹의 Activity 영역(스터디, 세미나, 개발 등) 데이터는 프론트엔드 코드(`studyData.ts` 등) 내에 **하드코딩**되어 관리되고 있습니다. 이로 인해 새로운 활동이 추가되거나 속성(필드)이 변경될 때마다 개발자가 직접 코드를 수정하고 서버를 재배포해야 하는 비효율이 발생합니다.
+
+본 기능 명세는 백오피스에서 Activity의 **유형(Type)** 과 **속성(Field)**, 그리고 실제 들어갈 **콘텐츠(Item)** 를 운영진이 직접 정의하고 관리할 수 있는 **동적 CMS(Content Management System)**를 구축하여 운영 효율성(UX)과 시스템 확장성(DX)을 극대화하는 것을 목표로 합니다.
+
+---
+
+## 2. 현재 상태 분석 (As-Is)
+
+### 2.1 문제점
+현재 웹사이트의 메인 화면에 출력되는 Activity(스터디, 세미나, 개발 등) 데이터는 프론트엔드 소스 코드 내에 완전히 하드코딩되어 있습니다.
+
+- `main/frontend/src/lib/activity/studyData.ts` 등 — 각 활동별 데이터(주제, 상세내용, 기간, 툴 등)가 정적 배열로 고정되어 있음.
+- 기술 스택 아이콘(`tools`) 마저 URL 검증을 개발자가 주석으로 남기며 수동으로 체크해야 하는 비효율성 존재.
+- 새로운 속성(예: 세미나의 '발표자' 필드)이 필요할 때마다 고정된 인터페이스(`ActivityItem`)를 개발자가 직접 수정하고 배포해야 함.
+- 이미지 자원의 URL(`https://pub-...r2.dev/*.jpeg` 등)이 코드상에 문자열로 직접 적혀 있어, 파일이 수정될 때마다 코드 수정 및 재배포가 강제됨.
+
+### 2.2 구체적 고정 항목들 (`studyData.ts` 기준)
+
+| 영역 | 고정된 내용 |
+|------|------------|
+| 인터페이스 구조 | `topic`, `details`, `date`, `images`, `tools`, `link` 등으로 필드 형태가 제한됨 |
+| 이미지 배열 | Cloudflare R2 스토리지의 URL 문자열들이 개별 `items` 속성에 정적으로 들어가 있음 |
+| 노출 순서 | 소스 코드 상의 배열(`studyItems`) 선언 순서에 강제적으로 의존하게 됨 |
+
+> **결론:** 아주 단순한 오타 제보나, 새로운 학기의 신규 활동 추가 시에도 운영진이 직접 할 수 없고 무조건 프론트엔드 개발자가 코드를 수정 및 재배포해야 운영에 반영됩니다.
+
+### 2.3 현재 시스템 결합 구조
+
+이 기능의 가장 큰 목적은, 기존의 '프론트엔드 코드 내부 종속식 렌더링'을 '백오피스 DB 변경 즉시 동적 렌더링'으로 아키텍처를 뒤집는 것에 있습니다.
+
+```mermaid
+graph LR
+ subgraph Current["현재 구조 (As-Is)"]
+ direction LR
+ subgraph MainFE["main/frontend"]
+ direction TB
+ MF1["lib/activity/studyData.ts (정적 데이터 하드코딩)"]
+ MF2["lib/activity/seminaData.ts"]
+ MF3["components/ActivityCard.tsx (고정 인터페이스 렌더링)"]
+ end
+ Cloud["Cloudflare R2 (이미지 스토리지)"]
+ MainFE -.->|URL 하드코딩| Cloud
+ end
+ Admin["운영진"] -.->|활동 내용 수정 요청| Dev["개발자"]
+ Dev -.->|수동 코드 수정 & 서버 재배포| MainFE
+```
+
+- 현재 구조에서는 데이터의 주인이 코드를 작성한 개발자에게 종속되어, 콘텐츠가 유연하게 관리되지 못하는 가장 큰 병목이 발생합니다.
+
+---
+
+## 3. 핵심 아키텍처 및 해결 방안 (To-Be)
+
+우리가 만들 CMS는 **"구글 폼을 만드는 관리자 화면"** 과 완벽하게 동일한 원리로 동작합니다.
+
+### 3.1. 문제 해결 매핑 (As-Is ➡️ To-Be)
+2장에서 정의한 문제점들을 아래와 같은 기술적 전략으로 일대일(1:1) 매칭하여 해결합니다.
+
+| As-Is (해결할 문제점) | To-Be (기술적 해결 전략) | 관련 목차 |
+| --- | --- | --- |
+| **하드코딩으로 인한 개발자 종속성 및 확장성 한계** (고정된 데이터 구조 & 잦은 재배포) | **데이터베이스(MongoDB) 기반 조회 및 Schema-driven UI 도입** 하드코딩 데이터를 DB로 완전 이관(Type/Field/Item 분리)하여, **프론트엔드 코드 수정 및 재배포 없이** 백오피스에서 즉시 메인 웹 화면(신규 필드 포함)을 유연하게 제어. | 3.3, 4, 5.2 |
+| **이미지 관리의 비효율성** (코드 의존적 리소스 관리) | **Presigned URL 기반 다이렉트 클라우드 업로드** 스토리지 이미지와 DB URL 저장을 결합하여 프론트엔드 코드 배포와 파일 관리를 완전히 분리 | 5.1 |
+
+### 3.2. 주요 용어 사전 (Glossary)
+처음 CMS 구조를 접하는 팀원들을 위해, 본 문서와 개발 과정에서 가장 자주 쓰일 핵심 용어들을 정리합니다.
+
+- **Type (활동의 종류) / `ActivityTypes` 컬렉션:**
+ - '스터디', '세미나', '해커톤' 등 메인 웹에서 하나의 **메뉴(탭)** 단위가 되는 큼직한 껍데기를 의미합니다.
+ - 백오피스에서 "새로운 활동 탭을 하나 만들자!"라고 할 때 이 Type을 하나 생성하게 됩니다.
+- **Field / `ActivityField`(질문 문항 / 설계도):**
+ - 특정 ActivityType 안에서 데이터를 받기 위해 뚫어놓은 **입력칸(항목)** 들입니다.
+ - 예시: 스터디 Type 내부의 '기간(Date)', '상세설명(LongText)', '사진(Image)' 필드들.
+ - 이 필드들이 모여서 하나의 "ActivityType(Schema 설계도)"을 이룹니다.
+- **Item (실제 게시물 알맹이) / `ActivityItems` 컬렉션:**
+ - 운영진이 만들어진 Field 양식에 맞춰 빈칸에 실제로 타이핑해서 넣은 **진짜 데이터(글, 사진)**입니다.
+ - 메인 웹에 예쁘게 그려지는 실제 활동들이 Item입니다.
+- **하드코딩 (Hardcoding):**
+ - 데이터를 관리자 웹이나 DB에서 가져오지 않고, 개발자가 소스 코드 파일(예: `.ts`, `.json`) 안에 직접 텍스트로 박아 넣는 행위. 우리가 이 프로젝트에서 **없애려는 가장 큰 문제점(As-Is)**입니다.
+- **Schema-driven UI 패러다임:**
+ - 프론트엔드가 화면을 그릴 때 코드에 하드코딩된 값(예: `item.topic`)을 찾는 것이 아니라, 백엔드가 주는 설계도(Schema) 문서만 쳐다보고 거기에 있는 필드를 꺼내서 화면을 자동으로 찍어내는(Dynamic Render) 최신 프론트엔드 설계 기법입니다.
+- **Presigned URL (사전 발급된 URL):**
+ - 무거운 이미지나 파일을 사용자가 업로드할 때, 우리 백엔드 서버를 거치지 않게 하기 위해 **클라우드(AWS S3, Cloudflare R2 등)에서 발급받는 "일회용 클라우드 출입증 티켓"**입니다. 이 티켓을 쓰면 프론트엔드에서 클라우드로 파일을 직행시킬 수 있어 서버 부하가 줄어듭니다.
+
+### 3.3. 역할 분담
+
+#### 백오피스: "설계도를 만들고 데이터를 채우는 곳"
+- **Backoffice Frontend (기획 UX)**
+ - 운영진이 마우스 조작만으로 `Type`과 `Field`를 동적으로 추가/삭제하는 **컨트롤 타워** 역할을 합니다.
+ - 드래그 앤 드롭 기능을 지원해, 노출할 필드나 실제 카드(`Item`)의 '순서(order)'를 직관적으로 조작합니다.
+- **Backoffice Backend (저장소 관리)**
+ - 운영 관리자로서의 권한을 깐깐하게 검증(Auth)합니다.
+ - 프론트엔드에서 조합된 자유로운 구조(Schema)의 양식과 실제 데이터(JSON)를 받아, MongoDB의 특성을 살려 에러 없이 유연하게 영구 저장(C/U/D)합니다.
+
+#### 메인 웹: "전달받은 설계도대로 렌더링"
+- **Main Backend (단순 정보 배달부)**
+ - 메인 접속자들에 대한 복잡한 시스템 로직을 줄이고, MongoDB에 저장된 '설계도와 내용물(Item)'을 꺼내어 화면 쪽에 안전하고 빠르게 **단순 전달(Read)** 해주는 역할을 합니다.
+- **Main Frontend (코어 렌더러)**
+ - 서버로부터 넘어온 `Type`, `Field`, `Item` 구조를 해석합니다.
+ - 사전에 정의된 규칙에 따라, **프론트엔드 소스 코드를 단 한 줄도 수정(재배포)하지 않고도** 그 안에 정의된 `Item(콘텐츠)`의 내용들을 알아서 예쁘게 화면에 그려냅니다(Dynamic Rendering).
+
+### 3.4. 시스템 간 DB 연결 구조 (Shared Database)
+서비스 간 의존도를 낮추고 운영 인프라를 단순화하기 위해, **백오피스와 메인 시스템이 서로 API로 통신하지 않고 동일한 MongoDB를 각각 직접 연결(Direct Connection)하여 조회하는 구조**를 채택합니다.
+- **단일 장애점(SPOF) 방지 및 의존성 분리:** 백오피스 백엔드가 API를 제공하고 메인 백엔드가 이를 호출(Fetch)하는 구독 형태를 피합니다. 이를 통해 백오피스 서버가 다운되거나 점검 중일 때도 메인 웹사이트의 고객 조회 트래픽에는 전혀 영향을 주지 않도록 격리합니다.
+- **직접 연결(Direct Connection):** 기존 퀴푸 아키텍처와 동일하게 `Backoffice Backend`와 `Main Backend`는 각각 독립적인 서버로 동작하며, 공통된 하나의 MongoDB 클러스터에 접속하여 각자의 역할(백오피스는 C/U/D 위주, 메인은 R 위주)에 맞게 쿼리합니다.
+
+---
+
+## 4. 데이터베이스 및 스키마 설계 (MongoDB)
+
+RDBMS처럼 고정된 열(Column)을 가지는 대신, 동적으로 변하는 데이터를 담기 위해 유연한 Document 구조(NoSQL)를 채택합니다.
+두 가지의 핵심 컬렉션(`ActivityTypes`, `ActivityItems`)으로 구성되며, 이에 대한 명확한 TypeScript 인터페이스 명세는 다음과 같습니다.
+
+### 4.1. `ActivityTypes` 컬렉션 (양식 설계도 박스)
+
+ActivityType은 어떤 활동(Type)이 있고, 그 활동은 어떤 항목(Field)를 필요로 하는지 정의합니다.
+
+```typescript
+// [공통 타입] 메인/백오피스, 프론트엔드 및 백엔드 등 모든 영역에서 공통으로 사용되는 타입
+// 지원하는 입력 필드 타입 종류
+type FieldType =
+ | "shortText" // 단답형 텍스트 (예: 주제, 멘토 이름)
+ | "longText" // 서술형 텍스트 (예: 상세 설명)
+ | "date" // 단일 날짜
+ | "dateRange" // 기간 (시작일 - 종료일)
+ | "image" // 단일 이미지 업로드
+ | "imageList" // 다중 이미지 업로드
+ | "url" // 외부 링크 (예: Github, 퀴푸 메인웹 링크 등)
+ | "icon"; // 기술 스택 아이콘 (예: Devicon 키워드)
+
+// 개별 필드(질문 문항) 설계도
+interface ActivityField {
+ fieldId: string; // 데이터를 넣고 뺄 고유 영문 키 (예: "topic", "details"), 수정 불가
+ label: string; // 백오피스 입력 폼에 보여질 한글 라벨 (예: "주제", "상세 설명")
+ inputType: FieldType; // 입력 필드의 형태 (프론트엔드 렌더링 기준)
+ required: boolean; // 필수 입력 여부
+ order: number; // 백오피스 입력 폼에서의 출력 순서
+
+ // type이 "image" 또는 "imageList"일 경우의 추가 설정
+ fileConfig?: {
+ maxFiles?: number; // 최대 허용 이미지 개수
+ maxSizeMB?: number; // 장당 최대 용량 제한 (예: 5MB)
+ };
+}
+
+// [백엔드/공통 타입] DB 모델 스키마의 기준이자, 프론트엔드가 응답받게 되는 타입
+// 하나의 Type(스터디, 세미나 등)에 대한 전체 정의
+interface ActivityType {
+ _id: ObjectId;
+ typeId: string; // (예: "study", "semina") 수정 불가
+ displayName: string; // 메인 웹 네비게이션에 노출될 이름 (예: "스터디")
+ description?: string; // 활동에 대한 전반적인 설명
+ order: number; // 메인 웹 탭 메뉴에서의 노출 순서 (DnD 정렬용)
+ isActive: boolean; // 메인 노출 여부 토글 (비활성화 시 숨김)
+ fields: ActivityField[]; // 💡 이 활동에서 입력받을 필드 항목들의 배열 (Schema 설계도)
+ createdAt: Date;
+ updatedAt: Date;
+}
+```
+
+
+실제 MongoDB 저장 예시 (JSON 데이터)
+
+```json
+{
+ "_id": "ObjectId", // 몽고DB 자동 생성 id
+ "typeId": "study", // 스터디 고유 키값, 수정 불가
+ "displayName": "스터디", // 메인 웹 네비게이션에 노출될 이름
+ "description": "개발 공부부터 코딩 테스트까지 등등...",
+ "order": 1, // 👈 메인 웹 네비게이션 메뉴 노출 순서
+ "isActive": true, // 메인 노출 여부 토글
+ "fields": [ // 💡 어떤 입력 항목(질문)을 받을 것인가?
+ {
+ "fieldId": "topic", // 나중에 실제 값을 꺼낼 키
+ "label": "주제", // 백오피스 폼에 보일 질문 이름
+ "inputType": "shortText", // 입력 타입 (Text, Date, Image 등 프론트엔드 렌더링 기준)
+ "required": true,
+ "order": 1 // 👈 백오피스 입력 폼에서의 위치
+ },
+ { "fieldId": "date", "label": "기간", "inputType": "dateRange", "required": true, "order": 2 },
+ // ... 세미나 타입이라면 여기에 "fieldId": "speaker" (발표자) 필드가 동적으로 들어감.
+ ]
+}
+```
+
+
+### 4.2. `ActivityItems` 컬렉션 (실제 데이터 창고)
+
+Type 설계도에 맞춰 운영진이 직접 작성한 실제 게시물이 저장됩니다. 필드 값은 설계도의 불변 식별자인 `fieldId`를 키로 하는 유연한 구조(`Record`)를 가집니다.
+
+```typescript
+// [백엔드/공통 타입] DB 모델 스키마의 기준이자, 프론트엔드가 응답받게 되는 실제 데이터 타입
+interface ActivityItem {
+ _id: ObjectId;
+ typeKey: string; // 부모격인 ActivityType의 typeId 참조값 (예: "study", "semina")
+ order: number; // 👈 동일 Type 내에서 리스트 노출 순서 (DnD 정렬용)
+ isVisible: boolean; // 승인/공개 여부 (임시저장 기능 등에 활용)
+
+ // 💡 설계도(fields)의 규칙에 맞춰 자유롭게 들어가는 실제 데이터
+ data: Record;
+
+ createdAt: Date;
+ updatedAt: Date;
+}
+```
+
+
+실제 MongoDB 저장 예시 (JSON 데이터)
+
+```json
+{
+ "_id": "ObjectId",
+ "typeKey": "study", // ActivityType 참조값
+ "order": 1, // 👈 해당 Type 내에서 리스트 노출 순서 (드래그 앤 드롭으로 변경됨)
+ "isVisible": true, // 승인/공개 여부
+ "data": { // 💡 설계도(fields)의 규칙에 얽매이지 않고 들어가는 유연한 데이터 박스!
+ "topic": "리액트 기초 스터디",
+ "date": "24.03.01 - 24.06.30",
+ "details": "프론트엔드의 대명사 리액트를 다루는 스터디입니다.",
+ "images": [
+ // DB에는 무거운 이미지 파일이 아닌 클라우드에 직접 올린 흔적(URL)만 저장됨
+ "https://pub-e688831b...dev/study-react1.jpeg"
+ ]
+ },
+ "createdAt": "2026-03-10T09:00:00Z",
+ "updatedAt": "2026-03-10T09:00:00Z"
+}
+```
+
+
+> **결론:** 메인 웹의 프론트엔드는 `ActivityType`를 조회해 탭을 구성하고 각 카드가 어떤 필드를 갖고 있는지 파악한 뒤, `ActivityItem`의 `data` 객체에서 해당 필드 값을 동적으로 꺼내어 화면을 그립니다.
+
+---
+
+## 5. 상세 구현 컴포넌트 및 API 흐름도
+
+### 5.1. 이미지 업로드: Presigned URL 방식 도입
+
+#### 5.1.1. 이미지 업로드 흐름 (Direct Upload)
+서버 부하와 보안을 위해, 무거운 파일은 백엔드를 거치지 않고 프론트엔드에서 클라우드 스토리지(Cloudflare R2 등)로 직접 업로드합니다.
+1. `Backoffice 프론트` ➡️ `Backoffice 백엔드`: "나 이미지 올릴 건데 클라우드 일회용 출입증(Presigned URL) 줘!" (권한/토큰 검사)
+2. `Backoffice 백엔드` ➡️ `Backoffice 프론트`: 발급된 티켓(URL) 전달
+3. `Backoffice 프론트` ➡️ `클라우드 스토리지`: 이미지 100% 직행 전송
+4. 업로드 완료 후 생성된 URL 텍스트만 `ActivityItem`의 `data.images` 배열에 저장.
+
+#### 5.1.2. 🚨 Orphaned Image (미사용 이미지) 방지 정책
+작성 도중 이미지만 업로드하고 **게시물 작성을 취소하거나 브라우저 창을 닫아버리는 경우**에 발생하는 스토리지 용량 누수(고아 이미지)를 다음과 같이 즉각적으로 방지합니다.
+- **클라이언트 측 즉시 삭제 (우선 고려):** 백오피스 프론트엔드에서 '작성 취소/뒤로가기' 버튼 클릭이나 브라우저 이탈(Unmount, beforeunload) 감지 시, 업로드 큐에 있던 이미지 URL들을 모아 백엔드의 이미지 삭제 API(`DELETE /bo/images`)를 호출하여 클라우드 스토리지를 즉각 비워냅니다.
+- **주기적 크론(Cron) 정리 (보완책):** 클라이언트 측의 네트워크 에러나 강제 브라우저 종료 등으로 인한 삭제 API 호출 실패를 대비하여, 백엔드는 주기적(예: 매일 자정)으로 Cloudflare R2 스토리지의 목록과 DB(`ActivityItem`)에 정상 등록된 이미지 URL들을 대조해 쓰이지 않는 잉여 이미지들을 일괄 제거하는 배치(Batch) 스케줄러를 운영합니다.
+
+### 5.2. 프론트엔드 동적 렌더링 (Dynamic UI Rendering) 및 신규 필드 추가 대응
+메인 프론트엔드는 더 이상 하드코딩된 값(`item.topic`)을 찾지 않습니다. 동적으로 변하는 키 값을 감지하여 예외 상황을 처리하는 **Schema-driven UI** 패턴을 적용합니다.
+
+- **유연한 렌더링 방식 (프론트엔드 무수정 원칙):**
+ - 메인 프론트는 `item.data.mentor` 처럼 특정 필드 이름을 하드코딩해서 찾지 않습니다.
+ - 대신, 백엔드가 전달해 준 설계도(`ActivityType`의 `fields` 배열)를 순회(`map`)하며, 설계도에 있는 필드 이름(`field.fieldId`)으로 실제 데이터(`item.data[field.fieldId]`)를 동적으로 꺼내서 화면에 그립니다.
+ - 이 핵심 로직은 프론트엔드 코드에 아래와 같이 **단 한 번만 작성**됩니다.
+
+ ```tsx
+ // [메인 프로젝트: 프론트엔드] 코어 로직 - Schema-driven UI 렌더링 핵심 컴포넌트 예시
+ function DynamicActivityCard({ schema, item }) {
+ // schema: 백엔드에서 내려준 ActivityType (어떤 필드들이 있는지)
+ // item: 백엔드에서 내려준 ActivityItem 객체 전체 (단일 게시물)
+
+ return (
+
+ {schema.fields.map((field) => {
+ // 💡 포인트 1: 필드 이름표(e.g., 'mentor', 'topic')로 실제 데이터를 동적으로 뽑아옴
+ const fieldValue = item.data[field.fieldId];
+
+ // 💡 포인트 2: 만약 이번 글에 이 항목(e.g., 새로 추가된 mentor)이 안 적혀있거나 값 타입이 안 맞으면? -> 부드럽게 생략! (Fallback)
+ if (fieldValue === undefined || fieldValue === null) return null;
+
+ // 💡 포인트 3: 어떤 타입의 질문(field.inputType)이었느냐에 따라 알아서 알맞은 UI 스위칭
+ switch (field.inputType) {
+ case 'shortText':
+ return