Authentication in Rocket

authentication.jpg

Last week we enhanced our Rocket web server. We combined our server with our Diesel schema to enable a series of basic CRUD endpoints. This week, we'll continue this integration, but bring in some more cool Rocket features. We'll explore two different methods of authentication. First, we'll create a "Request Guard" to allow a form of Basic Authentication. Then we'll also explore Rocket's amazingly simple Cookies integration.

As always, you can explore the code for this series by heading to our Github repository. For this article specifically, you'll want to take a look at the rocket_auth.rs file

If you're just starting your Rust journey, feel free to check out our Beginners Series as well!

New Data Types

To start off, let's make a few new types to help us. First, we'll need a new database table, auth_infos, based on this struct:

#[derive(Insertable)]
pub struct AuthInfo {
  pub user_id: i32,
  pub password_hash: String
}

When the user creates their account, they'll provide a password. We'll store a hash of that password in our database table. Of course, you'll want to run through all the normal steps we did with Diesel to create this table. This includes having the corresponding Entity type.

We'll also want a couple new form types to accept authentication information. First off, when we create a user, we'll now include the password in the form.

#[derive(FromForm, Deserialize)]
struct CreateInfo {
    name: String,
    email: String,
    age: i32,
    password: String
}

Second, when a user wants to login, they'll pass their username (email) and their password.

#[derive(FromForm, Deserialize)]
struct LoginInfo {
    username: String,
    password: String,
}

Both these types should derive FromForm and Deserialize so we can grab them out of "post" data. You might wonder, do we need another type to store the same information that already exists in User and UserEntity? It would be possible to write CreateInfo to have a User within it. But then we'd have to manually write the FromForm instance. This isn't difficult, but it might be more tedious than using a new type.

Creating a User

So in the first place, we have to create our user so they're matched up with their password. This requires taking the CreateInfo in our post request. We'll first unwrap the user fields and insert our User object. This follows the patterns we've seen so far in this series with Diesel.

#[post("/users/create", format="json", data="<create_info>")]
fn create(db: State<String>, create_info: Json<CreateInfo>)
  -> Json<i32> {
    let user: User = User
        { name: create_info.name.clone(),
          email: create_info.email.clone(),
          age: create_info.age};
    let connection = ...;
    let user_entity: UserEntity = diesel::insert_into(users::table)...
    …
}

Now we'll want a function for hashing our password. We'll use the SHA3 algorithm, courtesy of the rust-crypto library:

fn hash_password(password: &String) -> String {
    let mut hasher = Sha3::sha3_256();
    hasher.input_str(password);
    hasher.result_str()
}

We'll apply this function on the input password and attach it to the created user ID. Then we can insert the new AuthInfo and return the created ID.

#[post("/users/create", format="json", data="<create_info>")]
fn create(db: State<String>, create_info: Json<CreateInfo>)
  -> Json<i32> {
    ...
    let user_entity: UserEntity = diesel::insert_into(users::table)...

    let password_hash = hash_password(&create_info.password);
    let auth_info: AuthInfo = AuthInfo
          {user_id: user_entity.id, password_hash: password_hash};
    let auth_info_entity: AuthInfoEntity = 
          diesel::insert_into(auth_infos::table)..
    Json(user_entity.id)
}

Now whenever we create our user, they'll have their password attached!

Gating an Endpoint

Now that our user has a password, how do we gate endpoints on authentication? Well the first approach we can try is something like "Basic Authentication". This means that every authenticated request contains the username and the password. In our example we'll get these directly out of header elements. But in a real application you would want to double check that the request is encrypted before doing this.

But it would be tiresome to apply the logic of reading the headers in every handler. So Rocket has a powerful functionality called "Request Guards". Rocket has a special trait called FromRequest. Whenever a particular type is an input to a handler function, it runs the from_request function. This determines how to derive the value from the request. In our case, we'll make a wrapper type AuthenticatedUser. This represents a user that has included their auth info in the request.

struct AuthenticatedUser {
    user_id: i32
}

Now we can include this type in a handler signature. For this endpoint, we only allow a user to retrieve their data if they've logged in:

#[get("/users/my_data")]
fn login(db: State<String>, user: AuthenticatedUser)
  -> Json<Option<UserEntity>> {
    Json(fetch_user_by_id(&db, user.user_id))
}

Implementing the Request Trait

The trick of course is that we need to implement the FromRequest trait! This is more complicated than it sounds! Our handler will have the ability to short-circuit the request and return an error. So let's start by specifying a couple potential login errors we can throw.

#[derive(Debug)]
enum LoginError {
    InvalidData,
    UsernameDoesNotExist,
    WrongPassword
}

The from_request function will take in a request and return an Outcome. The outcome will either provide our authentication type or an error. The last bit of adornment we need on this is lifetime specifiers for the request itself and the reference to it.

impl<'a, 'r> FromRequest<'a, 'r> for AuthenticatedUser {
    type Error = LoginError;
    fn from_request(request: &'a Request<'r>)
      -> Outcome<AuthenticatedUser, LoginError> {
        ...
    }
}

Now the actual function definition involves several layers of case matching! It consists of a few different operations that have to query the request or query our database. For example, let's consider the first layer. We insist on having two headers in our request: one for the username, and one for the password. We'll use request.headers() to check for these values. If either doesn't exist, we'll send a Failure outcome with invalid data. Here's what that looks like:

impl<'a, 'r> FromRequest<'a, 'r> for AuthenticatedUser {
    type Error = LoginError;
    fn from_request(request: &'a Request<'r>)
      -> Outcome<AuthenticatedUser, LoginError> {
        let username = request.headers().get_one("username");
        let password = request.headers().get_one("password");
        match (username, password) {
            (Some(u), Some(p)) => {
                ...
            }
            _ => Outcome::Failure(
                (Status::BadRequest,
                 LoginError::InvalidData))
        }
    }
}

In the main branch of the function, we'll do 3 steps:

  1. Find the user in our database based on their email address/username.
  2. Find their authentication information based on the ID
  3. Hash the input password and compare it to the database hash

If we are successful, then we'll return a successful outcome:

Outcome::Success(AuthenticatedUser(user_id: user.id))

The number of match levels required makes the function definition very verbose. So we've included it at the bottom as an appendix. We know how to take such a function and write it more cleanly in Haskell using monads. In a couple weeks, we'll use this function as a case study to explore Rust's monadic abilities.

Logging In with Cookies

In most applications though, we'll won't want to include the password in the request each time. In HTTP, "Cookies" provide a way to store information about a particular user that we can track on our server.

Rocket makes this very easy with the Cookies type! We can always include this mutable type in our requests. It works like a key-value store, where we can access certain information with a key like "user_id". Since we're storing auth information, we'll also want to make sure it's encoded, or "private". So we'll use these functions:

add_private(...)
get_private(...)
remove_private(...)

Let's start with a "login" endpoint. This will take our LoginInfo object as its post data, but we'll also have the Cookies input:

#[post("/users/login", format="json", data="<login_info>")]
fn login_post(db: State<String>, login_info: Json<LoginInfo>, mut cookies: Cookies) -> Json<Option<i32>> {
    ...
}

First we have to make sure a user of that name exists in the database:

#[post("/users/login", format="json", data="<login_info>")]
fn login_post(
  db: State<String>,
  login_info: Json<LoginInfo>,
  mut cookies: Cookies)
  -> Json<Option<i32>> {
    let maybe_user = fetch_user_by_email(&db, &login_info.username);
    match maybe_user {
        Some(user) => {
            ...
            }
        }
        None => Json(None)
    }
}

Then we have to get their auth info again. We'll hash the password and compare it. If we're successful, then we'll add the user's ID as a cookie. If not, we'll return None.

#[post("/users/login", format="json", data="<login_info>")]
fn login_post(
  db: State<String>,
  login_info: Json<LoginInfo>,
  mut cookies: Cookies)
  -> Json<Option<i32>> {
    let maybe_user = fetch_user_by_email(&db, &login_info.username);
    match maybe_user {
        Some(user) => {
            let maybe_auth = fetch_auth_info_by_user_id(&db, user.id);
            match maybe_auth {
                Some(auth_info) => {
                    let hash = hash_password(&login_info.password);
                    if hash == auth_info.password_hash {
                        cookies.add_private(Cookie::new(
                            "user_id", u ser.id.to_string()));
                        Json(Some(user.id))
                    } else {
                        Json(None)
                    }
                }
                None => Json(None)
            }
        }
        None => Json(None)
    }
}

A more robust solution of course would loop in some error behavior instead of returning None.

Using Cookies

Using our cookie now is pretty easy. Let's make a separate "fetch user" endpoint using our cookies. It will take the Cookies object and the user ID as inputs. The first order of business is to retrieve the user_id cookie and verify it exists.

#[get("/users/cookies/<uid>")]
fn fetch_special(db: State<String>, uid: i32, mut cookies: Cookies) 
  -> Json<Option<UserEntity>> {
    let logged_in_user = cookies.get_private("user_id");
    match logged_in_user {
        Some(c) => {
            ...
        },
        None => Json(None)
    }

}

Now we need to parse the string value as a user ID and compare it to the value from the endpoint. If they're a match, we just fetch the user's information from our database!

#[get("/users/cookies/<uid>")]
fn fetch_special(db: State<String>, uid: i32, mut cookies: Cookies) 
  -> Json<Option<UserEntity>> {
    let logged_in_user = cookies.get_private("user_id");
    match logged_in_user {
        Some(c) => {
            let logged_in_uid = c.value().parse::<i32>().unwrap();
            if logged_in_uid == uid {
                Json(fetch_user_by_id(&db, uid))
            } else {
                Json(None)
            }
        },
        None => Json(None)
    }

And when we're done, we can also post a "logout" request that will remove the cookie!

#[post("/users/logout", format="json")]
fn logout(mut cookies: Cookies) -> () {
    cookies.remove_private(Cookie::named("user_id"));
}

Conclusion

We've got one more article on Rocket before checking out some different Rust concepts. So far, we've only dealt with the backend part of our API. Next week, we'll investigate how we can use Rocket to send templated HTML files and other static web content!

Maybe you're more experienced with Haskell but still need a bit of an introduction to Rust. We've got some other materials for you! Watch our Rust Video Tutorial for an in-depth look at the basics of the language!

Appendix: From Request Function

impl<'a, 'r> FromRequest<'a, 'r> for AuthenticatedUser {
    type Error = LoginError;
    fn from_request(request: &'a Request<'r>) -> Outcome<AuthenticatedUser, LoginError> {
        let username = request.headers().get_one("username");
        let password = request.headers().get_one("password");
        match (username, password) {
            (Some(u), Some(p)) => {
                let conn_str = local_conn_string();
                let maybe_user = fetch_user_by_email(&conn_str, &String::from(u));
                match maybe_user {
                    Some(user) => {
                        let maybe_auth_info = fetch_auth_info_by_user_id(&conn_str, user.id);
                        match maybe_auth_info {
                            Some(auth_info) => {
                                let hash = hash_password(&String::from(p));
                                if hash == auth_info.password_hash {
                                    Outcome::Success(AuthenticatedUser{user_id: 1})
                                } else {
                                    Outcome::Failure((Status::Forbidden, LoginError::WrongPassword))
                                }
                            }
                            None => {
                                Outcome::Failure((Status::MovedPermanently, LoginError::WrongPassword))
                            }
                        }
                    }
                    None => Outcome::Failure((Status::NotFound, LoginError::UsernameDoesNotExist))
                }
            },
            _ => Outcome::Failure((Status::BadRequest, LoginError::InvalidData))
        }
    }
}
Previous
Previous

Rocket Frontend: Templates and Static Assets

Next
Next

Joining Forces: An Integrated Rust Web Server