190 lines
7.1 KiB
JavaScript
190 lines
7.1 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import db from './sql.mjs';
|
|
import lib from './lib.mjs';
|
|
import cfg from './config.mjs';
|
|
|
|
import security from './security.mjs';
|
|
|
|
/**
|
|
* IP of the request in its storable form (see security.storableIP): null when IP logging is off.
|
|
* @param {object} req
|
|
* @returns {string|null}
|
|
*/
|
|
export function resolveAuditIP(req) {
|
|
if (!req) return null;
|
|
return security.storableIP(security.getRealIP(req));
|
|
}
|
|
|
|
/**
|
|
* Log activity for an anonymous user (or session).
|
|
* @param {object} req
|
|
* @param {{ action: string, targetId?: number|string, details?: object, hwFingerprint?: string }} params
|
|
*/
|
|
export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) {
|
|
try {
|
|
const rawIp = security.getRealIP(req);
|
|
const ip = security.storableIP(rawIp);
|
|
const userId = req?.session?.id || null;
|
|
if (!userId) return;
|
|
const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null;
|
|
const hwFp = hwFingerprint || req?.session?.hw_fingerprint || null;
|
|
const numTargetId = targetId ? parseInt(targetId, 10) : null;
|
|
|
|
await db`
|
|
INSERT INTO anon_activity_log (user_id, fingerprint, hw_fingerprint, ip, action, target_id, details)
|
|
VALUES (${userId}, ${fingerprint}, ${hwFp}, ${ip}, ${action}, ${!isNaN(numTargetId) ? numTargetId : null}, ${details ? JSON.stringify(details) : null})
|
|
`;
|
|
|
|
await security.logUserIP(userId, rawIp);
|
|
} catch (err) {
|
|
console.error('[ANON_ACTIVITY_LOG] Failed to log activity:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find or create a shadow user in the database for a passkey-authenticated anonymous identity.
|
|
*
|
|
* @param {string} credentialId - base64url WebAuthn credential ID
|
|
* @param {object} [req]
|
|
* @param {string} [hwFingerprint]
|
|
* @returns {Promise<{ userId: number, isNew: boolean, fingerprint: string }>}
|
|
*/
|
|
export async function getOrCreateAnonUserByCredential(credentialId, req = null, hwFingerprint = null) {
|
|
const auditIp = req ? resolveAuditIP(req) : null;
|
|
|
|
// Derive a stable "fingerprint" from the credential ID (for ban checks / display)
|
|
const fpBytes = crypto.createHash('sha256').update(Buffer.from(credentialId)).digest();
|
|
const fingerprint = 'SHA256:' + fpBytes.toString('base64').replace(/=+$/, '');
|
|
|
|
// Check if we already have a row for this credential
|
|
const existing = await db`
|
|
SELECT user_id FROM anon_identities
|
|
WHERE credential_id = ${credentialId}
|
|
LIMIT 1
|
|
`;
|
|
|
|
if (existing.length > 0) {
|
|
await db`
|
|
UPDATE anon_identities
|
|
SET last_seen = NOW()
|
|
${auditIp ? db`, last_ip = ${auditIp}` : db``}
|
|
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
|
|
WHERE credential_id = ${credentialId}
|
|
`.catch(() => {});
|
|
return { userId: existing[0].user_id, isNew: false, fingerprint };
|
|
}
|
|
|
|
// Generate unique shadow username based on fingerprint short hash
|
|
const shortHash = fpBytes.toString('hex').slice(0, 8);
|
|
let baseLogin = `anon_${shortHash}`;
|
|
let finalLogin = baseLogin;
|
|
let counter = 1;
|
|
|
|
while (true) {
|
|
const check = await db`SELECT id FROM "user" WHERE login = ${finalLogin} LIMIT 1`;
|
|
if (check.length === 0) break;
|
|
finalLogin = `${baseLogin}_${counter++}`;
|
|
}
|
|
|
|
const userRows = await db`
|
|
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated)
|
|
VALUES (${finalLogin}, ${finalLogin}, '!', false, false, true)
|
|
RETURNING id
|
|
`;
|
|
const userId = userRows[0].id;
|
|
|
|
await db`
|
|
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name, use_alternative_infobox)
|
|
VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous', ${cfg.websrv.user_alternative_infobox !== false})
|
|
ON CONFLICT (user_id) DO NOTHING
|
|
`;
|
|
|
|
await db`
|
|
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
|
|
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
|
|
ON CONFLICT (credential_id) DO UPDATE
|
|
SET last_seen = NOW()
|
|
${auditIp ? db`, last_ip = ${auditIp}` : db``}
|
|
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
|
|
`;
|
|
|
|
return { userId, isNew: true, fingerprint };
|
|
}
|
|
|
|
/**
|
|
* Create a valid session in user_sessions for an anonymous user.
|
|
* @param {number} userId
|
|
* @param {object} req
|
|
* @param {string} [hwFingerprint]
|
|
* @returns {Promise<{ session: string, csrf_token: string }>}
|
|
*/
|
|
export async function createAnonSession(userId, req, hwFingerprint = null, credentialId = null) {
|
|
const auditIp = resolveAuditIP(req);
|
|
|
|
// Update anon_identities last_ip and hw_fingerprint
|
|
await db`
|
|
UPDATE anon_identities
|
|
SET last_ip = ${auditIp},
|
|
created_ip = COALESCE(created_ip, ${auditIp})
|
|
${hwFingerprint ? db`, hw_fingerprint = COALESCE(${hwFingerprint}, hw_fingerprint)` : db``}
|
|
WHERE user_id = ${userId}
|
|
`.catch(() => {});
|
|
|
|
// Remember which passkey this session runs on (settings: can't delete the one in use)
|
|
const markCredential = async (sessionHash) => {
|
|
if (!credentialId) return;
|
|
await db`UPDATE user_sessions SET passkey_credential_id = ${credentialId} WHERE session = ${sessionHash}`.catch(() => {});
|
|
};
|
|
|
|
// If req.session is already active for this exact userId, reuse it
|
|
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
|
|
await markCredential(lib.sha256(req.cookies.session));
|
|
await logAnonActivity(req, { action: 'handshake', hwFingerprint });
|
|
return { session: req.cookies.session, csrf_token: req.session.csrf_token };
|
|
}
|
|
|
|
// If client has a session cookie that maps to this userId in DB, reuse it
|
|
if (req?.cookies?.session) {
|
|
const existingHash = lib.sha256(req.cookies.session);
|
|
const existing = await db`
|
|
SELECT session, csrf_token FROM user_sessions
|
|
WHERE user_id = ${userId} AND session = ${existingHash}
|
|
LIMIT 1
|
|
`;
|
|
if (existing.length > 0) {
|
|
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
|
|
await markCredential(existingHash);
|
|
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
|
|
return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
|
|
}
|
|
}
|
|
|
|
const session = crypto.randomBytes(32).toString('hex');
|
|
const sessionHash = lib.sha256(session);
|
|
const csrfToken = crypto.randomBytes(24).toString('hex');
|
|
const stamp = ~~(Date.now() / 1e3);
|
|
const ip = auditIp;
|
|
const ua = req?.headers ? (req.headers['user-agent'] || '') : '';
|
|
|
|
const sessRecord = {
|
|
user_id: userId,
|
|
session: sessionHash,
|
|
csrf_token: csrfToken,
|
|
browser: ua,
|
|
created_at: stamp,
|
|
last_used: stamp,
|
|
last_action: '/anon/passkey/auth',
|
|
kmsi: 1,
|
|
ip: ip
|
|
};
|
|
|
|
await db`
|
|
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
|
|
`;
|
|
await markCredential(sessionHash);
|
|
|
|
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
|
|
|
|
return { session, csrf_token: csrfToken };
|
|
}
|