feat(server): 新增云端缓存与同步服务端骨架

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Developer
2026-03-18 00:27:04 +08:00
parent df12a6ac72
commit 6764f4c53b
14 changed files with 2183 additions and 0 deletions

146
server/src/auth.js Normal file
View File

@@ -0,0 +1,146 @@
const crypto = require('crypto');
const db = require('./db');
const TOKEN_TTL_DAYS = 30;
function parseJsonMaybe(value) {
if (!value) {
return null;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (error) {
return null;
}
}
function sha256(value) {
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
}
function randomToken(size) {
return crypto.randomBytes(size).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
function hashPassword(password) {
return new Promise(function(resolve, reject) {
var salt = crypto.randomBytes(16).toString('base64');
crypto.scrypt(String(password), salt, 64, function(error, derivedKey) {
if (error) {
reject(error);
return;
}
resolve('scrypt$' + salt + '$' + derivedKey.toString('base64'));
});
});
}
function verifyPassword(password, storedHash) {
return new Promise(function(resolve, reject) {
var parts = String(storedHash || '').split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') {
resolve(false);
return;
}
var salt = parts[1];
var expected = Buffer.from(parts[2], 'base64');
crypto.scrypt(String(password), salt, expected.length, function(error, derivedKey) {
if (error) {
reject(error);
return;
}
resolve(crypto.timingSafeEqual(expected, derivedKey));
});
});
}
async function createAuthToken(userId, deviceFingerprint) {
var rawToken = randomToken(32);
var expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
await db.execute(
'INSERT INTO auth_tokens (user_id, token_hash, device_fingerprint, expires_at, last_seen_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)',
[userId, sha256(rawToken), deviceFingerprint || null, expiresAt]
);
return rawToken;
}
async function touchAuthToken(tokenHash) {
await db.execute('UPDATE auth_tokens SET last_seen_at = CURRENT_TIMESTAMP WHERE token_hash = ?', [tokenHash]);
}
async function revokeAuthToken(tokenHash) {
await db.execute('UPDATE auth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ? AND revoked_at IS NULL', [tokenHash]);
}
async function getAuthContextFromToken(rawToken) {
if (!rawToken) {
return null;
}
var tokenHash = sha256(rawToken);
var rows = await db.query(
'SELECT t.id AS token_id, t.user_id, t.token_hash, t.device_fingerprint, t.expires_at, t.revoked_at, u.email, u.status, k.wrapped_dek, k.kdf_salt, k.kdf_params, k.key_version FROM auth_tokens t INNER JOIN users u ON u.id = t.user_id LEFT JOIN user_keyrings k ON k.user_id = u.id WHERE t.token_hash = ? LIMIT 1',
[tokenHash]
);
if (!rows.length) {
return null;
}
var row = rows[0];
if (row.revoked_at) {
return null;
}
if (new Date(row.expires_at).getTime() <= Date.now()) {
return null;
}
if (Number(row.status || 0) !== 1) {
return null;
}
await touchAuthToken(tokenHash);
return {
tokenHash: tokenHash,
tokenId: row.token_id,
user: {
id: row.user_id,
email: row.email
},
keyring: row.wrapped_dek ? {
wrappedDek: row.wrapped_dek,
kdfSalt: row.kdf_salt,
kdfParams: parseJsonMaybe(row.kdf_params),
keyVersion: Number(row.key_version || 1)
} : null
};
}
async function requireAuth(request, reply) {
var header = request.headers.authorization || '';
var match = String(header).match(/^Bearer\s+(.+)$/i);
if (!match) {
reply.code(401);
throw new Error('未登录');
}
var authContext = await getAuthContextFromToken(match[1]);
if (!authContext) {
reply.code(401);
throw new Error('登录已失效');
}
request.authContext = authContext;
}
module.exports = {
createAuthToken,
getAuthContextFromToken,
hashPassword,
normalizeEmail,
parseJsonMaybe,
requireAuth,
revokeAuthToken,
sha256,
verifyPassword
};