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"] }
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
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;
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);
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!()
}
}
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
.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
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
}
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},
};
Then we'll create a public struct -
pub struct Authentication;
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)),
})
}
}
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>>,
}
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>>>>;
..
..
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 {
..
..
}
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"),
);
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");
}
}
}
}
}
}
}
}
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(),
))
})
}
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?;
That's it.
This is how casbin can be used in an actix-web app.
Top comments (0)