mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(security): throttle app lock PIN attempts
Persist failed unlock attempts in the lock flag so restarting the desktop app cannot reset the limit. Block further attempts for 30 seconds after five failures and surface the remaining cooldown on the lock screen.
This commit is contained in:
1 parent
10ead5a306
commit
e7dd418642
8 files changed
+203
-26
No files matched your search
@@ -95,11 +95,39 @@ test('desktop app lock persists its canonical config and handles manual, idle, a
|
||||
|
||||
assert.deepEqual(lockManager.lockApp(), { success: true })
|
||||
assert.equal(lockManager.getLockState(), 'locked')
|
||||
assert.deepEqual(await lockManager.unlockApp('0000'), {
|
||||
success: false,
|
||||
error: 'wrong-password',
|
||||
wrongPassword: true,
|
||||
})
|
||||
for (let attempt = 1; attempt < 5; attempt++) {
|
||||
assert.deepEqual(await lockManager.unlockApp('0000'), {
|
||||
success: false,
|
||||
error: 'wrong-password',
|
||||
wrongPassword: true,
|
||||
})
|
||||
}
|
||||
|
||||
const blockedResult = await lockManager.unlockApp('0000')
|
||||
assert.equal(blockedResult.success, false)
|
||||
assert.equal(blockedResult.error, 'too-many-attempts')
|
||||
assert.equal(blockedResult.wrongPassword, true)
|
||||
assert.ok(blockedResult.retryAfterSeconds && blockedResult.retryAfterSeconds > 0)
|
||||
|
||||
const blockedFlag = JSON.parse(fs.readFileSync(path.join(settingsDir, '.app-lock-flag'), 'utf-8')) as {
|
||||
failureCount: number
|
||||
cooldownUntil: number
|
||||
}
|
||||
assert.equal(blockedFlag.failureCount, 5)
|
||||
assert.ok(blockedFlag.cooldownUntil > Date.now())
|
||||
|
||||
lockManager.cleanupLockManager()
|
||||
lockManager.initLockManager(mainWindow as never)
|
||||
assert.equal(lockManager.getLockState(), 'locked')
|
||||
|
||||
const blockedAfterRestart = await lockManager.unlockApp('1234')
|
||||
assert.equal(blockedAfterRestart.error, 'too-many-attempts')
|
||||
assert.ok(blockedAfterRestart.retryAfterSeconds && blockedAfterRestart.retryAfterSeconds > 0)
|
||||
|
||||
blockedFlag.cooldownUntil = Date.now() - 1
|
||||
fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), JSON.stringify(blockedFlag), 'utf-8')
|
||||
lockManager.cleanupLockManager()
|
||||
lockManager.initLockManager(mainWindow as never)
|
||||
assert.deepEqual(await lockManager.unlockApp('1234'), { success: true })
|
||||
assert.deepEqual(mainWindow.sentMessages.slice(-2), [
|
||||
['app-lock-state-changed', true],
|
||||
|
||||
@@ -17,19 +17,31 @@ interface LockSettings {
|
||||
lockOnStartup: boolean
|
||||
}
|
||||
|
||||
interface UnlockAttemptState {
|
||||
failureCount: number
|
||||
cooldownUntil: number
|
||||
}
|
||||
|
||||
const LOCK_CONFIG_FILE = 'app-lock.json'
|
||||
const LOCK_FLAG_FILE = '.app-lock-flag'
|
||||
const PIN_PATTERN = /^\d{4}$/
|
||||
const IDLE_CHECK_INTERVAL_MS = 10000
|
||||
const MAX_UNLOCK_FAILURES = 5
|
||||
const UNLOCK_COOLDOWN_MS = 30000
|
||||
const IDLE_TIMEOUT_OPTIONS = new Set([0, 1, 3, 5, 10, 15, 30, 60])
|
||||
const DEFAULT_LOCK_SETTINGS: LockSettings = {
|
||||
idleTimeoutMinutes: 0,
|
||||
lockOnStartup: false,
|
||||
}
|
||||
const DEFAULT_UNLOCK_ATTEMPTS: UnlockAttemptState = {
|
||||
failureCount: 0,
|
||||
cooldownUntil: 0,
|
||||
}
|
||||
|
||||
let lockState: AppLockState = 'unlocked'
|
||||
let currentSettings: LockSettings = { ...DEFAULT_LOCK_SETTINGS }
|
||||
let storedPasswordHash: PasswordHash | null = null
|
||||
let unlockAttempts: UnlockAttemptState = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
let idleTimer: ReturnType<typeof setInterval> | null = null
|
||||
let isTransitioning = false
|
||||
let mainWindowRef: BrowserWindow | null = null
|
||||
@@ -113,12 +125,35 @@ function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null):
|
||||
}
|
||||
}
|
||||
|
||||
function setLockFlag(): boolean {
|
||||
function isUnlockAttemptState(value: unknown): value is UnlockAttemptState {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
Object.keys(record).every((key) => key === 'failureCount' || key === 'cooldownUntil') &&
|
||||
Number.isInteger(record.failureCount) &&
|
||||
(record.failureCount as number) >= 0 &&
|
||||
(record.failureCount as number) <= MAX_UNLOCK_FAILURES &&
|
||||
Number.isSafeInteger(record.cooldownUntil) &&
|
||||
(record.cooldownUntil as number) >= 0
|
||||
)
|
||||
}
|
||||
|
||||
function saveLockFlag(attemptState: UnlockAttemptState = unlockAttempts): boolean {
|
||||
const flagPath = getLockFlagPath()
|
||||
const tempPath = `${flagPath}.tmp`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(getLockFlagPath(), Date.now().toString(), 'utf-8')
|
||||
fs.mkdirSync(getSettingsDir(), { recursive: true })
|
||||
fs.writeFileSync(tempPath, JSON.stringify(attemptState), 'utf-8')
|
||||
fs.renameSync(tempPath, flagPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`Failed to write app lock flag: ${error instanceof Error ? error.message : error}`)
|
||||
try {
|
||||
fs.unlinkSync(tempPath)
|
||||
} catch {
|
||||
// 临时文件可能尚未创建。
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -134,10 +169,23 @@ function clearLockFlag(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function hasLockFlag(): boolean {
|
||||
function loadLockFlag(): boolean {
|
||||
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
|
||||
try {
|
||||
return fs.existsSync(getLockFlagPath())
|
||||
} catch {
|
||||
const flagPath = getLockFlagPath()
|
||||
if (!fs.existsSync(flagPath)) return false
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(flagPath, 'utf-8')) as unknown
|
||||
if (isUnlockAttemptState(data)) unlockAttempts = data
|
||||
else logger.warn('App lock flag contains invalid unlock attempt state')
|
||||
} catch (error) {
|
||||
logger.warn(`App lock flag contains invalid JSON: ${error instanceof Error ? error.message : error}`)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read app lock flag: ${error instanceof Error ? error.message : error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -156,14 +204,17 @@ export function initLockManager(mainWindow: BrowserWindow): void {
|
||||
mainWindowRef = mainWindow
|
||||
lockState = 'unlocked'
|
||||
isTransitioning = false
|
||||
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
|
||||
const loaded = loadConfig()
|
||||
if (!loaded || !isEnabled()) clearLockFlag()
|
||||
const wasLocked = isEnabled() && hasLockFlag()
|
||||
const wasLocked = isEnabled() && loadLockFlag()
|
||||
|
||||
logger.info(`App lock initialized: enabled=${isEnabled()}, wasLocked=${wasLocked}`)
|
||||
|
||||
if (isEnabled() && (currentSettings.lockOnStartup || wasLocked)) {
|
||||
if (wasLocked) {
|
||||
enterLockedState()
|
||||
} else if (isEnabled() && currentSettings.lockOnStartup) {
|
||||
lockApp()
|
||||
} else if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) {
|
||||
startIdleTimer()
|
||||
@@ -177,21 +228,34 @@ export function lockApp(): AppLockResult {
|
||||
|
||||
isTransitioning = true
|
||||
try {
|
||||
if (!setLockFlag()) return { success: false, error: 'save-failed' }
|
||||
lockState = 'locked'
|
||||
stopIdleTimer()
|
||||
logger.info('App locked')
|
||||
notifyLockState()
|
||||
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
if (!saveLockFlag()) return { success: false, error: 'save-failed' }
|
||||
enterLockedState()
|
||||
return { success: true }
|
||||
} finally {
|
||||
isTransitioning = false
|
||||
}
|
||||
}
|
||||
|
||||
function enterLockedState(): void {
|
||||
lockState = 'locked'
|
||||
stopIdleTimer()
|
||||
logger.info('App locked')
|
||||
notifyLockState()
|
||||
}
|
||||
|
||||
export async function unlockApp(password: unknown): Promise<AppLockUnlockResult> {
|
||||
if (lockState === 'unlocked') return { success: true }
|
||||
if (isTransitioning) return { success: false, error: 'busy' }
|
||||
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
|
||||
|
||||
const cooldownRemaining = getUnlockCooldownRemaining()
|
||||
if (cooldownRemaining > 0) {
|
||||
return { success: false, error: 'too-many-attempts', retryAfterSeconds: cooldownRemaining }
|
||||
}
|
||||
if (unlockAttempts.cooldownUntil > 0 && !resetUnlockAttempts()) {
|
||||
return { success: false, error: 'save-failed' }
|
||||
}
|
||||
if (typeof password !== 'string' || !PIN_PATTERN.test(password)) {
|
||||
return { success: false, error: 'wrong-password', wrongPassword: true }
|
||||
}
|
||||
@@ -199,9 +263,20 @@ export async function unlockApp(password: unknown): Promise<AppLockUnlockResult>
|
||||
isTransitioning = true
|
||||
try {
|
||||
if (!(await verifyPasswordHash(password, storedPasswordHash))) {
|
||||
const retryAfterSeconds = recordUnlockFailure()
|
||||
if (retryAfterSeconds === null) return { success: false, error: 'save-failed' }
|
||||
if (retryAfterSeconds > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'too-many-attempts',
|
||||
wrongPassword: true,
|
||||
retryAfterSeconds,
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'wrong-password', wrongPassword: true }
|
||||
}
|
||||
|
||||
if (!resetUnlockAttempts()) return { success: false, error: 'save-failed' }
|
||||
clearLockFlag()
|
||||
lockState = 'unlocked'
|
||||
logger.info('App unlocked')
|
||||
@@ -213,6 +288,35 @@ export async function unlockApp(password: unknown): Promise<AppLockUnlockResult>
|
||||
}
|
||||
}
|
||||
|
||||
function getUnlockCooldownRemaining(): number {
|
||||
if (unlockAttempts.cooldownUntil <= Date.now()) return 0
|
||||
return Math.ceil((unlockAttempts.cooldownUntil - Date.now()) / 1000)
|
||||
}
|
||||
|
||||
function recordUnlockFailure(): number | null {
|
||||
const failureCount = Math.min(unlockAttempts.failureCount + 1, MAX_UNLOCK_FAILURES)
|
||||
const nextAttempts: UnlockAttemptState = {
|
||||
failureCount,
|
||||
cooldownUntil: failureCount >= MAX_UNLOCK_FAILURES ? Date.now() + UNLOCK_COOLDOWN_MS : 0,
|
||||
}
|
||||
unlockAttempts = nextAttempts
|
||||
|
||||
if (!saveLockFlag(nextAttempts)) return null
|
||||
if (nextAttempts.cooldownUntil > 0) {
|
||||
logger.warn('App unlock temporarily blocked after repeated PIN failures')
|
||||
}
|
||||
return getUnlockCooldownRemaining()
|
||||
}
|
||||
|
||||
function resetUnlockAttempts(): boolean {
|
||||
if (unlockAttempts.failureCount === 0 && unlockAttempts.cooldownUntil === 0) return true
|
||||
|
||||
const nextAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
if (!saveLockFlag(nextAttempts)) return false
|
||||
unlockAttempts = nextAttempts
|
||||
return true
|
||||
}
|
||||
|
||||
export async function setPassword(newPassword: unknown): Promise<AppLockResult> {
|
||||
if (lockState === 'locked') return { success: false, error: 'locked' }
|
||||
if (isTransitioning) return { success: false, error: 'busy' }
|
||||
@@ -226,6 +330,7 @@ export async function setPassword(newPassword: unknown): Promise<AppLockResult>
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
|
||||
storedPasswordHash = passwordHash
|
||||
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
|
||||
logger.info('App lock password initialized')
|
||||
return { success: true }
|
||||
@@ -254,6 +359,7 @@ export async function changePassword(oldPassword: unknown, newPassword: unknown)
|
||||
const passwordHash = await hashPassword(newPassword)
|
||||
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
|
||||
storedPasswordHash = passwordHash
|
||||
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
logger.info('App lock password changed')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -270,6 +376,7 @@ export function resetAppLockPassword(): AppLockResult {
|
||||
if (!saveConfig(currentSettings, null)) return { success: false, error: 'save-failed' }
|
||||
|
||||
storedPasswordHash = null
|
||||
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
|
||||
stopIdleTimer()
|
||||
clearLockFlag()
|
||||
logger.info('App lock disabled and password cleared')
|
||||
|
||||
@@ -53,6 +53,7 @@ export type AppLockErrorCode =
|
||||
| 'password-not-set'
|
||||
| 'same-password'
|
||||
| 'wrong-password'
|
||||
| 'too-many-attempts'
|
||||
| 'save-failed'
|
||||
| 'invalid-config'
|
||||
| 'internal-error'
|
||||
@@ -75,6 +76,7 @@ export interface AppLockResult {
|
||||
|
||||
export interface AppLockUnlockResult extends AppLockResult {
|
||||
wrongPassword?: boolean
|
||||
retryAfterSeconds?: number
|
||||
}
|
||||
|
||||
export interface SecurityApi {
|
||||
|
||||
@@ -10,10 +10,15 @@ const isUnlocking = ref(false)
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const retryAfterSeconds = ref(0)
|
||||
const passwordInputRef = ref<HTMLInputElement | null>(null)
|
||||
const canUnlock = computed(() => /^\d{4}$/.test(password.value))
|
||||
const canUnlock = computed(() => /^\d{4}$/.test(password.value) && retryAfterSeconds.value === 0)
|
||||
const cooldownMessage = computed(() =>
|
||||
t('settings.security.lockScreen.errorTooManyAttempts', { seconds: retryAfterSeconds.value })
|
||||
)
|
||||
|
||||
let removeLockListener: (() => void) | null = null
|
||||
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
removeLockListener = window.securityApi.onLockStateChanged((locked) => {
|
||||
@@ -27,7 +32,10 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => removeLockListener?.())
|
||||
onUnmounted(() => {
|
||||
removeLockListener?.()
|
||||
clearCooldownTimer()
|
||||
})
|
||||
|
||||
async function showOverlay(): Promise<void> {
|
||||
visible.value = true
|
||||
@@ -46,6 +54,8 @@ function resetForm(): void {
|
||||
password.value = ''
|
||||
showPassword.value = false
|
||||
errorMessage.value = ''
|
||||
retryAfterSeconds.value = 0
|
||||
clearCooldownTimer()
|
||||
}
|
||||
|
||||
async function handleUnlock(): Promise<void> {
|
||||
@@ -55,6 +65,11 @@ async function handleUnlock(): Promise<void> {
|
||||
try {
|
||||
const result = await window.securityApi.unlock(password.value)
|
||||
if (result.success) return
|
||||
if (result.retryAfterSeconds && result.retryAfterSeconds > 0) {
|
||||
password.value = ''
|
||||
startCooldownTimer(result.retryAfterSeconds)
|
||||
return
|
||||
}
|
||||
errorMessage.value = result.wrongPassword
|
||||
? t('settings.security.lockScreen.errorWrongPassword')
|
||||
: t('settings.security.lockScreen.errorService')
|
||||
@@ -66,6 +81,24 @@ async function handleUnlock(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldownTimer(seconds: number): void {
|
||||
clearCooldownTimer()
|
||||
retryAfterSeconds.value = seconds
|
||||
cooldownTimer = setInterval(() => {
|
||||
retryAfterSeconds.value = Math.max(0, retryAfterSeconds.value - 1)
|
||||
if (retryAfterSeconds.value > 0) return
|
||||
|
||||
clearCooldownTimer()
|
||||
nextTick(() => passwordInputRef.value?.focus())
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function clearCooldownTimer(): void {
|
||||
if (!cooldownTimer) return
|
||||
clearInterval(cooldownTimer)
|
||||
cooldownTimer = null
|
||||
}
|
||||
|
||||
function handlePasswordInput(event: Event): void {
|
||||
password.value = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 4)
|
||||
}
|
||||
@@ -100,7 +133,7 @@ function handlePasswordInput(event: Event): void {
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('settings.security.lockScreen.placeholderPassword')"
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-100 py-3 pl-4 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder-gray-500"
|
||||
:disabled="isUnlocking"
|
||||
:disabled="isUnlocking || retryAfterSeconds > 0"
|
||||
maxlength="4"
|
||||
@input="handlePasswordInput"
|
||||
/>
|
||||
@@ -108,13 +141,16 @@ function handlePasswordInput(event: Event): void {
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:aria-label="t('settings.security.lockScreen.placeholderPassword')"
|
||||
:disabled="isUnlocking"
|
||||
:disabled="isUnlocking || retryAfterSeconds > 0"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<UIcon :name="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-center text-xs text-red-500">
|
||||
<p v-if="retryAfterSeconds > 0" class="text-center text-xs text-amber-500">
|
||||
{{ cooldownMessage }}
|
||||
</p>
|
||||
<p v-else-if="errorMessage" class="text-center text-xs text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<UButton
|
||||
|
||||
@@ -644,7 +644,8 @@
|
||||
"btnUnlock": "Unlock",
|
||||
"verifying": "Verifying...",
|
||||
"errorService": "Unlock failed, please try again",
|
||||
"errorWrongPassword": "Incorrect PIN"
|
||||
"errorWrongPassword": "Incorrect PIN",
|
||||
"errorTooManyAttempts": "Too many attempts. Try again in {seconds} seconds"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -644,7 +644,8 @@
|
||||
"btnUnlock": "ロック解除",
|
||||
"verifying": "検証中...",
|
||||
"errorService": "ロック解除に失敗しました。再試行してください",
|
||||
"errorWrongPassword": "PINが正しくありません"
|
||||
"errorWrongPassword": "PINが正しくありません",
|
||||
"errorTooManyAttempts": "試行回数が多すぎます。{seconds} 秒後に再試行してください"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -644,7 +644,8 @@
|
||||
"btnUnlock": "解锁",
|
||||
"verifying": "验证中...",
|
||||
"errorService": "解锁失败,请重试",
|
||||
"errorWrongPassword": "密码错误"
|
||||
"errorWrongPassword": "密码错误",
|
||||
"errorTooManyAttempts": "尝试次数过多,请在 {seconds} 秒后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -644,7 +644,8 @@
|
||||
"btnUnlock": "解鎖",
|
||||
"verifying": "驗證中...",
|
||||
"errorService": "解鎖失敗,請重試",
|
||||
"errorWrongPassword": "密碼錯誤"
|
||||
"errorWrongPassword": "密碼錯誤",
|
||||
"errorTooManyAttempts": "嘗試次數過多,請在 {seconds} 秒後重試"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user