로컬 환경 분리와 회원가입 중복 검증 보강

로컬 개발 환경은 .env.local을 우선 읽고 운영은 .env.production만 읽도록 분리해 인증 우회 설정이 운영에 섞이지 않게 정리했다. 회원가입/로그인 이메일 정규화와 중복 예외 처리를 보강해 대소문자·공백 변형 중복 가입을 안정적으로 차단했다.

Made-with: Cursor
This commit is contained in:
2026-04-27 11:33:10 +09:00
committed by DUCK JIN
parent 069eda3544
commit 73d198f5a9
12 changed files with 105 additions and 25 deletions

18
.env.local.example Normal file
View File

@@ -0,0 +1,18 @@
MARIADB_ROOT_PASSWORD=tier_cursor_root_local
MARIADB_DATABASE=tier_cursor
MARIADB_USER=tier_cursor
MARIADB_PASSWORD=tier_cursor1234
DB_HOST=127.0.0.1
DB_PORT=3307
DB_USER=tier_cursor
DB_PASSWORD=tier_cursor1234
DB_NAME=tier_cursor
SESSION_SECRET=tmaker-local-session-secret
APP_ORIGIN=http://localhost:5173
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=local-mailer@tmaker.local
SMTP_PASS=local-mailer-app-password
SMTP_FROM="Tier Maker <local-mailer@tmaker.local>"
SKIP_EMAIL_VERIFICATION=true

View File

@@ -1,13 +1,13 @@
MARIADB_ROOT_PASSWORD=change-this-root-password
MARIADB_ROOT_PASSWORD=tier_cursor_root_production
MARIADB_DATABASE=tier_cursor
MARIADB_USER=tier_cursor
MARIADB_PASSWORD=change-this-db-password
SESSION_SECRET=change-this-session-secret
MARIADB_PASSWORD=tier_cursor_production_password
SESSION_SECRET=tmaker-production-session-secret
APP_ORIGIN=https://tmaker.sori.studio
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_SECURE=true
SMTP_USER=your-gmail-account@gmail.com
SMTP_PASS=change-this-gmail-app-password
SMTP_FROM="Tier Maker <your-gmail-account@gmail.com>"
SMTP_USER=mailer@tmaker.sori.studio
SMTP_PASS=mailer-app-password
SMTP_FROM="Tier Maker <mailer@tmaker.sori.studio>"
SKIP_EMAIL_VERIFICATION=false

View File

@@ -18,6 +18,7 @@ docker compose up -d mariadb phpmyadmin
### 2) 백엔드 실행
```bash
cp .env.local.example .env.local
cd backend
npm install
npm run dev

View File

@@ -6,7 +6,22 @@ const cors = require('cors')
const session = require('express-session')
const FileStoreFactory = require('session-file-store')
dotenv.config({ path: path.join(__dirname, '..', '.env.production') })
function loadEnvFileIfExists(fileName) {
const envPath = path.join(__dirname, '..', fileName)
if (!fs.existsSync(envPath)) return false
dotenv.config({ path: envPath })
return true
}
loadEnvFileIfExists('.env')
const nodeEnv = String(process.env.NODE_ENV || 'development').trim().toLowerCase()
if (nodeEnv === 'production') {
loadEnvFileIfExists('.env.production')
} else {
const loadedLocal = loadEnvFileIfExists('.env.local')
if (!loadedLocal) loadEnvFileIfExists('.env.production')
}
const { ensureData } = require('./src/db')
const authRoutes = require('./src/routes/auth')

View File

@@ -4,8 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"dev": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
"dev": "NODE_ENV=development nodemon --legacy-watch --watch index.js --watch src index.js",
"start": "NODE_ENV=production node index.js",
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
"images:shard-assets": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-flat-assets-to-sharded.js",

View File

@@ -699,9 +699,16 @@ async function countUsers() {
}
async function findUserByEmail(email) {
const normalized = String(email || '').trim().toLowerCase()
if (!normalized) return null
const rows = await query(
'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
[email]
`
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(email)) = TRIM(LOWER(?))
LIMIT 1
`,
[normalized]
)
const row = rows[0]
if (!row) return null
@@ -745,12 +752,14 @@ async function findUserById(id) {
async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) {
const createdAt = now()
const normalizedEmail = String(email || '').trim().toLowerCase()
const normalizedNickname = String(nickname || '').trim()
await query(
`
INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
[id, normalizedEmail, normalizedNickname, createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
)
return findUserById(id)
}

View File

@@ -145,6 +145,14 @@ function hashToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex')
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase()
}
function normalizeNickname(value) {
return String(value || '').trim()
}
function resolveAppOrigin(req) {
const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim()
if (envOrigin) return envOrigin.replace(/\/+$/, '')
@@ -207,7 +215,9 @@ router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, nickname, password } = parsed.data
const email = normalizeEmail(parsed.data.email)
const nickname = normalizeNickname(parsed.data.nickname)
const { password } = parsed.data
const exists = await findUserByEmail(email)
if (exists) return res.status(409).json({ error: 'email_taken' })
if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' })
@@ -221,14 +231,22 @@ router.post('/signup', async (req, res) => {
return res.status(503).json({ error: 'mail_not_configured' })
}
const user = await createUser({
id: nanoid(),
email,
nickname,
passwordHash,
emailVerified: isAdmin || skipEmailVerification,
isAdmin,
})
let user = null
try {
user = await createUser({
id: nanoid(),
email,
nickname,
passwordHash,
emailVerified: isAdmin || skipEmailVerification,
isAdmin,
})
} catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_or_nickname_taken' })
}
throw err
}
if (!isAdmin && !skipEmailVerification) {
try {
@@ -260,7 +278,8 @@ router.post('/login', async (req, res) => {
}).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data
const email = normalizeEmail(parsed.data.email)
const { password } = parsed.data
const user = await findUserByEmail(email)
if (!user) return res.status(401).json({ error: 'invalid_credentials' })
@@ -320,7 +339,7 @@ router.post('/email/resend', async (req, res) => {
const parsed = resendVerificationSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserByEmail(parsed.data.email)
const user = await findUserByEmail(normalizeEmail(parsed.data.email))
if (!user || user.emailVerified) return res.json({ ok: true })
try {
@@ -336,7 +355,7 @@ router.post('/password-reset/request', async (req, res) => {
const parsed = requestPasswordResetSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserByEmail(parsed.data.email)
const user = await findUserByEmail(normalizeEmail(parsed.data.email))
if (!user) return res.json({ ok: true })
try {

View File

@@ -4,6 +4,8 @@
- 캐릭터 이름 표시를 켠 상태에서 긴 라벨이 잘리는 문제는 글자 수 제한으로 자르기보다, 카드 안에서 overflow 시에만 자동 슬라이드시키는 편이 정보 전달과 가독성 모두에 유리하다고 판단했다.
- 짧은 라벨까지 항상 움직이면 오히려 시선 피로가 생기므로, 실제 렌더링 너비를 기준으로 overflow 여부를 계산해 마퀴 애니메이션을 조건부로 켜는 방식으로 정리했다.
- 로컬 개발에서는 SMTP가 없거나 불안정한 경우 가입/로그인 플로우 자체를 막아버리기 쉬우므로, 운영 환경과 분리된 개발 전용 스위치(`SKIP_EMAIL_VERIFICATION`)로 메일 인증 단계를 건너뛸 수 있게 두는 편이 낫다고 판단했다.
- 이메일/닉네임 중복은 DB unique 제약에만 기대기보다 입력 정규화(`trim + lowercase`)와 앱 레벨 중복 체크를 함께 두는 편이 재현 케이스(대소문자/공백 변형)에 더 안전하다고 정리했다.
- 로컬 개발과 운영이 같은 `.env.production` 파일을 공유하면 인증 우회 같은 개발용 옵션이 운영에 섞일 위험이 있으므로, 실행 모드(`NODE_ENV`) 기준으로 로컬 `.env.local`과 운영 `.env.production`을 분리해 읽는 편이 더 안전하다고 판단했다.
## 2026-04-08 v1.1.30
- 관리자 목록관리의 상단 고정 템플릿 후보가 많아지면 모든 후보를 화면에 상시 렌더링하는 방식은 탐색성이 떨어지고 관리 영역을 불필요하게 길게 만든다고 판단했다. 따라서 고정 목록은 현재 선택된 항목만 보여주고, 추가는 검색 모달로 분리하는 쪽으로 정리했다.

View File

@@ -20,7 +20,13 @@ docker compose up -d mariadb phpmyadmin
- phpMyAdmin: `http://localhost:8081`
## 3. 백엔드 실행
아래 명령은 로컬 MariaDB 기준 환경변수를 포함한다.
로컬 실행은 루트 `.env.local`을 기준으로 동작한다. 처음 한 번 아래처럼 복사해 둔다.
```bash
cp .env.local.example .env.local
```
그 다음 백엔드를 실행한다.
```bash
cd backend

View File

@@ -420,6 +420,7 @@
- 프런트엔드
- `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소
- 백엔드
- 환경파일 로딩 기준: `NODE_ENV=production`이면 `.env.production`, 그 외 로컬 개발 기본은 `.env.local`을 우선 읽는다. 로컬 파일이 없으면 `.env.production`을 fallback으로 읽는다.
- `DB_HOST`: MariaDB 호스트
- `DB_PORT`: MariaDB 포트
- `DB_USER`: MariaDB 계정
@@ -444,6 +445,7 @@
- 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다.
- 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다.
- 단, 개발 환경에서 `SKIP_EMAIL_VERIFICATION=true`를 주면 두 번째 이후 일반 회원도 메일 인증 단계를 건너뛰고 즉시 로그인한다.
- 이메일 식별은 가입/로그인/재전송/재설정 요청 모두 `trim + lowercase` 정규화 기준으로 처리한다.
- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다.
- 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다.
- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다.

View File

@@ -1,6 +1,9 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.31` 이후 로컬 실행(`NODE_ENV=development`)에서 `.env.local` 값이 실제로 우선 적용되고, 운영 실행(`NODE_ENV=production`)에서는 `.env.production`만 적용되는지 확인한다.
- `v1.1.31` 이후 회원가입에서 같은 이메일을 대소문자만 바꾼 경우(`A@x.com` vs `a@x.com`)와 앞뒤 공백 케이스가 모두 중복으로 차단되는지 확인한다.
- `v1.1.31` 이후 같은 닉네임(대소문자/공백 변형 포함)으로 재가입 시 `nickname_taken` 또는 `email_or_nickname_taken`으로 안정적으로 막히는지 확인한다.
- `v1.1.31` 이후 로컬에서 `SKIP_EMAIL_VERIFICATION=true`를 적용했을 때 두 번째 가입 계정도 인증 메일 단계 없이 즉시 로그인되는지, 운영 환경(`false`)에서는 기존처럼 인증 메일 단계가 유지되는지 확인한다.
- `v1.1.31` 이후 티어표 편집 화면과 `preview=1` 화면에서 `캐릭터 이름 표시`를 켰을 때, 긴 이름만 천천히 무한 슬라이드되고 짧은 이름은 고정 표시로 유지되는지 확인한다.
- `v1.1.30` 이후 관리자 목록관리에서 `템플릿 추가` 모달을 열고 검색·선택·닫기 흐름이 자연스러운지, 이미 고정된 템플릿이 추가 후보에서 제외되는지 확인한다.

View File

@@ -6,6 +6,11 @@
- 같은 이름 오버레이를 쓰는 편집 보드와 `preview=1` 완성본 양쪽에 동일 동작을 적용했다.
- 백엔드 회원가입 흐름에 `SKIP_EMAIL_VERIFICATION` 환경변수를 추가했다. 개발 환경에서만 `true`일 때 일반 회원도 메일 인증을 건너뛰고 바로 로그인되도록 처리했다.
- `.env.production.example`에 `SKIP_EMAIL_VERIFICATION=false` 기본값을 추가해 로컬/운영 설정을 분리해 적용할 수 있게 했다.
- 이메일/닉네임 중복 검사를 보강했다. 회원가입/로그인/비밀번호 재설정 메일 요청에서 이메일을 `trim + lowercase`로 정규화해 비교하도록 맞췄고, 가입 시 DB `ER_DUP_ENTRY`도 409(`email_or_nickname_taken`)로 안전 처리한다.
- 사용자 생성 시에도 이메일/닉네임을 정규화 저장하도록 보강해, 대소문자/앞뒤 공백 차이로 중복 계정이 생기던 케이스를 줄였다.
- 환경파일 로딩을 로컬/운영으로 분리했다. 백엔드는 `NODE_ENV=production`일 때만 `.env.production`을 읽고, 로컬 개발(`npm run dev`)에서는 `.env.local`을 우선 읽도록 조정했다.
- `backend/package.json`의 `dev/start` 스크립트에서 DB/APP 값을 하드코딩하지 않도록 정리하고, `.env.local.example` 파일을 새로 추가해 로컬 기준값과 `SKIP_EMAIL_VERIFICATION=true` 예시를 분리했다.
- `.env.local.example`, `.env.production.example`의 값은 바로 복사해도 실행 가능한 형태로 기본값을 채워두고, 실제 운영 파일 `.env.production`에는 `SKIP_EMAIL_VERIFICATION=false`를 명시해 실수로 인증 우회가 켜지지 않게 했다.
- 확인: `frontend/src/views/TierEditorView.vue` lint 0건
## 2026-04-08 v1.1.30