Rust Actix Web + Magic link authentication

If you don't already know, actix-web is an extremely fast yet simple and safe web framework for Rust. In this tutorial, we'll walk-through creating a web app that demonstrates magic link authentication using the approveapi-rs Rust crate.

The completed source code is available on GitHub actix-web-magic-link-auth.

The completed demo app is also live on the web for you to try.

Getting Started

First, let's create our demo app.

$ cargo init --bin magic-link-demo && cd magic-link-demo

Now let's setup basic actix-web app. In your Cargo.toml add actix-web as a dependency.

[dependencies]
actix-web = "0.7.19"

In main.rs define the basic app structure to run a simple server and respond to an empty GET request.

use actix_web::{server, HttpResponse, Responder};
use actix_web::http::{StatusCode, Method, header};

fn main() {
    server::new(|| {
        App::new().route("/", Method::GET, home)
    })
    .bind("0.0.0.0:80").unwrap()
    .run()
}

fn home(request: HttpRequest) -> impl Responder {
    HttpResponse::build(StatusCode::OK)
        .content_type("text/html; charset=utf-8")
        .body("Welcome Home!".to_string())
}

Now let's run the server to make sure everything's working.

$ cargo run # Go to http://localhost/

There are several different ways you might want to store session data. In this demo, we'll store the information about who is logged in a cookie. To keep it simple, we'll simply store the email address of the logged in user. To make sure that clients will not be able to edit the cookie (and insert a different user's email address to bypass login), we'll used an private (encrypted) cookie.

We'll use the SessionStorage middleware along with CookieSessionBackend made available by actix-web to maintain an encrypted Since private cookies are encrypted, we'll need to set and we'll need to add base64 as a dependency to decode our COOKIE_SECRET_KEY.

// ...add new imports
use base64;
use std::env;
use actix_web::middleware::session::{CookieSessionBackend, SessionStorage};

fn main() {
    // This is the key used to encrypt/decrypt the private cookie
    // Note: Changing this value will make all existing sessions invalid!
    let cookie_secret = base64::decode(&env::var("COOKIE_SECRET_KEY").unwrap()).unwrap();

    server::new(|| {
        App::new()
            .middleware(SessionStorage::new(
                // Note: secure = false here so we can test without TLS on http://localhost
                CookieSessionBackend::private(&cookie_secret).secure(false)
            ))
            .route("/", Method::GET, home)
    })
    .bind("0.0.0.0:80").unwrap()
    .run()
}

Don't forget to generate a local cookie encryption key for testing.

# 32-byte random, base64 encoded secret
$ export COOKIE_SECRET_KEY=`cat /dev/urandom | head -c 32 | base64`

Step 1: Render a login page with a login form

The first step is to create our login page where we will allow the user to enter their email address or phone number to login. First, let's create the route.

// in `main.rs: main`
.route("/login", Method::GET, login_page)
.route("/login", Method::POST, login_submit)

Then we'll add the route handler for the login page.

use actix_web::{HttpRequest, Result};

fn login_page(_: HttpRequest) -> Result<HttpResponse> {
    Ok(HttpResponse::build(StatusCode::OK)
        .content_type("text/html; charset=utf-8")
        .body(include_str!("../static/login.html"))) // 
}

Next, create a static/ directory in the root directory and add an HTML login page to it.

The login HTML form will POST /login to our login_submit handler, defined below. Actix-web uses serde for deserialization of form and parameter values so make sure to add this dependency to Cargo.toml.

#[derive(Deserialize)]
struct LoginForm {
    user: String, // an email address or phone
}

fn login_submit((form, request): (Form<LoginForm>, HttpRequest)) -> impl Responder {
    // the user we are authenticating
    let user = form.into_inner().user;

    // TODO: this is where the challenge will be created and sent
    // the challenge token for the user to verify later

}

Step 2: Generate and store the login challenge

Now we can generate a random challenge token using the rand crate.

// Create a 32-byte challenge token
use rand::rngs::{OsRng};
use rand::RngCore;

fn random_challenge() -> String {
    let mut nonce = vec![0u8; 32];
    OsRng::new().unwrap().fill_bytes(&mut nonce);
    base64::encode_config(&nonce, base64::URL_SAFE)
}

We also need to store the challenge token to user binding in the session so it can be verified later.

// Struct to store the user <-> challenge binding
use serde::Serialize;

#[derive(Serialize, Deserialize)]
struct LoginChallenge {
    user: String,
    challenge: String,
}

fn login_submit((form, request): (Form<LoginForm>, HttpRequest)) -> impl Responder {
    let user = form.into_inner().user;
    let challenge = random_challenge();

    // Store the LoginChallenge in the private session cookie
    let _ = request.session().set("pending_login_challenge", LoginChallenge {
        user: user.clone(),
        challenge: challenge.clone(),
    });

    // Next: Send the challenge token in a magic link
}

Step 3: Send the "magic" challenge token link

We'll use ApproveAPI to send the challenge token to the user.

First, we'll need to grab a free API Key. Next, we can set this key in an environment variable APPROVEAPI_TEST_KEY.

Next, add the ApproveAPI crate to the Cargo.toml file. We'll also need to use the futures and failure.

[dependencies]
approveapi = "1.0.3"
failure = "0.1.5"
futures = "0.1.25"

Use the ApproveAPI Prompt API to create and send the magic link.

use futures::prelude::*;
use failure::Fail;
use approveapi::CreatePromptRequest;

#[derive(Debug, Fail)]
#[fail(display = "Internal Server Error Occured")]
struct ServerError(String);
impl actix_web::ResponseError for ServerError {}

fn send_magic_link(user: String, challenge_token: String) -> impl Future<Item=(), Error=ServerError> {
        // create the prompt for magic sign-in approval
    let client = approveapi::create_client(env::var("APPROVEAPI_TEST_KEY").unwrap());
    let mut prompt_request = CreatePromptRequest::new(
        user,
        r#"Click the link below to sign in to your account.
        This link will expire in 24 hours."#.to_string(),
    );
    prompt_request.title = Some("Magic sign-in link".to_string());
    prompt_request.approve_text = Some("Sign-in".to_string());
    prompt_request.approve_redirect_url = Some(format!("http://localhost/verify_login?c={}", challenge_token));

    client.create_prompt(prompt_request).map_err(|e| {
        eprintln!("approveapi error: {:?}", e);
        ServerError(format!("approveapi error: {:?}", e))
    })
}

And finally, call the send_magic_link function from our login_submit handler.

fn login_submit((form, request): (Form<LoginForm>, HttpRequest)) -> impl Responder {
    let user = form.into_inner().user;
    let challenge = random_challenge();

    let _ = request.session().set("pending_login_challenge", LoginChallenge {
        user: user.clone(),
        challenge: challenge.clone(),
    });

    // After sending, tell the user to check their email/phone
    send_magic_link(user, challenge).and_then(|_| {
        Ok("Check your email or phone for a magic link to sign in!".to_string())
    }).responder()
}

Step 4: Verify the login challenge

In the last step, we used ApproveAPI to send the user a magic login link http://localhost/verify_login?c=<challenge>. The next step is to add a route handler to actually verify the challenge token and then if valid store the authenticated user in the session cookie.

Add the verify_login route.

// in main.rs: main
.route("/verify_login", Method::GET, verify_login)

Next we implement its route handler. use actix_web::Query;

#[derive(Deserialize)]
struct VerifyLoginQuery {
    #[serde(rename = "c")]
    challenge: String,
}

fn verify_login((query, request): (Query<VerifyLoginQuery>, HttpRequest)) -> Result<HttpResponse> {
    // 1. loading the pending challenge
    let pending_challenge: Option<LoginChallenge> = request.session().get("pending_login_challenge")?;

    let login_challenge = match pending_challenge {
        Some(lc) => lc,
        None => {
            // Couldn't parse the session, so return an error
            return Ok(HttpResponse::build(StatusCode::BAD_REQUEST)
                .content_type("text/html; charset=utf-8")
                .body("Invalid session"));
        }
    };

    // 2. compare the pending challenge to the one received
    if login_challenge.challenge == query.into_inner().challenge {
        // 3. store the authenticated user bound to the challenge token
        let _ = request.session().set("authenticated_user", login_challenge.user);

        // redirect to home    
        Ok(HttpResponse::build(StatusCode::TEMPORARY_REDIRECT)
            .header(header::LOCATION, "/")
            .finish())

    } else {
        // the challenge does not match!
        // The user is not logged in.
        Ok(HttpResponse::build(StatusCode::UNAUTHORIZED)
            .content_type("text/html; charset=utf-8")
            .body("Invalid challenge"))
    }
}

Verifying a challenge is straightforward:

  1. Use the session cookie to look up the pending challenge to know what the challenge token is supposed to be
  2. Compare the incoming token in the query parameter to that of what we have stored
  3. Save the user bound to the challenge (also found pending challenge cookie) as the authenticated user in a new session cookie.

Final step: show the user they're signed in

The last step is to modify our home handler to check if the user is logged in via the authenticated user session cookie. If the user is logged in then show them a welcome screen. Otherwise redirect them to the login page.

fn home(request: HttpRequest) -> impl Responder {
    // check to see if we have an authenticated user in the session cookie
    let authenticated_user:Option<String> = request.session().get("authenticated_user").unwrap_or(None);

    // if we have a logged in user, render a hello page
    if let Some(user) = authenticated_user {
        return HttpResponse::build(StatusCode::OK)
            .content_type("text/html; charset=utf-8")
            .body(format!("Welcome, {}! You're successfully logged in.", user));
    }

    // else not logged in, redirect to /login page!
    HttpResponse::build(StatusCode::TEMPORARY_REDIRECT)
        .header(header::LOCATION, "/login")
        .finish()
}

That's it!

See the this demo app in action: checkout a live running version.

Test, Clone or copy the source code: actix-web-magic-link-auth on GitHub.