feat: add administrator password recovery menu
This commit is contained in:
@@ -83,6 +83,8 @@ sudo newszxcn-email rollback
|
||||
```bash
|
||||
sudo ns
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
@@ -90,6 +92,8 @@ sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
@@ -16,6 +16,8 @@ sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email rollback
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
```
|
||||
|
||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||
@@ -24,6 +26,8 @@ sudo newszxcn-email guide
|
||||
|
||||
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
|
||||
|
||||
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
||||
|
||||
@@ -103,12 +103,16 @@ sudo newszxcn-email logs
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email rollback
|
||||
sudo newszxcn-email guide
|
||||
sudo newszxcn-email credentials
|
||||
sudo newszxcn-email reset-password
|
||||
```
|
||||
|
||||
命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。
|
||||
|
||||
`guide` 命令会读取当前安装地址、管理员用户名、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`。
|
||||
|
||||
`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后重置配置管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。
|
||||
|
||||
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
|
||||
|
||||
## 必要端口
|
||||
|
||||
+129
-4
@@ -35,6 +35,8 @@ NewSzxcn Email 管理命令
|
||||
certificate 申请或续期自动模式的 SSL 证书
|
||||
rollback 回滚到上次更新前版本
|
||||
guide 显示并更新 NewSzxcn 邮箱指南
|
||||
credentials 查看管理员登录信息和记录密码
|
||||
reset-password 重置管理员统一登录密码(含名下邮箱)
|
||||
uninstall 停止并移除容器,保留邮件与配置
|
||||
EOF
|
||||
}
|
||||
@@ -233,7 +235,7 @@ prompt_choice() {
|
||||
}
|
||||
|
||||
prompt_menu_choice() {
|
||||
local default_value="$1" max_value="${2:-10}" value="${LANQIN_MENU_ACTION:-}"
|
||||
local default_value="$1" max_value="${2:-12}" value="${LANQIN_MENU_ACTION:-}"
|
||||
if [[ -z "${value}" ]] && ! has_tty; then
|
||||
fail "非交互环境请直接使用 install、update、status 等子命令。"
|
||||
fi
|
||||
@@ -320,6 +322,50 @@ prompt_admin_password() {
|
||||
done
|
||||
}
|
||||
|
||||
prompt_reset_password() {
|
||||
local password="${LANQIN_RESET_PASSWORD:-}" confirm=""
|
||||
local safe_password_re='^[A-Za-z0-9][A-Za-z0-9._!@#%+,=:;?*/()^-]*$'
|
||||
if [[ -n "${password}" ]]; then
|
||||
[[ ${#password} -ge 6 ]] || fail "新密码至少需要 6 个字符。"
|
||||
[[ "${password}" =~ ${safe_password_re} ]] || fail "新密码包含安装配置不支持的字符。"
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
fi
|
||||
if ! has_tty; then
|
||||
password="$(random_admin_password)"
|
||||
prompt_text "[提示] 已自动生成 12 位新密码:${password}\n"
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
fi
|
||||
while true; do
|
||||
read -r -s -p "新密码(回车自动生成 12 位,或输入至少 6 位): " password </dev/tty
|
||||
printf '\n' >/dev/tty
|
||||
if [[ -z "${password}" ]]; then
|
||||
password="$(random_admin_password)"
|
||||
prompt_text "[提示] 已自动生成 12 位新密码:${password}\n"
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
fi
|
||||
if [[ ${#password} -lt 6 ]]; then
|
||||
prompt_text "[提示] 新密码至少需要 6 个字符。\n"
|
||||
continue
|
||||
fi
|
||||
if [[ ! "${password}" =~ ${safe_password_re} ]]; then
|
||||
prompt_text "[提示] 密码必须以字母或数字开头,只能使用字母、数字和常用符号。\n"
|
||||
continue
|
||||
fi
|
||||
read -r -s -p "再次输入新密码: " confirm </dev/tty
|
||||
printf '\n' >/dev/tty
|
||||
if [[ "${password}" != "${confirm}" ]]; then
|
||||
prompt_text "[提示] 两次输入的密码不一致,请重新输入。\n"
|
||||
password=""
|
||||
continue
|
||||
fi
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
done
|
||||
}
|
||||
|
||||
configure_first_install() {
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
return
|
||||
@@ -1030,6 +1076,8 @@ SSL 证书:有效期至 ${certificate_expiry}
|
||||
查看状态:newszxcn-email status
|
||||
查看日志:newszxcn-email logs
|
||||
恢复版本:newszxcn-email rollback
|
||||
查看管理员登录信息:newszxcn-email credentials
|
||||
重置管理员密码:newszxcn-email reset-password
|
||||
|
||||
公开教程:
|
||||
https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md
|
||||
@@ -1044,6 +1092,77 @@ do_guide() {
|
||||
success "指南已更新并保存到 ${GUIDE_FILE}。"
|
||||
}
|
||||
|
||||
do_show_admin_credentials() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
|
||||
local username password public_url
|
||||
username="$(env_value LANQIN_ADMIN_USERNAME || true)"
|
||||
password="$(env_value LANQIN_ADMIN_PASSWORD || true)"
|
||||
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
|
||||
cat <<EOF
|
||||
|
||||
==================================================
|
||||
NewSzxcn 管理员登录信息
|
||||
==================================================
|
||||
登录地址:${public_url:-未记录}
|
||||
管理员用户名:${username:-admin}
|
||||
记录密码:${password:-未记录}
|
||||
==================================================
|
||||
EOF
|
||||
warn "当前密码采用 bcrypt 加密,无法从数据库反向查看。这里显示的是安装或最近一次命令行重置时记录的密码;如果之后在网页修改过密码,该记录可能已经失效。"
|
||||
}
|
||||
|
||||
generate_admin_password_hash() {
|
||||
local password="$1" hash
|
||||
hash="$(compose exec -T lanqin-email doveadm pw -s BLF-CRYPT -r 10 -p "${password}" 2>/dev/null | tail -n 1)"
|
||||
hash="${hash#\{BLF-CRYPT\}}"
|
||||
[[ "${hash}" =~ ^\$2[aby]\$[0-9]{2}\$.{53}$ ]] || return 1
|
||||
printf '%s' "${hash}"
|
||||
}
|
||||
|
||||
do_reset_admin_password() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
|
||||
local username password user_id hash image timestamp backup env_backup result user_changes mailbox_changes
|
||||
ensure_docker
|
||||
username="$(env_value LANQIN_ADMIN_USERNAME || true)"
|
||||
username="${username:-admin}"
|
||||
[[ "${username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名配置无效,无法安全重置。"
|
||||
user_id="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "SELECT id FROM users WHERE login_name='${username}' AND role='admin' LIMIT 1;" | tr -d '\r\n')"
|
||||
[[ "${user_id}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "没有找到管理员账号 ${username}。"
|
||||
password="$(prompt_reset_password)"
|
||||
hash="$(generate_admin_password_hash "${password}")" || fail "无法生成安全密码哈希,管理员密码未修改。"
|
||||
image="$(current_image_id || true)"
|
||||
[[ -n "${image}" ]] || fail "无法确定当前镜像,管理员密码未修改。"
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
backup="${INSTALL_DIR}/data/backups/password-reset-${timestamp}.db"
|
||||
backup_database "${backup}" "${image}" || fail "数据库备份失败,管理员密码未修改。"
|
||||
|
||||
env_backup="$(mktemp)"
|
||||
install -m 0600 "${INSTALL_DIR}/.env" "${env_backup}"
|
||||
if ! set_env LANQIN_ADMIN_PASSWORD "${password}"; then
|
||||
rm -f "${env_backup}"
|
||||
fail "管理员密码记录更新失败,数据库未修改。"
|
||||
fi
|
||||
chmod 0600 "${INSTALL_DIR}/.env"
|
||||
if ! result="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "BEGIN IMMEDIATE; UPDATE users SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id='${user_id}' AND role='admin'; SELECT 'user=' || changes(); UPDATE mailboxes SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id='${user_id}' AND EXISTS (SELECT 1 FROM users WHERE id='${user_id}' AND role='admin'); SELECT 'mailboxes=' || changes(); COMMIT;" | tr -d '\r')"; then
|
||||
install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env"
|
||||
rm -f "${env_backup}"
|
||||
fail "管理员密码写入失败,已恢复原密码记录。"
|
||||
fi
|
||||
user_changes="$(printf '%s\n' "${result}" | sed -n 's/^user=//p' | tail -n 1)"
|
||||
mailbox_changes="$(printf '%s\n' "${result}" | sed -n 's/^mailboxes=//p' | tail -n 1)"
|
||||
if [[ "${user_changes}" != "1" || ! "${mailbox_changes}" =~ ^[0-9]+$ ]]; then
|
||||
install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env"
|
||||
rm -f "${env_backup}"
|
||||
fail "管理员账号不存在或身份已变化,密码未修改;已恢复原密码记录。"
|
||||
fi
|
||||
rm -f "${env_backup}"
|
||||
success "管理员 ${username} 的统一登录密码已重置。"
|
||||
printf '新密码:%s\n' "${password}"
|
||||
log "重置前数据库备份:${backup}"
|
||||
log "已同步 ${mailbox_changes} 个管理员邮箱的 SMTP/IMAP 密码。"
|
||||
warn "此次操作只修改管理员账号及其名下邮箱,不会修改普通用户或其邮箱密码。"
|
||||
}
|
||||
|
||||
do_status() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||
compose ps
|
||||
@@ -1181,11 +1300,13 @@ do_menu() {
|
||||
prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
|
||||
prompt_text ' 8. 回滚到上次更新前版本\n'
|
||||
prompt_text ' 9. NewSzxcn 邮箱指南\n'
|
||||
prompt_text ' 10. 卸载服务(保留数据)\n'
|
||||
prompt_text ' 10. 查看管理员登录信息\n'
|
||||
prompt_text ' 11. 重置管理员统一登录密码\n'
|
||||
prompt_text ' 12. 卸载服务(保留数据)\n'
|
||||
prompt_text ' 0. 退出\n'
|
||||
prompt_text '==================================================\n'
|
||||
|
||||
choice="$(prompt_menu_choice "${default_choice}" "10")"
|
||||
choice="$(prompt_menu_choice "${default_choice}" "12")"
|
||||
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
|
||||
fail "尚未安装,请先选择 1。"
|
||||
fi
|
||||
@@ -1201,7 +1322,9 @@ do_menu() {
|
||||
7) do_certificate ;;
|
||||
8) ensure_docker; do_rollback ;;
|
||||
9) do_guide ;;
|
||||
10) ensure_docker; do_uninstall ;;
|
||||
10) do_show_admin_credentials ;;
|
||||
11) do_reset_admin_password ;;
|
||||
12) ensure_docker; do_uninstall ;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -1228,6 +1351,8 @@ case "${COMMAND}" in
|
||||
certificate) require_root; require_curl; do_certificate ;;
|
||||
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
|
||||
guide) require_root; require_curl; do_guide ;;
|
||||
credentials) require_root; require_curl; do_show_admin_credentials ;;
|
||||
reset-password) require_root; require_curl; do_reset_admin_password ;;
|
||||
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
|
||||
*) usage; fail "未知命令:${COMMAND}" ;;
|
||||
esac
|
||||
|
||||
+76
-3
@@ -36,6 +36,11 @@ test_password_validation() {
|
||||
if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then
|
||||
fail_test "password beginning with an env-file comment marker accepted"
|
||||
fi
|
||||
LANQIN_RESET_PASSWORD="reset1"
|
||||
assert_eq "reset1" "$(prompt_reset_password)" "six-character reset password"
|
||||
if (LANQIN_RESET_PASSWORD="reset" prompt_reset_password >/dev/null 2>&1); then
|
||||
fail_test "five-character reset password accepted"
|
||||
fi
|
||||
}
|
||||
|
||||
test_install_configuration() {
|
||||
@@ -114,14 +119,79 @@ test_menu_choice() {
|
||||
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
|
||||
export LANQIN_MENU_ACTION=1
|
||||
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
|
||||
export LANQIN_MENU_ACTION=10
|
||||
assert_eq "10" "$(prompt_menu_choice 1 10)" "menu uninstall action"
|
||||
if (has_tty() { return 1; }; LANQIN_MENU_ACTION=11 prompt_menu_choice 1 10 >/dev/null 2>&1); then
|
||||
export LANQIN_MENU_ACTION=12
|
||||
assert_eq "12" "$(prompt_menu_choice 1 12)" "menu uninstall action"
|
||||
if (has_tty() { return 1; }; LANQIN_MENU_ACTION=13 prompt_menu_choice 1 12 >/dev/null 2>&1); then
|
||||
fail_test "out-of-range menu action accepted"
|
||||
fi
|
||||
unset LANQIN_MENU_ACTION
|
||||
}
|
||||
|
||||
test_admin_credentials() (
|
||||
local temp_dir output
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
cat > "${INSTALL_DIR}/.env" <<'EOF'
|
||||
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
LANQIN_ADMIN_PASSWORD=recorded-password
|
||||
EOF
|
||||
output="$(do_show_admin_credentials 2>&1)"
|
||||
[[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing"
|
||||
[[ "${output}" == *'管理员用户名:admin'* ]] || fail_test "administrator username missing"
|
||||
[[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing"
|
||||
[[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing"
|
||||
)
|
||||
|
||||
test_admin_password_hash_parsing() (
|
||||
compose() {
|
||||
# shellcheck disable=SC2016
|
||||
printf '{BLF-CRYPT}$2y$10$123456789012345678901u1234567890123456789012345678901\n'
|
||||
}
|
||||
# shellcheck disable=SC2016
|
||||
assert_eq '$2y$10$123456789012345678901u1234567890123456789012345678901' "$(generate_admin_password_hash 'unused')" "Dovecot bcrypt hash parsing"
|
||||
)
|
||||
|
||||
test_admin_password_reset_only_updates_admin_account() (
|
||||
local temp_dir compose_calls backup_path
|
||||
temp_dir="$(mktemp -d)"
|
||||
INSTALL_DIR="${temp_dir}/install"
|
||||
compose_calls="${temp_dir}/compose-calls"
|
||||
mkdir -p "${INSTALL_DIR}/data/backups"
|
||||
cat > "${INSTALL_DIR}/.env" <<'EOF'
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
LANQIN_ADMIN_PASSWORD=old-password
|
||||
EOF
|
||||
printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db"
|
||||
|
||||
ensure_docker() { return 0; }
|
||||
current_image_id() { printf 'sha256:test-image\n'; }
|
||||
backup_database() {
|
||||
backup_path="$1"
|
||||
printf 'backup\n' > "${backup_path}"
|
||||
}
|
||||
prompt_reset_password() { printf 'new-password'; }
|
||||
# shellcheck disable=SC2016
|
||||
generate_admin_password_hash() { printf '$2y$10$123456789012345678901u1234567890123456789012345678901'; }
|
||||
compose() {
|
||||
printf '%s\n' "$*" >> "${compose_calls}"
|
||||
if [[ "$*" == *'SELECT id FROM users'* ]]; then
|
||||
printf 'admin-user-id\n'
|
||||
elif [[ "$*" == *'UPDATE users SET password_hash'* ]]; then
|
||||
printf 'user=1\nmailboxes=2\n'
|
||||
fi
|
||||
}
|
||||
|
||||
do_reset_admin_password >/dev/null
|
||||
assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password"
|
||||
[[ -s "${backup_path}" ]] || fail_test "password reset database backup missing"
|
||||
grep -Fq "login_name='admin' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not role restricted"
|
||||
grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated"
|
||||
grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized"
|
||||
grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator"
|
||||
)
|
||||
|
||||
test_offline_database_backup() (
|
||||
local temp_dir destination
|
||||
temp_dir="$(mktemp -d)"
|
||||
@@ -439,6 +509,9 @@ test_nginx_configuration
|
||||
test_compose_configuration
|
||||
test_legacy_configuration_is_preserved
|
||||
test_menu_choice
|
||||
test_admin_credentials
|
||||
test_admin_password_hash_parsing
|
||||
test_admin_password_reset_only_updates_admin_account
|
||||
test_offline_database_backup
|
||||
test_guide_generation
|
||||
test_acme_cron_detection
|
||||
|
||||
Reference in New Issue
Block a user