From 6367e62ef080a5a16ea585a0f47cfb18c24eab9a Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 12:39:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 5 +++ docs/deploy.md | 4 +- docs/spec.md | 1 + docs/update.md | 6 +++ package-lock.json | 4 +- package.json | 2 +- server/routes/admin/api/auth/login.post.js | 48 ++++++++++++++++++++-- 7 files changed, 63 insertions(+), 7 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 8c30e97..d806c6f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.0.2 + +- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강. +- 배포 문서의 운영 환경 변수 생성 안내를 정리. + ## v1.0.1 - Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가. diff --git a/docs/deploy.md b/docs/deploy.md index 3e982cc..5471fea 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -127,8 +127,9 @@ cd sori.studio # 운영 환경 변수 설정 # .env.production은 Git에 올리지 않는 운영 전용 파일 -cp .env.production +cp .env.example .env.production # .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력 +# 운영 DB가 비어 있으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨 # MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력 # Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경 # Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용 @@ -165,6 +166,7 @@ docker compose --env-file .env.production up -d --build - NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio` - `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음 - 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용 +- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB가 비어 있는 최초 관리자 생성에만 사용한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다. - 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용 - 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리 - 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음 diff --git a/docs/spec.md b/docs/spec.md index 8a845d7..32f39e7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -567,6 +567,7 @@ components/content/ - 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다. - DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다. +- DB에 사용자가 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 이미 사용자가 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다. - 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다. - 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다. - `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. diff --git a/docs/update.md b/docs/update.md index f6ed757..9a77b50 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.0.2 + +- 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정. +- 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리. +- 패키지 버전 `1.0.2`로 갱신. + ## v1.0.1 - Docker Compose 기본 네트워크 주소 풀 충돌을 피하기 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가. diff --git a/package-lock.json b/package-lock.json index 2074680..f3b4c95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.0.1", + "version": "1.0.2", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index affb6ad..8996074 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.0.1", + "version": "1.0.2", "private": true, "type": "module", "imports": { diff --git a/server/routes/admin/api/auth/login.post.js b/server/routes/admin/api/auth/login.post.js index 8a13549..5950c97 100644 --- a/server/routes/admin/api/auth/login.post.js +++ b/server/routes/admin/api/auth/login.post.js @@ -1,8 +1,8 @@ import { z } from 'zod' import { createError, getRequestIP, readBody } from 'h3' import bcrypt from 'bcrypt' -import { setAdminSession } from '../../../../utils/admin-auth' -import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository' +import { safeCompare, setAdminSession } from '../../../../utils/admin-auth' +import { createUser, getAdminUserByEmail, getMemberBootstrapState, touchUserActivity } from '../../../../repositories/member-repository' import { setMemberSession } from '../../../../utils/member-auth' const loginSchema = z.object({ @@ -10,6 +10,48 @@ const loginSchema = z.object({ password: z.string().min(1) }) +/** + * 이메일에서 최초 관리자 닉네임을 만든다. + * @param {string} email - 관리자 이메일 + * @returns {string} 닉네임 + */ +const createBootstrapUsername = (email) => { + const localPart = String(email).split('@')[0] || 'admin' + return localPart.replace(/[^a-zA-Z0-9._-]/g, '').trim() || 'admin' +} + +/** + * 운영 환경 변수 기반 최초 관리자 계정을 생성한다. + * @param {{ email: string, password: string }} credentials - 로그인 입력값 + * @returns {Promise} 생성된 관리자 + */ +const createBootstrapAdminUser = async (credentials) => { + const config = useRuntimeConfig() + const adminEmail = String(config.adminEmail || '').trim().toLowerCase() + const adminPassword = String(config.adminPassword || '') + + if (!adminEmail || !adminPassword || credentials.email.trim().toLowerCase() !== adminEmail || !safeCompare(credentials.password, adminPassword)) { + return null + } + + const bootstrap = await getMemberBootstrapState() + if (!bootstrap.needsAdminSetup) { + return null + } + + const passwordHash = await bcrypt.hash(credentials.password, 12) + const created = await createUser({ + username: createBootstrapUsername(adminEmail), + email: adminEmail, + passwordHash + }) + + return { + ...created, + passwordHash + } +} + /** * 관리자 로그인 API * @param {import('h3').H3Event} event - 요청 이벤트 @@ -27,7 +69,7 @@ export default defineEventHandler(async (event) => { const body = parsedBody.data - const adminUser = await getAdminUserByEmail(body.email) + const adminUser = await getAdminUserByEmail(body.email) || await createBootstrapAdminUser(body) const passwordMatched = adminUser ? await bcrypt.compare(body.password, adminUser.passwordHash) : false