From 64bbf5c109aa07cdba47b5e6f13b4f2878666659 Mon Sep 17 00:00:00 2001 From: wangbo Date: Wed, 15 Jul 2026 11:29:41 +0800 Subject: [PATCH] feat(deploy): initialize authentication security secrets --- .env.sample | 29 ++++++++- scripts/Initialize-SecurityEnv.ps1 | 74 +++++++++++++++++++++++ scripts/init-security-env.sh | 95 ++++++++++++++++++++++++++++++ start.ps1 | 14 ++++- start.sh | 15 +++++ update.ps1 | 5 ++ update.sh | 10 ++++ 7 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 scripts/Initialize-SecurityEnv.ps1 create mode 100644 scripts/init-security-env.sh diff --git a/.env.sample b/.env.sample index 76f439e..abb8fed 100644 --- a/.env.sample +++ b/.env.sample @@ -106,7 +106,34 @@ CONFIG_MQ_VHOST=/ # ========== 8. 鉴权与安全 ========== CONFIG_TOKEN_EXPIRE=1800 -CONFIG_JWT_SECRET='this is a very secret secret' +# JWT 签名密钥。启动/升级脚本发现空值、历史默认值或不足 32 字节时会生成并持久化到 .env。 +# 也可手动生成:openssl rand -base64 48 +CONFIG_JWT_SECRET= + +# 后台 JWT 动态配置的 AES-256-GCM 独立主密钥,建议与 JWT 签名密钥分别生成。 +# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。 +CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY= + +# 验证码 HMAC、设备/指纹哈希和审计完整性密钥,由启动/升级脚本独立生成并持久化。 +CONFIG_OTP_HASH_SECRET= +CONFIG_AUDIT_HASH_PEPPER= +CONFIG_AUDIT_INTEGRITY_KEY= + +# 允许访问后端的浏览器 Origin,多个值用英文逗号分隔,生产环境禁止使用 *。 +# 域名部署示例:https://yourwebsite.com,https://www.yourwebsite.com +CONFIG_SECURITY_ORIGIN=http://127.0.0.1:3010,http://localhost:3010 + +# 可信反向代理 IP/CIDR,多个值用英文逗号分隔;未使用反向代理时保持为空。 +# 仅填写实际 Nginx/负载均衡地址,不要使用 0.0.0.0/0。 +CONFIG_TRUSTED_PROXY= + +# 完成身份冲突扫描和人工处置后才能启用手机号、邮箱和微信身份唯一索引。 +CONFIG_AUTH_UNIQUE_INDEXES_ENABLED=false + +# 仅在首次创建 admin 账号时使用;启动脚本会生成随机值并持久化到 .env。 +# 已存在 admin 的升级环境不会使用或修改现有管理员密码。 +CONFIG_INITIAL_ADMIN_PASSWORD= + CONFIG_TOKEN_SIGN_SK=easyai2025easyai # ========== 9. 运维与调试 ========== diff --git a/scripts/Initialize-SecurityEnv.ps1 b/scripts/Initialize-SecurityEnv.ps1 new file mode 100644 index 0000000..076cac8 --- /dev/null +++ b/scripts/Initialize-SecurityEnv.ps1 @@ -0,0 +1,74 @@ +function New-SecuritySecret { + $bytes = New-Object byte[] 48 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + $rng.GetBytes($bytes) + } finally { + $rng.Dispose() + } + return [Convert]::ToBase64String($bytes) +} + +function Get-EnvValue { + param([string]$Content, [string]$Key) + $match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$") + if (-not $match.Success) { return "" } + $value = $match.Groups[1].Value.Trim() + if ($value.Length -ge 2) { + $first = $value.Substring(0, 1) + $last = $value.Substring($value.Length - 1, 1) + if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) { + return $value.Substring(1, $value.Length - 2) + } + } + return $value +} + +function Set-EnvValue { + param([string]$Content, [string]$Key, [string]$Value) + $pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?" + $replacement = "$Key=$Value`r`n" + if ([regex]::IsMatch($Content, $pattern)) { + return [regex]::Replace($Content, $pattern, $replacement, 1) + } + if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" } + return $Content + $replacement +} + +function Initialize-SecurityEnv { + param( + [Parameter(Mandatory = $true)][string]$Path, + [ValidateSet("new", "upgrade")][string]$Mode = "upgrade" + ) + if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" } + + $content = Get-Content $Path -Raw -Encoding UTF8 + if ($null -eq $content) { $content = "" } + $definitions = @( + @{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 } + ) + if ($Mode -eq "new") { + $definitions += @( + @{ Key = "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"; Legacy = ""; MinLength = 32 }, + @{ Key = "CONFIG_OTP_HASH_SECRET"; Legacy = ""; MinLength = 32 }, + @{ Key = "CONFIG_AUDIT_HASH_PEPPER"; Legacy = ""; MinLength = 32 }, + @{ Key = "CONFIG_AUDIT_INTEGRITY_KEY"; Legacy = ""; MinLength = 32 }, + @{ Key = "CONFIG_INITIAL_ADMIN_PASSWORD"; Legacy = ""; MinLength = 12 } + ) + } + + foreach ($definition in $definitions) { + $current = Get-EnvValue $content $definition.Key + $needsInitialization = $current.Length -lt $definition.MinLength -or + ($definition.Legacy -and $current -eq $definition.Legacy) + if (-not $needsInitialization) { continue } + $content = Set-EnvValue $content $definition.Key (New-SecuritySecret) + Write-Host " [OK] Initialized security secret: $($definition.Key)" -ForegroundColor Green + } + + [System.IO.File]::WriteAllText( + (Resolve-Path $Path), + $content, + [System.Text.UTF8Encoding]::new($false) + ) +} diff --git a/scripts/init-security-env.sh b/scripts/init-security-env.sh new file mode 100644 index 0000000..d45715b --- /dev/null +++ b/scripts/init-security-env.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# 为本地 .env 初始化持久化安全密钥。只处理空值和已知历史默认值, +# 已存在的自定义值保持不变;数据库中的 JWT 轮转配置仍由后端优先使用。 + +generate_security_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -base64 48 | tr -d '\r\n' + return + fi + if [ -r /dev/urandom ]; then + od -An -N48 -tx1 /dev/urandom | tr -d ' \r\n' + return + fi + echo "❌ 无法生成安全随机值:需要 openssl 或 /dev/urandom" >&2 + return 1 +} + +read_env_value() { + local file="$1" + local key="$2" + local value + value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} + +write_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp "${file}.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { replaced = 0 } + index($0, key "=") == 1 { + if (!replaced) { + print key "=" value + replaced = 1 + } + next + } + { print } + END { + if (!replaced) print key "=" value + } + ' "$file" > "$tmp" + chmod 600 "$tmp" + mv "$tmp" "$file" +} + +ensure_security_secret() { + local file="$1" + local key="$2" + local legacy_value="${3:-}" + local minimum_length="${4:-32}" + local current + current="$(read_env_value "$file" "$key")" + if [ "${#current}" -ge "$minimum_length" ] && { [ -z "$legacy_value" ] || [ "$current" != "$legacy_value" ]; }; then + return 0 + fi + write_env_value "$file" "$key" "$(generate_security_secret)" + echo " ✓ 已初始化安全密钥: ${key}" +} + +init_security_env() { + local file="${1:-.env}" + local mode="${2:-upgrade}" + if [ ! -f "$file" ]; then + echo "❌ 环境配置文件不存在: $file" >&2 + return 1 + fi + if [ "$mode" != "new" ] && [ "$mode" != "upgrade" ]; then + echo "❌ 安全环境初始化模式无效: $mode(仅支持 new/upgrade)" >&2 + return 1 + fi + + ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret" + if [ "$mode" = "new" ]; then + ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" + ensure_security_secret "$file" "CONFIG_OTP_HASH_SECRET" + ensure_security_secret "$file" "CONFIG_AUDIT_HASH_PEPPER" + ensure_security_secret "$file" "CONFIG_AUDIT_INTEGRITY_KEY" + ensure_security_secret "$file" "CONFIG_INITIAL_ADMIN_PASSWORD" "" 12 + fi + chmod 600 "$file" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + init_security_env "${1:-.env}" "${2:-upgrade}" +fi diff --git a/start.ps1 b/start.ps1 index 9189aa6..d23eb1c 100644 --- a/start.ps1 +++ b/start.ps1 @@ -22,6 +22,7 @@ $script:LogFile = Join-Path $script:Root "start.ps1.log" $script:DeployDryRun = ($env:DEPLOY_DRY_RUN -eq "1") $script:DeployIP = "" $script:SkipDeployQuestions = $false +$script:SecurityEnvMode = "upgrade" $script:DockerDesktopUrl = "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe" function Write-Log { @@ -159,6 +160,7 @@ function Setup-EnvFiles { Ensure-FileFromSample ".env.tools" ".env.tools.sample" Ensure-FileFromSample ".env.ASG" ".env.ASG.sample" Ensure-FileFromSample ".env.AMS" ".env.AMS.sample" + if (-not (Test-Path ".env")) { $script:SecurityEnvMode = "new" } Ensure-FileFromSample ".env" ".env.sample" $content = Get-Content ".env" -Raw -Encoding UTF8 @@ -167,6 +169,9 @@ function Setup-EnvFiles { $content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" "http://$($script:DeployIP):3001" $content = Upsert-Env $content "NUXT_PUBLIC_BASE_SOCKETURL" "ws://$($script:DeployIP):3002" $content = Upsert-Env $content "NUXT_PUBLIC_SG_APIURL" "http://$($script:DeployIP):3003" + $webPortMatch = [regex]::Match($content, '(?m)^WEB_PORT=(\d+)$') + $webPort = if ($webPortMatch.Success) { $webPortMatch.Groups[1].Value } else { "3010" } + $content = Upsert-Env $content "CONFIG_SECURITY_ORIGIN" "http://$($script:DeployIP):$webPort" [System.IO.File]::WriteAllText((Join-Path $script:Root ".env"), $content, [System.Text.UTF8Encoding]::new($false)) Ok ".env configured for IP=$($script:DeployIP)" @@ -372,6 +377,9 @@ function Main { Ensure-FileFromSample ".env.AMS" ".env.AMS.sample" } + . (Join-Path $script:Root "scripts\Initialize-SecurityEnv.ps1") + Initialize-SecurityEnv -Path (Join-Path $script:Root ".env") -Mode $script:SecurityEnvMode + if ($script:DeployDryRun) { Warn "dry-run mode: skip docker and services" } else { @@ -388,7 +396,7 @@ function Main { Write-Host "" Write-Host " 预期访问: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan Write-Host "" - Write-Host " 默认管理员: admin / 123456(启动服务后用于登录)" -ForegroundColor DarkGray + Write-Host " 初始管理员: admin;随机密码保存在 .env 的 CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor DarkGray } else { Write-Host "================================" -ForegroundColor Green Write-Host " 部署成功" -ForegroundColor Green @@ -396,9 +404,9 @@ function Main { Write-Host "" Write-Host " 访问地址: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan Write-Host "" - Write-Host " -------- 默认管理员(首次登录后请修改密码)--------" -ForegroundColor Yellow + Write-Host " -------- 初始管理员(首次登录后请修改密码)--------" -ForegroundColor Yellow Write-Host " 账号: " -NoNewline; Write-Host "admin" -ForegroundColor White - Write-Host " 密码: " -NoNewline; Write-Host "123456" -ForegroundColor White + Write-Host " 密码位置: " -NoNewline; Write-Host ".env -> CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor White } Write-Host "" Wait-ForExit diff --git a/start.sh b/start.sh index 1cd1c69..18d118c 100755 --- a/start.sh +++ b/start.sh @@ -29,6 +29,7 @@ DEPLOY_IP="${DEPLOY_IP:-}" DEPLOY_DOMAIN="${DEPLOY_DOMAIN:-}" DEPLOY_HTTPS_ENV="${DEPLOY_HTTPS_INPUT:-${DEPLOY_HTTPS:-}}" DEPLOY_HTTPS=false +SECURITY_ENV_MODE="upgrade" is_non_interactive() { [ "${DEPLOY_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ] @@ -233,19 +234,27 @@ setup_env_files() { # 4/5. 配置 .env if [ ! -f .env ]; then cp .env.sample .env + SECURITY_ENV_MODE="new" fi + local web_port + web_port="$(awk -F= '$1 == "WEB_PORT" { print $2; exit }' .env | tr -d '[:space:]')" + web_port="${web_port:-3010}" + if [ "$DEPLOY_MODE" = "ip" ]; then # IP 模式 sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:3001|" .env sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=ws://${DEPLOY_IP}:3002|" .env sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=http://${DEPLOY_IP}:3003|" .env + sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=http://${DEPLOY_IP}:${web_port}|" .env echo " ✓ .env 已配置为 IP 模式 (${DEPLOY_IP})" else # 域名模式 sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=/api|" .env sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=wss://${DEPLOY_DOMAIN}/socket.io|" .env sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=/asg-api|" .env + # 域名模式同时允许同域 HTTP/HTTPS,兼容首次签证书和复用既有证书两种流程。 + sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=https://${DEPLOY_DOMAIN},http://${DEPLOY_DOMAIN}|" .env echo " ✓ .env 已配置为域名模式 (${DEPLOY_DOMAIN})" # 7. Nginx 配置(域名模式) @@ -475,6 +484,12 @@ main() { fi fi + # 空值或历史默认值只在首次发现时生成并写回 .env,后续启动保持稳定。 + # 数据库中的后台 JWT 轮转配置由后端优先使用,不受此处影响。 + # shellcheck source=scripts/init-security-env.sh + . ./scripts/init-security-env.sh + init_security_env .env "$SECURITY_ENV_MODE" + if [ "$DEPLOY_DRY_RUN" = "1" ]; then echo "" echo "⚠️ dry-run 模式:跳过 Docker 安装和服务启动" diff --git a/update.ps1 b/update.ps1 index 63626a6..133124f 100644 --- a/update.ps1 +++ b/update.ps1 @@ -172,6 +172,11 @@ if (-not $skipRepoUpdate) { Write-Host "" } +# 空值或历史默认值只初始化一次并写回 .env;已有自定义值及数据库轮转配置不受影响。 +if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps1 完成初始化" } +. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1") +Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade" + # ==================== Docker 检查 ==================== function Test-DockerInstalled { $docker = Get-Command docker -ErrorAction SilentlyContinue diff --git a/update.sh b/update.sh index 89ebe61..c505933 100755 --- a/update.sh +++ b/update.sh @@ -122,6 +122,16 @@ else echo "" fi +# 升级已有部署时,将空值或历史默认 JWT 密钥一次性替换为持久化随机值。 +# 已存在的自定义值保持不变,数据库 JWT 轮转配置仍由后端优先使用。 +if [ ! -f .env ]; then + echo "❌ 未找到 .env,请先执行 start.sh 完成初始化" + exit 1 +fi +# shellcheck source=scripts/init-security-env.sh +. ./scripts/init-security-env.sh +init_security_env .env upgrade + echo "===========================" echo "🚀 开始自动安装 Docker 和 Docker Compose" echo "==========================="