mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-28 01:13:15 +08:00
feat: 用户密码改为加盐哈希存储,替代明文存储
- 新增 src/lib/password.ts,基于 Node.js crypto scrypt 实现加盐哈希与验证 - registerUser/changePassword 改为存储 salt:hash 格式 - verifyUser 兼容明文与哈希两种格式,登录成功时自动升级为哈希 - 新增独立的密码批量迁移方法 migratePasswords,与 migrateData 分离 - DbManager 启动时依次执行数据结构迁移和密码哈希迁移 - 同步修改 BaseRedisStorage 和 UpstashRedisStorage
This commit is contained in:
@@ -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
63
src/lib/password.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -86,6 +86,9 @@ export interface IStorage {
|
||||
// 数据迁移(旧扁平 key → Hash 结构)
|
||||
migrateData?(): Promise<void>;
|
||||
|
||||
// 密码迁移(明文 → 加盐哈希)
|
||||
migratePasswords?(): Promise<void>;
|
||||
|
||||
// 数据清理相关
|
||||
clearAllData(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user