Let's make a simple authentication server in Rust with Warp

Joshua Cooper

First, create a new project using cargo.

cargo new warp_auth_server
cd warp_auth_server

Then add the warp dependency to Cargo.toml.

[dependencies]
warp = "0.2.0"

When using async Rust, we also need to use an executor to poll Futures, so let's add a dependency on tokio to do that for us. tokio is already used by warp internally but we still need to explicity include it for our project.

[dependencies]
warp = "0.2.0"
tokio = { version = "0.2", features = ["macros"] }

Then edit src/main.rs and replace the hello world program with a warp hello world.

use warp::Filter;

#[tokio::main]
async fn main() {
    let routes = warp::any().map(|| "Hello, World!");
    warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}

You can then run this with cargo run and open 127.0.0.1:3030 in your browser to see "Hello, World!".

Already in the hello world program, you have been introduced to Filters, the main concept in warp. Each incoming request passes through a chain of Filters which can either do something with that request, or reject it. This is fundamentally very simple but still powerful enough to enable things like sophisticated routing and middleware, which we will explore soon. In the hello world example, warp::any() is a Filter that will accept any request.

Let's look at how to handle the routing for our project using warp Filters. Replace routes in the hello world example with three different paths.

let register = warp::path("register").map(|| "Hello from register");
let login = warp::path("login").map(|| "Hello from login");
let logout = warp::path("logout").map(|| "Hello from logout");
let routes = register.or(login).or(logout);

Notice that instead of using the warp::any() Filter, we are using warp::path() which will accept any request to the path matching the given string, and reject any other request. To combine all of the routes, we used or. or will try another chain of Filters after a rejection, so in our case, any request that is rejected by the "register" Filter will be sent down the next Filter chain, which is the "login" route. This will carry on until one of the Filter chains yields a response, otherwise an error response is sent. As well as or, there is also and which is used for chaining filters together when a request isn't rejected. We can test this by putting all of our routes after an "/api" path.

let routes = register.or(login).or(logout);
let routes = warp::path("api").and(routes);

Here, if a request is accepted by the "api" Filter, i.e. any request to "/api/*", and one of the paths defined before, it will yield a response.

One final concept to grasp before moving on is that Filters can be used to share state throughout your project. We will look at a simple example of a shared counter.

use std::sync::Arc;
use tokio::sync::Mutex;
use warp::Filter;

#[tokio::main]
async fn main() {
    let db = Arc::new(Mutex::new(0));
    let db = warp::any().map(move || Arc::clone(&db));
    let routes = warp::path("counter").and(db.clone()).and_then(counter);
    warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}

async fn counter(db: Arc<Mutex<u8>>) -> Result<impl warp::Reply, warp::Rejection> {
    let mut counter = db.lock().await;
    *counter += 1;
    Ok(counter.to_string())
}

The things to notice here are that we define an initial state of 0 in main that is wrapped by a tokio Mutex and an Arc so that it can be shared and mutated asynchronously. After that, we turn the counter into a Filter so that we can combine it with others. In this example, our routes Filter chain accepts any request with the path "counter" then adds db to the request to be used by the following Filters, then finally passes it to the counter function. Note that the final and is replaced with and_then for use with an async function.

Implementing the authentication server

That's enough of the basics, now we can move on to implementing the authentication server. First, we need to replace the counter with a databse of users. For that we'll just use an in memory database, but it could easily be replaced later.

let db = Arc::new(Mutex::new(HashMap::<String, User>::new()));
let db = warp::any().map(move || Arc::clone(&db));

Remember to include HashMap from the standard library.

use std::collections::HashMap;

Before we define the User struct, we should add a dependency called serde so that we can deserialize JSON data from requests.

[dependencies]
warp = "0.2.0"
tokio = { version = "0.2", features = ["macros"] }
serde = { version = "1.0", features = ["derive"] }

Then use serde::Deserialize in main.rs and define the User struct.

use serde::Deserialize;

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

Next we can make the functions that will be called at the end of each Filter chain. In these, we will use HTTP status codes exported by warp for our replies, so we need to include them in main.rs.

use warp::http::StatusCode;

Now let's make the function for registering a user.

async fn register(
    new_user: User,
    db: Arc<Mutex<HashMap<String, User>>>,
) -> Result<impl warp::Reply, warp::Rejection> {
    let mut users = db.lock().await;
    if users.contains_key(&new_user.username) {
        return Ok(StatusCode::BAD_REQUEST);
    }
    users.insert(new_user.username.clone(), new_user);
    Ok(StatusCode::CREATED)
}

This function takes a new user and the databse of users then adds the new user to the database if a user with that username doesn't already exist. Note that this will store the password in plain text which you should never do, we will fix that later.

Now the function that handles logging in.

async fn login(
    credentials: User,
    db: Arc<Mutex<HashMap<String, User>>>,
) -> Result<impl warp::Reply, warp::Rejection> {
    let users = db.lock().await;
    match users.get(&credentials.username) {
        None => Ok(StatusCode::BAD_REQUEST),
        Some(user) => {
            if credentials.password == user.password {
                Ok(StatusCode::OK)
            } else {
                Ok(StatusCode::UNAUTHORIZED)
            }
        }
    }
}

This function takes the given credentials and checks to see if there is a user with those credentials. Our example just returns a 200 OK response on success, but you could return something like a cookie or JWT and handle sessions here too.

Finally, let's define the routes.

let register = warp::post()
    .and(warp::path("register"))
    .and(warp::body::json())
    .and(db.clone())
    .and_then(register);
let login = warp::post()
    .and(warp::path("login"))
    .and(warp::body::json())
    .and(db.clone())
    .and_then(login);

let routes = register.or(login);
warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;

You can probably guess by now what the warp::body::json() Filter does. Like our database Filter, it adds the request body to our request to by used by the rest of the Filter chain. We also use the warp::post() Filter here to reject anything that isn't a HTTP POST request.

Now you can run the server and test it using something like curl or HTTPie.

Storing password hashes

Remember how we are just storing the passwords in plain text in our database? That is bad practice and instead we should be storing hashes of the passwords, so let's implement that now. We will need to add some dependencies for password hashing.

[dependencies]
warp = "0.2.0"
tokio = { version = "0.2", features = ["macros"] }
serde = { version = "1.0", features = ["derive"] }
rand = "0.7.2"
rust-argon2 = "0.6.0"

For convenience, we will make some wrapper functions for hashing and verifying passwords.

use argon2::{self, Config};
use rand::Rng;

pub fn hash(password: &[u8]) -> String {
    let salt = rand::thread_rng().gen::<[u8; 32]>();
    let config = Config::default();
    argon2::hash_encoded(password, &salt, &config).unwrap()
}

pub fn verify(hash: &str, password: &[u8]) -> bool {
    argon2::verify_encoded(hash, password).unwrap_or(false)
}

The most important thing to notice here is that we are generating a random salt in the hash function. It is best practice to generate random salts for each password as it protects against various attacks that an attacker might use.

Now we need to replace the insert in our register function.

let hashed_user = User {
    username: new_user.username,
    password: hash(new_user.password.as_bytes()),
};
users.insert(hashed_user.username.clone(), hashed_user);

And the if in our login function.

if verify(&user.password, credentials.password.as_bytes()) {
    Ok(StatusCode::OK)
} else {
    Ok(StatusCode::UNAUTHORIZED)
}

Much better. That's all for now. This is a very simple authentication server but I hope this post gave you the building blocks needed to expand it for your own needs. I strongly recommend taking a look at the warp documentation and if you need help, don't hesitate to ask me. Also, any feedback is welcome!

I have put the full code for the final authentication server on GitHub.