第二十六章:权限管理与路由守卫

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第二十六章:权限管理与路由守卫

1. 权限模型

用户 ──→ 角色 ──→ 权限
                    │
         ┌──────────┴──────────┐
         ▼                     ▼
    页面级权限             组件级权限
  (路由守卫)            (按钮/操作)

三种常见的权限模型:

| 模型 | 说明 | 适用场景 | |------|------|---------| | RBAC(角色基) | 用户→角色→权限 | 博客后台管理 | | ABAC(属性基) | 根据用户/资源属性动态判断 | 多租户系统 | | 简单认证 | 仅区分 登录/未登录 | 个人博客基础需求 |

2. 认证状态管理

2.1 用户 Session 管理

use dioxus::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, PartialEq, Serialize, Deserialize)]
struct User {
    id: i64,
    username: String,
    avatar: Option<String>,
    role: UserRole,
}

#[derive(Clone, PartialEq, Serialize, Deserialize)]
enum UserRole {
    Visitor,     // 游客
    Reader,      // 读者
    Author,      // 作者
    Admin,       // 管理员
}

impl UserRole {
    fn can_write(&self) -> bool {
        matches!(self, UserRole::Author | UserRole::Admin)
    }

    fn can_manage(&self) -> bool {
        matches!(self, UserRole::Admin)
    }
}

// 认证上下文
#[derive(Clone, PartialEq)]
struct AuthContext {
    user: Option<User>,
    token: Option<String>,
}

fn use_auth() -> (
    Signal<AuthContext>,
    impl Fn(String, String),  // login
    impl Fn(),                // logout
    impl Fn() -> bool,        // is_authenticated
) {
    let ctx = use_signal(|| {
        // 从 localStorage 恢复会话
        let token = get_local_storage("auth_token");
        let user = token.as_ref()
            .and_then(|t| decode_token(t))
            .and_then(|claims| fetch_user(&claims.sub).ok());

        AuthContext { user, token }
    });

    let login = move |username: String, password: String| {
        spawn(async move {
            match login_api(&username, &password).await {
                Ok((token, user)) => {
                    set_local_storage("auth_token", &token);
                    ctx.set(AuthContext {
                        user: Some(user),
                        token: Some(token),
                    });
                }
                Err(e) => {
                    console_log!("登录失败: {e}");
                }
            }
        });
    };

    let logout = move || {
        remove_local_storage("auth_token");
        ctx.set(AuthContext { user: None, token: None });
    };

    let is_authenticated = move || ctx.read().user.is_some();

    (ctx, login, logout, is_authenticated)
}

2.2 提供 Auth Context

#[component]
fn AuthProvider(children: Element) -> Element {
    let ctx = use_signal(|| AuthContext { user: None, token: None });

    use_context_provider(|| ctx);

    // 初始化时从 token 恢复
    use_effect(move || {
        let token = get_local_storage("auth_token");
        if let Some(t) = token {
            // 验证 token 有效性
            spawn(async move {
                match verify_token(&t).await {
                    Ok(user) => {
                        ctx.write().user = Some(user);
                        ctx.write().token = Some(t);
                    }
                    Err(_) => {
                        // token 过期,清除
                        remove_local_storage("auth_token");
                    }
                }
            });
        }
    });

    rsx! { {children} }
}

// 在 App 根组件使用
#[component]
fn App() -> Element {
    rsx! {
        AuthProvider {
            Router::<Route> {}
        }
    }
}

3. 路由守卫

3.1 简单认证守卫

/// 需要登录的路由
#[component]
fn RequireAuth(children: Element) -> Element {
    let auth = use_context::<Signal<AuthContext>>();
    let navigator = use_navigator();

    if auth.read().user.is_none() {
        // 未登录 → 重定向到登录页,携带回跳地址
        let current_path = web_sys::window()
            .and_then(|w| w.location().pathname().ok())
            .unwrap_or_default();

        rsx! {
            Redirect {
                to: "/login?redirect={current_path}",
            }
        }
    } else {
        rsx! { {children} }
    }
}

/// 需要特定角色的路由
#[component]
fn RequireRole(role: UserRole, children: Element) -> Element {
    let auth = use_context::<Signal<AuthContext>>();

    match &auth.read().user {
        Some(user) if user.role as u8 >= role as u8 => {
            rsx! { {children} }
        }
        Some(_) => rsx! {
            div { class: "text-center py-16",
                h2 { "权限不足" }
                p { "你没有访问此页面的权限" }
                a { href: "/", "返回首页" }
            }
        },
        None => rsx! {
            Redirect { to: "/login" }
        },
    }
}

// 在路由中使用
#[derive(Routable, Clone, PartialEq)]
enum Route {
    #[route("/")]
    Home {},

    #[route("/blog")]
    BlogList { series: Option<String>, category: Option<String> },

    #[route("/login")]
    Login {},

    // 需要登录的路由
    #[nest("/admin")]
    #[layout(RequireAuth)]
        #[route("/admin")]
        AdminDashboard {},
        #[route("/admin/articles")]
        ArticleManager {},
        #[route("/admin/articles/new")]
        ArticleEditor { slug: Option<String> },
    #[end_nest]
    #[nest("/admin")]
    #[layout(RequireRole { role: UserRole::Admin })]
        #[route("/admin/users")]
        UserManager {},
        #[route("/admin/settings")]
        SiteSettings {},
    #[end_nest]
}

3.2 动态守卫

/// 基于属性的权限检查
#[component]
fn RequirePermission(
    permission: &'static str,
    fallback: Option<Element>,
    children: Element,
) -> Element {
    let auth = use_context::<Signal<AuthContext>>();

    let has_perm = use_memo(move || {
        check_permission(auth.read().user.as_ref(), permission)
    });

    if has_perm() {
        rsx! { {children} }
    } else {
        rsx! { {fallback.unwrap_or_else(|| rsx! { div {} })} }
    }
}

fn check_permission(user: Option<&User>, permission: &str) -> bool {
    let Some(user) = user else { return false };
    match permission {
        "article:create" => user.role.can_write(),
        "article:edit" => user.role.can_write(),
        "article:delete" => user.role.can_manage(),
        "user:manage" => user.role.can_manage(),
        "comment:moderate" => user.role.can_manage(),
        _ => false,
    }
}

// 使用
RequirePermission {
    permission: "article:create",
    fallback: rsx! { p { "需要作者权限" } },
    button { "新建文章" }
}

4. 登录页面

#[component]
fn LoginPage() -> Element {
    let auth = use_context::<Signal<AuthContext>>();
    let navigator = use_navigator();
    let redirect = use_route().query::<LoginQuery>()
        .unwrap_or_default()
        .redirect
        .unwrap_or_else(|| "/".to_string());

    let username = use_signal(String::new);
    let password = use_signal(String::new);
    let error = use_signal(|| Option::<String>::None);
    let loading = use_signal(|| false);

    let do_login = move |_| {
        if username().is_empty() || password().is_empty() {
            error.set(Some("请输入用户名和密码".to_string()));
            return;
        }
        loading.set(true);
        error.set(None);

        spawn({
            let u = username();
            let p = password();
            let nav = navigator.clone();
            let redirect = redirect.clone();
            async move {
                match login_api(&u, &p).await {
                    Ok((token, user)) => {
                        set_local_storage("auth_token", &token);
                        auth.write().set(AuthContext {
                            user: Some(user),
                            token: Some(token),
                        });
                        nav.push(&redirect);
                    }
                    Err(msg) => {
                        error.set(Some(msg));
                    }
                }
                loading.set(false);
            }
        });
    };

    rsx! {
        div { class: "max-w-sm mx-auto mt-16",
            h1 { class: "text-xl font-bold mb-6 text-center", "登录" }

            if let Some(ref msg) = error() {
                div { class: "bg-red-100 text-red-700 px-4 py-2 rounded mb-4 text-sm",
                    "{msg}"
                }
            }

            form { onsubmit: do_login, class: "space-y-4",
                div {
                    label { class: "block text-sm font-medium mb-1", "用户名" }
                    input {
                        class: "w-full border rounded-lg px-3 py-2",
                        value: "{username}",
                        oninput: move |e| username.set(e.value()),
                    }
                }
                div {
                    label { class: "block text-sm font-medium mb-1", "密码" }
                    input {
                        r#type: "password",
                        class: "w-full border rounded-lg px-3 py-2",
                        value: "{password}",
                        oninput: move |e| password.set(e.value()),
                    }
                }
                button {
                    class: "w-full py-2 rounded-lg text-white font-medium",
                    style: "background: var(--primary);",
                    disabled: loading(),
                    if loading() { "登录中..." } else { "登录" }
                }
            }
        }
    }
}

5. JWT Token 管理

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct JwtClaims {
    sub: String,          // 用户 ID
    exp: usize,           // 过期时间
    iat: usize,           // 签发时间
    role: String,         // 角色
}

fn decode_token(token: &str) -> Option<JwtClaims> {
    // 注意:前端只解码 payload,不验证签名(由服务端验证)
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() != 3 { return None; }

    use base64::Engine;
    let payload = base64::engine::general_purpose::URL_SAFE
        .decode(parts[1]).ok()?;
    serde_json::from_slice(&payload).ok()
}

fn is_token_expired(token: &str) -> bool {
    decode_token(token)
        .map(|claims| {
            let now = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_secs() as usize;
            claims.exp <= now
        })
        .unwrap_or(true)
}

// 自动刷新 Token
fn use_auto_refresh() {
    let auth = use_context::<Signal<AuthContext>>();

    use_interval(300_000, move || {  // 每 5 分钟检查
        let token = auth.read().token.clone();
        if let Some(t) = token {
            if is_token_expired(&t) {
                spawn(async move {
                    match refresh_token(&t).await {
                        Ok(new_token) => {
                            set_local_storage("auth_token", &new_token);
                            auth.write().token = Some(new_token);
                        }
                        Err(_) => {
                            // 刷新失败,强制登出
                            remove_local_storage("auth_token");
                            auth.write().user = None;
                            auth.write().token = None;
                        }
                    }
                });
            }
        }
    });
}

6. API 请求携带 Token

fn use_authenticated_client() -> reqwest::Client {
    let auth = use_context::<Signal<AuthContext>>();
    let mut headers = reqwest::header::HeaderMap::new();

    if let Some(token) = &auth.read().token {
        headers.insert(
            reqwest::header::AUTHORIZATION,
            reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
        );
    }

    reqwest::Client::builder()
        .default_headers(headers)
        .build()
        .unwrap()
}

async fn fetch_with_auth(url: &str) -> Result<serde_json::Value, String> {
    let client = use_authenticated_client();
    let resp = client.get(url).send().await.map_err(|e| e.to_string())?;

    match resp.status() {
        reqwest::StatusCode::UNAUTHORIZED => {
            // Token 过期,清除本地会话
            remove_local_storage("auth_token");
            Err("未授权,请重新登录".to_string())
        }
        reqwest::StatusCode::FORBIDDEN => {
            Err("权限不足".to_string())
        }
        status if status.is_success() => {
            resp.json().await.map_err(|e| e.to_string())
        }
        _ => {
            Err(format!("请求失败: {}", resp.status()))
        }
    }
}

7. 组件级权限控制

#[component]
fn AdminActions(article: ArticleSummary) -> Element {
    let auth = use_context::<Signal<AuthContext>>();

    // 判断当前用户是否有权操作此文章
    let can_edit = use_memo(move || {
        let user = auth.read().user.as_ref()?;
        Some(user.id == article.author_id || user.role.can_manage())
    });

    rsx! {
        div { class: "flex gap-2 mt-4",
            if can_edit() == Some(true) {
                button { class: "px-3 py-1 rounded text-sm border", "编辑" }
                button { class: "px-3 py-1 rounded text-sm border text-red-500", "删除" }
            }
        }
    }
}

// 权限组件抽象
#[component]
fn IfGranted(
    permission: &'static str,
    children: Element,
) -> Element {
    let auth = use_context::<Signal<AuthContext>>();
    let has_perm = use_memo(move || {
        check_permission(auth.read().user.as_ref(), permission)
    });

    if has_perm() { rsx! { {children} } }
    else { rsx! { div {} } }
}

// 使用:只有管理员能看到
IfGranted {
    permission: "user:manage",
    button { "管理用户" }
}

8. 登录后重定向

#[component]
fn LoginRedirect() -> Element {
    let auth = use_context::<Signal<AuthContext>>();
    let navigator = use_navigator();

    use_effect(move || {
        if auth.read().user.is_some() {
            // 已登录 → 跳转到之前的页面或首页
            let redirect = use_route().query::<LoginQuery>()
                .ok()
                .and_then(|q| q.redirect)
                .unwrap_or_else(|| "/".to_string());
            navigator.push(&redirect);
        }
    });

    rsx! { div {} }
}

9. 完整权限系统集成

// 在 App 根组件中组装
#[component]
fn App() -> Element {
    rsx! {
        AuthProvider {
            Router::<Route> {}
        }
    }
}

// 路由配置示例
#[derive(Routable, Clone, PartialEq)]
enum Route {
    #[route("/")]
    Home {},

    #[route("/blog")]
    BlogList { series: Option<String>, category: Option<String> },

    #[route("/article/:slug")]
    BlogPost { slug: String },

    #[route("/login")]
    LoginPage,

    // 管理后台 —— 需要登录
    #[layout(AdminLayout)]
    #[nest("/admin")]
        #[layout(RequireAuth)]
            #[route("/admin")]
            AdminDashboard {},
            #[route("/admin/articles")]
            ArticleManager {},
        #[end_nest]
        #[layout(RequireRole { role: UserRole::Admin })]
            #[route("/admin/users")]
            UserManager {},
        #[end_nest]
    #[end_nest]

    #[route("/:404")]
    NotFound {},
}

10. 小结

  • use_context + Signal 实现全局认证状态管理
  • 路由守卫组件包裹需要保护的路由,未认证时自动重定向
  • RequireRole 守卫实现基于角色的访问控制
  • RequirePermission 守卫实现细粒度的权限检查
  • 组件内部使用 IfGranted 控制按钮/操作的可见性
  • JWT Token 自动刷新机制保持会话持续
  • API 请求携带 Bearer Token,处理 401/403 响应
  • 登录成功后根据 redirect 参数跳转回原始页面
dioxusauthpermissionroute-guardroleaccess-control