Skip to main content

yggdrasil/pages/admin/settings/
security_section.rs

1//! 安全分区:CSRF 可信来源、Cookie Secure、可信代理段数、并发会话上限。
2//!
3//! 保存后失效 moka 缓存,数秒内全链路生效(CSRF 校验、cookie 构造、真实 IP 提取、
4//! 登录会话淘汰都走 runtime accessor)。
5
6use leptos::prelude::*;
7
8use super::kit::{use_section, SettingRow, SettingsCard, SwitchRow};
9use super::SettingsSection;
10use crate::api::error::ApiError;
11use crate::api::settings::{get_security_settings, update_security_settings};
12use crate::components::forms::{FormInput, FormSelect, FORM_SELECT_COMPACT_CLASS};
13use crate::components::ui::{SegmentedControl, SegmentedItem, SegmentedKind, SegmentedSize};
14use crate::models::settings::SecuritySettings;
15
16async fn save(d: SecuritySettings) -> Result<SecuritySettings, ApiError> {
17    update_security_settings(
18        d.app_base_url,
19        d.cookie_secure,
20        d.trusted_proxy_count,
21        d.max_sessions_per_user,
22    )
23    .await
24}
25
26/// 安全分区组件。
27#[component]
28pub fn SecuritySection() -> impl IntoView {
29    let st = use_section(SettingsSection::Security, get_security_settings, save);
30    let proxy_items = Signal::stored(
31        (0..=3u32)
32            .map(|n| SegmentedItem::new(n, n.to_string()))
33            .collect::<Vec<_>>(),
34    );
35    let session_options = Signal::stored(vec![
36        (1u32, "1(仅单设备)"),
37        (3, "3"),
38        (5, "5(默认)"),
39        (10, "10"),
40        (20, "20"),
41        (50, "50"),
42    ]);
43    view! {
44        <SettingsCard
45            section=SettingsSection::Security
46            description="CSRF 可信来源、Cookie 与真实 IP 提取。保存后即时生效。"
47            ready=st.ready()
48            load_failed=st.load_failed()
49            failed=st.failed()
50        >
51            <SettingRow
52                label="可信来源"
53                html_for="sec-base-url"
54                hint="即 APP_BASE_URL,须带 http:// 或 https://,写请求的 CSRF 校验以它为准。留空时只要求来源主机与请求 Host 一致,生产环境建议显式设置。"
55            >
56                <FormInput
57                    id="sec-base-url"
58                    class="field settings-mono"
59                    r#type="url"
60                    placeholder="https://你的站点"
61                    value=st.field(|d| d.app_base_url.clone())
62                    oninput=move |v: String| st.edit(|d| d.app_base_url = v)
63                />
64            </SettingRow>
65            <SwitchRow
66                label="Cookie Secure"
67                hint="只在 HTTPS 下发送会话 Cookie。HTTPS 生产环境务必开启。"
68                checked=st.field(|d| d.cookie_secure)
69                ontoggle=move |()| st.edit(|d| d.cookie_secure = !d.cookie_secure)
70            />
71            <SettingRow
72                label="可信代理段数"
73                hint="即 TRUSTED_PROXY_COUNT:X-Forwarded-For 中真实客户端 IP 右侧的段数,不等于代理层数。直连填 0;单层反代覆盖 XFF 并设置 X-Real-IP 时填 1;CDN + 源站反代填 1 而非 2。设大会允许伪造 IP 绕过限流。"
74            >
75                <SegmentedControl
76                    items=proxy_items
77                    value=st.field(|d| d.trusted_proxy_count)
78                    on_change=move |n: u32| st.edit(|d| d.trusted_proxy_count = n)
79                    aria_label="可信代理段数"
80                    kind=SegmentedKind::Radio
81                    size=SegmentedSize::Sm
82                    class="settings-segmented-square"
83                />
84            </SettingRow>
85            <SettingRow
86                label="并发会话上限"
87                html_for="sec-sessions"
88                hint="单个账号同时保持登录的设备数,超出时最早的会话自动失效。"
89            >
90                <FormSelect
91                    id="sec-sessions"
92                    value=st.field(|d| d.max_sessions_per_user)
93                    options=session_options
94                    trigger_class=FORM_SELECT_COMPACT_CLASS
95                    onchange=move |v: u32| st.edit(|d| d.max_sessions_per_user = v)
96                />
97            </SettingRow>
98        </SettingsCard>
99    }
100}