add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-06 15:21:55 +08:00
parent 7ba03ad4db
commit 095a0d4b3c
15 changed files with 756 additions and 211 deletions
+149
View File
@@ -0,0 +1,149 @@
/***********************************************************************************************************************************************************************
* File Name: trustedDevice.service.js
* Type of Program: Service
* Description: Lets a login from an already-verified device skip the OTP
* gate. A device is trusted the first time its user clears an
* OTP; trust rolls forward 30 days on each trusted login and is
* tied to both an opaque cookie token (device_trust) and a
* User-Agent fingerprint, so a stolen cookie alone isn't enough
* once the fingerprint no longer matches. Trust is revoked on
* logout, password change/reset, admin ban/deactivate, or a
* single session being terminated.
* Author: Kenneth Obsequio
* Date Created: Jul. 5, 2026
***********************************************************************************************************************************************************************
* HOW TO USE:
* const trustedDevice = require('../services/trustedDevice.service');
* const fingerprintHash = trustedDevice.getFingerprintHash(req);
* const trusted = await trustedDevice.findValid(user.user_id, req.cookies.device_trust, fingerprintHash);
* if (trusted) { ...skip OTP... }
* await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
***********************************************************************************************************************************************************************/
const crypto = require('crypto');
const mdl_TrustedDevices = require('../models/users/trusted_devices.mdl');
const { parseUA } = require('../utils/session_info.util');
const { hashToken } = require('../utils/token.util');
const TRUST_DAYS = 30;
const COOKIE_NAME = 'device_trust';
const getFingerprintHash = (req) => {
const { browser, os, device } = parseUA(req.headers['user-agent']);
return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex');
};
const cookieOptions = (maxAge) => ({
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge,
});
/**
* Looks up a non-revoked, non-expired trusted device matching both the
* cookie token and the current request's fingerprint.
*
* Fails safe: any lookup error (e.g. table not migrated yet) is treated as
* "not trusted" rather than propagating — a broken trust check should never
* take down the login/OTP path itself, it should just fall back to OTP.
* @returns {Promise<import('../models/users/trusted_devices.mdl')|null>}
*/
const findValid = async (userId, rawToken, fingerprintHash) => {
if (!rawToken) return null;
try {
const row = await mdl_TrustedDevices.findOne({
where: {
user_id: userId,
device_token_hash: hashToken(rawToken),
fingerprint_hash: fingerprintHash,
revoked_at: null,
},
});
return (row && new Date(row.expires_at) > new Date()) ? row : null;
} catch (err) {
console.error('[TRUSTED DEVICE] findValid failed, falling back to OTP:', err.message);
return null;
}
};
/**
* Marks the current device as trusted for TRUST_DAYS, rolling the window
* forward on repeat use, and sets the device_trust cookie.
*
* Fails safe: called after tokens/session are already minted, so a failure
* here (e.g. table not migrated yet) must not break an otherwise-successful
* login — it just means this device won't skip OTP next time.
*/
const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => {
try {
const rawToken = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + TRUST_DAYS * 24 * 60 * 60 * 1000);
const fields = {
device_token_hash: hashToken(rawToken),
expires_at: expiresAt,
revoked_at: null,
last_session_id: sessionId,
};
// Plain find-then-create/update rather than findOrCreate() — Sequelize's
// postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity
// that CockroachDB doesn't support ("cannot create user-defined functions
// under a temporary schema").
const row = await mdl_TrustedDevices.findOne({ where: { user_id: userId, fingerprint_hash: fingerprintHash } });
if (row) {
await row.update(fields);
} else {
await mdl_TrustedDevices.create({ user_id: userId, fingerprint_hash: fingerprintHash, ...fields });
}
res.cookie(COOKIE_NAME, rawToken, cookieOptions(TRUST_DAYS * 24 * 60 * 60 * 1000));
} catch (err) {
console.error('[TRUSTED DEVICE] issueOrRefresh failed:', err.message);
}
};
const revokeByToken = async (userId, rawToken) => {
if (!rawToken) return;
try {
await mdl_TrustedDevices.update(
{ revoked_at: new Date() },
{ where: { user_id: userId, device_token_hash: hashToken(rawToken), revoked_at: null } }
);
} catch (err) {
console.error('[TRUSTED DEVICE] revokeByToken failed:', err.message);
}
};
const revokeAllForUser = async (userId) => {
try {
await mdl_TrustedDevices.update(
{ revoked_at: new Date() },
{ where: { user_id: userId, revoked_at: null } }
);
} catch (err) {
console.error('[TRUSTED DEVICE] revokeAllForUser failed:', err.message);
}
};
const revokeBySessionId = async (sessionId) => {
if (!sessionId) return;
try {
await mdl_TrustedDevices.update(
{ revoked_at: new Date() },
{ where: { last_session_id: sessionId, revoked_at: null } }
);
} catch (err) {
console.error('[TRUSTED DEVICE] revokeBySessionId failed:', err.message);
}
};
module.exports = {
COOKIE_NAME,
getFingerprintHash,
findValid,
issueOrRefresh,
revokeByToken,
revokeAllForUser,
revokeBySessionId,
};