From bd5aa223fb5d0ae682cfab5079b7079a1f9deeed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:15:33 +0000 Subject: [PATCH 1/2] Prevent a doubled portal domain when the full domain is typed by mistake install_authelia()'s and add_authelia_domain()'s "subdomain for the login portal" prompts concatenated whatever was typed directly with the apex domain (AUTHELIA_PORTAL_SUBDOMAIN + "." + AUTHELIA_DOMAIN), with no guard against someone typing the full portal domain they actually want (e.g. "authelia.mydomain.com") instead of just the subdomain label ("authelia"). That produces a silently broken, doubled hostname like "authelia.mydomain.com.mydomain.com" -- which never matches a real request, so Caddy falls through to some default response instead of ever reaching real Authelia policy evaluation. Confirmed live: this is exactly what happened on a real box, and explains a much bigger symptom than the obviously-wrong hostname alone would suggest -- every forward_auth-gated site on the instance silently bypassed Authelia entirely, not just requests to the portal itself, since the forward_auth subrequest to the (wrong) portal URL never got a real answer either. Both prompts now detect and strip an accidentally-included apex suffix (with a one-line notice), and fall back to "auth" if someone enters the bare apex domain itself (which can't work as the portal -- it would collide with the wildcard rule protecting every other domain). Verified against the exact doubled-domain input, a bare-apex input, and two ordinary short-label inputs before shipping. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/authelia.sh | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/services/authelia.sh b/services/authelia.sh index 72cfea7..4e67952 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -315,6 +315,24 @@ install_authelia() { # it back from configuration.yml's session.cookies authelia_url instead # of assuming "auth." — see those functions for why. prompt_text " Subdomain for the login portal (e.g. 'auth' -> auth.${AUTHELIA_DOMAIN}):" "auth" AUTHELIA_PORTAL_SUBDOMAIN + # Auto-correct the full domain being typed here by mistake (e.g. + # "authelia.mydomain.com" instead of just "authelia") — concatenating + # that with .${AUTHELIA_DOMAIN} below would otherwise silently produce + # a doubled, broken domain like "authelia.mydomain.com.mydomain.com" + # that never matches any real request. Confirmed live: this is exactly + # what happened on a real box, and it explained a much bigger mystery + # than the obviously-wrong hostname alone would suggest — every + # forward_auth-gated site on the instance silently bypassed Authelia, + # because Caddy had no site block matching the real portal hostname at + # all, so the forward_auth subrequest never reached real policy + # evaluation in the first place. + if [[ "$AUTHELIA_PORTAL_SUBDOMAIN" == *".${AUTHELIA_DOMAIN}" ]]; then + AUTHELIA_PORTAL_SUBDOMAIN="${AUTHELIA_PORTAL_SUBDOMAIN%.${AUTHELIA_DOMAIN}}" + log_info "That already included the domain — using just '${AUTHELIA_PORTAL_SUBDOMAIN}' as the subdomain." + elif [[ "$AUTHELIA_PORTAL_SUBDOMAIN" == "$AUTHELIA_DOMAIN" ]]; then + log_warning "That's the apex domain itself, not a subdomain — the portal can't live at the bare apex (it would collide with the wildcard rule protecting everything else). Using 'auth' instead." + AUTHELIA_PORTAL_SUBDOMAIN="auth" + fi AUTHELIA_PORTAL_DOMAIN="${AUTHELIA_PORTAL_SUBDOMAIN}.${AUTHELIA_DOMAIN}" prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY @@ -695,6 +713,17 @@ add_authelia_domain() { local NEW_PORTAL_SUBDOMAIN NEW_PORTAL_DOMAIN prompt_text " Subdomain for this domain's own login portal (e.g. 'auth' -> auth.${NEW_DOMAIN}):" "auth" NEW_PORTAL_SUBDOMAIN + # See install_authelia's identical guard on AUTHELIA_PORTAL_SUBDOMAIN + # for why this matters — typing the full domain here instead of just + # the subdomain silently produces a doubled, broken hostname that + # never matches any real request. + if [[ "$NEW_PORTAL_SUBDOMAIN" == *".${NEW_DOMAIN}" ]]; then + NEW_PORTAL_SUBDOMAIN="${NEW_PORTAL_SUBDOMAIN%.${NEW_DOMAIN}}" + log_info "That already included the domain — using just '${NEW_PORTAL_SUBDOMAIN}' as the subdomain." + elif [[ "$NEW_PORTAL_SUBDOMAIN" == "$NEW_DOMAIN" ]]; then + log_warning "That's the apex domain itself, not a subdomain — the portal can't live at the bare apex (it would collide with the wildcard rule protecting everything else). Using 'auth' instead." + NEW_PORTAL_SUBDOMAIN="auth" + fi NEW_PORTAL_DOMAIN="${NEW_PORTAL_SUBDOMAIN}.${NEW_DOMAIN}" # ── access_control.rules: insert right after "rules:" ──────────────────── From 9a7989b31df8a3664b9a83957007ffe11ef71995 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 00:54:14 +0000 Subject: [PATCH 2/2] Add Authelia menu option to export/import user data Lets accounts (users.yml, portable argon2id hashes included) and 2FA/session state (data/db.sqlite3 + the storage_secret needed to decrypt it) round-trip through a reinstall without resetting passwords or forcing everyone to re-enroll their authenticator. --- services/authelia.sh | 153 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) 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