feat: 足迹 Footprints 个人留痕系统
桌面应用(Tauri+React+SQLite):时间线/记录/学年学科成绩梯度/统计/类型与字段模板/附件内容寻址/详情视图/PWA 同步服务端(sync-server):多用户+邀请码/LWW推拉/seq游标/附件去重/Docker部署 多端同步地基:uuid v7/HLC混合逻辑时钟/软删除墓碑/sync_oplog,Node与Rust双端一致并通过自动化测试
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
// 认证:scrypt 口令散列 + Bearer token + 设备登记
|
||||
import crypto from 'node:crypto';
|
||||
import { getDb } from './db.mjs';
|
||||
|
||||
export function register({ username, password, device_name, invite_code }) {
|
||||
const d = getDb();
|
||||
// 多用户管控:邀请码(设置后必填)+ 用户数上限(0=不限)
|
||||
const invite = process.env.INVITE_CODE;
|
||||
if (invite && invite_code !== invite) throw new Error('邀请码无效');
|
||||
const maxUsers = Number(process.env.MAX_USERS || 0);
|
||||
if (maxUsers > 0) {
|
||||
const cnt = d.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
if (cnt >= maxUsers) throw new Error('注册名额已满,请联系管理员');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username || '')) throw new Error('用户名需为 3-32 位字母数字-_');
|
||||
if (!password || password.length < 8) throw new Error('密码至少 8 位');
|
||||
const exists = d.prepare('SELECT 1 FROM users WHERE username=?').get(username);
|
||||
if (exists) throw new Error('用户名已存在');
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||
const user_id = crypto.randomUUID();
|
||||
d.prepare('INSERT INTO users (id, username, password_hash, salt) VALUES (?,?,?,?)')
|
||||
.run(user_id, username, hash, salt);
|
||||
return issueToken(user_id, device_name);
|
||||
}
|
||||
|
||||
export function login({ username, password, device_name }) {
|
||||
const d = getDb();
|
||||
const row = d.prepare('SELECT * FROM users WHERE username=?').get(username || '');
|
||||
if (!row) throw new Error('用户名或密码错误');
|
||||
const hash = crypto.scryptSync(password || '', row.salt, 64);
|
||||
const expect = Buffer.from(row.password_hash, 'hex');
|
||||
if (hash.length !== expect.length || !crypto.timingSafeEqual(hash, expect)) {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
return issueToken(row.id, device_name);
|
||||
}
|
||||
|
||||
function issueToken(user_id, device_name) {
|
||||
const d = getDb();
|
||||
const name = (device_name || '未知设备').slice(0, 64);
|
||||
let device = d.prepare('SELECT id FROM devices WHERE user_id=? AND name=?').get(user_id, name);
|
||||
let device_id;
|
||||
if (device) {
|
||||
device_id = device.id;
|
||||
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(device_id);
|
||||
} else {
|
||||
device_id = crypto.randomUUID();
|
||||
d.prepare('INSERT INTO devices (id, user_id, name) VALUES (?,?,?)').run(device_id, user_id, name);
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + 365 * 86400_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
d.prepare('INSERT INTO tokens (token, user_id, device_id, expires_at) VALUES (?,?,?,?)')
|
||||
.run(token, user_id, device_id, expires);
|
||||
return { token, user_id, device_id, device_name: name, expires_at: expires };
|
||||
}
|
||||
|
||||
export function authUser(req) {
|
||||
const d = getDb();
|
||||
const header = req.headers['authorization'] || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
if (!token) throw Object.assign(new Error('未提供凭据'), { code: 401 });
|
||||
const row = d.prepare(`
|
||||
SELECT t.user_id, t.device_id, t.expires_at FROM tokens t WHERE t.token=?`).get(token);
|
||||
if (!row) throw Object.assign(new Error('凭据无效'), { code: 401 });
|
||||
if (row.expires_at < new Date().toISOString().slice(0, 19).replace('T', ' ')) {
|
||||
throw Object.assign(new Error('凭据已过期,请重新登录'), { code: 401 });
|
||||
}
|
||||
d.prepare("UPDATE devices SET last_seen_at=datetime('now','localtime') WHERE id=?").run(row.device_id);
|
||||
return { user_id: row.user_id, device_id: row.device_id };
|
||||
}
|
||||
Reference in New Issue
Block a user