2FA-Restlaufzeit korrigiert, manuellen API-Token entfernt
Countdown des 2FA-Codes: Die Anzeige zaehlte blind im Sekundentakt herunter und lief dadurch aus dem Takt. Am Ende eines Zeitfensters wurde zwar ein neuer Code angefordert, der Zaehler aber nicht angehalten - bei langsamer oder fehlschlagender Antwort lief er ins Negative und stiess jede Sekunde eine weitere Anfrage an (inkl. Audit-Eintrag je Abruf), waehrend die Anzeige auf dem alten Wert stehenblieb. Die Restlaufzeit wird jetzt aus einem festen Ablaufzeitpunkt berechnet, sodass gedrosselte oder ausgefallene Ticks sie nicht verschieben. Pro Ablauf wird genau einmal nachgeladen; liefert der Server keinen Code mehr (gesperrt/offline), stoppt der Timer und die Anzeige wird geleert. Dieselbe Rechnung gilt fuer die Benachrichtigung im Seiteninhalt; die Periode steckt dort wie im Popup in einer Konstanten statt als 30 im Ausdruck. Manueller API-Token: Eingabefeld, "Token speichern" und "Verbindung testen" sind aus den Optionen entfernt; die Server-URL laesst sich weiterhin separat speichern. getAccessToken() kennt nur noch den SSO-Weg, und ein aus einer frueheren Version uebernommener Token wird bei onInstalled aus chrome.storage.local geloescht. Doku und Store-Listing entsprechend angepasst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
This commit is contained in:
parent
ea5cd8d623
commit
19051bbacd
10 changed files with 115 additions and 122 deletions
|
|
@ -13,13 +13,13 @@ async function getServerUrl() {
|
|||
return c.serverUrl || null;
|
||||
}
|
||||
|
||||
// ── Zugangstoken beschaffen (SSO-Refresh oder manueller Token) ──────────────
|
||||
// Reihenfolge: manueller Token (Erweitert) > SSO-Access-Token (mit Auto-Refresh).
|
||||
// ── Zugangstoken beschaffen (SSO) ──────────────────────────────────────────
|
||||
// Kurzlebiger Access-Token aus der Session; ist er abgelaufen, wird er über den
|
||||
// rotierenden Refresh-Token erneuert.
|
||||
async function getAccessToken() {
|
||||
const cfg = await new Promise(r => chrome.storage.local.get(
|
||||
['serverUrl', 'apiToken', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
|
||||
['serverUrl', 'apiRefreshToken', 'apiRefreshExpiresAt'], r));
|
||||
if (!cfg.serverUrl) return null;
|
||||
if (cfg.apiToken) return cfg.apiToken; // manueller Token
|
||||
if (!cfg.apiRefreshToken) return null; // nicht angemeldet
|
||||
const sess = await chrome.storage.session.get(['accessToken', 'accessExpiresAt']);
|
||||
if (sess.accessToken && sess.accessExpiresAt && Date.now() < sess.accessExpiresAt - 30000) {
|
||||
|
|
@ -298,6 +298,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||
if (msg.type === 'CLEAR_CACHE') { cachedEntries = null; cacheTime = 0; faviconCache.clear(); sendResponse({ ok: true }); return true; }
|
||||
});
|
||||
|
||||
// Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer
|
||||
// früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt.
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.storage.local.remove('apiToken');
|
||||
});
|
||||
|
||||
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
|
||||
chrome.alarms.create('clearCache', { periodInMinutes: 5 });
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
|
|
|
|||
|
|
@ -511,6 +511,8 @@ const _obs = new MutationObserver(() => {
|
|||
try { _obs.observe(document.documentElement, { childList: true, subtree: true }); } catch {}
|
||||
|
||||
// ── TOTP-Benachrichtigung (unten rechts) ────────────────────────────────────
|
||||
const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert)
|
||||
|
||||
function showTotpNotification(code, remaining) {
|
||||
const ID = '__vault_totp_notif__';
|
||||
const old = document.getElementById(ID);
|
||||
|
|
@ -531,18 +533,20 @@ function showTotpNotification(code, remaining) {
|
|||
+ '<span style="color:#adb5bd;font-size:10px;flex:1;">2FA-Code kopiert</span></div>'
|
||||
+ '<div style="font-family:monospace;font-size:18px;font-weight:700;letter-spacing:.12em;">' + esc(formatted) + '</div>'
|
||||
+ '<div style="display:flex;align-items:center;gap:7px;"><div style="flex:1;height:3px;background:rgba(255,255,255,.15);border-radius:2px;overflow:hidden;">'
|
||||
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + (remaining / 30 * 100) + '%;transition:width 1s linear;"></div></div>'
|
||||
+ '<div id="__vault_totp_bar" style="height:100%;background:#28a745;width:' + Math.min(100, remaining / TOTP_PERIOD * 100) + '%;transition:width 1s linear;"></div></div>'
|
||||
+ '<span id="__vault_totp_t" style="font-size:10px;color:#adb5bd;min-width:22px;text-align:right;">' + remaining + 's</span></div>';
|
||||
document.documentElement.appendChild(notif);
|
||||
|
||||
let secs = remaining;
|
||||
// Gegen einen festen Ablaufzeitpunkt rechnen, damit die Anzeige auch nach
|
||||
// gedrosselten oder ausgefallenen Timer-Ticks stimmt.
|
||||
const deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000;
|
||||
const iv = setInterval(() => {
|
||||
secs--;
|
||||
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
const t = document.getElementById('__vault_totp_t');
|
||||
const bar = document.getElementById('__vault_totp_bar');
|
||||
if (secs <= 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
|
||||
if (secs === 0 || !t) { clearInterval(iv); notif.style.transition = 'opacity .4s'; notif.style.opacity = '0'; setTimeout(() => notif.remove(), 400); return; }
|
||||
t.textContent = secs + 's';
|
||||
if (bar) { bar.style.width = (secs / 30 * 100) + '%'; if (secs < 10) bar.style.background = '#dc3545'; }
|
||||
if (bar) { bar.style.width = Math.min(100, secs / TOTP_PERIOD * 100) + '%'; if (secs <= 10) bar.style.background = '#dc3545'; }
|
||||
}, 1000);
|
||||
notif.addEventListener('click', () => { clearInterval(iv); notif.remove(); });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,29 +229,13 @@ label.control-label {
|
|||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:2px;"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
Mit OpenNIT anmelden
|
||||
</button>
|
||||
<button class="btn btn-default" id="btnSave">URL speichern</button>
|
||||
<button class="btn btn-default" id="btnLogout" style="display:none;">Abmelden</button>
|
||||
<span id="ssoMsg"></span>
|
||||
<span id="savedMsg"></span>
|
||||
</div>
|
||||
<span class="form-text">Empfohlen: Anmeldung wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert.</span>
|
||||
<span class="form-text">Die Anmeldung erfolgt wie an OpenNIT (lokal + 2FA / Microsoft 365 / Keycloak). Der Zugang wird automatisch erneuert und lässt sich jederzeit im Vault widerrufen.</span>
|
||||
<div id="statusMsg"></div>
|
||||
|
||||
<!-- Erweitert: manueller Token -->
|
||||
<details style="margin-top:1rem;">
|
||||
<summary style="cursor:pointer;font-size:.82rem;color:#6c757d;font-weight:600;">Erweitert: manueller Token</summary>
|
||||
<div style="margin-top:.75rem;">
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="apiToken">API-Token</label>
|
||||
<input type="password" class="form-control" id="apiToken"
|
||||
placeholder="Token aus dem Vault einfügen…">
|
||||
<span class="form-text">Alternative ohne SSO (z. B. Kiosk/Headless): Vault → „Extension“ → Token generieren.</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-default btn-sm" id="btnSave">Token speichern</button>
|
||||
<button class="btn btn-default btn-sm" id="btnTest">Verbindung testen</button>
|
||||
<span id="savedMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -289,9 +273,9 @@ label.control-label {
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="steps-list">
|
||||
<li><span class="step-badge">1</span><span>Öffnen Sie den <strong>Passwort-Vault</strong> in Ihrer OpenNIT-Anwendung.</span></li>
|
||||
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Extension“</strong> in der Toolbar und generieren Sie einen API-Token.</span></li>
|
||||
<li><span class="step-badge">3</span><span>Fügen Sie den Token oben in das Token-Feld ein und klicken Sie auf <strong>Speichern</strong>.</span></li>
|
||||
<li><span class="step-badge">1</span><span>Tragen Sie oben die <strong>Server-URL</strong> Ihrer OpenNIT-Instanz ein (nur <strong>https://</strong>).</span></li>
|
||||
<li><span class="step-badge">2</span><span>Klicken Sie auf <strong>„Mit OpenNIT anmelden“</strong> und melden Sie sich wie gewohnt an.</span></li>
|
||||
<li><span class="step-badge">3</span><span>Bestätigen Sie den Zugriff auf den <strong>Passwort-Vault</strong> – die Erweiterung ist damit verbunden.</span></li>
|
||||
<li><span class="step-badge">4</span><span>Fokussieren Sie ein Login-Feld auf einer Website – Vault-Vorschläge erscheinen automatisch.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
// Gespeicherte Werte laden (überschreibt das vorausgefüllte Feld nur wenn bereits gespeichert)
|
||||
chrome.storage.local.get(['serverUrl', 'apiToken'], cfg => {
|
||||
chrome.storage.local.get(['serverUrl'], cfg => {
|
||||
if (cfg.serverUrl) $('serverUrl').value = cfg.serverUrl;
|
||||
if (cfg.apiToken) $('apiToken').value = '••••••••';
|
||||
});
|
||||
|
||||
// Sicherheits-Einstellungen laden
|
||||
|
|
@ -25,8 +24,8 @@ $('btnSaveSec').addEventListener('click', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// HTTPS erzwingen (außer localhost) – sonst gingen Token und Passwörter im
|
||||
// Klartext über die Leitung.
|
||||
// HTTPS erzwingen (außer localhost) – sonst gingen Zugangsdaten und Passwörter
|
||||
// im Klartext über die Leitung.
|
||||
function isSecureServerUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
|
|
@ -37,58 +36,20 @@ function isSecureServerUrl(url) {
|
|||
}
|
||||
|
||||
$('btnSave').addEventListener('click', () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
if (!url) { showStatus('Server-URL darf nicht leer sein.', false); return; }
|
||||
if (!isSecureServerUrl(url)) {
|
||||
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Token und Passwörter unverschlüsselt übertragen.', false);
|
||||
showStatus('Bitte eine <strong>https://</strong>-Adresse verwenden (nur localhost darf http:// sein). Sonst würden Zugangsdaten und Passwörter unverschlüsselt übertragen.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = { serverUrl: url };
|
||||
if (token && !token.startsWith('•')) data.apiToken = token;
|
||||
|
||||
chrome.storage.local.set(data, () => {
|
||||
chrome.storage.local.set({ serverUrl: url }, () => {
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
$('savedMsg').innerHTML = '<span class="save-ok">✓ Gespeichert</span>';
|
||||
setTimeout(() => { $('savedMsg').textContent = ''; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
$('btnTest').addEventListener('click', async () => {
|
||||
const url = $('serverUrl').value.trim().replace(/\/$/, '');
|
||||
const token = $('apiToken').value.trim();
|
||||
|
||||
if (!url || !token || token.startsWith('•')) {
|
||||
showStatus('Bitte zuerst URL und Token eingeben und speichern.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = '<span class="fa-spin-sm"></span> Teste…';
|
||||
$('btnTest').disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/api/vault/extension/status`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
showStatus(`Verbunden als <strong>${esc(data.user)}</strong>`, true);
|
||||
if (data.user) {
|
||||
$('headerUser').textContent = data.user;
|
||||
$('headerStatus').style.display = '';
|
||||
}
|
||||
} else {
|
||||
showStatus('Ungültiger Token oder Server-Fehler.', false);
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Server nicht erreichbar: ' + esc(e.message), false);
|
||||
}
|
||||
|
||||
$('btnTest').innerHTML = 'Verbindung testen';
|
||||
$('btnTest').disabled = false;
|
||||
});
|
||||
|
||||
// ── SSO-Anmeldung (OAuth 2.0 + PKCE via chrome.identity) ────────────────────
|
||||
function b64url(bytes) {
|
||||
let s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||
|
|
@ -149,7 +110,6 @@ async function loginWithSso() {
|
|||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.access_token) { ssoMsg('Token konnte nicht ausgestellt werden.', false); return; }
|
||||
await ssoSet('local', { serverUrl: url, apiRefreshToken: data.refresh_token, apiRefreshExpiresAt: Date.now() + (data.refresh_expires_in || 0) * 1000 });
|
||||
await ssoRemove('local', ['apiToken']);
|
||||
await ssoSet('session', { accessToken: data.access_token, accessExpiresAt: Date.now() + (data.expires_in || 0) * 1000 });
|
||||
chrome.runtime.sendMessage({ type: 'CLEAR_CACHE' });
|
||||
ssoMsg('Angemeldet.', true);
|
||||
|
|
@ -184,7 +144,7 @@ function reflectAuthState() {
|
|||
});
|
||||
}
|
||||
|
||||
// Verbindungsstatus über den Background (nutzt SSO-Access-Token oder manuellen Token)
|
||||
// Verbindungsstatus über den Background (nutzt den SSO-Access-Token)
|
||||
function loadConnStatus() {
|
||||
chrome.runtime.sendMessage({ type: 'CHECK_STATUS' }, data => {
|
||||
if (data && data.ok) {
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ function generatePassword() {
|
|||
}
|
||||
|
||||
// Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker,
|
||||
// der den gültigen Zugang (SSO oder manueller Token) beisteuert.
|
||||
// der den gültigen Zugang beisteuert.
|
||||
function saveNewEntry() {
|
||||
const title = $('neTitle').value.trim();
|
||||
if (!title) { $('newEntryMsg').textContent = 'Titel ist erforderlich.'; return; }
|
||||
|
|
@ -475,6 +475,12 @@ async function copyDetailPassword() {
|
|||
else showToast('Kein Passwort');
|
||||
}
|
||||
|
||||
const TOTP_PERIOD = 30; // Sekunden pro Code (RFC 6238, Serverseite nutzt denselben Wert)
|
||||
|
||||
// Restlaufzeit des 2FA-Codes. Die Anzeige rechnet gegen einen festen Ablaufzeitpunkt
|
||||
// statt blind herunterzuzählen – so bleibt sie korrekt, wenn der Timer gedrosselt wird
|
||||
// oder ein Tick ausfällt. Nachgeladen wird erst, wenn der Code abgelaufen ist, und pro
|
||||
// Ablauf nur einmal.
|
||||
function loadDetailTotp(id) {
|
||||
const codeEl = $('detailTotp');
|
||||
const secsEl = $('detailTotpSecs');
|
||||
|
|
@ -483,33 +489,55 @@ function loadDetailTotp(id) {
|
|||
codeEl.dataset.code = '';
|
||||
secsEl.textContent = '';
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
|
||||
if (!detailState || detailState.id !== String(id)) return;
|
||||
if (!resp?.code) { codeEl.textContent = '—'; return; }
|
||||
let deadline = 0;
|
||||
let refetching = false;
|
||||
|
||||
const apply = (code, remaining) => {
|
||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||
codeEl.dataset.code = code;
|
||||
secsEl.textContent = remaining + 's';
|
||||
if (barEl) barEl.style.width = Math.round(remaining / 30 * 100) + '%';
|
||||
};
|
||||
const stillOpen = () => detailState && detailState.id === String(id);
|
||||
|
||||
const render = () => {
|
||||
const secs = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
secsEl.textContent = secs + 's';
|
||||
if (barEl) {
|
||||
barEl.style.width = Math.min(100, Math.round(secs / TOTP_PERIOD * 100)) + '%';
|
||||
barEl.style.background = secs <= 10 ? '#dc3545' : '#34d399';
|
||||
}
|
||||
return secs;
|
||||
};
|
||||
|
||||
const apply = (code, remaining) => {
|
||||
codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
|
||||
codeEl.dataset.code = code;
|
||||
// Der Server liefert die Restsekunden des laufenden Zeitfensters; daraus wird
|
||||
// ein Ablaufzeitpunkt, gegen den die Anzeige lokal rechnet.
|
||||
deadline = Date.now() + (Number(remaining) > 0 ? Number(remaining) : TOTP_PERIOD) * 1000;
|
||||
render();
|
||||
};
|
||||
|
||||
const refetch = () => {
|
||||
if (refetching) return;
|
||||
refetching = true;
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r => {
|
||||
refetching = false;
|
||||
if (!stillOpen()) return;
|
||||
if (r?.code) { apply(r.code, r.remaining); return; }
|
||||
// Kein Code mehr (gesperrt oder Verbindung weg) – Anzeige zurücksetzen
|
||||
// statt weiter auf einem abgelaufenen Wert stehen zu bleiben.
|
||||
closeDetailTimers();
|
||||
codeEl.textContent = '—';
|
||||
codeEl.dataset.code = '';
|
||||
secsEl.textContent = '';
|
||||
if (barEl) barEl.style.width = '0%';
|
||||
});
|
||||
};
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, resp => {
|
||||
if (!stillOpen()) return;
|
||||
if (!resp?.code) { codeEl.textContent = '—'; return; }
|
||||
apply(resp.code, resp.remaining);
|
||||
|
||||
let secs = resp.remaining;
|
||||
detailState.totpInterval = setInterval(() => {
|
||||
secs--;
|
||||
if (secs <= 0) {
|
||||
chrome.runtime.sendMessage({ type: 'GET_TOTP', id }, r2 => {
|
||||
if (!detailState || detailState.id !== String(id)) return;
|
||||
if (r2?.code) { secs = r2.remaining; apply(r2.code, r2.remaining); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
secsEl.textContent = secs + 's';
|
||||
if (barEl) {
|
||||
barEl.style.width = Math.round(secs / 30 * 100) + '%';
|
||||
barEl.style.background = secs < 10 ? '#dc3545' : '#34d399';
|
||||
}
|
||||
if (!stillOpen()) return;
|
||||
if (render() === 0) refetch();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue