From dceea4ca3bd0d8a44b15c374913164605f51fd16 Mon Sep 17 00:00:00 2001 From: shinya Date: Fri, 27 Feb 2026 20:18:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8A=A0=E7=9B=90=E5=93=88=E5=B8=8C=E5=AD=98?= =?UTF-8?q?=E5=82=A8=EF=BC=8C=E6=9B=BF=E4=BB=A3=E6=98=8E=E6=96=87=E5=AD=98?= =?UTF-8?q?=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/lib/password.ts,基于 Node.js crypto scrypt 实现加盐哈希与验证 - registerUser/changePassword 改为存储 salt:hash 格式 - verifyUser 兼容明文与哈希两种格式,登录成功时自动升级为哈希 - 新增独立的密码批量迁移方法 migratePasswords,与 migrateData 分离 - DbManager 启动时依次执行数据结构迁移和密码哈希迁移 - 同步修改 BaseRedisStorage 和 UpstashRedisStorage --- src/lib/db.ts | 7 ++++- src/lib/password.ts | 63 ++++++++++++++++++++++++++++++++++++++++ src/lib/redis-base.db.ts | 53 +++++++++++++++++++++++++++++---- src/lib/types.ts | 3 ++ src/lib/upstash.db.ts | 55 ++++++++++++++++++++++++++++++----- 5 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 src/lib/password.ts diff --git a/src/lib/db.ts b/src/lib/db.ts index 7c3e743..f413c18 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -54,7 +54,12 @@ export class DbManager { this.storage = getStorage(); // 启动时自动触发数据迁移(异步,不阻塞构造) if (this.storage && typeof this.storage.migrateData === 'function') { - this.migrationPromise = this.storage.migrateData().catch((err) => { + this.migrationPromise = this.storage.migrateData().then(async () => { + // 数据结构迁移完成后,执行密码哈希迁移 + if (typeof this.storage.migratePasswords === 'function') { + await this.storage.migratePasswords(); + } + }).catch((err) => { console.error('数据迁移异常:', err); }); } diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..6f1e9ea --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,63 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; + +const SALT_LENGTH = 16; +const KEY_LENGTH = 64; +const SCRYPT_COST = 16384; // N +const BLOCK_SIZE = 8; // r +const PARALLELIZATION = 1; // p + +/** + * 对密码进行加盐哈希,返回格式: `salt:hash` + */ +export function hashPassword(password: string): string { + const salt = randomBytes(SALT_LENGTH).toString('hex'); + const hash = scryptSync(password, salt, KEY_LENGTH, { + N: SCRYPT_COST, + r: BLOCK_SIZE, + p: PARALLELIZATION, + }).toString('hex'); + return `${salt}:${hash}`; +} + +/** + * 验证密码是否匹配存储的哈希值 + * 支持两种格式: + * - 加盐哈希: `salt:hash` (新格式) + * - 明文密码: 不含 `:` 或长度不符合哈希格式 (旧格式,兼容迁移期) + */ +export function verifyPassword( + password: string, + storedValue: string +): boolean { + // 判断是否为加盐哈希格式 (salt:hash, salt 32 hex chars, hash 128 hex chars) + const parts = storedValue.split(':'); + if ( + parts.length === 2 && + parts[0].length === SALT_LENGTH * 2 && + parts[1].length === KEY_LENGTH * 2 + ) { + const [salt, storedHash] = parts; + const hash = scryptSync(password, salt, KEY_LENGTH, { + N: SCRYPT_COST, + r: BLOCK_SIZE, + p: PARALLELIZATION, + }); + const storedHashBuf = Buffer.from(storedHash, 'hex'); + return timingSafeEqual(hash, storedHashBuf); + } + + // 旧格式:明文密码直接比较(兼容未迁移的数据) + return storedValue === password; +} + +/** + * 判断存储的密码值是否已经是加盐哈希格式 + */ +export function isHashed(storedValue: string): boolean { + const parts = storedValue.split(':'); + return ( + parts.length === 2 && + parts[0].length === SALT_LENGTH * 2 && + parts[1].length === KEY_LENGTH * 2 + ); +} diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index 2be13e9..e50d3f8 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -3,6 +3,7 @@ import { createClient, RedisClientType } from 'redis'; import { AdminConfig } from './admin.types'; +import { hashPassword, isHashed, verifyPassword } from './password'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; // 搜索历史最大条数 @@ -251,8 +252,8 @@ export abstract class BaseRedisStorage implements IStorage { } async registerUser(userName: string, password: string): Promise { - // 简单存储明文密码,生产环境应加密 - await this.withRetry(() => this.client.set(this.userPwdKey(userName), password)); + const hashed = hashPassword(password); + await this.withRetry(() => this.client.set(this.userPwdKey(userName), hashed)); // 维护用户集合 await this.withRetry(() => this.client.sAdd(this.usersSetKey(), userName)); } @@ -262,8 +263,14 @@ export abstract class BaseRedisStorage implements IStorage { this.client.get(this.userPwdKey(userName)) ); if (stored === null) return false; - // 确保比较时都是字符串类型 - return ensureString(stored) === password; + const storedStr = ensureString(stored); + const ok = verifyPassword(password, storedStr); + // 平滑迁移:如果是明文密码且验证通过,自动升级为加盐哈希 + if (ok && !isHashed(storedStr)) { + const hashed = hashPassword(password); + await this.withRetry(() => this.client.set(this.userPwdKey(userName), hashed)); + } + return ok; } // 检查用户是否存在 @@ -277,9 +284,9 @@ export abstract class BaseRedisStorage implements IStorage { // 修改用户密码 async changePassword(userName: string, newPassword: string): Promise { - // 简单存储明文密码,生产环境应加密 + const hashed = hashPassword(newPassword); await this.withRetry(() => - this.client.set(this.userPwdKey(userName), newPassword) + this.client.set(this.userPwdKey(userName), hashed) ); } @@ -534,6 +541,40 @@ export abstract class BaseRedisStorage implements IStorage { } } + // ---------- 密码迁移:明文 → 加盐哈希 ---------- + private pwdMigrationKey() { + return 'sys:migration:pwd_hash_v1'; + } + + async migratePasswords(): Promise { + const migrated = await this.withRetry(() => this.client.get(this.pwdMigrationKey())); + if (migrated === 'done') return; + + console.log('开始密码迁移:明文 → 加盐哈希...'); + + try { + const pwdKeys = await this.withRetry(() => this.client.keys('u:*:pwd')); + let count = 0; + + for (const key of pwdKeys) { + const stored = await this.withRetry(() => this.client.get(key)); + if (stored === null) continue; + const storedStr = ensureString(stored); + // 跳过已经是哈希格式的 + if (isHashed(storedStr)) continue; + // 将明文密码转为加盐哈希 + const hashed = hashPassword(storedStr); + await this.withRetry(() => this.client.set(key, hashed)); + count++; + } + + await this.withRetry(() => this.client.set(this.pwdMigrationKey(), 'done')); + console.log(`密码迁移完成,共迁移 ${count} 个用户`); + } catch (error) { + console.error('密码迁移失败:', error); + } + } + // 清空所有数据 async clearAllData(): Promise { try { diff --git a/src/lib/types.ts b/src/lib/types.ts index ba7efae..427c7b9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -86,6 +86,9 @@ export interface IStorage { // 数据迁移(旧扁平 key → Hash 结构) migrateData?(): Promise; + // 密码迁移(明文 → 加盐哈希) + migratePasswords?(): Promise; + // 数据清理相关 clearAllData(): Promise; } diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 13861f8..3d2c52d 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -3,6 +3,7 @@ import { Redis } from '@upstash/redis'; import { AdminConfig } from './admin.types'; +import { hashPassword, isHashed, verifyPassword } from './password'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; // 搜索历史最大条数 @@ -159,8 +160,8 @@ export class UpstashRedisStorage implements IStorage { } async registerUser(userName: string, password: string): Promise { - // 简单存储明文密码,生产环境应加密 - await withRetry(() => this.client.set(this.userPwdKey(userName), password)); + const hashed = hashPassword(password); + await withRetry(() => this.client.set(this.userPwdKey(userName), hashed)); // 维护用户集合 await withRetry(() => this.client.sadd(this.usersSetKey(), userName)); } @@ -170,8 +171,14 @@ export class UpstashRedisStorage implements IStorage { this.client.get(this.userPwdKey(userName)) ); if (stored === null) return false; - // 确保比较时都是字符串类型 - return ensureString(stored) === password; + const storedStr = ensureString(stored as any); + const ok = verifyPassword(password, storedStr); + // 平滑迁移:如果是明文密码且验证通过,自动升级为加盐哈希 + if (ok && !isHashed(storedStr)) { + const hashed = hashPassword(password); + await withRetry(() => this.client.set(this.userPwdKey(userName), hashed)); + } + return ok; } // 检查用户是否存在 @@ -185,9 +192,9 @@ export class UpstashRedisStorage implements IStorage { // 修改用户密码 async changePassword(userName: string, newPassword: string): Promise { - // 简单存储明文密码,生产环境应加密 + const hashed = hashPassword(newPassword); await withRetry(() => - this.client.set(this.userPwdKey(userName), newPassword) + this.client.set(this.userPwdKey(userName), hashed) ); } @@ -426,7 +433,7 @@ export class UpstashRedisStorage implements IStorage { }) .filter((u): u is string => typeof u === 'string'); if (userNames.length > 0) { - await withRetry(() => this.client.sadd(this.usersSetKey(), ...userNames)); + await withRetry(() => this.client.sadd(this.usersSetKey(), userNames)); console.log(`迁移了 ${userNames.length} 个用户到 Set`); } } @@ -439,6 +446,40 @@ export class UpstashRedisStorage implements IStorage { } } + // ---------- 密码迁移:明文 → 加盐哈希 ---------- + private pwdMigrationKey() { + return 'sys:migration:pwd_hash_v1'; + } + + async migratePasswords(): Promise { + const migrated = await withRetry(() => this.client.get(this.pwdMigrationKey())); + if (migrated === 'done') return; + + console.log('开始密码迁移:明文 → 加盐哈希...'); + + try { + const pwdKeys: string[] = await withRetry(() => this.client.keys('u:*:pwd')); + let count = 0; + + for (const key of pwdKeys) { + const stored = await withRetry(() => this.client.get(key)); + if (stored === null) continue; + const storedStr = ensureString(stored as any); + // 跳过已经是哈希格式的 + if (isHashed(storedStr)) continue; + // 将明文密码转为加盐哈希 + const hashed = hashPassword(storedStr); + await withRetry(() => this.client.set(key, hashed)); + count++; + } + + await withRetry(() => this.client.set(this.pwdMigrationKey(), 'done')); + console.log(`密码迁移完成,共迁移 ${count} 个用户`); + } catch (error) { + console.error('密码迁移失败:', error); + } + } + // 清空所有数据 async clearAllData(): Promise { try {