短信发送失败增加冷却防连点限流并配置trust proxy恢复真实客户端IP
This commit is contained in:
@@ -7,6 +7,8 @@ CADDY_SITE_ADDR=http://localhost
|
|||||||
WORKER_SITE_ADDR=http://worker.localhost
|
WORKER_SITE_ADDR=http://worker.localhost
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
BACKEND_PORT=3000
|
BACKEND_PORT=3000
|
||||||
|
# 反向代理层数:Caddy 后保持 1(默认),后端直连暴露时设为 false
|
||||||
|
HTTP_TRUST_PROXY=1
|
||||||
|
|
||||||
# Claim Links
|
# Claim Links
|
||||||
CLAIM_BASE_URL=http://localhost/#/claim
|
CLAIM_BASE_URL=http://localhost/#/claim
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ CADDY_SITE_ADDR=https://order.khhao.com
|
|||||||
WORKER_SITE_ADDR=https://worker.khhao.com
|
WORKER_SITE_ADDR=https://worker.khhao.com
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
BACKEND_PORT=3000
|
BACKEND_PORT=3000
|
||||||
|
# 反向代理层数:Caddy 后保持 1(默认),后端直连暴露时设为 false
|
||||||
|
HTTP_TRUST_PROXY=1
|
||||||
|
|
||||||
# 构建镜像源(可选,默认阿里云公共源)
|
# 构建镜像源(可选,默认阿里云公共源)
|
||||||
# 阿里云 ECS 同地域内网可改为 mirrors.cloud.aliyuncs.com 加速 apt/apk
|
# 阿里云 ECS 同地域内网可改为 mirrors.cloud.aliyuncs.com 加速 apt/apk
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ type CreateAppOptions = {
|
|||||||
export function createApp({ startupState, isShutdownStarted, config }: CreateAppOptions) {
|
export function createApp({ startupState, isShutdownStarted, config }: CreateAppOptions) {
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// 部署在反代(Caddy)之后时让 req.ip 取 X-Forwarded-For 的真实客户端地址;限流、访问日志与登录设备记录都依赖它。
|
||||||
|
if (config.server?.trustProxy !== undefined && config.server?.trustProxy !== null) {
|
||||||
|
app.set('trust proxy', config.server.trustProxy)
|
||||||
|
}
|
||||||
|
|
||||||
app.use(accessLogMiddleware)
|
app.use(accessLogMiddleware)
|
||||||
app.use(createCorsMiddleware(config))
|
app.use(createCorsMiddleware(config))
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
return {
|
return {
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
// 默认信任一层反代(Caddy),req.ip 才能取到真实客户端地址;直连暴露端口时设为 false。
|
||||||
|
trustProxy: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type EnvOverride = {
|
|||||||
|
|
||||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||||
integerEnv('PORT', ['server', 'port']),
|
integerEnv('PORT', ['server', 'port']),
|
||||||
|
trustProxyEnv('HTTP_TRUST_PROXY', ['server', 'trustProxy']),
|
||||||
stringEnv('DATA_ROOT', ['data', 'root'], path.resolve),
|
stringEnv('DATA_ROOT', ['data', 'root'], path.resolve),
|
||||||
stringEnv('LOG_LEVEL', ['logging', 'level']),
|
stringEnv('LOG_LEVEL', ['logging', 'level']),
|
||||||
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
|
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
|
||||||
@@ -258,6 +259,31 @@ function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trustProxyEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||||
|
return {
|
||||||
|
env,
|
||||||
|
path: configPath,
|
||||||
|
read: parseTrustProxy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP_TRUST_PROXY 支持 true/false、反代层数(数字)与 loopback 等预定义子网,其余值回落到默认。 */
|
||||||
|
function parseTrustProxy(rawValue: unknown): RuntimeConfigValue | null {
|
||||||
|
const value = parseString(rawValue)
|
||||||
|
if (value === null) return null
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === 'yes') return true
|
||||||
|
if (normalized === 'false' || normalized === 'no') return false
|
||||||
|
if (normalized === 'loopback' || normalized === 'uniquelocal' || normalized === 'linklocal') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(normalized)) {
|
||||||
|
const hops = Number(normalized)
|
||||||
|
return hops >= 0 ? hops : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||||
return {
|
return {
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export function validateRuntimeConfig(
|
|||||||
const productionLike = isProductionLike(env)
|
const productionLike = isProductionLike(env)
|
||||||
|
|
||||||
requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 })
|
requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 })
|
||||||
|
const trustProxy = config.server?.trustProxy
|
||||||
|
if (trustProxy !== undefined && trustProxy !== null && !isValidTrustProxy(trustProxy)) {
|
||||||
|
issues.push({
|
||||||
|
path: 'server.trustProxy',
|
||||||
|
message: 'server.trustProxy 仅支持 true/false、非负整数或 loopback/uniquelocal/linklocal',
|
||||||
|
})
|
||||||
|
}
|
||||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||||
requireOptionalInteger(issues, 'database.idleTimeoutMs', config.database?.idleTimeoutMs, {
|
requireOptionalInteger(issues, 'database.idleTimeoutMs', config.database?.idleTimeoutMs, {
|
||||||
min: 1,
|
min: 1,
|
||||||
@@ -288,6 +295,12 @@ function requireOptionalInteger(
|
|||||||
requireInteger(issues, configPath, value, options)
|
requireInteger(issues, configPath, value, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidTrustProxy(value: unknown): boolean {
|
||||||
|
if (typeof value === 'boolean') return true
|
||||||
|
if (typeof value === 'number') return Number.isInteger(value) && value >= 0
|
||||||
|
return ['loopback', 'uniquelocal', 'linklocal'].includes(String(value))
|
||||||
|
}
|
||||||
|
|
||||||
function isHttpUrl(value: string): boolean {
|
function isHttpUrl(value: string): boolean {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type AffiliateDashSkuMapping = Record<string, string>
|
|||||||
export type RuntimeConfig = {
|
export type RuntimeConfig = {
|
||||||
server: {
|
server: {
|
||||||
port: number
|
port: number
|
||||||
|
/** Express trust proxy:true/false、反代层数或 loopback 等预定义子网;默认 1(单层反代)。 */
|
||||||
|
trustProxy?: boolean | number | string
|
||||||
}
|
}
|
||||||
data: {
|
data: {
|
||||||
root: string
|
root: string
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth'
|
|||||||
import { isWorkerPasswordStrong, WORKER_PASSWORD_RULE_MESSAGE } from '@/utils/worker-password'
|
import { isWorkerPasswordStrong, WORKER_PASSWORD_RULE_MESSAGE } from '@/utils/worker-password'
|
||||||
|
|
||||||
const SMS_COUNTDOWN_SECONDS = 60
|
const SMS_COUNTDOWN_SECONDS = 60
|
||||||
|
/** 发送失败也冷却一段时间再允许重试,避免连续点击触发服务端每分钟发送次数限制。 */
|
||||||
|
const SMS_FAILURE_COUNTDOWN_SECONDS = 15
|
||||||
|
|
||||||
const brandFeatures = [
|
const brandFeatures = [
|
||||||
{
|
{
|
||||||
@@ -147,6 +149,7 @@ export default function WorkerLoginPage() {
|
|||||||
: `验证码已发送,${response.data.expiresInSeconds} 秒内有效`,
|
: `验证码已发送,${response.data.expiresInSeconds} 秒内有效`,
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setLoginSmsCountdown(SMS_FAILURE_COUNTDOWN_SECONDS)
|
||||||
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -217,6 +220,7 @@ export default function WorkerLoginPage() {
|
|||||||
: `验证码已发送,${response.data.expiresInSeconds} 秒内有效`,
|
: `验证码已发送,${response.data.expiresInSeconds} 秒内有效`,
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setCountdown(SMS_FAILURE_COUNTDOWN_SECONDS)
|
||||||
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -239,6 +243,7 @@ export default function WorkerLoginPage() {
|
|||||||
: `验证码已发送,${response.data.expiresInSeconds} 秒内有效`,
|
: `验证码已发送,${response.data.expiresInSeconds} 秒内有效`,
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setResetCountdown(SMS_FAILURE_COUNTDOWN_SECONDS)
|
||||||
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
message.error(error instanceof Error ? error.message : '验证码发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
PORT: ${BACKEND_PORT:-3000}
|
PORT: ${BACKEND_PORT:-3000}
|
||||||
|
HTTP_TRUST_PROXY: ${HTTP_TRUST_PROXY:-1}
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site}
|
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site}
|
||||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||||
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10}
|
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-10}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
PORT: ${BACKEND_PORT:-3000}
|
PORT: ${BACKEND_PORT:-3000}
|
||||||
|
HTTP_TRUST_PROXY: ${HTTP_TRUST_PROXY:-1}
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site}
|
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site}
|
||||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||||
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8}
|
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8}
|
||||||
|
|||||||
Reference in New Issue
Block a user