mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
- Rate limit: max 5 failed pairing attempts per user per 5 minutes - Default closed: reject all users when no allowedUsers/pairedUsers configured - Extract isAllowedUser() to common/pairing.ts to eliminate duplication Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
/**
|
||
* 配对核心逻辑
|
||
*
|
||
* - generatePairingCode(): 生成 6 位安全配对码
|
||
* - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集)
|
||
* - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code
|
||
*/
|
||
|
||
import * as fs from 'node:fs'
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
import * as crypto from 'node:crypto'
|
||
import type { PairedUser, PairingState } from './config.js'
|
||
|
||
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
|
||
|
||
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
|
||
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
|
||
const RATE_LIMIT_MAX_ATTEMPTS = 5
|
||
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
|
||
|
||
function isRateLimited(userId: string | number): boolean {
|
||
const key = String(userId)
|
||
const record = failedAttempts.get(key)
|
||
if (!record) return false
|
||
if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
||
failedAttempts.delete(key)
|
||
return false
|
||
}
|
||
return record.count >= RATE_LIMIT_MAX_ATTEMPTS
|
||
}
|
||
|
||
function recordFailedAttempt(userId: string | number): void {
|
||
const key = String(userId)
|
||
const record = failedAttempts.get(key)
|
||
if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
||
failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
|
||
} else {
|
||
record.count++
|
||
}
|
||
}
|
||
const CODE_LENGTH = 6
|
||
const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
|
||
|
||
function getConfigPath(): string {
|
||
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||
return path.join(configDir, 'adapters.json')
|
||
}
|
||
|
||
function readConfigFile(): Record<string, any> {
|
||
try {
|
||
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
function writeConfigFile(data: Record<string, any>): void {
|
||
const filePath = getConfigPath()
|
||
const dir = path.dirname(filePath)
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||
const tmp = `${filePath}.tmp.${Date.now()}`
|
||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
||
fs.renameSync(tmp, filePath)
|
||
}
|
||
|
||
export function generatePairingCode(): string {
|
||
let code = ''
|
||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||
code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]
|
||
}
|
||
return code
|
||
}
|
||
|
||
/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */
|
||
export function isPaired(
|
||
platform: 'telegram' | 'feishu',
|
||
userId: string | number,
|
||
config: Record<string, any>,
|
||
): boolean {
|
||
const platformConfig = config[platform] ?? {}
|
||
const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? []
|
||
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
||
|
||
// allowedUsers 非空时检查
|
||
if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true
|
||
// 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
|
||
if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
|
||
|
||
return pairedUsers.some((p) => String(p.userId) === String(userId))
|
||
}
|
||
|
||
/**
|
||
* 尝试配对:验证消息文本是否匹配当前有效配对码。
|
||
* 成功则写入 pairedUsers 并清除 pairing.code,返回 true。
|
||
*/
|
||
export function tryPair(
|
||
messageText: string,
|
||
senderInfo: { userId: string | number; displayName: string },
|
||
platform: 'telegram' | 'feishu',
|
||
): boolean {
|
||
const file = readConfigFile()
|
||
const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
|
||
|
||
// 速率限制检查
|
||
if (isRateLimited(senderInfo.userId)) return false
|
||
|
||
// 检查配对码是否有效
|
||
if (!pairing.code || !pairing.expiresAt) return false
|
||
if (Date.now() > pairing.expiresAt) return false
|
||
|
||
// 比较(忽略大小写和空格)
|
||
const input = messageText.trim().toUpperCase()
|
||
if (input !== pairing.code.toUpperCase()) {
|
||
recordFailedAttempt(senderInfo.userId)
|
||
return false
|
||
}
|
||
|
||
// 配对成功:写入 pairedUsers
|
||
const platformConfig = file[platform] ?? {}
|
||
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
||
|
||
// 避免重复
|
||
const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId))
|
||
if (!exists) {
|
||
pairedUsers.push({
|
||
userId: senderInfo.userId,
|
||
displayName: senderInfo.displayName,
|
||
pairedAt: Date.now(),
|
||
})
|
||
}
|
||
|
||
// 更新 config
|
||
file[platform] = { ...platformConfig, pairedUsers }
|
||
file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用
|
||
writeConfigFile(file)
|
||
|
||
return true
|
||
}
|
||
|
||
/** 统一的用户授权检查(供各 adapter 调用) */
|
||
export function isAllowedUser(platform: 'telegram' | 'feishu', userId: string | number): boolean {
|
||
try {
|
||
const cfgFile = readConfigFile()
|
||
return isPaired(platform, userId, cfgFile)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|