JWT & Access Roles in Rocket

This experiment:

Note: This is purely an experiment. It is ugly code and you should not use it for literally anything.

Purpose

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").

Diesel

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>,
}

JWT

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()
}

Authentication

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("/")
}

Restricted Pages

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,
    }
}

Code

Working demo site can be found here.

Author

Shawn Kinkade — January, 2017