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:
Daniel 2026-04-14 04:32:53 +02:00
parent 6dffdf91e5
commit e161c221c4
4 changed files with 36 additions and 7 deletions

View file

@ -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>

View file

@ -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');

View file

@ -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 });

View file

@ -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' }); }
});