mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user