This experiment:
Note: This is purely an experiment. It is ugly code and you should not use it for literally anything.
In the Yii framework for PHP, a relatively easy form of access control is to assign accounts roles (or derive them from LDAP/AD), and check them with:
Yii::$app->user->can('role');
I wanted to see if I could do something similar in Rust, and I also wanted to learn a bit about JSON Web Tokens. What I have at the moment, is putting a string array in the token, so we can just more or less do token.roles.contains("role")
.
This is a less-than-ideal setup but is sufficient for a test. Our Diesel migration and model are simply username, password, and a (PostgreSQL-specific) string array of roles.
CREATE TABLE users (
username TEXT PRIMARY KEY,
pw_hash TEXT NOT NULL,
user_roles TEXT[] NOT NULL DEFAULT '{"user"}'
);
#[derive(Queryable)]
pub struct User {
pub username: String,
pub pw_hash: String,
pub user_roles: Vec<String>,
}
There are a number of JWT crates of varying maturity. I settled on jsonwebtoken, but you would need e.g. franke_jwt if you want signatures and not just HMAC.
#[derive(Debug, RustcEncodable, RustcDecodable)]
struct UserRolesToken {
// issued at
iat: i64,
// expiration
exp: i64,
user: String,
roles: Vec<String>,
}
impl UserRolesToken {
fn has_role(&self, role: &str) -> bool {
self.roles.contains(&role.to_string())
}
}
fn jwt_generate(user: String, roles: Vec<String>) -> String {
let now = time::get_time().sec;
let payload = UserRolesToken {
iat: now,
exp: now + ONE_WEEK,
user: user,
roles: roles,
};
encode(Header::default(), &payload, KEY).unwrap()
}
Pretty straight-forward. Get the user, verify the password. If things check out, generate a JWT and store it in their cookies.
#[derive(FromForm)]
struct Login {
username: String,
password: String,
}
#[post("/login", data="<login_form>")]
fn login(cookies: &Cookies, login_form: Form<Login>) -> Redirect {
use schema::users::dsl::*;
let login = login_form.get();
let connection = establish_connection();
let user = match users.filter(username.eq(&login.username))
.first::<models::User>(&connection) {
Ok(u) => u,
Err(_) => return Redirect::to("/login"),
};
let hash = user.pw_hash.into_bytes();
// Argon2 password verifier
let db_hash = Encoded::from_u8(&hash).expect("Failed to read password hash");
if !db_hash.verify(login.password.as_ref()) {
return Redirect::to("/login");
}
// Add JWT to cookies
cookies.add(Cookie::new("jwt".into(), jwt_generate(user.username, user.roles)));
Redirect::to("/")
}
Here’s where things get a bit messy. Obviously we don’t want to be constantly checking and re-checking cookies and roles for every route. What I would really like is some additional macro goodness that would allow us to simply write something like…
#[jwt(user == "admin" || roles.contains("c-level"))]
#[get("/path")]
fn special_page() -> ...
In this case, I have a dynamic path for a /admin
prefix which performs the token verification, and returns the results of non-routed functions.
#[get("/admin/<path>")]
fn admin_handler(cookies: &Cookies, path: &str) -> Option<Template> {
let token = match cookies.find("jwt").map(|cookie| cookie.value) {
Some(jwt) => jwt,
_ => return None,
};
// You'll want to match on and log errors instead of unwrapping, of course
let token_data = decode::<UserRolesToken>(&token, KEY, Algorithm::HS256).unwrap();
if !token_data.claims.has_role("admin") {
return None;
}
match path {
"index" => return Some(admin_index()),
"user" => return Some(display_user(token_data.claims.user)),
_ => return None,
}
}
Working demo site can be found here.
Shawn Kinkade — January, 2017