Files
starr-philproperties/apps/api/services/trustedDevice.service.js
T

165 lines
6.7 KiB
JavaScript

/***********************************************************************************************************************************************************************
* 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. Ordinary logout does
* NOT revoke trust or clear the device_trust cookie — expires_at
* is the only thing that ends the OTP-skip window in the normal
* case, so logging out and back in on the same device still
* skips OTP until the 30-day window actually lapses. Trust is
* only force-revoked by password change/reset, admin ban/
* deactivate/force-logout, or a single session being explicitly
* 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');
};
// sameSite:'none' (not 'strict') in production — the frontend (Vercel) and
// this API (Render) are different sites, so this cookie only travels on the
// cross-site fetch/XHR calls the frontend makes if SameSite allows it.
// 'none' requires secure:true, which is already forced above in production.
const cookieOptions = (maxAge) => ({
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'none' : '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);
}
};
/**
* Revokes trust for one specific device by its cookie token. Not called by
* the normal logout flow (see auth.controller.js exports.logout) — ordinary
* logout intentionally leaves trust intact. Kept as a primitive for a
* future explicit "forget this device" action, should one be added.
*/
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,
};