login.js 2.11 KB
/**
 * 登录流程:防爆破前置检查 → hasher 验密 → 失败计数落库 / 成功签发会话。
 *
 * 防爆破计数走注入 repo(不靠进程内 Map,多实例下仍生效);判定纯逻辑在 bruteforce.js。
 * 失败一律 INVALID_CREDENTIALS(不区分「用户不存在」与「密码错」以免账号枚举),
 * 超阈值返 TOO_MANY_ATTEMPTS + retryAfterMs。
 */

import { AuthError } from './errors.js'
import { issueSession } from './session.js'
import { attemptKey, evaluateAttempts, DEFAULT_MAX_ATTEMPTS, DEFAULT_WINDOW_MS } from './bruteforce.js'

/**
 * @param {Object} deps
 * @param {import('./index.js').UserRepo} deps.userRepo
 * @param {import('./index.js').TokenSigner} deps.tokenSigner
 * @param {import('./index.js').Hasher} deps.hasher
 * @param {number} [deps.maxAttempts]
 * @param {number} [deps.windowMs]
 * @param {number} [deps.accessTtlMs]
 * @param {number} [deps.refreshTtlMs]
 * @returns {(args: { email: string, password: string, ip?: string, device_id?: string }) => Promise<import('./index.js').AuthTokens>}
 */
export function makeLogin(deps) {
  const {
    userRepo,
    hasher,
    maxAttempts = DEFAULT_MAX_ATTEMPTS,
    windowMs = DEFAULT_WINDOW_MS,
  } = deps

  return async function login({ email, password, ip }) {
    const normalized = String(email ?? '').trim().toLowerCase()
    const key = attemptKey(normalized, ip)

    const recentCount = await userRepo.countRecentAttempts({ key, ip, windowMs })
    const gate = evaluateAttempts({ recentCount, maxAttempts, windowMs })
    if (gate.locked) {
      throw new AuthError({ code: 'TOO_MANY_ATTEMPTS', retryAfterMs: gate.retryAfterMs })
    }

    const user = await userRepo.findByEmail(normalized)
    const ok = user && user.password_hash
      ? await hasher.verify(password, user.password_hash)
      : false

    if (!ok || !user) {
      await userRepo.recordAttempt({ key, ip, at: new Date() })
      throw new AuthError({ code: 'INVALID_CREDENTIALS' })
    }

    const issued = await issueSession(deps, { user_id: user.id })
    return { user, access_token: issued.access_token, refresh_token: issued.refresh_token }
  }
}