mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
165 lines
6.7 KiB
JavaScript
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,
|
|
};
|