diff --git a/services/authelia.sh b/services/authelia.sh index 4e67952..5afde51 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -242,10 +242,12 @@ install_authelia() { echo " or type one on a different box — gates it with a login, same as any" echo " other service already protected this way)" echo " 11) Un-protect a site (undoes option 10 for one site)" + echo " 12) Export/import user data (backup accounts + 2FA before a reinstall," + echo " or restore a previous export)" echo " 0) Leave as-is / exit" echo "" local EXISTING_CHOICE="" - prompt_text " Choice [1-11, 0 to exit]:" "0" EXISTING_CHOICE + prompt_text " Choice [1-12, 0 to exit]:" "0" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain @@ -290,6 +292,10 @@ install_authelia() { _authelia_unprotect_site return 0 ;; + 12) + _authelia_export_import_users_menu + return 0 + ;; 0|*) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 @@ -1776,6 +1782,151 @@ _authelia_set_remember_me() { log_info "changes how long checking it actually keeps you signed in." } +# Export/import accounts (+ optionally 2FA/session state) — for migrating to +# a fresh instance or restoring after a reinstall without losing accounts or +# forcing everyone to re-enroll 2FA. Passwords are never exported as +# plaintext — Authelia only ever stores an argon2id hash — but that hash is +# fully portable: dropping it into another instance's users.yml (same +# hashing settings, which this repo's installer always uses) makes the +# original password keep working, no reset required. +# +# TOTP secrets inside data/db.sqlite3 are AES-encrypted with this instance's +# own storage encryption key (config/secrets/storage_secret) — NOT with +# anything derived from the password. install_authelia's fresh-install path +# generates a brand-new storage_secret every time (openssl rand -hex 32, +# same as jwt_secret/session_secret), so a db.sqlite3 copied onto an +# instance with a different storage_secret has 2FA data Authelia can't +# decrypt. Export/import both carry storage_secret alongside db.sqlite3 so a +# "remove and recreate" round-trip (export, reinstall, import) keeps 2FA +# working — session_secret/jwt_secret don't need to match (only sign +# cookies / password-reset links, safe to rotate) so those are left alone. +_authelia_export_import_users_menu() { + local authelia_dir="$DOCKER_DIR/authelia" + [ -f "$authelia_dir/config/users.yml" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } + + echo "" + echo " Export/import user data" + echo " 1) Export (users.yml + 2FA/session data) to a backup folder" + echo " 2) Import from a previous export (overwrites current users)" + echo " 0) Back" + local choice="" + prompt_text " Choice [1-2, 0 to go back]:" "0" choice + case "$choice" in + 1) _authelia_export_users ;; + 2) _authelia_import_users ;; + 0|*) return 0 ;; + esac +} + +_authelia_export_users() { + local authelia_dir="$DOCKER_DIR/authelia" + local users_file="$authelia_dir/config/users.yml" + local db_file="$authelia_dir/data/db.sqlite3" + local storage_secret_file="$authelia_dir/config/secrets/storage_secret" + + local default_dest="${ACTUAL_HOME:-$HOME}/authelia-export-$(date +%Y%m%d)" + local dest="" + prompt_text " Export to which directory? [${default_dest}]:" "$default_dest" dest + [ -z "$dest" ] && dest="$default_dest" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would export $users_file, $db_file, and $storage_secret_file to $dest" + return 0 + fi + + mkdir -p "$dest" + cp "$users_file" "$dest/users.yml" + + local exported_2fa="no" + if [ -f "$db_file" ] && [ -f "$storage_secret_file" ]; then + cp "$db_file" "$dest/db.sqlite3" + cp "$storage_secret_file" "$dest/storage_secret" + exported_2fa="yes" + fi + + # Readable summary alongside the raw file — username / display name / + # email / groups, no password hash — handy to eyeball or hand off + # without pasting the full users.yml. + awk ' + /^ [a-zA-Z0-9_-]+:$/ { if (u) print u, "|", d, "|", e, "|", g; u=$1; sub(":","",u); d=""; e=""; g="" } + /^ displayname:/ { d=$0; sub(/^ displayname: */,"",d) } + /^ email:/ { e=$0; sub(/^ email: */,"",e) } + /^ - / { line=$0; gsub(/^ - /,"",line); g = g line "," } + END { if (u) print u, "|", d, "|", e, "|", g } + ' "$users_file" > "$dest/users-summary.txt" + + chown -R "${ACTUAL_USER:-$(id -un)}:${ACTUAL_USER:-$(id -un)}" "$dest" 2>/dev/null || true + chmod 600 "$dest/users.yml" "$dest/storage_secret" 2>/dev/null || true + + log_success "Exported to $dest" + echo " users.yml — full account data incl. password hashes (portable, works as-is on import)" + if [ "$exported_2fa" = "yes" ]; then + echo " db.sqlite3 — 2FA/TOTP registrations + session storage" + echo " storage_secret — required alongside db.sqlite3 to decrypt the 2FA data (keep this file private)" + else + log_warning " No data/db.sqlite3 or secrets/storage_secret found — 2FA registrations were NOT exported. Users will need to re-enroll 2FA after an import." + fi + echo " users-summary.txt — readable username/displayname/email/groups list, no password hash" +} + +_authelia_import_users() { + local authelia_dir="$DOCKER_DIR/authelia" + local users_file="$authelia_dir/config/users.yml" + local db_file="$authelia_dir/data/db.sqlite3" + local storage_secret_file="$authelia_dir/config/secrets/storage_secret" + + local src="" + prompt_text " Import from which directory (containing users.yml)?:" "" src + [ -z "$src" ] && { log_info "Cancelled — nothing changed."; return 0; } + src="${src%/}" + + if [ ! -f "$src/users.yml" ]; then + log_warning "No users.yml found in $src — nothing to import." + return 1 + fi + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would replace $users_file with $src/users.yml" + [ -f "$src/db.sqlite3" ] && echo "[DRY-RUN] Would replace $db_file and $storage_secret_file with the exported copies" + return 0 + fi + + local ts + ts="$(date +%Y%m%d-%H%M%S)" + [ -f "$users_file" ] && cp "$users_file" "$users_file.bak.$ts" + + cp "$src/users.yml" "$users_file" + chown 1000:1000 "$users_file" + log_success "Imported users.yml (previous version backed up to $(basename "$users_file").bak.$ts)" + + if [ -f "$src/db.sqlite3" ] && [ -f "$src/storage_secret" ]; then + local import_db="" + prompt_yn " Also import 2FA/session data (db.sqlite3 + storage_secret) — restores everyone's existing TOTP enrollment instead of forcing a re-scan? (y/n):" "y" import_db + if [[ "$import_db" =~ ^[Yy]$ ]]; then + [ -f "$db_file" ] && cp "$db_file" "$db_file.bak.$ts" + [ -f "$storage_secret_file" ] && cp "$storage_secret_file" "$storage_secret_file.bak.$ts" + cp "$src/db.sqlite3" "$db_file" + cp "$src/storage_secret" "$storage_secret_file" + chown 1000:1000 "$db_file" "$storage_secret_file" + chmod 600 "$storage_secret_file" + log_success "Imported db.sqlite3 + storage_secret (previous versions backed up alongside them)." + log_warning "storage_secret must match what encrypted this db.sqlite3 — don't import one without the other, or 2FA data becomes undecryptable." + fi + elif [ -f "$src/db.sqlite3" ] || [ -f "$src/storage_secret" ]; then + log_warning "Found only one of db.sqlite3 / storage_secret in $src — need both together to safely restore 2FA data, so skipping. Imported users will need to re-enroll 2FA on first login." + else + log_info "No 2FA/session export found in $src — imported users will need to re-enroll 2FA on first login." + fi + + local restart_auth="" + prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth + if [[ "$restart_auth" =~ ^[Yy]$ ]]; then + (cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \ + && log_success "Authelia restarted" \ + || log_warning "Restart failed — check: docker compose logs authelia" + fi +} + # action="exempt": inserts a "policy: one_factor / subject: user:" rule # immediately before EVERY plain "policy: two_factor" catch-all domain rule in # configuration.yml (handles multi-domain instances from add_authelia_domain