Passwort nicht mehr speichern: OAuth-Token nur im RAM, 1x-Login pro Sitzung
Das DocuWare-Passwort wird nicht mehr in storage.local abgelegt. Das Hintergrundskript fungiert als Auth-Broker und hält den OAuth-Token nur im Speicher der TB-Sitzung. Der Ablage-Dialog fragt das Passwort einmalig per Overlay ab, holt darüber den Token und verwirft das Passwort sofort. - store.js: password aus DEFAULTS entfernt; Settings.set() löscht password defensiv - auth.js: setToken/currentToken für Token-Transfer, Cookie-Modus als Sentinel - background.js: Broker (auth:status/auth:logon/auth:logout) - dialog: ensureAuth() + Passwort-Overlay statt direktem Auth.logon - options: Passwort nur transient für Test/Diagnose, nicht gespeichert; Export ohne Passwort - manifest: lib/store.js + lib/auth.js ins Hintergrundskript geladen Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
15b2d578b0
commit
f1454540d5
15
README.md
15
README.md
@ -49,9 +49,12 @@ Add-on-Einstellungen öffnen und ausfüllen:
|
||||
- **Verbindung testen** → lädt die Aktenschränke (Bestätigung, dass Login & URL passen)
|
||||
- optional **Standard-Aktenschrank** und Default-Optionen (eml/pdf/Anhänge)
|
||||
|
||||
> ⚠️ Das Passwort wird unverschlüsselt in `storage.local` gespeichert (v1). Für den
|
||||
> Produktiveinsatz sollte auf den DocuWare Identity Service / OAuth umgestellt werden
|
||||
> (`lib/auth.js` ist dafür gekapselt).
|
||||
> 🔒 **Das Passwort wird nicht gespeichert.** Es wird beim ersten Ablegen pro
|
||||
> Thunderbird-Sitzung einmal abgefragt, daraus ein OAuth-Token geholt und sofort
|
||||
> verworfen. Nur der Token bleibt im Speicher des Hintergrundskripts (überlebt keinen
|
||||
> Neustart). In `storage.local` liegen ausschließlich Server, Organisation, Benutzer
|
||||
> und Standardwerte – keine Zugangsdaten. Auch der Einstellungs-Export enthält **kein**
|
||||
> Passwort.
|
||||
|
||||
## Benutzung
|
||||
|
||||
@ -78,7 +81,7 @@ Das Add-on wird **nicht** öffentlich auf addons.thunderbird.net gelistet, sonde
|
||||
Gitea verteilt + aktualisiert.
|
||||
|
||||
> Im XPI und in `updates.json` stehen **keine** Zugangsdaten. Das DocuWare-Passwort
|
||||
> liegt ausschließlich lokal in `storage.local` und wird nie mitverteilt.
|
||||
> wird ohnehin nirgends gespeichert (nur Sitzungs-Token im Speicher) und nie mitverteilt.
|
||||
|
||||
**Einmalige Einrichtung:**
|
||||
|
||||
@ -102,7 +105,9 @@ neueste dort gelistete Version.
|
||||
## Bekannte Grenzen / nächste Schritte
|
||||
|
||||
- **PDF** ist v1 ein einfaches Text-PDF (Kopf + Klartext-Body) ohne HTML-Layout/Bilder.
|
||||
- **Auth** nur Cookie-Logon; OAuth/Identity Service noch nicht implementiert.
|
||||
- **Auth**: primär DocuWare Identity Service (OAuth, ROPC mit öffentlichem Client),
|
||||
Fallback Cookie-Logon für alte On-Prem-Server. Passwort wird nicht gespeichert
|
||||
(Token nur im RAM, 1× Abfrage pro Sitzung).
|
||||
- **Upload-Multipart-Format** ggf. je nach DocuWare-Version anpassen
|
||||
(`docuware.js → uploadDocument`).
|
||||
- Mehrfachauswahl von Mails: aktuell wird die erste markierte Mail abgelegt.
|
||||
|
||||
@ -1,7 +1,43 @@
|
||||
// Hintergrund-Skript: Button + Kontextmenü, öffnet den Ablage-Dialog.
|
||||
// Zusätzlich Auth-Broker: hält den OAuth-Token nur im Speicher dieser TB-Sitzung
|
||||
// (siehe auth.js). Das Passwort wird einmalig zum Login durchgereicht, daraus ein
|
||||
// Token geholt und sofort verworfen – es wird NIE gespeichert.
|
||||
|
||||
const DIALOG_URL = "dialog/dialog.html";
|
||||
|
||||
// Auth-Broker: Dialog-/Optionsfenster fragen hier den Sitzungs-Token an bzw.
|
||||
// melden sich mit dem Passwort an. So muss das Passwort nur 1x pro Sitzung getippt
|
||||
// werden und liegt nirgends auf der Platte.
|
||||
browser.runtime.onMessage.addListener((msg) => {
|
||||
if (!msg || !msg.type) return undefined;
|
||||
|
||||
if (msg.type === "auth:status") {
|
||||
return Promise.resolve({ hasToken: Auth.hasValidToken(), token: Auth.currentToken() });
|
||||
}
|
||||
|
||||
if (msg.type === "auth:logon") {
|
||||
return (async () => {
|
||||
try {
|
||||
const settings = await Settings.get();
|
||||
if (!settings.serverUrl) throw new Error("Keine DocuWare-Server-URL konfiguriert.");
|
||||
Auth.reset();
|
||||
const r = await Auth.logon({ ...settings, password: msg.password || "" });
|
||||
// Passwort wird hier verworfen – nur der Token bleibt im Speicher.
|
||||
return { ok: true, mode: r.mode, token: Auth.currentToken() };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String((e && e.message) || e) };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (msg.type === "auth:logout") {
|
||||
Auth.reset();
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function openDialog(messageIds) {
|
||||
const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
|
||||
// Pro Nachricht ein eigenes Ablagefenster (leicht versetzt gestapelt).
|
||||
|
||||
@ -62,3 +62,23 @@ footer { border-top: 1px solid #e5e7eb; padding: 10px 18px; background: #fafafa;
|
||||
.muted { color: #6b7280; font-size: 12px; }
|
||||
.err { color: #b91c1c; } .ok { color: #15803d; }
|
||||
.field.invalid input, .field.invalid textarea, .field.invalid select { border-color: #b91c1c; }
|
||||
|
||||
/* Passwort-Abfrage (Sitzungs-Login, nicht gespeichert) */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, .35);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 50;
|
||||
}
|
||||
.overlay[hidden] { display: none; }
|
||||
.overlay-box {
|
||||
background: #fff; border-radius: 6px; padding: 18px 20px; width: 340px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.overlay-box h2 { font-size: 15px; font-weight: 600; margin: 0 0 8px; }
|
||||
.overlay-box input[type="password"] {
|
||||
width: 100%; padding: 7px 9px; border: 1px solid #b9c0c8; border-radius: 3px;
|
||||
font: inherit; margin-top: 8px;
|
||||
}
|
||||
.overlay-box input[type="password"]:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb33; }
|
||||
.overlay-box .err { min-height: 1em; margin-top: 6px; }
|
||||
.overlay-actions { display: flex; justify-content: flex-end; align-items: center; gap: 12px; margin-top: 10px; }
|
||||
.overlay-box .pw-note { margin-top: 10px; }
|
||||
|
||||
@ -33,6 +33,21 @@
|
||||
<div id="fields" class="fields"><div class="muted">Ziel wählen …</div></div>
|
||||
</main>
|
||||
|
||||
<!-- Passwort-Abfrage: nur für diese Sitzung, wird nicht gespeichert. -->
|
||||
<div id="pwOverlay" class="overlay" hidden>
|
||||
<div class="overlay-box">
|
||||
<h2>DocuWare-Anmeldung</h2>
|
||||
<div id="pwWho" class="muted"></div>
|
||||
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Passwort" />
|
||||
<div id="pwErr" class="err"></div>
|
||||
<div class="overlay-actions">
|
||||
<button id="pwCancel" class="link">Abbrechen</button>
|
||||
<button id="pwOk" class="primary">Anmelden</button>
|
||||
</div>
|
||||
<div class="muted pw-note">Wird nur für diese Sitzung verwendet und nicht gespeichert.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<section class="options">
|
||||
<label class="radio"><input type="radio" name="scope" value="both" checked /> E-Mail und Anhänge</label>
|
||||
|
||||
@ -87,7 +87,7 @@ async function init() {
|
||||
|
||||
try {
|
||||
status("Verbinde mit DocuWare …");
|
||||
await Auth.logon(STATE.settings);
|
||||
await ensureAuth();
|
||||
await loadCabinets();
|
||||
status("");
|
||||
} catch (e) {
|
||||
@ -95,6 +95,61 @@ async function init() {
|
||||
}
|
||||
}
|
||||
|
||||
// Holt den Sitzungs-Token vom Hintergrundskript. Ist keiner vorhanden, wird das
|
||||
// Passwort einmalig abgefragt (nur im RAM, nicht gespeichert) und ein Token geholt.
|
||||
async function ensureAuth() {
|
||||
const st = await browser.runtime.sendMessage({ type: "auth:status" });
|
||||
if (st && st.hasToken && st.token) {
|
||||
Auth.setToken(st.token);
|
||||
return;
|
||||
}
|
||||
let err = "";
|
||||
while (true) {
|
||||
const pw = await askPassword(err);
|
||||
if (pw === null) throw new Error("Anmeldung abgebrochen.");
|
||||
status("Anmeldung …");
|
||||
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
|
||||
if (res && res.ok) {
|
||||
Auth.setToken(res.token);
|
||||
return;
|
||||
}
|
||||
err = (res && res.error) || "Anmeldung fehlgeschlagen.";
|
||||
}
|
||||
}
|
||||
|
||||
// Zeigt das Passwort-Overlay und löst mit dem eingegebenen Passwort (oder null
|
||||
// bei Abbruch) auf. Das Passwort verlässt diese Funktion nur Richtung Broker.
|
||||
function askPassword(errMsg) {
|
||||
return new Promise((resolve) => {
|
||||
const ov = $("pwOverlay");
|
||||
const input = $("pwInput");
|
||||
$("pwWho").textContent = [STATE.settings.username, STATE.settings.organization]
|
||||
.filter(Boolean)
|
||||
.join(" @ ");
|
||||
$("pwErr").textContent = errMsg || "";
|
||||
input.value = "";
|
||||
ov.hidden = false;
|
||||
input.focus();
|
||||
|
||||
const cleanup = (val) => {
|
||||
ov.hidden = true;
|
||||
$("pwOk").removeEventListener("click", onOk);
|
||||
$("pwCancel").removeEventListener("click", onCancel);
|
||||
input.removeEventListener("keydown", onKey);
|
||||
resolve(val);
|
||||
};
|
||||
const onOk = () => { if (input.value) cleanup(input.value); };
|
||||
const onCancel = () => cleanup(null);
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); onOk(); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
||||
};
|
||||
$("pwOk").addEventListener("click", onOk);
|
||||
$("pwCancel").addEventListener("click", onCancel);
|
||||
input.addEventListener("keydown", onKey);
|
||||
});
|
||||
}
|
||||
|
||||
function applyDefaults() {
|
||||
$("optPdf").checked = STATE.settings.storePdf;
|
||||
const hasAtt = STATE.attachments.length > 0;
|
||||
|
||||
43
lib/auth.js
43
lib/auth.js
@ -16,12 +16,16 @@
|
||||
// Fallback: Findet sich kein Identity Service (alte On-Prem-Server), wird das
|
||||
// klassische Cookie-Logon versucht.
|
||||
//
|
||||
// Token wird im Speicher gehalten (TOKEN) und von authHeaders() ausgegeben.
|
||||
// Token wird NUR im Speicher gehalten (TOKEN), nie persistiert. Im Hintergrund-
|
||||
// skript überlebt es die TB-Sitzung und wird per Messaging an die Fenster gereicht
|
||||
// (siehe background.js). Das Passwort wird ausschließlich zum Holen des Tokens
|
||||
// verwendet und danach verworfen.
|
||||
|
||||
const DW_PUBLIC_CLIENT_ID = "docuware.platform.net.client";
|
||||
const DW_SCOPE = "docuware.platform";
|
||||
const COOKIE_TTL_MS = 8 * 60 * 60 * 1000; // Cookie-Session pragmatisch ~8h gültig
|
||||
|
||||
let TOKEN = null; // { accessToken, refreshToken, expiresAt, tokenEndpoint }
|
||||
let TOKEN = null; // { accessToken, expiresAt, mode, tokenEndpoint }
|
||||
|
||||
const Auth = {
|
||||
/**
|
||||
@ -47,9 +51,35 @@ const Auth = {
|
||||
return {};
|
||||
},
|
||||
|
||||
/** Aktuell ein Token vorhanden und (noch) gültig? */
|
||||
/** Aktuell ein Token vorhanden und (noch) gültig? (auch Cookie-Session) */
|
||||
hasValidToken() {
|
||||
return !!(TOKEN && TOKEN.accessToken && TOKEN.expiresAt > Date.now() + 5000);
|
||||
return !!(
|
||||
TOKEN &&
|
||||
(TOKEN.accessToken || TOKEN.mode === "cookie") &&
|
||||
TOKEN.expiresAt > Date.now() + 5000
|
||||
);
|
||||
},
|
||||
|
||||
/** Serialisierbare Token-Sicht für den Transfer per Messaging. */
|
||||
currentToken() {
|
||||
if (!TOKEN) return null;
|
||||
return { accessToken: TOKEN.accessToken || null, expiresAt: TOKEN.expiresAt, mode: TOKEN.mode };
|
||||
},
|
||||
|
||||
/**
|
||||
* Übernimmt ein vom Broker (Hintergrundskript) geliefertes Token in diesen
|
||||
* Kontext, damit authHeaders()/credentials greifen. Kein Passwort nötig.
|
||||
*/
|
||||
setToken(t) {
|
||||
if (!t || (!t.accessToken && t.mode !== "cookie")) {
|
||||
TOKEN = null;
|
||||
return;
|
||||
}
|
||||
TOKEN = {
|
||||
accessToken: t.accessToken || null,
|
||||
expiresAt: t.expiresAt || Date.now() + COOKIE_TTL_MS,
|
||||
mode: t.mode || (t.accessToken ? "identity" : "cookie"),
|
||||
};
|
||||
},
|
||||
|
||||
/** Token verwerfen (z.B. beim Wechsel der Zugangsdaten). */
|
||||
@ -126,8 +156,8 @@ const Auth = {
|
||||
|
||||
TOKEN = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
expiresAt: Date.now() + (Number(data.expires_in) || 3600) * 1000,
|
||||
mode: "identity",
|
||||
tokenEndpoint,
|
||||
};
|
||||
},
|
||||
@ -156,6 +186,9 @@ const Auth = {
|
||||
const text = await this._safeText(res);
|
||||
throw new Error(`Logon fehlgeschlagen (HTTP ${res.status}). ${text}`.trim());
|
||||
}
|
||||
// Cookie-Session: kein Bearer-Token, Folgeaufrufe per credentials:"include".
|
||||
// Wir merken nur, dass eine Session besteht (für hasValidToken/Sitzungs-Cache).
|
||||
TOKEN = { accessToken: null, expiresAt: Date.now() + COOKIE_TTL_MS, mode: "cookie" };
|
||||
},
|
||||
|
||||
async _safeText(res) {
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
// Einstellungen lesen/schreiben (browser.storage.local).
|
||||
// Hinweis: Credentials werden v1 im Klartext gespeichert (siehe README / Risiken).
|
||||
// Wichtig: Das Passwort wird NICHT gespeichert. Es wird beim Login einmalig
|
||||
// abgefragt, daraus ein OAuth-Token geholt und sofort verworfen (siehe auth.js
|
||||
// + background.js). In storage.local liegen nur unkritische Daten.
|
||||
|
||||
const DEFAULTS = {
|
||||
serverUrl: "", // z.B. https://docuware.example.com (ohne /DocuWare/Platform)
|
||||
organization: "",
|
||||
username: "",
|
||||
password: "",
|
||||
defaultCabinetId: "",
|
||||
dialogByCabinet: {}, // gemerkter Ablagedialog je Schrank: { [cabinetId]: dialogId }
|
||||
storeEml: true,
|
||||
@ -23,6 +24,8 @@ const Settings = {
|
||||
async set(partial) {
|
||||
const current = await this.get();
|
||||
const next = { ...current, ...partial };
|
||||
// Sicherheitsnetz: Passwort darf NIE persistiert werden (z.B. aus Alt-Importen).
|
||||
delete next.password;
|
||||
await browser.storage.local.set({ settings: next });
|
||||
return next;
|
||||
},
|
||||
|
||||
@ -27,7 +27,11 @@
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background/background.js"]
|
||||
"scripts": [
|
||||
"lib/store.js",
|
||||
"lib/auth.js",
|
||||
"background/background.js"
|
||||
]
|
||||
},
|
||||
"message_display_action": {
|
||||
"default_title": "In DocuWare ablegen",
|
||||
|
||||
@ -35,8 +35,9 @@
|
||||
<input type="text" id="username" autocomplete="username" />
|
||||
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" autocomplete="current-password" />
|
||||
<div class="hint">⚠️ Wird unverschlüsselt lokal gespeichert.</div>
|
||||
<input type="password" id="password" autocomplete="off" />
|
||||
<div class="hint">Wird <strong>nicht gespeichert</strong> – nur zum Anmelden/Testen für
|
||||
diese Sitzung. Im Ablage-Fenster wird es 1× pro Sitzung abgefragt.</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
@ -61,8 +62,9 @@
|
||||
|
||||
<fieldset>
|
||||
<legend>Einstellungen sichern</legend>
|
||||
<div class="hint">Exportiert/importiert alle Einstellungen (inkl. Login) als Datei –
|
||||
so musst du nach einer Neuinstallation nichts neu eintippen.</div>
|
||||
<div class="hint">Exportiert/importiert die Einstellungen (Server, Organisation,
|
||||
Benutzer, Standardwerte) als Datei. <strong>Das Passwort ist nicht enthalten</strong>
|
||||
und muss nach einer Neuinstallation einmal neu eingegeben werden.</div>
|
||||
<div class="row">
|
||||
<button id="exportBtn" class="secondary">Exportieren</button>
|
||||
<button id="importBtn" class="secondary">Importieren</button>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
// Logik der Einstellungsseite.
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const FIELDS = ["serverUrl", "organization", "username", "password"];
|
||||
// Passwort ist bewusst NICHT dabei – es wird nie gespeichert, nur zum Testen/Login
|
||||
// transient verwendet (siehe "Verbindung testen").
|
||||
const FIELDS = ["serverUrl", "organization", "username"];
|
||||
const CHECKS = ["storeEml", "storePdf", "storeAttachments", "tagOnSuccess"];
|
||||
|
||||
function setStatus(msg, kind) {
|
||||
@ -53,10 +55,17 @@ $("save").addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
$("test").addEventListener("click", async () => {
|
||||
const pw = $("password").value;
|
||||
if (!pw) { setStatus("Bitte Passwort eingeben.", "err"); return; }
|
||||
setStatus("Verbinde …");
|
||||
try {
|
||||
const s = await Settings.set(collect()); // erst speichern, dann testen
|
||||
await Auth.logon(s);
|
||||
const s = await Settings.set(collect()); // erst speichern (ohne Passwort), dann testen
|
||||
// Anmeldung über den Hintergrund-Broker: primt zugleich die Sitzung, sodass
|
||||
// das Ablege-Fenster danach nicht erneut nach dem Passwort fragt.
|
||||
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
|
||||
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
|
||||
Auth.setToken(res.token);
|
||||
$("password").value = ""; // Passwort nicht behalten
|
||||
const cabinets = await DocuWare.listCabinets(s);
|
||||
await browser.storage.local.set({ cabinets });
|
||||
fillCabinets(cabinets, s.defaultCabinetId);
|
||||
@ -111,7 +120,18 @@ $("diagBtn").addEventListener("click", async () => {
|
||||
out.value = "Analysiere …";
|
||||
try {
|
||||
const s = await Settings.set(collect());
|
||||
await Auth.logon(s);
|
||||
// Sitzungs-Token nutzen; falls noch keiner da ist, Passwortfeld verwenden.
|
||||
const st = await browser.runtime.sendMessage({ type: "auth:status" });
|
||||
if (st && st.hasToken && st.token) {
|
||||
Auth.setToken(st.token);
|
||||
} else {
|
||||
const pw = $("password").value;
|
||||
if (!pw) { out.value = "Bitte Passwort eingeben (oder zuerst „Verbindung testen“)."; return; }
|
||||
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
|
||||
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
|
||||
Auth.setToken(res.token);
|
||||
$("password").value = "";
|
||||
}
|
||||
const dump = await DocuWare.diagnoseStoreDialog(s, cabinetId);
|
||||
out.value = JSON.stringify(dump, null, 2);
|
||||
} catch (e) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user