DEV Community

loading...

How to use casbin authorization in your rust web-app [Part - 3]

Samarpan
noob
・6 min read

In this blog we'll make a new project in which we'll use the authorization model talked about in the previous blog.
Here is the link to the github repository for reference -
https://github.com/casbin-rs/examples/tree/master/actix-middleware-example

We'll make a simple anonynous forum app using Actix-web, Casbin and Diesel, with JWT support.
There will be 2 roles in this app - admin and user

So, let's start.
First, configure the Cargo.toml -

[dependencies]
http = "0.2.1"
actix =  "0.11.0"
actix-web = "3.3.2"
actix-service = "2.0.0"
actix-rt = "1.1.1"
actix-cors = "0.4.0"
futures = "0.3.5"
failure = "0.1.8"
serde = "1.0.116"
serde_derive = "1.0.116"
serde_json = "1.0.57"
derive_more = "0.99.10"
chrono = { version = "0.4.18", features = ["serde"] }
diesel = { version = "1.4.5", features = ["postgres","r2d2", "chrono"] }
diesel_migrations = "1.4.0"
dotenv = "0.15.0"
env_logger = "0.8.1"
log = "0.4.11"
jsonwebtoken = "7.2.0"
bcrypt = "0.9.0"
csv = "1.1.3"
walkdir = "2.3.1"
actix-casbin= {version = "0.4.2", default-features = false, features = [ "runtime-async-std" ]}
actix-casbin-auth = {version = "0.4.4", default-features = false, features = [ "runtime-async-std" ]}
diesel-adapter = { version = "0.8.1", default-features = false, features = ["postgres","runtime-async-std"] }
uuid = {version = "0.8.1", features = ["v4"] }
Enter fullscreen mode Exit fullscreen mode

Include the casbin.conf. (see repo)
And the preset_policy.csv. (see the repo)
Create a .env -

APP_HOST=127.0.0.1
APP_PORT=8080
DATABASE_URL=postgres://databasename:password@127.0.0.1:5432/test
POOL_SIZE=8
HASH_ROUNDS=12
Enter fullscreen mode Exit fullscreen mode

Then, in the src folder, modify the main.rs -
Import external crates first and modules(to be made later) -

#![allow(proc_macro_derive_resolution_fallback)]

#[macro_use]
extern crate diesel;
#[macro_use]
extern crate log;
#[macro_use]
extern crate diesel_migrations;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate serde_json;

use crate::utils::csv_utils::{load_csv, walk_csv};
use actix::Supervisor;
use actix_casbin::casbin::{
    function_map::key_match2, CachedEnforcer, CoreApi, DefaultModel, MgmtApi, Result,
};
use actix_casbin::CasbinActor;
use actix_casbin_auth::CasbinService;
use actix_cors::Cors;
use actix_web::middleware::normalize::TrailingSlash;
use actix_web::middleware::Logger;
use actix_web::middleware::NormalizePath;
use actix_web::{App, HttpServer};
use diesel_adapter::DieselAdapter;
use std::env;

mod api;
mod config;
mod constants;
mod errors;
mod middleware;
mod models;
mod routers;
mod schema;
mod services;
mod utils;
Enter fullscreen mode Exit fullscreen mode

We spawn a server using HttpServer.
All default values such as APP_HOST are defined in the .env.
We define a connection pool -

let pool = config::db::migrate_and_config_db(&database_url, pool_size);
Enter fullscreen mode Exit fullscreen mode

With a default pool size of 8.
We import our casbin model -

let model = DefaultModel::from_file("casbin.conf").await?;
    let adapter = DieselAdapter::new(database_url, pool_size)?;
    let mut casbin_middleware = CasbinService::new(model, adapter).await.unwrap();
    casbin_middleware
        .write()
        .await
        .get_role_manager()
        .write()
        .unwrap()
        .matching_fn(Some(key_match2), None);

    let share_enforcer = casbin_middleware.get_enforcer();
    let clone_enforcer = share_enforcer.clone();
    let casbin_actor = CasbinActor::<CachedEnforcer>::set_enforcer(share_enforcer)?;
    let started_actor = Supervisor::start(|_| casbin_actor);
 let preset_rules = load_csv(walk_csv("."));
    for mut policy in preset_rules {
        let ptype = policy.remove(0);
        if ptype.starts_with('p') {
            match clone_enforcer.write().await.add_policy(policy).await {
                Ok(_) => info!("Preset policies(p) add successfully"),
                Err(err) => error!("Preset policies(p) add error: {}", err.to_string()),
            };
            continue;
        } else if ptype.starts_with('g') {
            match clone_enforcer
                .write()
                .await
                .add_named_grouping_policy(&ptype, policy)
                .await
            {
                Ok(_) => info!("Preset policies(p) add successfully"),
                Err(err) => error!("Preset policies(g) add error: {}", err.to_string()),
            };
            continue;
        } else {
            unreachable!()
        }
    }
Enter fullscreen mode Exit fullscreen mode

Then we can define our modules.
Inside the src/ dir, make the following dirs - api/, config/, middleware/, models/, routers/, services/ and utils/.
Also make these files - constants.rs and errors.rs.
Create models in models/ -
post.rs, response.rs, user.rs and user_token.rs

Run the following command in the root of the project -

cargo install diesel_cli --no-default-features --features postgres
Enter fullscreen mode Exit fullscreen mode

.env DATABASE_URL property that Diesel will use to get the connection details of your Postgres instance.
Now run diesel setup in the project root folder . Diesel will create a new database (confessions), as well as a set of empty migrations.

Now run -

diesel migration generate casbin_rules post users ⏎
diesel migration run
Enter fullscreen mode Exit fullscreen mode

This creates the schema.rs in the src/ dir. You can check that out.
In the config/ dir created before, define the database config -

pub fn migrate_and_config_db(url: &str, pool_size: u32) -> Pool {
    info!("Migrating and configurating database...");
    let manager = ConnectionManager::<Connection>::new(url);
    let pool = r2d2::Pool::builder()
        .connection_timeout(Duration::from_secs(10))
        .max_size(pool_size)
        .build(manager)
        .expect("Failed to create pool.");
    embedded_migrations::run(&pool.get().expect("Failed to migrate."))
        .expect("Failed to migrate.");

    pool
}
Enter fullscreen mode Exit fullscreen mode

Now, let's write the middleware. In the middleware/ dir, make a file authn.rs.
This is where we implement role-Based HTTP authorization.
Import the external crates and libs -

#![allow(clippy::type_complexity)]
use crate::{
    config::db::Pool, constants, models::response::ResponseBody, utils::token_utils,
};

use actix_casbin_auth::CasbinVals;
use actix_service::{Service, Transform};
use actix_web::{
    dev::{ServiceRequest, ServiceResponse},
    http::{HeaderName, HeaderValue, Method},
    web::Data,
    Error, HttpMessage, HttpResponse,
};
use futures::{
    future::{ok, Ready},
    Future,
};
use std::cell::RefCell;
use std::rc::Rc;
use std::{
    pin::Pin,
    task::{Context, Poll},
};
Enter fullscreen mode Exit fullscreen mode

Then we'll create a public struct -

pub struct Authentication;
Enter fullscreen mode Exit fullscreen mode

Now implement a trait Transform (see docs) -

impl<S, B> Transform<S, ServiceRequest> for Authentication
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    B: MessageBody,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = AuthenticationMiddleware<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ok(AuthenticationMiddleware {
            service: Rc::new(RefCell::new(service)),
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Response, Error, InitError, Transform and Future are all associated types defined in the default implementations of the trait Transform.
The new_transform function returns a Future.

We make another public struct AuthenticationMiddleware -

pub struct AuthenticationMiddleware<S> {
    service: Rc<RefCell<S>>,
}
Enter fullscreen mode Exit fullscreen mode

Implement Service for AuthenticationMiddleware (see docs) -

impl<S, B> Service<ServiceRequest> for AuthenticationMiddleware<S>
where 
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    B: MessageBody,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
..
..
Enter fullscreen mode Exit fullscreen mode

poll_ready is an underlying method that makes a Future work, similar to the regular poll on the Future trait.
Define a function call inside the Service impl

fn call(&mut self, mut req: ServiceRequest) -> Self::Future {
..
..
}
Enter fullscreen mode Exit fullscreen mode

Inside the call function, we define certain variables.
We store the casbin service which is a smart pointer in rust - Rc<RefCell<S>>.
Why do we use Rc<RefCell<>>? - Well, that is because the the actix actor is single-threaded, whereas our casbin enforcer is multi-threaded, hence the service is a pointer to each thread.
Then we have authenticate_pass, public_route and authenticate_username -

let mut srv = self.service.clone();
let mut authenticate_pass: bool = false;
let mut public_route: bool = false;
let mut authenticate_username: String = String::from("");

// Bypass some account routes
let headers = req.headers_mut();
headers.append(
    HeaderName::from_static("content-length"),
    HeaderValue::from_static("true"),
);
Enter fullscreen mode Exit fullscreen mode

This is the main logic in this file -

        if Method::OPTIONS == *req.method() {
            authenticate_pass = true;
        } else {
            for ignore_route in constants::IGNORE_ROUTES.iter() {
                if req.path().starts_with(ignore_route) {
                    authenticate_pass = true;
                    public_route = true;
                }
            }
            if !authenticate_pass {
                if let Some(pool) = req.app_data::<Data<Pool>>() {
                    info!("Connecting to database...");
                    if let Some(authen_header) =
                        req.headers().get(constants::AUTHORIZATION)
                    {
                        info!("Parsing authorization header...");
                        if let Ok(authen_str) = authen_header.to_str() {
                            if authen_str.starts_with("bearer")
                                || authen_str.starts_with("Bearer")
                            {
                                info!("Parsing token...");
                                let token = authen_str[6..].trim();
                                if let Ok(token_data) =
                                    token_utils::decode_token(token.to_string())
                                {
                                    info!("Decoding token...");
                                    if token_utils::verify_token(&token_data, pool)
                                        .is_ok()
                                    {
                                        info!("Valid token");
                                        authenticate_username = token_data.claims.user;
                                        authenticate_pass = true;
                                    } else {
                                        error!("Invalid token");
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward - connect to db, get auth token from headers, parse token, decode token, verify token, authenticate.
Then casbin checks if the particular user is authorized to access the route -

if authenticate_pass {
            if public_route {
                let vals = CasbinVals {
                    subject: "anonymous".to_string(),
                    domain: None,
                };
                req.extensions_mut().insert(vals);
                Box::pin(async move { srv.call(req).await })
            } else {
                let vals = CasbinVals {
                    subject: authenticate_username,
                    domain: None,
                };
                req.extensions_mut().insert(vals);
                Box::pin(async move { srv.clone().call(req).await })
            }
        } else {
            Box::pin(async move {
                Ok(req.into_response(
                    HttpResponse::Unauthorized()
                        .json(ResponseBody::new(
                            constants::MESSAGE_INVALID_TOKEN,
                            constants::EMPTY,
                        ))
                        .into_body(),
                ))
            })
        }
Enter fullscreen mode Exit fullscreen mode

We then use this authn.rs in our main.rs when we spawn the our http server -

    HttpServer::new(move || {
        App::new()
            .data(pool.clone())
            .data(started_actor.clone())
            .wrap(
                Cors::new()
                    .send_wildcard()
                    .allowed_methods(vec!["GET", "POST", "DELETE"])
                    .allowed_headers(vec![
                        http::header::AUTHORIZATION,
                        http::header::ACCEPT,
                    ])
                    .allowed_header(http::header::CONTENT_TYPE)
                    .max_age(3600)
                    .finish(),
            )
            .wrap(NormalizePath::new(TrailingSlash::Trim))
            .wrap(Logger::default())
            .wrap(casbin_middleware.clone())
            .wrap(crate::middleware::authn::Authentication)
            .configure(routers::routes)
    })
    .bind(&app_url)?
    .run()
    .await?;
Enter fullscreen mode Exit fullscreen mode

That's it.
This is how casbin can be used in an actix-web app.

Discussion (0)