Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
147 lines
4.0 KiB
JavaScript
147 lines
4.0 KiB
JavaScript
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
|
|
};
|