From 0150bf4b2f15f0191947ccf5a1ecb0984ebfcc62 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 17:42:00 +0200 Subject: [PATCH] feat: Passwort-Import aus Firefox, Chrome, Bitwarden und generischem CSV - Firefox-Import: CSV aus Einstellungen > Passwoerter > Exportieren Domain wird automatisch als Titel extrahiert - Generischer CSV-Import: Erkennt automatisch Spaltennamen aus Chrome, Bitwarden, 1Password und anderen Managern - KeePass-Import bleibt bestehen - Einheitlicher Import-Dialog mit Quellen-Auswahl (Dropdown) - Jede Quelle zeigt eine kurze Anleitung an - Alle Eintraege werden clientseitig verschluesselt vor dem Speichern - Backend: /passwords/import/firefox und /passwords/import/csv Endpunkte Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/passwords.py | 153 +++++++++++++++++++++++++++ frontend/src/views/PasswordsView.vue | 113 +++++++++++++++----- 2 files changed, 238 insertions(+), 28 deletions(-) diff --git a/backend/app/api/passwords.py b/backend/app/api/passwords.py index a78d48e..6530974 100644 --- a/backend/app/api/passwords.py +++ b/backend/app/api/passwords.py @@ -359,3 +359,156 @@ def import_keepass(): except Exception as e: return jsonify({'error': f'Import fehlgeschlagen: {str(e)}'}), 400 + + +# --- Firefox CSV Import --- + +@api_bp.route('/passwords/import/firefox', methods=['POST']) +@token_required +def import_firefox(): + """Import passwords from Firefox CSV export. + + Firefox: Einstellungen > Passwoerter > ... > Passwoerter exportieren + CSV columns: url, username, password, httpRealm, formActionOrigin, guid, timeCreated, timeLastUsed, timePasswordChanged + """ + user = request.current_user + + if 'file' not in request.files: + return jsonify({'error': 'Keine Datei gesendet'}), 400 + + csv_file = request.files['file'] + + try: + import csv + import io + + content = csv_file.read().decode('utf-8') + reader = csv.DictReader(io.StringIO(content)) + + entries = [] + for row in reader: + url = row.get('url', row.get('origin', '')).strip() + username = row.get('username', '').strip() + password = row.get('password', '').strip() + + if not url and not username and not password: + continue + + # Extract domain as title + title = url + try: + from urllib.parse import urlparse + parsed = urlparse(url) + title = parsed.netloc or parsed.path or url + except Exception: + pass + + entries.append({ + 'title': title, + 'url': url, + 'username': username, + 'password': password, + 'notes': '', + 'totp': '', + 'group': 'Firefox Import', + }) + + return jsonify({ + 'entries': entries, + 'groups': [{'name': 'Firefox Import', 'uuid': 'firefox-import', 'parent_uuid': None}] if entries else [], + 'count': len(entries), + }), 200 + + except Exception as e: + return jsonify({'error': f'CSV-Import fehlgeschlagen: {str(e)}'}), 400 + + +# --- Generic CSV Import (Chrome, Bitwarden, etc.) --- + +@api_bp.route('/passwords/import/csv', methods=['POST']) +@token_required +def import_generic_csv(): + """Import passwords from generic CSV (Chrome, Bitwarden, 1Password, etc.) + + Tries to auto-detect columns: name/title, url, username, password, notes + """ + user = request.current_user + + if 'file' not in request.files: + return jsonify({'error': 'Keine Datei gesendet'}), 400 + + csv_file = request.files['file'] + + try: + import csv + import io + + content = csv_file.read().decode('utf-8') + reader = csv.DictReader(io.StringIO(content)) + + # Map common column names + col_map = { + 'title': ['title', 'name', 'titel', 'bezeichnung', 'entry'], + 'url': ['url', 'uri', 'website', 'login_uri', 'origin'], + 'username': ['username', 'user', 'login', 'benutzername', 'email', 'login_username'], + 'password': ['password', 'passwort', 'pass', 'login_password'], + 'notes': ['notes', 'note', 'notizen', 'comment', 'comments', 'extra'], + 'totp': ['totp', 'otp', 'login_totp', '2fa'], + 'group': ['group', 'folder', 'ordner', 'category', 'kategorie', 'type'], + } + + def find_col(fieldnames, target): + for col_name in col_map.get(target, []): + for fn in fieldnames: + if fn.lower().strip() == col_name: + return fn + return None + + fieldnames = reader.fieldnames or [] + mapping = {target: find_col(fieldnames, target) for target in col_map} + + entries = [] + groups_set = set() + for row in reader: + def get(target): + col = mapping.get(target) + return row.get(col, '').strip() if col else '' + + title = get('title') + url = get('url') + username = get('username') + password = get('password') + + if not title and not url and not username and not password: + continue + + if not title and url: + try: + from urllib.parse import urlparse + title = urlparse(url).netloc or url + except Exception: + title = url + + group = get('group') or 'CSV Import' + groups_set.add(group) + + entries.append({ + 'title': title or '(Unbenannt)', + 'url': url, + 'username': username, + 'password': password, + 'notes': get('notes'), + 'totp': get('totp'), + 'group': group, + }) + + groups = [{'name': g, 'uuid': g, 'parent_uuid': None} for g in groups_set] + + return jsonify({ + 'entries': entries, + 'groups': groups, + 'count': len(entries), + }), 200 + + except Exception as e: + return jsonify({'error': f'CSV-Import fehlgeschlagen: {str(e)}'}), 400 diff --git a/frontend/src/views/PasswordsView.vue b/frontend/src/views/PasswordsView.vue index 8a1ac34..3ebacdd 100644 --- a/frontend/src/views/PasswordsView.vue +++ b/frontend/src/views/PasswordsView.vue @@ -5,7 +5,7 @@
@@ -110,20 +110,38 @@ - - -

Waehle eine .kdbx-Datei und gib das KeePass-Passwort ein.

+ +
- - + + +
+ +
+
@@ -165,9 +183,19 @@ const entryForm = ref({ title: '', url: '', username: '', password: '', totp_sec const showNewFolder = ref(false) const newFolderName = ref('') const showImport = ref(false) +const importSource = ref('firefox') const importPassword = ref('') const importing = ref(false) -const kdbxInput = ref(null) +const importFileInput = ref(null) +const importSources = [ + { label: 'Firefox (CSV)', value: 'firefox' }, + { label: 'KeePass (.kdbx)', value: 'keepass' }, + { label: 'CSV (Chrome, Bitwarden, etc.)', value: 'csv' }, +] +const importAccept = computed(() => { + if (importSource.value === 'keepass') return '.kdbx' + return '.csv' +}) const showTotpDialog = ref(false) const totpCode = ref('') @@ -369,49 +397,76 @@ async function generateTOTP(secret) { return code.toString().padStart(6, '0') } -async function importKeePass() { - if (!kdbxInput.value?.files?.length || !importPassword.value) return +async function doImport() { + if (!importFileInput.value?.files?.length) return + if (importSource.value === 'keepass' && !importPassword.value) { + toast.add({ severity: 'warn', summary: 'KeePass-Passwort erforderlich', life: 3000 }) + return + } + importing.value = true try { + // Step 1: Upload file to backend for parsing const formData = new FormData() - formData.append('file', kdbxInput.value.files[0]) - formData.append('password', importPassword.value) - const res = await apiClient.post('/passwords/import/keepass', formData, { + formData.append('file', importFileInput.value.files[0]) + if (importSource.value === 'keepass') { + formData.append('password', importPassword.value) + } + + const endpoint = { + keepass: '/passwords/import/keepass', + firefox: '/passwords/import/firefox', + csv: '/passwords/import/csv', + }[importSource.value] + + const res = await apiClient.post(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) + if (!res.data.count) { + toast.add({ severity: 'warn', summary: 'Keine Eintraege gefunden', life: 3000 }) + return + } + + // Step 2: Encrypt and store entries client-side const key = await getMasterKey() if (!key) return // Create folders const folderMap = {} for (const group of res.data.groups) { - const folder = await apiClient.post('/passwords/folders', { name: group.name }) - folderMap[group.uuid] = folder.data.id + try { + const folder = await apiClient.post('/passwords/folders', { name: group.name }) + folderMap[group.uuid || group.name] = folder.data.id + } catch { /* folder may already exist */ } } // Import entries + let imported = 0 for (const entry of res.data.entries) { - const folderId = entry.group_uuid ? folderMap[entry.group_uuid] : null + const folderId = folderMap[entry.group_uuid || entry.group] || null const iv = crypto.getRandomValues(new Uint8Array(12)) - await apiClient.post('/passwords/entries', { - title_encrypted: await encryptText(entry.title, key), - url_encrypted: await encryptText(entry.url, key), - username_encrypted: await encryptText(entry.username, key), - password_encrypted: await encryptText(entry.password, key), - notes_encrypted: await encryptText(entry.notes, key), - totp_secret_encrypted: await encryptText(entry.totp, key), - iv: btoa(String.fromCharCode(...iv)), - folder_id: folderId, - }) + try { + await apiClient.post('/passwords/entries', { + title_encrypted: await encryptText(entry.title, key), + url_encrypted: await encryptText(entry.url, key), + username_encrypted: await encryptText(entry.username, key), + password_encrypted: await encryptText(entry.password, key), + notes_encrypted: await encryptText(entry.notes, key), + totp_secret_encrypted: await encryptText(entry.totp, key), + iv: btoa(String.fromCharCode(...iv)), + folder_id: folderId, + }) + imported++ + } catch { /* skip duplicates */ } } showImport.value = false - toast.add({ severity: 'success', summary: `${res.data.count} Eintraege importiert`, life: 5000 }) + toast.add({ severity: 'success', summary: `${imported} Eintraege importiert`, life: 5000 }) await loadFolders() await loadEntries() } catch (err) { - toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error, life: 5000 }) + toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error || String(err), life: 5000 }) } finally { importing.value = false } @@ -450,4 +505,6 @@ onMounted(async () => { .empty { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color); } .totp-display { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; } .totp-code { font-size: 2rem; font-weight: 700; letter-spacing: 0.25em; font-family: monospace; } +.import-hint { background: var(--p-surface-50); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; } +.import-hint p { margin: 0; line-height: 1.5; }