feat: 用户密码改为加盐哈希存储,替代明文存储

- 新增 src/lib/password.ts,基于 Node.js crypto scrypt 实现加盐哈希与验证
- registerUser/changePassword 改为存储 salt:hash 格式
- verifyUser 兼容明文与哈希两种格式,登录成功时自动升级为哈希
- 新增独立的密码批量迁移方法 migratePasswords,与 migrateData 分离
- DbManager 启动时依次执行数据结构迁移和密码哈希迁移
- 同步修改 BaseRedisStorage 和 UpstashRedisStorage
This commit is contained in:
shinya
2026-02-27 20:18:34 +08:00
parent eea5e3c490
commit dceea4ca3b
5 changed files with 167 additions and 14 deletions

View File

@@ -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);
});
}

63
src/lib/password.ts Normal file
View File

@@ -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
);
}

View File

@@ -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<void> {
// 简单存储明文密码,生产环境应加密
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<void> {
// 简单存储明文密码,生产环境应加密
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<void> {
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<void> {
try {

View File

@@ -86,6 +86,9 @@ export interface IStorage {
// 数据迁移(旧扁平 key → Hash 结构)
migrateData?(): Promise<void>;
// 密码迁移(明文 → 加盐哈希)
migratePasswords?(): Promise<void>;
// 数据清理相关
clearAllData(): Promise<void>;
}

View File

@@ -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<void> {
// 简单存储明文密码,生产环境应加密
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<void> {
// 简单存储明文密码,生产环境应加密
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<void> {
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<void> {
try {