I’ve been studying user authentication with the actix-web framework. It seems that a popular choice is to use crate actix-identity, which requires crate actix-session. To add these two (2) crates, the code of the existing application must be refactored a little. We first look at code refactoring and integration. Then we briefly discuss the official examples given in the documentation of the 2 (two) mentioned crates.

🦀 Index of the Complete Series.

094-feature-image.png
Rust: adding actix-session and actix-identity to an existing actix-web application.

🚀 Please note, complete code for this post can be downloaded from GitHub with:

git clone -b v0.4.0 https://github.com/behai-nguyen/rust_web_01.git

The actix-web learning application mentioned above has been discussed in the following three (3) previous posts:

  1. Rust web application: MySQL server, sqlx, actix-web and tera.
  2. Rust: learning actix-web middleware 01.
  3. Rust: retrofit integration tests to an existing actix-web application.

The code we’re developing in this post is a continuation of the code from the third post above. 🚀 To get the code of this third post, please use the following command:

git clone -b v0.3.0 https://github.com/behai-nguyen/rust_web_01.git

– Note the tag v0.3.0.

The session storage backend we use with actix-session is RedisSessionStore, it requires Redis server. We use the Redis Official Docker Image as discussed in the following post:

Code Refactoring and Integration

❶ Adding the two (2) crates to Cargo.toml:

[dependencies]
...
actix-session = {version = "0.8.0", features = ["redis-rs-session"]}
actix-identity = "0.6.0"

For crate actix-session, we need to enable the redis-rs-session feature as per the official document instructions.

❷ Update function run(...) to async.

The current code as in the last (third) post has run(...) in src/lib.rs as a synchronous function:

pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
...

This makes instantiating a required instance of RedisSessionStore impossible for me! “Impossible for me” because I tried and could not get it to work. I won’t list out what I’ve tried, it’d be a waste of time.

The next best option is to refactor it to async, and follow the official documentations to register IdentityMiddleware and SessionMiddleware.

Updated src/lib.rs:
pub async fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    ...

    let pool = database::get_mysql_pool(config.max_connections, &config.database_url).await;

    let secret_key = Key::generate();
    let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379")
        .await
        .unwrap();

    let server = HttpServer::new(move || {
        ...

        App::new()
            .app_data(web::Data::new(AppState {
                db: pool.clone()
            }))
            .wrap(IdentityMiddleware::default())
            .wrap(SessionMiddleware::new(
                    redis_store.clone(),
                    secret_key.clone()
            ))
            .wrap(cors)
			
            ...
    })
    .listen(listener)?
    .run();

    Ok(server)
}

💥 Please note the following:

  1. The two (2) new middleware get registered before the existing Cors middleware, (i.e., .wrap(cors)). Recall from the actix-web middleware documenation that middleware get called in reverse order of registration, we want the Cors middleware to run first to reject invalid requests at an early stage.
  2. Now that run(...) is an async function, we can call .await on database::get_mysql_pool(...) instead of wrap it in the async_std::task::block_on function.
  3. Apart from the above refactorings, nothing else has been changed.

All functions who call run(...) must also be refactored now that run(...) is an async function. They are main(), spawn_app() and all integration test methods which call spawn_app().

❸ Update function main().

Updated src/main.rs:
...
#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
    ...

    let server = run(listener).await.unwrap();
    server.await
}

Note, the code in the previous version:

    ...
    run(listener)?.await

❹ Update function spawn_app().

Updated tests/common.rs:
pub async fn spawn_app() -> String {
    ...
    let server = run(listener).await.unwrap();
    let _ = tokio::spawn(server);
    ...
}

Note, the code in the previous version:

    ...
    let server = run(listener).expect("Failed to create server");
    let _ = tokio::spawn(server);
    ...

❺ Accordingly, in tests/test_handlers.rs all calls to spawn_app() updated to let root_url = &spawn_app().await;.

tests/test_handlers.rs with get_helloemployee_has_data() updated:
#[actix_web::test]
async fn get_helloemployee_has_data() {
    let root_url = &spawn_app().await;

    ...
}

❻ Other, unrelated and general refactorings.

⓵ This could be regarded as a bug fix.

In src/handlers.rs, endpoint handler methods are async, and so where get_employees(...) gets called, chain .await to it instead of wrapping it in the async_std::task::block_on function – which does not make any sense!

⓶ In modules src/database.rs and src/models.rs, the documentations now have both synchronous and asynchronous examples where appropriate.

Some Notes on Crates actix-session and actix-identity Examples

For each crate, I try out two (2) examples as listed in the documentations: one using cookie and one using Redis. I start off using Testfully as the client, and none works! And they are short and easy to understand examples!

Then I try using browsers. This also involves writing a simple HTML page. All examples work in browsers.

❶ The actix-session example using cookie.

Content of Cargo.toml:
[dependencies]
actix-session = {version = "0.8.0", features = ["cookie-session"]}
log = "0.4.20"
env_logger = "0.10.1"

The complete src/main.rs can be found on GitHub. Run http://localhost:8080/ on browsers to see how it works.

❷ The actix-session example using Redis.

Content of Cargo.toml:
[dependencies]
actix-web = "4.4.0"
actix-session = {version = "0.8.0", features = ["redis-actor-session"]}

The actual example code I put together from the examples listed in the document page:

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // The secret key would usually be read from a configuration file/environment variables.
    let secret_key = Key::generate();
    let redis_connection_string = "127.0.0.1:6379";
    HttpServer::new(move ||
            App::new()
            // Add session management to your application using Redis for session state storage
            .wrap(
                SessionMiddleware::new(
                    RedisActorSessionStore::new(redis_connection_string),
                    secret_key.clone()
                )
            )
            .route("/index", web::get().to(index))
            .default_service(web::to(|| HttpResponse::Ok())))            
        .bind(("0.0.0.0", 8080))?
        .run()
        .await
}

async fn index(session: Session) -> Result<&'static str, Error> {    
    // access the session state
    if let Some(count) = session.get::<i32>("counter")? {
        println!("SESSION value: {}", count);
        // modify the session state
        session.insert("counter", count + 1)?;
    } else {
        session.insert("counter", 1)?;
    }

    Ok("Welcome!")
}

On browsers, repeatedly run http://localhost:8080/index, watch both the output on browsers and on the console.

❸ The actix-identity example using cookie.

Content of Cargo.toml:
[dependencies]
actix-web = "4.4.0"
actix-identity = "0.6.0"
actix-session = {version = "0.8.0", features = ["cookie-session"]}
env_logger = "0.10.1"

The complete src/main.rs can be found on GitHub.

We describe how to run this example after the listing of the next and last example. As both can be run using the same HTML page.

❹ The actix-identity example using Redis.

Content of Cargo.toml:
[dependencies]
actix-web = "4.4.0"
actix-session = {version = "0.8.0", features = ["redis-rs-session"]}
actix-identity = "0.6.0"

The actual example code I put together from the examples listed in the document page:

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let secret_key = Key::generate();
    let redis_store = RedisSessionStore::new("redis://127.0.0.1:6379")
        .await
        .unwrap();

    HttpServer::new(move || {
        App::new()
            // Install the identity framework first.
            .wrap(IdentityMiddleware::default())
            // The identity system is built on top of sessions. You must install the session
            // middleware to leverage `actix-identity`. The session middleware must be mounted
            // AFTER the identity middleware: `actix-web` invokes middleware in the OPPOSITE
            // order of registration when it receives an incoming request.
            .wrap(SessionMiddleware::new(
                 redis_store.clone(),
                 secret_key.clone()
            ))
            .service(index)
            .service(login)
            .service(logout)
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

#[get("/")]
async fn index(user: Option<Identity>) -> impl Responder {
    if let Some(user) = user {
        format!("Welcome! {}", user.id().unwrap())
    } else {
        "Welcome Anonymous!".to_owned()
    }
}

#[post("/login")]
async fn login(request: HttpRequest) -> impl Responder {
    // Some kind of authentication should happen here
    // e.g. password-based, biometric, etc.
    // [...]

    let token = String::from("test");

    // attach a verified user identity to the active session
    Identity::login(&request.extensions(), token.into()).unwrap();

    HttpResponse::Ok()
}

#[post("/logout")]
async fn logout(user: Identity) -> impl Responder {
    user.logout();
    HttpResponse::Ok()
}

Both the above two examples can be tested using the following HTML page:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
	<meta name="author" content="behai_nguyen@hotmail.com">
    <title>Test</title>
</head>

<body>
    <form method="post" action="http://localhost:8080/login" id="loginForm">
        <button type="submit">Login</button>
    </form>
	
    <form method="post" action="http://localhost:8080/logout" id="logoutForm">
        <button type="submit">Logout</button>
    </form>
</body>
</html>

Run the above HTML page, then:

  1. Click on the Login button
  2. Then run http://localhost:8080/
  3. Then click on the Logout button
  4. Then run http://localhost:8080/

Having been able to integrate these two (2) crates is a small step toward the authentication functionality which I’d like to build as a part of this learning application.

I write this post primarily just a record of what I’ve learned. I hope you somehow find the information helpful. Thank you for reading and stay safe as always.

✿✿✿

Feature image source:

🦀 Index of the Complete Series.