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) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-11 17:42:00 +02:00
parent 7220a2ef75
commit 0150bf4b2f
2 changed files with 238 additions and 28 deletions

View File

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

View File

@ -5,7 +5,7 @@
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-plus" label="Neuer Eintrag" size="small" @click="openNewEntry" />
<Button icon="pi pi-upload" label="KeePass Import" size="small" outlined @click="showImport = true" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="showImport = true" />
</div>
</div>
@ -110,20 +110,38 @@
</template>
</Dialog>
<!-- KeePass Import -->
<Dialog v-model:visible="showImport" header="KeePass Import" modal :style="{ width: '500px' }">
<p>Waehle eine .kdbx-Datei und gib das KeePass-Passwort ein.</p>
<!-- Import Dialog -->
<Dialog v-model:visible="showImport" header="Passwoerter importieren" modal :style="{ width: '520px' }">
<div class="field">
<label>KDBX-Datei</label>
<input ref="kdbxInput" type="file" accept=".kdbx" />
<label>Quelle</label>
<Select v-model="importSource" :options="importSources" optionLabel="label" optionValue="value" fluid />
</div>
<div class="import-hint">
<template v-if="importSource === 'keepass'">
<p>Waehle eine <strong>.kdbx</strong>-Datei und gib das KeePass-Master-Passwort ein.</p>
</template>
<template v-else-if="importSource === 'firefox'">
<p>Firefox: <strong>Einstellungen > Passwoerter > > Passwoerter exportieren</strong><br/>Waehle die exportierte CSV-Datei aus.</p>
</template>
<template v-else-if="importSource === 'csv'">
<p>CSV mit Spalten wie <em>title/name, url, username, password, notes</em>.<br/>Funktioniert mit Chrome, Bitwarden, 1Password und anderen Managern.</p>
</template>
</div>
<div class="field">
<label>Datei</label>
<input ref="importFileInput" type="file" :accept="importAccept" />
</div>
<div v-if="importSource === 'keepass'" class="field">
<label>KeePass-Passwort</label>
<Password v-model="importPassword" :feedback="false" toggle-mask fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showImport = false" />
<Button label="Importieren" @click="importKeePass" :loading="importing" />
<Button label="Importieren" @click="doImport" :loading="importing" />
</template>
</Dialog>
@ -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; }
</style>