diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 847bf6a..77334a8 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -53,6 +53,11 @@ const confirmPasswordResetSchema = z.object({ password: z.string().min(6), }) +const changePasswordSchema = z.object({ + currentPassword: z.string().min(6), + nextPassword: z.string().min(6), +}) + const profileSchema = z.object({ nickname: z.string().trim().min(1).max(40), removeAvatar: z.union([z.string(), z.undefined()]).optional(), @@ -322,6 +327,24 @@ router.post('/password-reset/confirm', async (req, res) => { } }) +router.post('/password', requireAuth, async (req, res) => { + const parsed = changePasswordSchema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const user = await findUserById(req.session.userId) + if (!user) return res.status(404).json({ error: 'not_found' }) + + const authUser = await findUserByEmail(user.email) + if (!authUser) return res.status(404).json({ error: 'not_found' }) + + const passwordMatched = await bcrypt.compare(parsed.data.currentPassword, authUser.passwordHash) + if (!passwordMatched) return res.status(401).json({ error: 'invalid_current_password' }) + + const passwordHash = await bcrypt.hash(parsed.data.nextPassword, 10) + const updated = await updateUserPassword({ id: authUser.id, passwordHash }) + res.json({ user: await serializeUser(updated) }) +}) + const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => { diff --git a/docs/history.md b/docs/history.md index 93c540d..e0e6ad5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-03 v1.4.49 +- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다. +- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다. +- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다. + ## 2026-04-03 v1.4.45 - 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다. - 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다. diff --git a/docs/map.md b/docs/map.md index df46261..affc486 100644 --- a/docs/map.md +++ b/docs/map.md @@ -42,8 +42,8 @@ ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` -- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리 -- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile` +- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 하단 로그아웃 처리 +- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password` ## 공통 레이아웃 - 앱 셸 파일: `frontend/src/App.vue` diff --git a/docs/spec.md b/docs/spec.md index a5428f9..77482ef 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -132,6 +132,8 @@ - `GET /api/auth/me` - `GET /api/auth/meta` - `POST /api/auth/profile` + - `POST /api/auth/password` + - 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다. - `POST /api/auth/email/verify` - `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다. - `POST /api/auth/email/resend` @@ -284,6 +286,8 @@ - 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다. - 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다. - 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다. +- 로그인한 상태로도 `login?resetToken=...` 재설정 링크를 열 수 있으며, 이때는 기존 로그인 세션이 있어도 자동으로 내 티어표 화면으로 보내지 않고 새 비밀번호 입력 화면을 먼저 보여준다. +- 설정 화면의 직접 비밀번호 변경은 현재 비밀번호가 맞는지 먼저 확인하고, 맞지 않으면 `invalid_current_password`로 차단한다. ## 운영 배포 메모 - 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다. diff --git a/docs/todo.md b/docs/todo.md index eeee7b1..750e49b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다. +- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다. +- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다. - `v1.4.48`에서 로컬 `APP_ORIGIN`을 `localhost:5173`으로 먼저 주입하도록 바꿨으므로, 백엔드를 다시 띄운 뒤 새 회원가입 인증 메일과 비밀번호 재설정 메일 링크가 운영 도메인이 아니라 로컬 주소로 열리는지 확인한다. - `v1.4.47`에서 로컬 백엔드가 루트 `.env.production`을 읽도록 바꿨으므로, `SMTP_PASS` 교체 후 백엔드를 다시 띄우고 로컬 회원가입이 더 이상 `mail_not_configured` 503으로 떨어지지 않는지 확인한다. - `.env.production`의 `SMTP_PASS=여기에_Gmail_앱_비밀번호_입력` placeholder를 실제 Gmail 앱 비밀번호로 교체한 뒤, 운영 컨테이너를 재기동해서 회원가입 인증 메일과 비밀번호 재설정 메일이 실제로 발송되는지 확인한다. diff --git a/docs/update.md b/docs/update.md index f33469b..c251174 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-03 v1.4.49 +- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다. +- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다. +- 로그인한 상태로 `login?resetToken=...` 비밀번호 재설정 링크를 열면 기존 로그인 감시가 바로 내 티어표 화면으로 보내버릴 수 있었으므로, 인증/재설정 토큰이 있는 동안에는 자동 리다이렉트를 멈추고 재설정 입력 화면을 우선 보여주도록 보정했다. + ## 2026-04-03 v1.4.48 - 로컬 백엔드도 `.env.production`을 읽는 구조가 되면서 이메일 인증/비밀번호 재설정 링크의 `APP_ORIGIN`이 운영 도메인으로 잡히던 문제를 막기 위해, `backend`의 `dev/start` 스크립트에서 로컬 실행 시 `APP_ORIGIN=http://localhost:5173`을 먼저 주입하도록 분리했다. - 이로써 로컬 개발에서는 인증 메일 링크가 `localhost:5173`으로 열리고, 상용 Docker 배포에서는 `docker-compose.prod.yml`의 `APP_ORIGIN=https://tmaker.sori.studio`를 그대로 사용하도록 환경이 구분된다. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 60ff491..3169156 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -62,6 +62,8 @@ export const api = { requestPasswordReset: ({ email }) => request('/api/auth/password-reset/request', { method: 'POST', body: { email } }), confirmPasswordReset: ({ token, password }) => request('/api/auth/password-reset/confirm', { method: 'POST', body: { token, password } }), + changePassword: ({ currentPassword, nextPassword }) => + request('/api/auth/password', { method: 'POST', body: { currentPassword, nextPassword } }), logout: () => request('/api/auth/logout', { method: 'POST' }), listTopics: () => request('/api/topics'), diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 18f400c..85b8ba5 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -115,6 +115,7 @@ watch( () => [auth.hydrated, auth.user], ([hydrated, user]) => { if (!hydrated || !user) return + if (verifyToken.value || resetToken.value) return router.replace(redirectPath.value) }, { immediate: true } diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 5a6d315..e0d684f 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' +import { api } from '../lib/api' import { homePath, loginPath } from '../lib/paths' import { toApiUrl } from '../lib/runtime' import { useToast } from '../composables/useToast' @@ -12,11 +13,18 @@ const toast = useToast() const error = ref('') const saving = ref(false) +const passwordSaving = ref(false) const nickname = ref('') +const nicknameError = ref('') const previewUrl = ref('') const avatarFile = ref(null) const removeAvatar = ref(false) const fileInput = ref(null) +const currentPassword = ref('') +const nextPassword = ref('') +const nextPasswordConfirm = ref('') +const currentPasswordError = ref('') +const nextPasswordError = ref('') watch(error, (message) => { if (!message) return @@ -67,6 +75,15 @@ function onAvatarChange(e) { previewUrl.value = URL.createObjectURL(file) } +function clearProfileFieldErrors() { + nicknameError.value = '' +} + +function clearPasswordFieldErrors() { + currentPasswordError.value = '' + nextPasswordError.value = '' +} + function clearAvatar() { error.value = '' avatarFile.value = null @@ -80,6 +97,14 @@ function clearAvatar() { async function saveProfile() { error.value = '' + clearProfileFieldErrors() + + if (nickname.value.trim().length < 2) { + nicknameError.value = '닉네임은 2자 이상 입력해주세요.' + error.value = '닉네임을 확인해주세요.' + return + } + saving.value = true try { const fd = new FormData() @@ -92,8 +117,13 @@ async function saveProfile() { credentials: 'include', body: fd, }) - if (!res.ok) throw new Error('upload_failed') const data = await res.json() + if (!res.ok) { + const requestError = new Error('profile_update_failed') + requestError.data = data + requestError.status = res.status + throw requestError + } auth.user = data.user avatarFile.value = null removeAvatar.value = false @@ -104,12 +134,60 @@ async function saveProfile() { if (fileInput.value) fileInput.value.value = '' toast.success('프로필을 저장했어요.') } catch (e2) { - error.value = '프로필 저장에 실패했어요.' + const code = e2?.data?.error + if (code === 'nickname_taken') { + nicknameError.value = '이미 사용 중인 닉네임입니다.' + error.value = '닉네임이 이미 사용 중이에요.' + } else if (code === 'nickname_reserved') { + nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.' + error.value = '사용할 수 없는 닉네임이에요.' + } else { + error.value = '프로필 저장에 실패했어요.' + } } finally { saving.value = false } } +async function savePassword() { + error.value = '' + clearPasswordFieldErrors() + + if (nextPassword.value.length < 6) { + nextPasswordError.value = '새 비밀번호는 6자 이상 입력해주세요.' + error.value = '새 비밀번호를 확인해주세요.' + return + } + + if (nextPassword.value !== nextPasswordConfirm.value) { + nextPasswordError.value = '비밀번호 확인이 일치하지 않아요.' + error.value = '비밀번호 확인이 일치하지 않아요.' + return + } + + passwordSaving.value = true + try { + const data = await api.changePassword({ + currentPassword: currentPassword.value, + nextPassword: nextPassword.value, + }) + auth.user = data.user + currentPassword.value = '' + nextPassword.value = '' + nextPasswordConfirm.value = '' + toast.success('비밀번호를 변경했어요.') + } catch (e2) { + if (e2?.data?.error === 'invalid_current_password') { + currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.' + error.value = '현재 비밀번호가 일치하지 않아요.' + } else { + error.value = '비밀번호 변경에 실패했어요.' + } + } finally { + passwordSaving.value = false + } +} + async function logout() { await auth.logout() toast.success('로그아웃했어요.') @@ -167,6 +245,7 @@ async function logout() { @@ -183,6 +262,59 @@ async function logout() { + +