Idle timeout observability + cut write frequency in half + hide local-auth UI for SSO-only users
Middleware:
- Log to console.warn + audit_log when a session is killed for
inactivity. Shows up in Grafana/Loki so you can see how often
users actually get kicked. Audit action: 'session_idle_timeout'
- last_activity throttle bumped 5 min → 10 min — halves DB writes
per active user. Idle precision slop widens to 24h00-24h10;
still invisible in practice.
Per-user local-auth visibility:
- /api/auth/me now returns user.canLocalAuth: true when the stored
password is a real bcrypt / argon2 hash, false for the random
blob OIDC auto-creates for SSO-only users.
- Settings page hides "Change Password" and "Two-Factor
Authentication" sections when canLocalAuth is false — those UIs
are meaningless for users whose sign-in lives at the IdP.
- Password hash is not leaked in the /me payload.
Mobile (restating existing behaviour for clarity): no idle check,
365-day JWT in Keychain/Keystore, never auto-logs-out. Only logout
triggers are: manual logout, password change, admin revoke, JWT hit
365d, or app uninstall.
This commit is contained in:
parent
6dffdf91e5
commit
e161c221c4
4 changed files with 36 additions and 7 deletions
|
|
@ -85,8 +85,8 @@
|
|||
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="settings-section card">
|
||||
<!-- Change Password (hidden for SSO-only users) -->
|
||||
<div class="settings-section card" id="change-password-section">
|
||||
<h3><i class="fas fa-key"></i> Change Password</h3>
|
||||
<div class="form-group" style="max-width:340px;">
|
||||
<label>Current Password</label>
|
||||
|
|
@ -104,8 +104,8 @@
|
|||
<span id="pw-change-status" style="font-size:13px;margin-left:8px;"></span>
|
||||
</div>
|
||||
|
||||
<!-- 2FA -->
|
||||
<div class="settings-section card">
|
||||
<!-- 2FA (hidden for SSO-only users — their IdP handles MFA) -->
|
||||
<div class="settings-section card" id="2fa-section">
|
||||
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||||
<p id="2fa-status">Status: Loading...</p>
|
||||
<button id="btn-setup-2fa" class="btn-sm btn-primary">Enable 2FA</button>
|
||||
|
|
|
|||
|
|
@ -725,6 +725,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data || !data.user) return;
|
||||
// Hide password-change + 2FA sections entirely for SSO-only users.
|
||||
// Their auth is owned by the IdP — changing a random-blob "password"
|
||||
// or adding TOTP here has no effect on how they sign in.
|
||||
var pwSection = document.getElementById('change-password-section');
|
||||
var twofaSection = document.getElementById('2fa-section');
|
||||
if (data.user.canLocalAuth === false) {
|
||||
if (pwSection) pwSection.style.display = 'none';
|
||||
if (twofaSection) twofaSection.style.display = 'none';
|
||||
return; // Nothing more to load for SSO-only users
|
||||
} else {
|
||||
if (pwSection) pwSection.style.display = '';
|
||||
if (twofaSection) twofaSection.style.display = '';
|
||||
}
|
||||
var status = document.getElementById('2fa-status');
|
||||
var setupBtn = document.getElementById('btn-setup-2fa');
|
||||
var disableBtn = document.getElementById('btn-disable-2fa');
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ const db = require('../db/database');
|
|||
const { hashToken } = require('../utils/sessions');
|
||||
const { isMobileClient } = require('../utils/platform');
|
||||
|
||||
// Sliding idle timeout — web only. Mobile stays persistent.
|
||||
// Sliding idle timeout — web only. Mobile stays persistent (no idle check,
|
||||
// 365-day JWT in Keychain/Keystore — user only logs out on manual action,
|
||||
// password change, admin revoke, or app uninstall).
|
||||
const WEB_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24h since last request
|
||||
const ACTIVITY_THROTTLE_MS = 5 * 60 * 1000; // don't UPDATE more than every 5 min
|
||||
const ACTIVITY_THROTTLE_MS = 10 * 60 * 1000; // throttle DB writes to ≤ 1 per 10 min / user
|
||||
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // cookie hard expiry — session table drives actual session
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
|
|
@ -65,6 +67,12 @@ async function authMiddleware(req, res, next) {
|
|||
|
||||
// Sliding idle timeout — web only. Mobile stays persistent.
|
||||
if (!mobile && idleMs > WEB_IDLE_TIMEOUT_MS) {
|
||||
var idleMinutes = Math.round(idleMs / 60000);
|
||||
console.warn('[Auth] session expired (idle ' + idleMinutes + ' min) user=' + user.id + ' ip=' + req.ip);
|
||||
db.run(
|
||||
'INSERT INTO audit_log (user_id, action, category, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[user.id, 'session_idle_timeout', 'auth', 'Idle ' + idleMinutes + ' minutes', req.ip, (req.headers['user-agent'] || '').substring(0, 255)]
|
||||
).catch(function(){});
|
||||
db.run('DELETE FROM user_sessions WHERE id = ?', [session.id]).catch(function(){});
|
||||
res.clearCookie('ped_auth', { path: '/' });
|
||||
return res.status(401).json({ error: 'Session expired due to inactivity', idleTimeout: true });
|
||||
|
|
|
|||
|
|
@ -596,9 +596,17 @@ router.post('/change-password', authMiddleware, async (req, res) => {
|
|||
router.get('/me', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
var user = await db.get(
|
||||
'SELECT id, email, name, role, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?',
|
||||
'SELECT id, email, name, role, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, oidc_sub, password, created_at FROM users WHERE id = ?',
|
||||
[req.user.id]
|
||||
);
|
||||
// canLocalAuth: true if the user has a real password hash (can log in
|
||||
// locally, so password change / 2FA are meaningful). SSO-auto-created
|
||||
// users have a random hex blob in `password` which can't be verified
|
||||
// — hide those UIs for them.
|
||||
var canLocalAuth = !!(user && user.password && (/^\$2[aby]\$/.test(user.password) || user.password.indexOf('$argon2') === 0));
|
||||
// Don't leak the password hash in the response.
|
||||
if (user) delete user.password;
|
||||
if (user) user.canLocalAuth = canLocalAuth;
|
||||
res.json({ user: user });
|
||||
} catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue