diff --git a/.env.example b/.env.example index d591d30..267238a 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ NUXT_PUBLIC_SITE_TITLE=sori.studio # Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기) # RESEND_API_KEY= # RESEND_FROM_EMAIL=noreply@yourdomain.com -# EMAIL_OTP_PEPPER=optional-extra-pepper +# EMAIL_OTP_PEPPER= ← 선택. OTP를 DB에 해시해 저장할 때 섞는 서버 전용 비밀(긴 난문자열 권장, 예: openssl rand -hex 32). 비우면 MEMBER_SESSION_SECRET을 대신 사용. # Server APP_PORT=43118 diff --git a/docs/deploy.md b/docs/deploy.md index 9af7287..2ace094 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -174,8 +174,8 @@ docker run -d -p 3000:3000 sori.studio:latest |------|------| | `RESEND_API_KEY` | [Resend](https://resend.com) API 키 | | `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) | -| `MEMBER_SESSION_SECRET` | 세션용 비밀값(OTP 해시 pepper로도 사용). 별도 pepper를 쓰려면 `EMAIL_OTP_PEPPER` 추가 | -| `EMAIL_OTP_PEPPER` | 선택. 있으면 OTP 해시에 우선 사용 | +| `MEMBER_SESSION_SECRET` | 세션 쿠키 서명용 비밀값. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. | +| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. | `RESEND_API_KEY`와 `RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다. - 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계 diff --git a/docs/history.md b/docs/history.md index e9cfe6c..65df680 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.100 + +### EMAIL_OTP_PEPPER 문서화 + +운영자가 짧은 숫자만 넣는 오해를 줄이기 위해, pepper는 세션 비밀과 별도로 OTP 해시에만 쓰이는 **긴 난수 문자열**임을 배포 문서·예시 env에 명시했다. + ## 2026-05-12 v0.0.99 ### 헤더 검색 중앙·Resend 이메일 OTP diff --git a/docs/spec.md b/docs/spec.md index 3b3d434..d4d5be4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -350,7 +350,7 @@ components/content/ - `GET /api/site-settings` - 공개 사이트 설정 - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절) - `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다. -- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL`·`MEMBER_SESSION_SECRET` 또는 `EMAIL_OTP_PEPPER` 존재 여부)를 반환한다. +- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다. - `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요. - `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다. - `POST /api/auth/login` - 회원 로그인 diff --git a/docs/update.md b/docs/update.md index 5bf78d8..650e13e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 이력 +## v0.0.100 + +- `.env.example`·`docs/deploy.md`·`docs/spec.md`: **`EMAIL_OTP_PEPPER` 의미**(OTP 해시용 서버 비밀, 긴 난문자열 권장·미설정 시 `MEMBER_SESSION_SECRET` 사용) 문구 보강. + ## v0.0.99 - `SiteHeader`: 헤더 내부 `grid-cols-3`로 검색 패널 **중앙 열 정렬**(Ghost류 레이아웃). diff --git a/package.json b/package.json index dcab769..c138bff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.99", + "version": "0.0.100", "private": true, "type": "module", "imports": {