在Rust中用Casbin实现基于角色的基本HTTP授权

370 阅读7分钟

上一篇文章中,我们研究了如何使用casbin在Go Web Service中实现基于角色的认证。在这篇文章中,我们将对Rust Web Service进行同样的操作。

在这个例子中,我们将定义两个用户角色memberadmin ,为每个角色创建一个用户,并使用基于 casbin 的认证过滤器来处理授权。

为了简单起见,认证机制只是基于用户的名字,使用一个硬编码的用户映射。

为了编写提供这种机制的Web服务,我们将在这里使用warp,但其背后的基本概念应该适用于任何Web框架,只是一些实现细节有不同程度的变化。

让我们开始吧!

免责声明:请不要把下面的示例代码作为生产级应用的模板,代码的重点在于清晰,而不是安全。

例子

我们将从定义一些类型开始。由于这是一个简单的例子,我们将在内存中保存我们的用户和会话。

mod handler;

const BEARER_PREFIX: &str = "Bearer ";

const MODEL_PATH: &str = "./auth/auth_model.conf";
const POLICY_PATH: &str = "./auth/policy.csv";

type UserMap = Arc<RwLock<HashMap<String, User>>>;
type Sessions = Arc<RwLock<HashMap<String, String>>>;
type WebResult<T> = std::result::Result<T, Rejection>;
type Result<T> = std::result::Result<T, Error>;
type SharedEnforcer = Arc<Enforcer>;

#[derive(Clone)]
pub struct UserCtx {
    pub user_id: String,
    pub token: String,
}

#[derive(Clone)]
pub struct User {
    pub user_id: String,
    pub name: String,
    pub role: String,
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("error")]
    SomeError(),
    #[error("no authorization header found")]
    NoAuthHeaderFoundError,
    #[error("wrong authorization header format")]
    InvalidAuthHeaderFormatError,
    #[error("no user found for this token")]
    InvalidTokenError,
    #[error("error during authorization")]
    AuthorizationError,
    #[error("user is not unauthorized")]
    UnauthorizedError,
    #[error("no user found with this name")]
    UserNotFoundError,
}

impl warp::reject::Reject for Error {}

好吧--这有很多的类型。我们首先定义casbin 模型和策略文件的路径,然后是用户和会话的内存存储的类型别名。UserMapuser_idUser 结构的映射,Sessions 地图是访问令牌到用户 ID 的映射。

我们还定义了一些Result 的别名,这样我们就可以区分来自网络处理程序的结果和内部结果。在类型别名之后,我们定义了UserUserCtx 的结构。这个用户上下文是我们要传递给认证处理程序的,所以处理程序可以访问用户ID和认证令牌--我们将在后面看到为什么这很有用。

最后,我们定义了一个自定义的Error 类型,它实现了warp的Reject 特质,所以我们可以向用户返回漂亮的错误。

接下来,让我们看一下main 函数,看看应用程序的结构的大致情况。

#[tokio::main]
async fn main() {
    let user_map = Arc::new(RwLock::new(create_user_map()));
    let sessions: Sessions = Arc::new(RwLock::new(HashMap::new()));
    let enforcer = Arc::new(
        Enforcer::new(MODEL_PATH, POLICY_PATH)
            .await
            .expect("can read casbin model and policy files"),
    );

    let member_route = warp::path!("member")
        .and(with_auth(
            enforcer.clone(),
            user_map.clone(),
            sessions.clone(),
        ))
        .and_then(handler::member_handler);

    let admin_route = warp::path!("admin")
        .and(with_auth(
            enforcer.clone(),
            user_map.clone(),
            sessions.clone(),
        ))
        .and_then(handler::admin_handler);

    let login_route = warp::path!("login")
        .and(warp::post())
        .and(warp::body::json())
        .and(with_user_map(user_map.clone()))
        .and(with_sessions(sessions.clone()))
        .and_then(handler::login_handler);

    let logout_route = warp::path!("logout")
        .and(with_auth(
            enforcer.clone(),
            user_map.clone(),
            sessions.clone(),
        ))
        .and(with_sessions(sessions.clone()))
        .and_then(handler::logout_handler);

    let routes = member_route
        .or(admin_route)
        .or(login_route)
        .or(logout_route);

    warp::serve(routes).run(([0, 0, 0, 0], 8080)).await;
}

让我们逐一来看看。首先,我们初始化上面提到的sessionsuser_map 。然后,重要的是,我们使用我们的模型和策略文件来初始化casbin::Enforcer 。这是一个基于请求方法、路径和用户ID来执行我们定义的授权规则的东西。我们把这个执行者放入一个Arc ,这样我们就可以把它的引用传递给其他线程。

之后,我们为loginlogout ,并为会员和管理员分别定义了一条路由,以查看授权方案是否有效。

模型和策略文件的定义如下。

// model.conf
[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
// policy.csv
p, admin, /*, *
p, anonymous, /login, *
p, member, /logout, *
p, member, /member, *

这些定义是非常简单的。在模型中,我们定义了不同的对象、策略格式和匹配器。在策略中,我们实际上为subjectobjectaction 提供了具体的值--在我们的例子中,这些是user_rolepathrequest method

在warp中,我们使用Filter 系统来实现处理传入请求的机制,所以让我们创建一个新的Filter ,它使用casbin 来处理授权。

fn with_auth(
    enforcer: SharedEnforcer,
    user_map: UserMap,
    sessions: Sessions,
) -> impl Filter<Extract = (UserCtx,), Error = Rejection> + Clone {
    full()
        .and(headers_cloned())
        .and(method())
        .map(
            move |path: FullPath, headers: HeaderMap<HeaderValue>, method: Method| {
                (
                    path,
                    enforcer.clone(),
                    headers,
                    method,
                    user_map.clone(),
                    sessions.clone(),
                )
            },
        )
        .and_then(user_authentication)
}

这个过滤器使用path::full() 过滤器获得完整的路径、头文件和请求方法。然后我们把这些值,连同执行者、用户映射和会话映射一起传递给user_authentication 函数,它看起来像这样。

async fn user_authentication(
    args: (
        FullPath,
        SharedEnforcer,
        HeaderMap<HeaderValue>,
        Method,
        UserMap,
        Sessions,
    ),
) -> WebResult<UserCtx> {
    let (path, enforcer, headers, method, user_map, sessions) = args;

    let token = token_from_header(&headers).map_err(|e| warp::reject::custom(e))?;
    let user_id = match sessions.read().await.get(&token) {
        Some(v) => v.clone(),
        None => return Err(warp::reject::custom(Error::InvalidTokenError)),
    };
    let user = match user_map.read().await.get(&user_id) {
        Some(v) => v.clone(),
        None => return Err(warp::reject::custom(Error::InvalidTokenError)),
    };
    match enforcer
        .enforce(&[&user.role.as_str(), &path.as_str(), &method.as_str()])
        .await
    {
        Ok(authorized) => {
            if authorized {
                Ok(UserCtx {
                    user_id: user.user_id,
                    token,
                })
            } else {
                Err(warp::reject::custom(Error::UnauthorizedError))
            }
        }
        Err(e) => {
            eprintln!("error during authorization: {}", e);
            Err(warp::reject::custom(Error::AuthorizationError))
        }
    }
}

fn token_from_header(headers: &HeaderMap<HeaderValue>) -> Result<String> {
    let header = match headers.get(AUTHORIZATION) {
        Some(v) => v,
        None => return Err(Error::NoAuthHeaderFoundError),
    };
    let auth_header = match from_utf8(header.as_bytes()) {
        Ok(v) => v,
        Err(_) => return Err(Error::NoAuthHeaderFoundError),
    };
    if !auth_header.starts_with(BEARER_PREFIX) {
        return Err(Error::InvalidAuthHeaderFormatError);
    }
    let without_prefix = auth_header.trim_start_matches(BEARER_PREFIX);
    Ok(without_prefix.to_owned())
}

好吧,首先我们对参数进行结构化处理。然后,我们从Authorization 头中获取授权令牌,如果没有,或者无效,则返回错误。然后,我们检查是否有一个会话与头中的令牌存在。

如果没有,用户会得到一个错误,如果有,我们再进一步,看看该令牌所属的用户是否仍然存在。如果所有这些都成功了,我们就使用casbinEnforcer ,使用路径、请求方法和用户角色来查看用户是否可以访问请求的资源。如果这些操作中的任何一个失败了,就会返回一个错误,否则就会把令牌和用户ID转发给处理函数。

这基本上是整个授权的神奇之处,我们可以重新使用这个过滤器来保护端点,如上面的路由所示。

为了在处理程序中使用user_mapsessions map,我们还需要为这些过滤器设置翘曲过滤器。

fn with_user_map(
    user_map: UserMap,
) -> impl Filter<Extract = (UserMap,), Error = Infallible> + Clone {
    warp::any().map(move || user_map.clone())
}

fn with_sessions(
    sessions: Sessions,
) -> impl Filter<Extract = (Sessions,), Error = Infallible> + Clone {
    warp::any().map(move || sessions.clone())
}

好吧。让我们创建一个硬编码的用户地图,这样我们就可以在以后测试整个机制。

fn create_user_map() -> HashMap<String, User> {
    let mut map = HashMap::new();
    map.insert(
        String::from("21"),
        User {
            user_id: String::from("21"),
            name: String::from("herbert"),
            role: String::from("member"),
        },
    );
    map.insert(
        String::from("100"),
        User {
            user_id: String::from("100"),
            name: String::from("sibylle"),
            role: String::from("admin"),
        },
    );
    map.insert(
        String::from("1"),
        User {
            user_id: String::from("1"),
            name: String::from("gordon"),
            role: String::from("anonymous"),
        },
    );
    map
}

有了这些,我们接下来看看处理程序的实现,从login 开始。

#[derive(Deserialize, Debug)]
pub struct LoginRequest {
    pub name: String,
}

pub async fn login_handler(
    body: LoginRequest,
    user_map: UserMap,
    sessions: Sessions,
) -> WebResult<impl Reply> {
    let name = body.name;
    match user_map
        .read()
        .await
        .iter()
        .filter(|(_, v)| *v.name == name)
        .nth(0)
    {
        Some(v) => {
            let token = Uuid::new_v4().to_string();
            sessions
                .write()
                .await
                .insert(token.clone(), String::from(v.0));
            Ok(token)
        }
        None => Err(warp::reject::custom(Error::UserNotFoundError)),
    }
}

为了登录一个用户,我们在user_map ,创建一个新的唯一的会话令牌(这里是uuid),并创建一个从该令牌到user_id的会话映射,将该令牌返回给用户。

注销和检查用户是否有会员或管理员权限的端点相当简单。

pub async fn logout_handler(user_ctx: UserCtx, sessions: Sessions) -> WebResult<impl Reply> {
    sessions.write().await.remove(&user_ctx.token);
    Ok("success")
}

pub async fn member_handler(user_ctx: UserCtx) -> WebResult<impl Reply> {
    Ok(format!("Member with id {}", user_ctx.user_id))
}

pub async fn admin_handler(user_ctx: UserCtx) -> WebResult<impl Reply> {
    Ok(format!("Admin with id {}", user_ctx.user_id))
}

注销本质上只是删除了会话映射。测试端点只是返回一个字符串,所以我们可以检查它们是否有效。检查授权的整个机制是在上面提到的Filter 层面上处理的。这很好,因为这意味着在处理程序中,我们根本不需要处理授权问题。

这就是了!在使用cargo run 运行应用程序后,我们可以使用cURL来测试它是否工作。

curl -X POST http://localhost:8080/login -d '{ "name": "herbert" }' -H "content-type: application/json"
=> $TOKEN
curl http://localhost:8080/member -H "authorization: Bearer $TOKEN" -H "content-type: application/json"
=> 200
curl http://localhost:8080/admin -H "authorization: Bearer $TOKEN" -H "content-type: application/json"
=> 200

curl -X POST http://localhost:8080/login -d '{ "name": "sibylle" }' -H "content-type: application/json"
=> $TOKEN
curl http://localhost:8080/member -H "authorization: Bearer $TOKEN" -H "content-type: application/json"
=> 200
curl http://localhost:8080/admin -H "authorization: Bearer $TOKEN" -H "content-type: application/json"
=> 401

curl -X POST http://localhost:8080/login -d '{ "name": "gordon" }' -H "content-type: application/json"
=> $TOKEN
curl http://localhost:8080/member -H "authorization: Bearer $TOKEN" -H "content-type: application/json"
=> 401
curl http://localhost:8080/admin -H "authorization: Bearer $TOKEN" -H "content-type: application/json"
=> 401

首先,我们以管理员身份登录,然后检查/member/admin 是否工作。然后,以会员身份登录,我们确保只有/member ,而不是/admin 。最后,既不是以管理员身份登录,也不是以会员身份登录,我们要确保/admin/member 都不能工作。

完整的示例代码可以在这里找到

总结

这篇文章展示了用于在Rust网络服务中进行授权的神奇的casbin库。casbin的好处是,它的总体概念被大量的语言所支持,并且有许多著名的网络服务器的中间件。这导致了更广泛的采用,在这个过程中,有更多的人在看代码,这就转化为更好的安全性。

casbin背后基于策略的方法既简单又强大,可用于实现许多不同的授权模式。

资源