mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Three features from this session, full server suite green (535/535). TOTP 2FA (#100) — backend shipped without a UI; add it: - Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify. - Settings > Account: enable (QR + confirm -> recovery codes once), regenerate, disable; SSO accounts see "managed by your identity provider". - /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri folds the request Host into the issuer so multi-instance accounts are distinguishable in the authenticator app. Email verification on signup — hosted HARD-block / self-host SOFT-nudge: - email_verified column; existing users asked on first login (SSO + platform admins grandfathered); single-use 24h tokens (SHA-256 hashed). - Gate engages only when email is configured (never locks out a no-mail instance). GET /verify-email + POST /resend-verification (generic, no account enumeration). - Client: "confirm your email" flow + resend, verified/error toasts, self-host banner; onAuthSuccess refuses a tokenless response (defensive). Tizen SSSP URL-Launcher install — Fusion-style one-URL native install: - Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the signed .wgt (/data mount wins, mirroring the APK). - build-wgt.sh also emits a static sssp_config.xml for CDN hosting. - Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
63 lines
2.9 KiB
JavaScript
63 lines
2.9 KiB
JavaScript
'use strict';
|
|
|
|
// #100: TOTP (RFC 6238) helper. The shared secret is REVERSIBLE (the server must
|
|
// recompute codes), so it's stored via secretbox (AES-256-GCM) - NOT hashed like the
|
|
// API token / recovery codes. Recovery codes ARE hashed (SHA-256, same discipline as
|
|
// api_tokens) - see generateRecoveryCodes / hashRecoveryCode.
|
|
|
|
const { authenticator } = require('otplib');
|
|
const crypto = require('crypto');
|
|
const secretbox = require('./secretbox');
|
|
const { hashToken } = require('../middleware/apiToken');
|
|
|
|
const STEP_SEC = 30;
|
|
const ISSUER = 'ScreenTinker';
|
|
authenticator.options = { window: 1 }; // accept ±1 step (±30s) for clock skew
|
|
|
|
function generateSecret() { return authenticator.generateSecret(); } // base32 plaintext
|
|
// otpauth:// URI for the QR. `instance` (the dashboard host, e.g. alpha.screentinker.com)
|
|
// is folded into the issuer so an authenticator app can tell apart accounts on more than
|
|
// one ScreenTinker — it shows "ScreenTinker (host)" instead of a bare, ambiguous "ScreenTinker".
|
|
function keyuri(email, secret, instance) {
|
|
const issuer = instance ? `${ISSUER} (${instance})` : ISSUER;
|
|
return authenticator.keyuri(email, issuer, secret);
|
|
}
|
|
function encryptSecret(secret) { return secretbox.encrypt(secret); } // for storage
|
|
function decryptSecret(enc) { return secretbox.decrypt(enc); } // for verification
|
|
|
|
function currentStep(now = Date.now()) { return Math.floor(now / 1000 / STEP_SEC); }
|
|
|
|
// Verify a 6-digit code against the PLAINTEXT secret, blocking intra-window replay
|
|
// via lastStep. Returns the matched step (always > lastStep) on success, else null.
|
|
// The caller persists the returned step as the user's new totp_last_step.
|
|
function verifyCode(token, secret, lastStep = 0, now = Date.now()) {
|
|
if (!secret || !/^[0-9]{6}$/.test(String(token || '').trim())) return null;
|
|
const delta = authenticator.checkDelta(String(token).trim(), secret); // -1|0|1 or null
|
|
if (delta == null) return null;
|
|
const step = currentStep(now) + delta;
|
|
if (step <= lastStep) return null; // a code from an already-consumed step (replay)
|
|
return step;
|
|
}
|
|
|
|
// 10 single-use recovery codes. Returns plaintext (shown ONCE) + SHA-256 hashes (stored).
|
|
function generateRecoveryCodes(n = 10) {
|
|
const plain = [], hashes = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const code = crypto.randomBytes(5).toString('hex').toUpperCase(); // 10 hex chars
|
|
plain.push(code);
|
|
hashes.push(hashToken(code));
|
|
}
|
|
return { plain, hashes };
|
|
}
|
|
|
|
// Normalize user input (strip spaces/hyphens, uppercase) then hash, so a code typed
|
|
// with stray formatting still matches the stored hash.
|
|
function hashRecoveryCode(input) {
|
|
return hashToken(String(input || '').toUpperCase().replace(/[^0-9A-F]/g, ''));
|
|
}
|
|
|
|
module.exports = {
|
|
generateSecret, keyuri, encryptSecret, decryptSecret,
|
|
verifyCode, currentStep, generateRecoveryCodes, hashRecoveryCode, STEP_SEC,
|
|
};
|