v0.1.43 - 인증 강제와 회원 탈퇴 흐름 정리

This commit is contained in:
2026-04-24 10:04:44 +09:00
parent 54f4b34e5e
commit a38714dfe4
9 changed files with 250 additions and 14 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI - 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.42` 준비 중 - 현재 기준 버전: `v0.1.43` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -223,6 +223,11 @@
- 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다. - 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다.
- Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다. - Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다.
- 인증/비밀번호 재설정 개발용 링크는 `AUTH_PREVIEW_LINKS=true`일 때만 API 응답에 포함된다. 운영 `.env`는 반드시 `AUTH_PREVIEW_LINKS=false`로 두어야 하며, 현재 예시 파일도 false가 기본값이다. - 인증/비밀번호 재설정 개발용 링크는 `AUTH_PREVIEW_LINKS=true`일 때만 API 응답에 포함된다. 운영 `.env`는 반드시 `AUTH_PREVIEW_LINKS=false`로 두어야 하며, 현재 예시 파일도 false가 기본값이다.
- 회원가입은 이제 자동 로그인되지 않는다. 인증 메일 발송 후 `이메일 인증 후 로그인` 안내만 보여주고, 일반 사용자는 이메일 인증 전까지 로그인할 수 없다.
- 기존에 발급된 세션이라도 일반 사용자 이메일 인증이 안 되어 있으면 `/api/auth/me` 단계에서 세션을 즉시 폐기한다. 운영 중 인증 정책을 켠 뒤에도 미인증 세션이 남지 않게 하기 위한 장치다.
- 비밀번호 재설정/이메일 인증용 개발 링크는 `AUTH_PREVIEW_LINKS=true`여도 `APP_BASE_URL``localhost` 또는 `127.0.0.1`일 때만 응답에 포함한다. 상용 서버에서 링크가 그대로 보이면 이 조건부터 확인한다.
- 프론트는 `/verify-email?token=...` 진입 시 인증을 바로 확정하고 로그인 모달에 결과 메시지를 띄운다. `/reset-password?token=...`은 기존처럼 비밀번호 재설정 모달을 연다.
- SETTINGS 화면에 일반 사용자 전용 `회원 탈퇴` 카드가 추가되었다. 현재 비밀번호 확인 후 계정, 플래너 기록, 목표, 세션, 인증 토큰이 함께 삭제된다. 기본 관리자 계정은 이 경로에서 삭제하지 못하게 막는다.
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다. - `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.

View File

@@ -61,7 +61,8 @@ docker compose up -d --build
관리자 계정은 백엔드 시작 시 `.env``ADMIN_ACCOUNT_*` 값으로 자동 생성된다. 관리자 계정은 백엔드 시작 시 `.env``ADMIN_ACCOUNT_*` 값으로 자동 생성된다.
관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다. 관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다.
일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하면 된다. 일반 사용자는 회원가입 후 이메일 인증을 완료해야 로그인할 수 있다.
운영 서버에서는 비밀번호 재설정/이메일 인증용 미리보기 링크가 API 응답에 노출되지 않도록 유지한다.
현재 `docker-compose.yml` 기준 내부 구성: 현재 `docker-compose.yml` 기준 내부 구성:

View File

@@ -61,5 +61,10 @@ export async function findAuthenticatedUser(request) {
.where(eq(users.id, session.userId)) .where(eq(users.id, session.userId))
.limit(1) .limit(1)
if (user && user.role !== 'admin' && !user.emailVerifiedAt) {
await db.delete(authSessions).where(eq(authSessions.id, session.id))
return null
}
return user ?? null return user ?? null
} }

View File

@@ -28,6 +28,10 @@ const passwordSchema = z.object({
newPassword: z.string().min(8).max(72), newPassword: z.string().min(8).max(72),
}) })
const deleteAccountSchema = z.object({
currentPassword: z.string().min(1).max(72),
})
const verificationRequestSchema = z.object({ const verificationRequestSchema = z.object({
email: z.string().trim().email().optional(), email: z.string().trim().email().optional(),
}) })
@@ -110,7 +114,11 @@ function sanitizeUser(user) {
} }
function withPreviewUrl(payload, key, previewUrl) { function withPreviewUrl(payload, key, previewUrl) {
if (!env.AUTH_PREVIEW_LINKS) { const allowPreviewLinks =
env.AUTH_PREVIEW_LINKS &&
/localhost|127\.0\.0\.1/i.test(env.APP_BASE_URL)
if (!allowPreviewLinks) {
return payload return payload
} }
@@ -175,13 +183,12 @@ export async function registerAuthRoutes(app) {
nickname, nickname,
role: 'user', role: 'user',
emailVerifiedAt: null, emailVerifiedAt: null,
lastLoginAt: now, lastLoginAt: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
.returning() .returning()
const { token } = await createSession(user.id)
const verification = await createEmailVerificationToken(user.id) const verification = await createEmailVerificationToken(user.id)
await sendVerificationEmail({ await sendVerificationEmail({
to: user.email, to: user.email,
@@ -189,9 +196,8 @@ export async function registerAuthRoutes(app) {
}) })
return reply.code(201).send(withPreviewUrl({ return reply.code(201).send(withPreviewUrl({
message: '회원가입이 완료되었습니다.', message: '회원가입이 완료되었습니다. 이메일 인증 후 로그인해 주세요.',
token, requiresEmailVerification: true,
user: sanitizeUser(user),
}, 'verificationPreviewUrl', verification.previewUrl)) }, 'verificationPreviewUrl', verification.previewUrl))
}) })
@@ -233,6 +239,12 @@ export async function registerAuthRoutes(app) {
}) })
} }
if (user.role !== 'admin' && !user.emailVerifiedAt) {
return reply.code(403).send({
message: '이메일 인증을 완료한 뒤 로그인해 주세요.',
})
}
const now = new Date() const now = new Date()
const [updatedUser] = await db const [updatedUser] = await db
@@ -364,6 +376,46 @@ export async function registerAuthRoutes(app) {
} }
}) })
app.delete('/api/auth/account', async (request, reply) => {
const user = await findAuthenticatedUser(request)
if (!user) {
return reply.code(401).send({
message: '인증이 필요합니다.',
})
}
if (user.role === 'admin') {
return reply.code(403).send({
message: '기본 관리자 계정은 여기서 삭제할 수 없습니다.',
})
}
const payload = deleteAccountSchema.safeParse(request.body)
if (!payload.success) {
return reply.code(400).send({
message: '회원 탈퇴 확인 비밀번호를 입력해 주세요.',
issues: payload.error.flatten(),
})
}
const passwordMatches = await verifyPassword(payload.data.currentPassword, user.passwordHash)
if (!passwordMatches) {
return reply.code(401).send({
message: '현재 비밀번호가 올바르지 않습니다.',
})
}
await db.delete(authSessions).where(eq(authSessions.userId, user.id))
await db.delete(users).where(eq(users.id, user.id))
return {
message: '회원 탈퇴가 완료되었습니다.',
}
})
app.post('/api/auth/verification/request', async (request, reply) => { app.post('/api/auth/verification/request', async (request, reply) => {
const authenticatedUser = await findAuthenticatedUser(request) const authenticatedUser = await findAuthenticatedUser(request)
const payload = verificationRequestSchema.safeParse(request.body ?? {}) const payload = verificationRequestSchema.safeParse(request.body ?? {})

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.42", "version": "0.1.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.42", "version": "0.1.43",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"private": true, "private": true,
"version": "0.1.42", "version": "0.1.43",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -11,6 +11,8 @@ import StatsDashboard from './components/StatsDashboard.vue'
import { fetchAdminOverview } from './lib/adminApi' import { fetchAdminOverview } from './lib/adminApi'
import { import {
clearAuthState, clearAuthState,
confirmVerification,
deleteAccount,
fetchCurrentUser, fetchCurrentUser,
confirmPasswordReset, confirmPasswordReset,
login, login,
@@ -88,10 +90,15 @@ const passwordForm = reactive({
newPassword: '', newPassword: '',
confirmPassword: '', confirmPassword: '',
}) })
const accountDeleteForm = reactive({
currentPassword: '',
})
const profileBusy = ref(false) const profileBusy = ref(false)
const passwordBusy = ref(false) const passwordBusy = ref(false)
const profileMessage = ref('') const profileMessage = ref('')
const passwordMessage = ref('') const passwordMessage = ref('')
const accountDeleteBusy = ref(false)
const accountDeleteMessage = ref('')
const carryoverMessage = ref('') const carryoverMessage = ref('')
const carryoverCheckPolicy = ref(readCarryoverCheckPolicy()) const carryoverCheckPolicy = ref(readCarryoverCheckPolicy())
const carryoverCheckPrompt = ref(null) const carryoverCheckPrompt = ref(null)
@@ -1443,6 +1450,10 @@ function resetPasswordForm() {
passwordForm.confirmPassword = '' passwordForm.confirmPassword = ''
} }
function resetAccountDeleteForm() {
accountDeleteForm.currentPassword = ''
}
function openAuthDialog(mode = 'login') { function openAuthDialog(mode = 'login') {
authMode.value = mode authMode.value = mode
authMessage.value = '' authMessage.value = ''
@@ -1472,6 +1483,43 @@ function openPasswordResetFromUrl() {
authDialogOpen.value = true authDialogOpen.value = true
} }
async function openVerificationFromUrl() {
if (typeof window === 'undefined') {
return
}
const url = new URL(window.location.href)
if (!url.pathname.includes('verify-email')) {
return
}
const token = url.searchParams.get('token') ?? ''
if (!token) {
authMode.value = 'login'
authMessage.value = '이메일 인증 링크가 올바르지 않습니다.'
authDialogOpen.value = true
return
}
authBusy.value = true
authMode.value = 'login'
authDialogOpen.value = true
try {
const result = await confirmVerification({ token })
authMessage.value = result.message || '이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다.'
url.pathname = '/'
url.search = ''
window.history.replaceState({}, '', url.toString())
} catch (error) {
authMessage.value = toUserFacingApiError(error, '이메일 인증 링크를 처리하지 못했습니다.')
} finally {
authBusy.value = false
}
}
function closeAuthDialog() { function closeAuthDialog() {
authDialogOpen.value = false authDialogOpen.value = false
authMessage.value = '' authMessage.value = ''
@@ -1537,7 +1585,14 @@ async function submitAuthForm() {
password: authForm.password, password: authForm.password,
}) })
await applyAuthSuccess(result, authMode.value === 'login' && authForm.rememberSession) if (authMode.value === 'signup') {
authMode.value = 'login'
authForm.password = ''
authMessage.value = result.message
return
}
await applyAuthSuccess(result, authForm.rememberSession)
} catch (error) { } catch (error) {
authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.') authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.')
} finally { } finally {
@@ -1588,6 +1643,7 @@ function logout() {
adminMessage.value = '' adminMessage.value = ''
adminUsers.value = [] adminUsers.value = []
adminRecentLogins.value = [] adminRecentLogins.value = []
accountDeleteMessage.value = ''
adminOverview.value = { adminOverview.value = {
totalUsers: 0, totalUsers: 0,
totalAdmins: 0, totalAdmins: 0,
@@ -1607,6 +1663,7 @@ function logout() {
restoreLocalPlannerRecords() restoreLocalPlannerRecords()
resetGoalForm() resetGoalForm()
resetPasswordForm() resetPasswordForm()
resetAccountDeleteForm()
} }
async function loadAdminDashboard() { async function loadAdminDashboard() {
@@ -1806,6 +1863,10 @@ function updatePasswordField({ field, value }) {
passwordForm[field] = value passwordForm[field] = value
} }
function updateAccountDeleteField({ field, value }) {
accountDeleteForm[field] = value
}
async function submitProfileForm() { async function submitProfileForm() {
profileBusy.value = true profileBusy.value = true
profileMessage.value = '' profileMessage.value = ''
@@ -1862,6 +1923,36 @@ async function submitPasswordForm() {
} }
} }
async function submitDeleteAccount() {
if (!accountDeleteForm.currentPassword) {
accountDeleteMessage.value = '회원 탈퇴를 위해 현재 비밀번호를 입력해 주세요.'
return
}
const confirmed = window.confirm('정말로 회원 탈퇴하시겠습니까? 작성한 플래너와 목표 데이터도 함께 삭제됩니다.')
if (!confirmed) {
return
}
accountDeleteBusy.value = true
accountDeleteMessage.value = ''
try {
const result = await deleteAccount(authToken.value, {
currentPassword: accountDeleteForm.currentPassword,
})
resetAccountDeleteForm()
logout()
authMessage.value = ''
window.alert(result.message || '회원 탈퇴가 완료되었습니다.')
} catch (error) {
accountDeleteMessage.value = error.message || '회원 탈퇴를 처리하지 못했습니다.'
} finally {
accountDeleteBusy.value = false
}
}
function replacePlannerRecords(nextRecords) { function replacePlannerRecords(nextRecords) {
Object.keys(plannerRecords).forEach((key) => { Object.keys(plannerRecords).forEach((key) => {
delete plannerRecords[key] delete plannerRecords[key]
@@ -2026,6 +2117,12 @@ async function printPlannerRange() {
window.print() window.print()
} }
async function initializeAppSession() {
await openVerificationFromUrl()
openPasswordResetFromUrl()
await restoreAuthSession()
}
onMounted(() => { onMounted(() => {
resetGoalForm() resetGoalForm()
setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', {
@@ -2034,8 +2131,7 @@ onMounted(() => {
updateWindowWidth() updateWindowWidth()
window.addEventListener('resize', updateWindowWidth) window.addEventListener('resize', updateWindowWidth)
window.addEventListener('keydown', handleGlobalKeydown) window.addEventListener('keydown', handleGlobalKeydown)
openPasswordResetFromUrl() initializeAppSession()
restoreAuthSession()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -2961,13 +3057,18 @@ onBeforeUnmount(() => {
:password-busy="passwordBusy" :password-busy="passwordBusy"
:profile-message="profileMessage" :profile-message="profileMessage"
:password-message="passwordMessage" :password-message="passwordMessage"
:account-delete-form="accountDeleteForm"
:account-delete-busy="accountDeleteBusy"
:account-delete-message="accountDeleteMessage"
:guide-tooltip-reset-message="guideTooltipResetMessage" :guide-tooltip-reset-message="guideTooltipResetMessage"
:carryover-check-policy="carryoverCheckPolicy" :carryover-check-policy="carryoverCheckPolicy"
@update:profile-field="updateProfileField" @update:profile-field="updateProfileField"
@update:password-field="updatePasswordField" @update:password-field="updatePasswordField"
@update:account-delete-field="updateAccountDeleteField"
@update:carryover-check-policy="updateCarryoverCheckPolicy" @update:carryover-check-policy="updateCarryoverCheckPolicy"
@submit:profile="submitProfileForm" @submit:profile="submitProfileForm"
@submit:password="submitPasswordForm" @submit:password="submitPasswordForm"
@submit:delete-account="submitDeleteAccount"
@reset-guide-tooltips="resetGuideTooltips" @reset-guide-tooltips="resetGuideTooltips"
/> />

View File

@@ -14,6 +14,10 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
accountDeleteForm: {
type: Object,
required: true,
},
profileBusy: { profileBusy: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -30,6 +34,14 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
accountDeleteBusy: {
type: Boolean,
default: false,
},
accountDeleteMessage: {
type: String,
default: '',
},
guideTooltipResetMessage: { guideTooltipResetMessage: {
type: String, type: String,
default: '', default: '',
@@ -45,6 +57,8 @@ const emit = defineEmits([
'update:password-field', 'update:password-field',
'submit:profile', 'submit:profile',
'submit:password', 'submit:password',
'update:account-delete-field',
'submit:delete-account',
'reset-guide-tooltips', 'reset-guide-tooltips',
'update:carryover-check-policy', 'update:carryover-check-policy',
]) ])
@@ -66,6 +80,13 @@ function updatePasswordField(field, event) {
value: event.target.value, value: event.target.value,
}) })
} }
function updateAccountDeleteField(field, event) {
emit('update:account-delete-field', {
field,
value: event.target.value,
})
}
</script> </script>
<template> <template>
@@ -219,6 +240,42 @@ function updatePasswordField(field, event) {
{{ passwordBusy ? '변경 중...' : '비밀번호 변경' }} {{ passwordBusy ? '변경 중...' : '비밀번호 변경' }}
</button> </button>
</form> </form>
<form
v-if="user.role !== 'admin'"
class="rounded-[28px] border border-rose-200 bg-white/75 p-6"
@submit.prevent="emit('submit:delete-account')"
>
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-rose-500">Delete Account</p>
<p class="mt-4 text-sm font-semibold leading-6 text-stone-700">
회원 탈퇴를 진행하면 플래너 기록, 목표, 계정 정보가 함께 삭제됩니다.
</p>
<div class="mt-5 space-y-2">
<label class="text-[11px] font-bold tracking-[0.16em] text-stone-600">현재 비밀번호 확인</label>
<input
:value="accountDeleteForm.currentPassword"
type="password"
class="w-full rounded-2xl border border-rose-200 bg-white px-4 py-3 text-sm font-semibold text-stone-800 outline-none transition focus:border-rose-400"
@input="updateAccountDeleteField('currentPassword', $event)"
/>
</div>
<p
v-if="accountDeleteMessage"
class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold leading-6 text-rose-700"
>
{{ accountDeleteMessage }}
</p>
<button
type="submit"
class="mt-5 rounded-full border border-rose-500 px-5 py-3 text-xs font-bold tracking-[0.18em] text-rose-600 transition hover:bg-rose-500 hover:text-white disabled:cursor-not-allowed disabled:border-rose-200 disabled:text-rose-300"
:disabled="accountDeleteBusy"
>
{{ accountDeleteBusy ? '처리 중...' : '회원 탈퇴' }}
</button>
</form>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -108,6 +108,14 @@ export async function updatePassword(token, { currentPassword, newPassword }) {
}) })
} }
export async function deleteAccount(token, { currentPassword }) {
return request('/api/auth/account', {
method: 'DELETE',
token,
body: { currentPassword },
})
}
export async function requestPasswordReset({ email }) { export async function requestPasswordReset({ email }) {
return request('/api/auth/password-reset/request', { return request('/api/auth/password-reset/request', {
method: 'POST', method: 'POST',
@@ -121,3 +129,10 @@ export async function confirmPasswordReset({ token, newPassword }) {
body: { token, newPassword }, body: { token, newPassword },
}) })
} }
export async function confirmVerification({ token }) {
return request('/api/auth/verification/confirm', {
method: 'POST',
body: { token },
})
}