헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 13:34:21 +09:00
parent 996965740f
commit 6a059a9a59
22 changed files with 984 additions and 34 deletions

View File

@@ -24,5 +24,10 @@ AVATAR_WEBP_QUALITY=82
NUXT_PUBLIC_SITE_URL=https://sori.studio
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
# Server
APP_PORT=43118

View File

@@ -152,8 +152,9 @@ onBeforeUnmount(() => {
<template>
<header class="site-header sticky top-0 z-20 backdrop-blur">
<div class="site-header__inner mx-auto flex h-full max-w-[1294px] items-center justify-between gap-3 px-4 sm:gap-4 lg:gap-5 lg:px-5 xl:gap-6 xl:px-6 2xl:px-0">
<NuxtLink class="site-header__brand flex min-w-0 shrink-1 items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,32vw)] xl:max-w-[min(320px,28vw)] 2xl:max-w-none 2xl:flex-1" to="/">
<div class="site-header__inner mx-auto grid h-full max-w-[1294px] grid-cols-3 items-center gap-2 px-4 sm:gap-3 lg:gap-4 lg:px-5 xl:gap-5 xl:px-6 2xl:px-0">
<div class="site-header__brand-slot flex min-w-0 justify-self-start">
<NuxtLink class="site-header__brand flex min-w-0 max-w-full items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,28vw)] xl:max-w-[min(300px,26vw)]" to="/">
<button
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
type="button"
@@ -185,18 +186,21 @@ onBeforeUnmount(() => {
</span>
</button>
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink>
<button
</NuxtLink>
</div>
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
<button
type="button"
class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
class="site-header__search site-header__search--responsive hidden h-9 w-full max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
aria-label="검색 열기"
@click="openSearchModal"
>
<span class="site-header__search-icon mr-2 text-lg leading-none"></span>
<span class="site-header__search-text site-soft">Search</span>
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
</button>
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
</button>
</div>
<nav class="site-header__nav site-header__actions flex min-w-0 shrink-0 items-center justify-end justify-self-end gap-3 text-sm sm:gap-3.5">
<div class="site-header__user-menu relative">
<button
ref="userMenuToggleRef"

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS email_otp_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
verify_attempt_count INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT email_otp_challenges_purpose_check CHECK (purpose IN ('signup', 'password_reset'))
);
CREATE INDEX IF NOT EXISTS email_otp_challenges_email_purpose_created_idx
ON email_otp_challenges (lower(email), purpose, created_at DESC);

View File

@@ -165,6 +165,19 @@ docker run -d -p 3000:3000 sori.studio:latest
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
### 이메일 인증(Resend, 선택)
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev``018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
| 변수 | 설명 |
|------|------|
| `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 해시에 우선 사용 |
`RESEND_API_KEY``RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.99
### 헤더 검색 중앙·Resend 이메일 OTP
헤더는 좌측 브랜드·우측 계정 사이에서 검색을 시각적 중심에 두는 편이 Ghost/Thred류 기대와 맞다. 트랜잭션 메일은 외부 SMTP 대신 Resend 단일 API로 운영 부담을 줄이고, 키가 없을 때는 기존 가입 흐름을 유지한다.
## 2026-05-12 v0.0.98
### 사이드바 푸터 링크 줄바꿈·상단 네비 호버 너비

View File

@@ -35,7 +35,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
@@ -110,8 +110,9 @@
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
| pages/pages/[slug].vue | 고정 페이지 상세 |
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글, 회원 로그인 API 연동, 이메일·비밀번호 입력 시에만 제출 버튼 활성 |
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
## 서버 API
@@ -126,8 +127,13 @@
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/api/navigation.get.js | 공개 네비게이션 API |
| server/api/auth/signup.post.js | 회원 가입 API |
| server/api/auth/bootstrap-status.get.js | 최초 관리자 등록 필요 여부 조회 API |
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`) |
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
| server/utils/email-otp.js | OTP 생성·해시 |
| server/utils/resend-mail.js | Resend REST 발송 |
| server/api/auth/login.post.js | 회원 로그인 API |
| server/api/auth/me.get.js | 회원 세션 조회 API |
| server/api/auth/logout.post.js | 회원 로그아웃 API |

View File

@@ -18,7 +18,7 @@
| 요소 | 크기/속성 |
|------|-----------|
| Header | 높이 57px, `sticky top-0`, `shrink-0`. `lg`~`xl` 구간은 내부 `px-5`~`px-6`로 좌우 여백을 두고, 검색창은 뷰포트에 맞춰 `max-w`로 단계 축소한다(`2xl`에서 고정 470px). |
| Header | 높이 57px, `sticky top-0`, `shrink-0`. 내부는 `grid-cols-3`**좌(브랜드·메뉴) / 중앙(검색, `md+`에서만 표시) / 우(사용자 메뉴)** 배치해 검색 패널을 가운데 열에 정렬한다. 검색 버튼은 중앙 열 안에서 `max-w-[min(470px,100%)]`로 폭을 제한한다. |
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
| 그리드(모바일 `lg` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
@@ -349,8 +349,10 @@ components/content/
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
- `POST /api/auth/signup` - 회원 가입
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회
- `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` 존재 여부)를 반환한다.
- `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` - 회원 로그인
- `GET /api/auth/me` - 현재 회원 세션 조회
- `POST /api/auth/logout` - 회원 로그아웃
@@ -544,7 +546,7 @@ components/content/
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v0.0.99
- `SiteHeader`: 헤더 내부 `grid-cols-3`로 검색 패널 **중앙 열 정렬**(Ghost류 레이아웃).
- Resend 기반 **이메일 OTP**: 마이그레이션 `018_email_otp_challenges.sql`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`, `signup`·`bootstrap-status` 연동.
- `pages/forgot-password.vue`, `signin`에서 비밀번호 찾기 링크. `getUserByEmail`·로그인 이메일 **대소문자 무시** 조회.
- `nuxt.config` `runtimeConfig`: `resendApiKey`, `resendFromEmail`, `emailOtpPepper`.
## v0.0.98
- `SidebarPrimaryNavList`: `navRowShell`**`w-full`** 추가해 `site-panel-hover` 배경이 행 가로 전체를 쓰도록 함.

View File

@@ -51,6 +51,9 @@ export default defineNuxtConfig({
adminEmail: process.env.ADMIN_EMAIL || '',
adminPassword: process.env.ADMIN_PASSWORD || '',
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
resendApiKey: process.env.RESEND_API_KEY || '',
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
uploadDir: process.env.UPLOAD_DIR || '/uploads',
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
avatarMinWidth: Number(process.env.AVATAR_MIN_WIDTH || 96),

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.98",
"version": "0.0.99",
"private": true,
"type": "module",
"imports": {

233
pages/forgot-password.vue Normal file
View File

@@ -0,0 +1,233 @@
<script setup>
definePageMeta({
layout: 'page'
})
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
emailOtpConfigured: false
})
})
const step = ref(1)
const isSubmitting = ref(false)
const errorMessage = ref('')
const statusMessage = ref('')
const otpRequestLoading = ref(false)
const otpCooldownSeconds = ref(0)
let otpCooldownTimerId = null
const form = reactive({
email: '',
code: '',
newPassword: '',
newPasswordConfirm: ''
})
const showPassword = ref(false)
const showPasswordConfirm = ref(false)
const emailOtpAvailable = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured))
/**
* 비밀번호 재설정용 인증번호를 요청한다.
* @returns {Promise<void>}
*/
const requestResetOtp = async () => {
errorMessage.value = ''
statusMessage.value = ''
if (!emailOtpAvailable.value) {
errorMessage.value = '비밀번호 재설정 이메일을 사용하려면 서버에 Resend(RESEND_API_KEY, RESEND_FROM_EMAIL)가 설정되어 있어야 합니다.'
return
}
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
errorMessage.value = '유효한 이메일을 입력해 주세요.'
return
}
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
return
}
otpRequestLoading.value = true
try {
const res = await $fetch('/api/auth/email-otp/request', {
method: 'POST',
body: {
email: form.email.trim(),
purpose: 'password_reset'
}
})
statusMessage.value = res?.message || '요청을 처리했습니다.'
step.value = 2
otpCooldownSeconds.value = 60
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
otpCooldownTimerId = setInterval(() => {
otpCooldownSeconds.value -= 1
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
otpCooldownTimerId = null
}
}, 1000)
} catch (error) {
errorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
} finally {
otpRequestLoading.value = false
}
}
/**
* 인증번호와 새 비밀번호로 재설정을 완료한다.
* @returns {Promise<void>}
*/
const submitReset = async () => {
errorMessage.value = ''
statusMessage.value = ''
const digits = String(form.code || '').replace(/\D/g, '')
if (digits.length !== 6) {
errorMessage.value = '6자리 인증번호를 입력해 주세요.'
return
}
if (!form.newPassword || form.newPassword.length < 8 || form.newPassword.length > 32) {
errorMessage.value = '새 비밀번호는 8~32자로 입력해 주세요.'
return
}
if (form.newPassword !== form.newPasswordConfirm) {
errorMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
return
}
isSubmitting.value = true
try {
await $fetch('/api/auth/password-reset/confirm', {
method: 'POST',
body: {
email: form.email.trim(),
code: digits,
newPassword: form.newPassword
}
})
statusMessage.value = '비밀번호가 변경되었습니다. 로그인 페이지로 이동합니다.'
await navigateTo('/signin')
} catch (error) {
errorMessage.value = error?.data?.message || '비밀번호 재설정에 실패했습니다.'
} finally {
isSubmitting.value = false
}
}
onBeforeUnmount(() => {
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
})
</script>
<template>
<section class="auth-forgot-password min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-5 py-12 sm:px-10 lg:px-16">
<div class="auth-forgot-password__panel w-full max-w-[430px] p-5 sm:p-8">
<p class="text-2xl font-semibold leading-tight">
비밀번호 찾기
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
가입에 사용한 이메일로 인증번호를 보낸 , 비밀번호를 설정합니다.
</p>
<div v-if="!emailOtpAvailable" class="auth-forgot-password__warn mt-6 rounded-[10px] border border-[#3d2a1a] bg-[#1a1410] p-4 text-sm text-[#d8dee6]">
서버에 Resend가 설정되지 않았습니다. 운영 환경에서 <span class="font-mono text-xs">RESEND_API_KEY</span>, <span class="font-mono text-xs">RESEND_FROM_EMAIL</span>, <span class="font-mono text-xs">MEMBER_SESSION_SECRET</span>(또는 <span class="font-mono text-xs">EMAIL_OTP_PEPPER</span>) 설정한 다시 시도해 주세요.
</div>
<template v-else>
<div v-if="step === 1" class="mt-8 space-y-5">
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">이메일</label>
<input
v-model="form.email"
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
type="email"
autocomplete="email"
>
</div>
<button
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="otpRequestLoading"
@click="requestResetOtp"
>
{{ otpRequestLoading ? '처리 중…' : '인증번호 받기' }}
</button>
</div>
<form v-else class="auth-forgot-password__step2 mt-8 space-y-5" @submit.prevent="submitReset">
<p class="text-xs text-[#9ba3af]">
{{ form.email }} 발송된 인증번호를 입력해 주세요.
</p>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">인증번호</label>
<input
v-model="form.code"
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]"> 비밀번호</label>
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
<input
v-model="form.newPassword"
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
>
<AuthPasswordVisibilityToggle v-model="showPassword" />
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]"> 비밀번호 확인</label>
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
<input
v-model="form.newPasswordConfirm"
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showPasswordConfirm ? 'text' : 'password'"
autocomplete="new-password"
>
<AuthPasswordVisibilityToggle v-model="showPasswordConfirm" field-name="새 비밀번호 확인" />
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="h-9 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-75"
type="button"
:disabled="isSubmitting"
@click="step = 1"
>
이메일 다시 입력
</button>
<button
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
:disabled="isSubmitting"
>
비밀번호 변경
</button>
</div>
</form>
</template>
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
{{ errorMessage }}
</p>
<p v-if="statusMessage" class="mt-4 text-xs text-[#9fc4ff]" aria-live="polite">
{{ statusMessage }}
</p>
<p class="mt-8 text-sm text-[#9ba3af]">
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signin">로그인으로 돌아가기</NuxtLink>
</p>
</div>
</div>
</section>
</template>

View File

@@ -126,6 +126,11 @@ const submitSignIn = async () => {
회원가입
</NuxtLink>
</p>
<p class="mt-3 text-sm text-[#9ba3af]">
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/forgot-password">
비밀번호를 잊으셨나요?
</NuxtLink>
</p>
<NuxtLink class="mt-2 inline-flex text-xs text-[#9ba3af] hover:opacity-80" to="/">
홈으로 돌아가기
</NuxtLink>

View File

@@ -18,13 +18,15 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,
needsAdminSetup: false
needsAdminSetup: false,
emailOtpConfigured: false
})
})
const form = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
@@ -32,14 +34,20 @@ const form = reactive({
const errors = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
const showSignupPassword = ref(false)
const showSignupPasswordConfirm = ref(false)
const otpRequestLoading = ref(false)
const otpCooldownSeconds = ref(0)
let otpCooldownTimerId = null
const isAdminBootstrapMode = computed(() => Boolean(bootstrapStatus.value?.needsAdminSetup))
/** Resend 등 설정이 되어 있고 최초 관리자 모드가 아닐 때만 이메일 OTP 필요 */
const emailOtpRequired = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured) && !isAdminBootstrapMode.value)
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
const welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
const stepTwoTitle = computed(() => (isAdminBootstrapMode.value ? '관리자 등록' : '회원 가입'))
@@ -54,6 +62,7 @@ const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
const resetErrors = () => {
errors.username = ''
errors.email = ''
errors.emailOtp = ''
errors.password = ''
errors.passwordConfirm = ''
}
@@ -95,9 +104,65 @@ const validateStepTwo = () => {
valid = false
}
if (emailOtpRequired.value) {
const digits = String(form.emailOtp || '').replace(/\D/g, '')
if (digits.length !== 6) {
errors.emailOtp = '이메일로 받은 6자리 인증번호를 입력해 주세요.'
valid = false
}
}
return valid
}
/**
* 회원가입용 이메일 인증번호를 요청한다.
* @returns {Promise<void>}
*/
const requestSignupEmailOtp = async () => {
submitErrorMessage.value = ''
errors.email = ''
errors.emailOtp = ''
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
errors.email = '인증번호를 받으려면 유효한 이메일을 먼저 입력해 주세요.'
return
}
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
return
}
otpRequestLoading.value = true
try {
await $fetch('/api/auth/email-otp/request', {
method: 'POST',
body: {
email: form.email.trim(),
purpose: 'signup'
}
})
otpCooldownSeconds.value = 60
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
otpCooldownTimerId = setInterval(() => {
otpCooldownSeconds.value -= 1
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
otpCooldownTimerId = null
}
}, 1000)
} catch (error) {
submitErrorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
} finally {
otpRequestLoading.value = false
}
}
onBeforeUnmount(() => {
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
})
/**
* 다음 단계로 이동한다.
* @returns {Promise<void>}
@@ -119,13 +184,17 @@ const goNextStep = async () => {
isSubmitting.value = true
try {
const signupBody = {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
if (emailOtpRequired.value) {
signupBody.emailOtp = String(form.emailOtp || '').replace(/\D/g, '')
}
const signupResult = await $fetch('/api/auth/signup', {
method: 'POST',
body: {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
body: signupBody
})
createdAdmin.value = Boolean(signupResult?.isAdmin)
@@ -207,6 +276,36 @@ const goPreviousStep = () => {
</p>
</div>
<div v-if="emailOtpRequired" class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">이메일 인증번호</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input
v-model="form.emailOtp"
class="auth-form-input h-10 min-w-0 flex-1 rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
type="text"
inputmode="numeric"
maxlength="6"
pattern="[0-9]*"
placeholder="6자리"
autocomplete="one-time-code"
>
<button
class="auth-signup__otp-send h-10 shrink-0 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="otpRequestLoading || otpCooldownSeconds > 0"
@click="requestSignupEmailOtp"
>
{{ otpCooldownSeconds > 0 ? `${otpCooldownSeconds}초 후 재요청` : (otpRequestLoading ? '발송 중…' : '인증번호 받기') }}
</button>
</div>
<p v-if="errors.emailOtp" class="text-xs text-[#e05d67]">
{{ errors.emailOtp }}
</p>
<p class="text-xs text-[#8c95a3]">
Resend로 발송됩니다. 메일이 보이지 않으면 스팸함을 확인해 주세요.
</p>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호' : '비밀번호' }}</label>
<div

View File

@@ -1,7 +1,17 @@
import { getMemberBootstrapState } from '../../repositories/member-repository'
import { isResendConfigured } from '../../utils/resend-mail'
/**
* 최초 관리자 등록 필요 여부를 조회한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean, emailOtpConfigured: boolean }>} 부트스트랩 상태
*/
export default defineEventHandler(async () => getMemberBootstrapState())
export default defineEventHandler(async () => {
const base = await getMemberBootstrapState()
const config = useRuntimeConfig()
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
const emailOtpConfigured = isResendConfigured(config) && hasPepper
return {
...base,
emailOtpConfigured
}
})

View File

@@ -0,0 +1,164 @@
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { getPostgresClient } from '../../../repositories/postgres-client'
import { getMemberBootstrapState, getUserByEmail } from '../../../repositories/member-repository'
import {
countOtpSendsLastHour,
hasRecentOtpSend,
insertOtpChallenge,
invalidatePendingOtpChallenges
} from '../../../repositories/email-otp-repository'
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
const bodySchema = z.object({
email: z.string().trim().email(),
purpose: z.enum(['signup', 'password_reset'])
})
const OTP_TTL_MS = 15 * 60 * 1000
const MAX_SENDS_PER_HOUR = 5
/**
* OTP 이메일 발송용 pepper
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
* @returns {string}
*/
const resolveOtpPepper = (config) => {
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
if (!pepper) {
throw createError({
statusCode: 500,
message: '이메일 인증을 사용하려면 MEMBER_SESSION_SECRET 또는 EMAIL_OTP_PEPPER를 설정해 주세요.'
})
}
return pepper
}
/**
* 인증번호 이메일 발송 요청
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: boolean, message: string }>}
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!isResendConfigured(config)) {
throw createError({
statusCode: 503,
message: '이메일 발송(Resend)이 서버에 설정되지 않았습니다. RESEND_API_KEY와 RESEND_FROM_EMAIL을 확인해 주세요.'
})
}
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
message: '요청 형식이 올바르지 않습니다.'
})
}
const email = normalizeOtpEmail(parsed.data.email)
const purpose = parsed.data.purpose
const sql = getPostgresClient()
if (!sql) {
throw createError({
statusCode: 500,
message: '데이터베이스 설정이 필요합니다.'
})
}
const genericOk = {
ok: true,
message: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
}
if (purpose === 'signup') {
const bootstrap = await getMemberBootstrapState()
if (bootstrap.needsAdminSetup) {
throw createError({
statusCode: 400,
message: '최초 관리자 등록 단계에서는 이메일 인증이 필요하지 않습니다.'
})
}
const existing = await getUserByEmail(email)
if (existing) {
throw createError({
statusCode: 409,
message: '이미 사용 중인 이메일입니다.'
})
}
}
if (await hasRecentOtpSend(sql, email, purpose)) {
throw createError({
statusCode: 429,
message: '잠시 후 다시 인증번호를 요청해 주세요.'
})
}
if ((await countOtpSendsLastHour(sql, email, purpose)) >= MAX_SENDS_PER_HOUR) {
throw createError({
statusCode: 429,
message: '인증번호 요청 횟수가 너무 많습니다. 1시간 후 다시 시도해 주세요.'
})
}
if (purpose === 'password_reset') {
const user = await getUserByEmail(email)
if (!user) {
const dummyHash = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
const createdIp = String(getRequestIP(event) || '')
await invalidatePendingOtpChallenges(sql, email, purpose)
await insertOtpChallenge(sql, {
email,
purpose,
codeHash: dummyHash,
expiresAt,
createdIp
})
return genericOk
}
}
const pepper = resolveOtpPepper(config)
const code = generateSixDigitOtp()
const codeHash = hashOtpCode({ pepper, email, purpose, code })
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
const createdIp = String(getRequestIP(event) || '')
await invalidatePendingOtpChallenges(sql, email, purpose)
await insertOtpChallenge(sql, {
email,
purpose,
codeHash,
expiresAt,
createdIp
})
const siteTitle = String(config.public?.siteTitle || 'sori.studio')
const subject = purpose === 'signup'
? `[${siteTitle}] 회원가입 인증번호`
: `[${siteTitle}] 비밀번호 재설정 인증번호`
const html = purpose === 'signup'
? `<p>회원가입을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
: `<p>비밀번호 재설정을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
await sendResendEmail({
apiKey: String(config.resendApiKey).trim(),
from: String(config.resendFromEmail).trim(),
to: email,
subject,
html
})
return {
ok: true,
message: purpose === 'signup'
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
}
})

View File

@@ -25,7 +25,8 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const user = await getUserByEmail(body.email)
const emailNorm = body.email.trim().toLowerCase()
const user = await getUserByEmail(emailNorm)
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
throw createError({

View File

@@ -0,0 +1,65 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { updateMemberPasswordByEmail } from '../../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository'
const bodySchema = z.object({
email: z.string().trim().email(),
code: z.string().regex(/^\d{6}$/),
newPassword: z.string().min(8).max(32)
})
/**
* 이메일 OTP로 비밀번호를 재설정한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: boolean }>}
*/
export default defineEventHandler(async (event) => {
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
message: '요청 형식이 올바르지 않습니다.'
})
}
const config = useRuntimeConfig()
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
if (!pepper) {
throw createError({
statusCode: 500,
message: '서버 인증 설정이 올바르지 않습니다.'
})
}
const email = parsed.data.email.trim().toLowerCase()
const verify = await verifyAndConsumeEmailOtp({
email,
purpose: 'password_reset',
code: parsed.data.code,
pepper
})
if (!verify.ok) {
throw createError({
statusCode: 400,
message: '인증번호가 올바르지 않거나 만료되었습니다.'
})
}
const nextHash = await bcrypt.hash(parsed.data.newPassword, 12)
const updated = await updateMemberPasswordByEmail({
email,
passwordHash: nextHash
})
if (!updated) {
throw createError({
statusCode: 400,
message: '계정을 찾을 수 없습니다. 다시 인증번호를 요청해 주세요.'
})
}
return { ok: true }
})

View File

@@ -1,16 +1,33 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createUser, getUserByEmail, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { createUser, getUserByEmail, getMemberBootstrapState, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repository'
import { setMemberSession } from '../../utils/member-auth'
import { setAdminSession } from '../../utils/admin-auth'
import { isResendConfigured } from '../../utils/resend-mail'
const signupSchema = z.object({
username: z.string().trim().min(1),
email: z.string().trim().email(),
password: z.string().min(8).max(32)
password: z.string().min(8).max(32),
emailOtp: z.string().regex(/^\d{6}$/).optional()
})
/**
* 이메일 OTP가 회원가입에 필요한지
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
* @param {{ needsAdminSetup: boolean }} bootstrap - 부트스트랩 상태
* @returns {boolean}
*/
const isSignupOtpRequired = (config, bootstrap) => {
if (bootstrap.needsAdminSetup) {
return false
}
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
return isResendConfigured(config) && hasPepper
}
/**
* 회원 가입 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -27,6 +44,11 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const config = useRuntimeConfig()
const bootstrap = await getMemberBootstrapState()
const otpRequired = isSignupOtpRequired(config, bootstrap)
const emailNorm = body.email.trim().toLowerCase()
const usernameTaken = await isUsernameTaken({
username: body.username
})
@@ -38,7 +60,7 @@ export default defineEventHandler(async (event) => {
})
}
const existingUser = await getUserByEmail(body.email)
const existingUser = await getUserByEmail(emailNorm)
if (existingUser) {
throw createError({
@@ -47,10 +69,33 @@ export default defineEventHandler(async (event) => {
})
}
if (otpRequired) {
const otp = body.emailOtp
if (!otp) {
throw createError({
statusCode: 400,
message: '이메일 인증번호를 입력해 주세요.'
})
}
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
const verify = await verifyAndConsumeEmailOtp({
email: emailNorm,
purpose: 'signup',
code: otp,
pepper
})
if (!verify.ok) {
throw createError({
statusCode: 400,
message: '이메일 인증번호가 올바르지 않거나 만료되었습니다.'
})
}
}
const passwordHash = await bcrypt.hash(body.password, 12)
const created = await createUser({
username: body.username,
email: body.email,
username: body.username.trim(),
email: emailNorm,
passwordHash
})
@@ -74,4 +119,3 @@ export default defineEventHandler(async (event) => {
isAdmin: Boolean(created.isAdmin)
}
})

View File

@@ -0,0 +1,164 @@
import { createError } from 'h3'
import { getPostgresClient } from './postgres-client'
import { hashOtpCode, normalizeOtpEmail, timingSafeEqualHex } from '../utils/email-otp'
/** 최대 검증 시도 횟수(초과 시 해당 챌린지는 더 이상 사용 불가) */
const MAX_OTP_VERIFY_ATTEMPTS = 8
/**
* DB 클라이언트 조회 (필수)
* @returns {ReturnType<typeof import('postgres').default>} postgres sql
*/
const requireSql = () => {
const sql = getPostgresClient()
if (!sql) {
throw createError({
statusCode: 500,
message: '데이터베이스 설정이 필요합니다.'
})
}
return sql
}
/**
* 동일 이메일·용도의 미소진 OTP를 무효화한다.
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
* @param {string} email - 정규화된 이메일
* @param {string} purpose - signup | password_reset
* @returns {Promise<void>}
*/
export const invalidatePendingOtpChallenges = async (sql, email, purpose) => {
await sql`
UPDATE email_otp_challenges
SET consumed_at = now()
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND consumed_at IS NULL
`
}
/**
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
* @param {import('postgres').Sql} sql - sql
* @param {string} email - 이메일
* @param {string} purpose - 용도
* @returns {Promise<boolean>} true면 재요청 쿨다운 중
*/
export const hasRecentOtpSend = async (sql, email, purpose) => {
const rows = await sql`
SELECT 1 AS "x"
FROM email_otp_challenges
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND created_at > now() - interval '55 seconds'
LIMIT 1
`
return Boolean(rows?.[0])
}
/**
* 1시간 내 발송 횟수
* @param {import('postgres').Sql} sql - sql
* @param {string} email - 이메일
* @param {string} purpose - 용도
* @returns {Promise<number>}
*/
export const countOtpSendsLastHour = async (sql, email, purpose) => {
const rows = await sql`
SELECT COUNT(*)::int AS "c"
FROM email_otp_challenges
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND created_at > now() - interval '1 hour'
`
return Number(rows?.[0]?.c || 0)
}
/**
* OTP 챌린지 행을 삽입한다.
* @param {import('postgres').Sql} sql - sql
* @param {{ email: string, purpose: string, codeHash: string, expiresAt: Date, createdIp: string }} input - 입력
* @returns {Promise<string>} 삽입된 id
*/
export const insertOtpChallenge = async (sql, input) => {
const rows = await sql`
INSERT INTO email_otp_challenges (email, purpose, code_hash, expires_at, created_ip)
VALUES (${input.email}, ${input.purpose}, ${input.codeHash}, ${input.expiresAt}, ${input.createdIp})
RETURNING id
`
const id = rows?.[0]?.id
if (!id) {
throw createError({
statusCode: 500,
message: '인증 정보 저장에 실패했습니다.'
})
}
return String(id)
}
/**
* 이메일 OTP를 검증하고 소진 처리한다.
* @param {{ email: string, purpose: string, code: string, pepper: string }} input - 입력
* @returns {Promise<{ ok: boolean, reason?: 'none' | 'expired' | 'locked' | 'mismatch' }>}
*/
export const verifyAndConsumeEmailOtp = async (input) => {
const sql = requireSql()
const email = normalizeOtpEmail(input.email)
const purpose = String(input.purpose || '').trim()
const code = String(input.code || '').trim()
const pepper = String(input.pepper || '')
if (!email || !purpose || !/^\d{6}$/.test(code) || !pepper) {
return { ok: false, reason: 'mismatch' }
}
return await sql.begin(async (tx) => {
const rows = await tx`
SELECT id, code_hash AS "codeHash", verify_attempt_count AS "verifyAttemptCount", expires_at AS "expiresAt"
FROM email_otp_challenges
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND consumed_at IS NULL
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE
`
const row = rows?.[0]
if (!row) {
return { ok: false, reason: 'none' }
}
const expiresAt = new Date(row.expiresAt)
if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() < Date.now()) {
await tx`
UPDATE email_otp_challenges
SET consumed_at = now()
WHERE id = ${row.id}
`
return { ok: false, reason: 'expired' }
}
if (Number(row.verifyAttemptCount || 0) >= MAX_OTP_VERIFY_ATTEMPTS) {
return { ok: false, reason: 'locked' }
}
const expected = hashOtpCode({ pepper, email, purpose, code })
if (!timingSafeEqualHex(expected, row.codeHash)) {
await tx`
UPDATE email_otp_challenges
SET verify_attempt_count = verify_attempt_count + 1
WHERE id = ${row.id}
`
return { ok: false, reason: 'mismatch' }
}
await tx`
UPDATE email_otp_challenges
SET consumed_at = now()
WHERE id = ${row.id}
`
return { ok: true }
})
}

View File

@@ -60,7 +60,7 @@ export const getUserByEmail = async (email) => {
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
FROM users
WHERE email = ${email}
WHERE lower(email) = lower(${email})
LIMIT 1
`
@@ -214,6 +214,25 @@ export const updateMemberProfile = async (input) => {
return rows?.[0] || null
}
/**
* 이메일(대소문자 무시)로 비밀번호 해시를 갱신한다.
* @param {{ email: string, passwordHash: string }} input - 입력
* @returns {Promise<boolean>} 갱신된 행이 있으면 true
*/
export const updateMemberPasswordByEmail = async (input) => {
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
password_hash = ${input.passwordHash},
updated_at = now()
WHERE lower(email) = lower(${input.email})
RETURNING id
`
return Boolean(rows?.[0])
}
/**
* 회원 비밀번호 변경
* @param {{ userId: string, passwordHash: string }} input - 수정 값

43
server/utils/email-otp.js Normal file
View File

@@ -0,0 +1,43 @@
import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
/**
* OTP용 이메일 정규화(소문자·trim)
* @param {string} email - 원본
* @returns {string}
*/
export const normalizeOtpEmail = (email) => String(email || '').trim().toLowerCase()
/**
* 6자리 숫자 인증 코드 생성
* @returns {string}
*/
export const generateSixDigitOtp = () => String(randomInt(0, 1_000_000)).padStart(6, '0')
/**
* OTP 코드 해시(hex)
* @param {{ pepper: string, email: string, purpose: string, code: string }} input - 입력
* @returns {string} sha256 hex
*/
export const hashOtpCode = (input) => {
const payload = `${String(input.pepper || '')}|${normalizeOtpEmail(input.email)}|${String(input.purpose || '')}|${String(input.code || '')}`
return createHash('sha256').update(payload, 'utf8').digest('hex')
}
/**
* 두 sha256 hex 문자열을 상수 시간으로 비교한다.
* @param {string} a - hex
* @param {string} b - hex
* @returns {boolean}
*/
export const timingSafeEqualHex = (a, b) => {
try {
const ba = Buffer.from(String(a || ''), 'hex')
const bb = Buffer.from(String(b || ''), 'hex')
if (ba.length !== bb.length || ba.length === 0) {
return false
}
return timingSafeEqual(ba, bb)
} catch {
return false
}
}

View File

@@ -0,0 +1,42 @@
import { createError } from 'h3'
/**
* Resend가 서버 설정으로 사용 가능한지
* @param {{ resendApiKey?: string, resendFromEmail?: string }} config - 런타임 설정
* @returns {boolean}
*/
export const isResendConfigured = (config) => {
const key = String(config?.resendApiKey || '').trim()
const from = String(config?.resendFromEmail || '').trim()
return Boolean(key && from)
}
/**
* Resend REST API로 이메일을 발송한다.
* @param {{ apiKey: string, from: string, to: string, subject: string, html: string }} input - 발송 입력
* @returns {Promise<void>}
*/
export const sendResendEmail = async (input) => {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${input.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: input.from,
to: [input.to],
subject: input.subject,
html: input.html
})
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
throw createError({
statusCode: 502,
message: '이메일 발송에 실패했습니다. Resend 발신 주소·도메인 설정을 확인해 주세요.',
data: process.env.NODE_ENV === 'development' && detail ? { detail } : undefined
})
}
}