소셜 로그인에 안 막히는 자체 인증 — 이메일+비밀번호 붙이기
로그인이 계속 우리가 통제 못 하는 OAuth 콘솔 상태에 막혔다(카카오 KOE006/KOE010/KOE237, iOS 배포 인증서 Apple 2FA). 그래서 첫 단추를 '사용자를 일단 들여보내는 일'을 외부 의존 없이 하도록, scrypt 기반 이메일+비밀번호 인증을 OAuth 위에 추가로 붙였다. users에 password_hash(nullable) 컬럼을 idempotent하게 더하고, /api/auth/signup·/password-login을 새로 뒀다. 로컬 서버에 curl로 전 시나리오(가입 200/중복 409/오답 401/로그인 200/Bearer 세션 200/짧은비번 400, password_hash 비노출) 통과, root·mobile tsc 모두 깨끗.
왜 자체 인증인가
이 앱의 로그인은 그동안 전적으로 소셜(카카오/구글/애플)에 기대고 있었다. 그런데 "사용자를 일단 들여보내는" 그 첫 단추가 자꾸 우리가 코드로 통제할 수 없는 외부 콘솔 상태에 막혔다 — 카카오 KOE006(리다이렉트 URI·플랫폼 도메인 미등록), KOE010(client secret), KOE237(토큰 요청 레이트리밋), 그리고 iOS는 eas build가 "Distribution Certificate is not validated for non-interactive builds"로 멈춰 Apple 2FA를 요구했다. 전부 콘솔/계정 작업이지, 우리 결함이 아니다.
그래서 결정: 소셜은 그대로 두고(additive), 외부 의존 0으로 동작하는 이메일+비밀번호 인증을 첫 번째 길로 깐다.
비밀번호는 의존성 없이
lib/password.ts는 Node 내장 crypto의 scrypt만 쓴다(새 패키지 추가 없음). 저장 형식은 scrypt$<salt_hex>$<hash_hex>, 검증은 crypto.timingSafeEqual. bcrypt/argon2 라이브러리를 끌어오지 않고도 솔트+키스트레칭을 갖춘다.
스키마는 한 컬럼, 그것도 idempotent하게
users에 password_hash(nullable) 한 칸만 추가했다. 소셜/dev 사용자는 null로 둔다. 핵심은 lib/db/init.ts의 부트스트랩 DDL에 ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash text를 넣은 것 — 이미 떠 있는 프로덕션 DB도 마이그레이션 단계 없이 부팅하면서 스스로 올라온다.
인증 로직의 디테일
lib/auth에 두 함수를 더했다.
signupWithPassword—email:<주소>네임스페이스로 기존 사용자를 upsert하되, 이미 가입된 이메일이면 거절(email_taken)해서 남의 보관함을 비번 설정으로 가로채지 못하게 한다.loginWithPassword— 사용자가 없거나 해시가 없어도 해시 검증을 한 번 수행하고 동일한 일반 메시지로 떨어뜨린다. 계정 존재 여부가 응답 시간/문구로 새지 않게.
라우트는 POST /api/auth/signup(중복 409), POST /api/auth/password-login(오답 401 invalid_credentials). 둘 다 응답은 기존 dev-login/session과 똑같이 { id, displayName } + box + sessionToken만 — password_hash는 어떤 응답에도 안 나간다.
웹·모바일 UI
웹 components/LoginForm.tsx는 로그인/가입 토글 폼으로 바꾸고, 기존 이름-only dev 로그인은 __DEV__ 뒤로 숨겼다(프로덕션 로그인 화면이 더 이상 "막다른 길"이 아니다). 모바일 mobile/App.tsx + src/api.ts도 같은 이메일/비밀번호 흐름을 추가하고 세션은 SecureStore에 저장한다. 애플/카카오/구글 버튼은 그대로다.
검증
로컬 dev 서버(localhost:3100)에 직접 요청해서 전부 확인했다:
| 시나리오 | 결과 |
|---|---|
| 가입 | 200 + Set-Cookie: bk_session + 토큰 + 보관함 자동생성 |
| 같은 이메일 재가입 | 409 |
| 틀린 비밀번호 | 401 |
| 올바른 로그인 | 200 — 가입 때와 같은 user.id/box.id |
| Bearer 토큰 세션 | 200 (모바일 인증 경로) |
| 3자 비밀번호 | 400 (검증) |
응답의 password_hash | 없음 |
tsc --noEmit은 root·mobile/ 모두 exit 0. iOS 번들도 에러 없이 컴파일(iOS Bundled … 823 modules).