Phase 3.4: backend self-managed auth cutover

All backend auth now goes through our own token service — Firebase Auth
dependency is fully removed from auth paths. FCM (firebase-admin messaging)
is still used for push.

Schema:
- auth_sessions (multi-device refresh tokens, bcrypt-hashed)
- otp_requests (Fazpass reference + rate-limit history)
- customers.email + google_sub + apple_sub (social identity)
- control_center_users.password_hash + failed_login_count + lockout_until
- firebase_uid columns made nullable (drop in later cleanup migration)
- 6 new app_config keys for OTP + CC lockout tuning

Services:
- password.service.js — bcrypt cost 12 + complexity (min 8, digit + upper +
  lower)
- token.service.js — JWT HS256 access (1h) + opaque refresh (30d, bcrypt-
  hashed, rotated on use); session_id claim pre-wires future Valkey-based
  instant revocation; revokeSession + revokeAllSessionsForUser helpers
- social-identity.service.js — Google via google-auth-library, Apple via
  jwks-rsa + jsonwebtoken
- otp.service.js — Fazpass stub (generates locally, logs the code) clearly
  marked for replacement once real API docs arrive; rate-limit + resend
  cooldown + verify-attempts all configurable via app_config
- auth.service.js — orchestrator: signInAnonymous, completeCustomer/Mitra-
  PhoneSignIn, signInWithGoogle, signInWithApple, signInCcUser, refresh,
  logout; reject-on-existing for identity conflicts
- cc-user.service.js — email+password helpers + lockout counters

Routes & middleware:
- authenticate middleware now verifies our JWT and attaches
  request.auth = { userType, userId, sessionId }
- WebSocket handshake verifies our JWT (no more Firebase lookup)
- All existing routes updated to use request.auth.userId instead of
  request.firebaseUser.uid
- New public routes:
    /api/shared/auth/anonymous /refresh /logout
    /api/client/auth/otp/request /otp/verify /google /apple /me /profile
    /api/mitra/auth/otp/request /otp/verify /me
- New internal routes:
    /internal/auth/login /refresh /logout /me (httpOnly cookie refresh)
    /internal/control-center-users (accepts plain password, bcrypt-hashed)
    /internal/control-center-users/me/password (self-service change)
    /internal/control-center-users/:id/password (admin forced reset)
- Deleted legacy customer.routes.js (anonymous + link handled by auth now)
- app.internal.js: @fastify/cookie + CORS credentials for CC httpOnly cookie

Config:
- AUTH_JWT_SECRET + ACCESS_TOKEN_TTL_SECONDS + REFRESH_TOKEN_TTL_DAYS env
- FAZPASS_* env vars (TBD until real API docs)
- GOOGLE_OAUTH_CLIENT_IDS, APPLE_SERVICES_ID/TEAM_ID/KEY_ID/PRIVATE_KEY
- ADMIN_EMAIL + ADMIN_PASSWORD for seed
- CC_ORIGIN for internal-app CORS origin allowlist

Dependencies:
- Added: bcrypt, jsonwebtoken, jwks-rsa, google-auth-library, @fastify/cookie
- Kept: firebase-admin (messaging only)

Still outstanding: Fazpass API integration (stub in place), Apple Developer
prereqs for end-to-end iOS testing, client_app/mitra_app/control_center auth
flow rewrites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 11:43:25 +08:00
parent 780cade3db
commit f860ab6c85
29 changed files with 1423 additions and 310 deletions

View File

@@ -0,0 +1,235 @@
import crypto from 'node:crypto'
import {
getCustomerById,
getCustomerByPhone,
getCustomerByGoogleSub,
getCustomerByAppleSub,
createAnonymousCustomerV2,
createCustomerWithIdentity,
upgradeCustomerIdentity,
} from './customer.service.js'
import {
getMitraByPhone,
getMitraById,
createMitra,
} from './mitra.service.js'
import {
getCcUserByEmail,
getCcUserById,
incrementCcUserFailedLogin,
resetCcUserFailedLogin,
} from './cc-user.service.js'
import { verifyGoogleIdToken, verifyAppleIdToken } from './social-identity.service.js'
import { verifyPassword } from './password.service.js'
import { issueTokens, refreshTokens as rotateRefresh, revokeSession } from './token.service.js'
import { getCcLoginLockoutConfig } from './config.service.js'
import { UserType } from '../constants.js'
const generateAnonymousDisplayName = () => {
const n = crypto.randomInt(1000, 10000)
return `Teman Anonim #${n}`
}
export class AuthError extends Error {
constructor(message, code, statusCode) {
super(message)
this.code = code
this.statusCode = statusCode
}
}
const normalizeIdentityConflict = async ({ existing, anonymousCustomerId }) => {
// If an authenticated identity is already linked to a DIFFERENT customer,
// reject. Merge is deferred per PRD.
if (existing && anonymousCustomerId && existing.id !== anonymousCustomerId) {
throw new AuthError(
'This account is already linked to another session. Please log out first.',
'IDENTITY_ALREADY_LINKED', 409,
)
}
}
// --- Anonymous ---
export const signInAnonymous = async ({ deviceInfo } = {}) => {
const customer = await createAnonymousCustomerV2({
display_name: generateAnonymousDisplayName(),
})
const tokens = await issueTokens({
userType: UserType.CUSTOMER,
userId: customer.id,
deviceInfo,
})
return { tokens, profile: customer }
}
// --- Phone OTP — Customer ---
export const completeCustomerPhoneSignIn = async ({ phone, anonymousCustomerId, deviceInfo }) => {
const existing = await getCustomerByPhone(phone)
await normalizeIdentityConflict({ existing, anonymousCustomerId })
let customer
if (existing) {
customer = existing
} else if (anonymousCustomerId) {
customer = await upgradeCustomerIdentity(anonymousCustomerId, { phone })
} else {
customer = await createCustomerWithIdentity({ phone, display_name: null })
}
const tokens = await issueTokens({
userType: UserType.CUSTOMER,
userId: customer.id,
deviceInfo,
})
return { tokens, profile: customer }
}
// --- Phone OTP — Mitra ---
export const completeMitraPhoneSignIn = async ({ phone, deviceInfo }) => {
let mitra = await getMitraByPhone(phone)
if (!mitra) {
mitra = await createMitra({ phone, display_name: phone })
}
const tokens = await issueTokens({
userType: UserType.MITRA,
userId: mitra.id,
deviceInfo,
})
return { tokens, profile: mitra }
}
// --- Google (customer only) ---
export const signInWithGoogle = async ({ idToken, anonymousCustomerId, deviceInfo }) => {
const google = await verifyGoogleIdToken(idToken)
const existing = await getCustomerByGoogleSub(google.sub)
await normalizeIdentityConflict({ existing, anonymousCustomerId })
let customer
if (existing) {
customer = existing
} else if (anonymousCustomerId) {
customer = await upgradeCustomerIdentity(anonymousCustomerId, {
google_sub: google.sub,
email: google.email,
display_name: google.name,
})
} else {
customer = await createCustomerWithIdentity({
google_sub: google.sub,
email: google.email,
display_name: google.name,
})
}
const tokens = await issueTokens({
userType: UserType.CUSTOMER,
userId: customer.id,
deviceInfo,
})
return { tokens, profile: customer }
}
// --- Apple (customer only) ---
export const signInWithApple = async ({ idToken, anonymousCustomerId, deviceInfo }) => {
const apple = await verifyAppleIdToken(idToken)
const existing = await getCustomerByAppleSub(apple.sub)
await normalizeIdentityConflict({ existing, anonymousCustomerId })
let customer
if (existing) {
customer = existing
} else if (anonymousCustomerId) {
customer = await upgradeCustomerIdentity(anonymousCustomerId, {
apple_sub: apple.sub,
email: apple.email,
})
} else {
customer = await createCustomerWithIdentity({
apple_sub: apple.sub,
email: apple.email,
display_name: null,
})
}
const tokens = await issueTokens({
userType: UserType.CUSTOMER,
userId: customer.id,
deviceInfo,
})
return { tokens, profile: customer }
}
// --- Control center email/password ---
export const signInCcUser = async ({ email, password, deviceInfo }) => {
const user = await getCcUserByEmail(email)
// Constant-time response — same branch for "not found" vs "wrong password" OR lockout
if (!user) {
throw new AuthError('Invalid credentials', 'INVALID_CREDENTIALS', 401)
}
// Lockout check
if (user.lockout_until && new Date(user.lockout_until) > new Date()) {
throw new AuthError(
`Account locked until ${user.lockout_until}. Try again later.`,
'ACCOUNT_LOCKED', 423,
)
}
const ok = await verifyPassword(password, user.password_hash)
if (!ok) {
const { max_attempts, lockout_minutes } = await getCcLoginLockoutConfig()
await incrementCcUserFailedLogin(user.id, lockout_minutes, max_attempts)
throw new AuthError('Invalid credentials', 'INVALID_CREDENTIALS', 401)
}
await resetCcUserFailedLogin(user.id)
const tokens = await issueTokens({
userType: UserType.CC_USER,
userId: user.id,
deviceInfo,
})
return {
tokens,
profile: {
id: user.id,
email: user.email,
display_name: user.display_name,
role: user.role,
},
}
}
// --- Refresh ---
export const refreshTokens = async ({ refreshToken, deviceInfo }) => {
const result = await rotateRefresh({ refreshToken, deviceInfo })
let profile = null
if (result.user_type === UserType.CUSTOMER) {
profile = await getCustomerById(result.user_id)
} else if (result.user_type === UserType.MITRA) {
profile = await getMitraById(result.user_id)
} else if (result.user_type === UserType.CC_USER) {
const ccUser = await getCcUserById(result.user_id)
profile = ccUser ? {
id: ccUser.id,
email: ccUser.email,
display_name: ccUser.display_name,
role: ccUser.role,
} : null
}
return { tokens: result, profile }
}
// --- Logout ---
export const logout = async ({ sessionId }) => {
if (!sessionId) return
await revokeSession(sessionId)
}