Authentication system using rust (actix-web) and sveltekit - Login and Logout
Tags: webdev, rust, tutorial, actix
Published: 2023-04-25 08:25:37

## Introduction

We have so far made efforts to register a user and save such user's data in our application. We then send a verification email to the user. This brings us to the question: *What happens if the user clicks on the link sent to his/her email address?* That is one of the questions we'll be addressing in this article. We'll also learn about generating, persisting and removing session cookies when a user logs in and out of our application.

## Source code

The source code for this series is hosted on GitHub via:

{% github Sirneij/rust-auth %}

## Implementation

> You can get the overview of the code for this article on [github][1].

### Step 1: Activate confirmed user

Currently, when a user registers, we save the user's details in the DB and set `is_active` to `false`. This ensures that only verified users can log in or perform other basic operations on our application. However, we need to turn this DB column, `is_active`, to `true` as soon as the user clicks on the activation link sent to his/her email address. Let's do this.

In `src/routes/users/` create a new file, `confirm_registration.rs`, and make it look like this:

```rust
// src/routes/users/confirm_registration.rs
#[derive(serde::Deserialize)]
pub struct Parameters {
    token: String,
}

#[tracing::instrument(name = "Activating a new user", skip(pool, parameters, redis_pool))]
#[actix_web::get("/register/confirm/")]
pub async fn confirm(
    parameters: actix_web::web::Query<Parameters>,
    pool: actix_web::web::Data<sqlx::postgres::PgPool>,
    redis_pool: actix_web::web::Data<deadpool_redis::Pool>,
) -> actix_web::HttpResponse {
    let settings = crate::settings::get_settings().expect("Failed to read settings.");

    let mut redis_con = redis_pool
        .get()
        .await
        .map_err(|e| {
            tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);

            actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/auth/error", settings.frontend_url),
                ))
                .json(crate::types::ErrorResponse {
                    error: "We cannot activate your account at the moment".to_string(),
                })
        })
        .expect("Redis connection cannot be gotten.");

    let confirmation_token = match crate::utils::verify_confirmation_token_pasetor(
        parameters.token.clone(),
        &mut redis_con,
        None,
    )
    .await
    {
        Ok(token) => token,
        Err(e) => {
            tracing::event!(target: "backend",tracing::Level::ERROR, "{:#?}", e);

            return actix_web::HttpResponse::SeeOther().insert_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/auth/regenerate-token", settings.frontend_url),
                )).json(crate::types::ErrorResponse {
                    error: "It appears that your confirmation token has expired or previously used. Kindly generate a new token".to_string(),
                });
        }
    };
    match activate_new_user(&pool, confirmation_token.user_id).await {
        Ok(_) => {
            tracing::event!(target: "backend", tracing::Level::INFO, "New user was activated successfully.");

            actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/auth/confirmed", settings.frontend_url),
                ))
                .json(crate::types::SuccessResponse {
                    message:
                        "Your account has been activated successfully!!! You can now log in"
                            .to_string(),
                })
        }
        Err(e) => {
            tracing::event!(target: "backend", tracing::Level::ERROR, "Cannot activate account : {}", e);

            actix_web::HttpResponse::SeeOther()
                .insert_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/auth/error?reason={e}", settings.frontend_url),
                ))
                .json(crate::types::ErrorResponse {
                    error: "We cannot activate your account at the moment".to_string(),
                })
        }
    }
}

#[tracing::instrument(name = "Mark a user active", skip(pool), fields(
    new_user_user_id = %user_id
))]
pub async fn activate_new_user(
    pool: &sqlx::postgres::PgPool,
    user_id: uuid::Uuid,
) -> Result<(), sqlx::Error> {
    match sqlx::query("UPDATE users SET is_active=true WHERE id = $1")
        .bind(user_id)
        .execute(pool)
        .await
    {
        Ok(_) => Ok(()),
        Err(e) => {
            tracing::error!("Failed to execute query: {:#?}", e);
            Err(e)
        }
    }
}
```

Remember that the link embedded in the email sent to the user after registration has this pattern: `.../users/register/confirm/?token=...` where `token` is the issued paseto token. In the URL, `token` is a query parameter and [Query extractors][2] are used to extract them from the URL in actix-web. Hence the `parameters: actix_web::web::Query<Parameters>,`. `pool` and `redis_pool` are the application states we made available in the second article of this series. We then tried to verify the token in the URL. If that was successful and the user's ID was returned from the verification process, we proceeded to set the user's `is_active` to `true` in the DB using the `activate_new_user` function. That's pretty simple. You can now add the route to `auth_routes_config` in `src/routes/users/mod.rs`.

### Step 2: Login and user session

Having activated the user, we need to find a way to log the user in whenever he/her provides his/her correct email/password combination. We also need to issue a token, this time a session cookie, with his/her details encrypted so that he/she can perform other "sacred" operations without having to log in every time. For the session management, we will be leveraging Rust's/Actix-web's ecosystem once more. Let's grab [actix-session][3]:

```shell
~/rust-auth/backend$ cargo add actix-session --features cookie-session
```

We're opting to store the cookies in the user's browser. You can store them in redis by activating either `redis-actor-session` or `redis-rs-session` feature flag instead.

Next, let's wrap our entire app with it. Open `src/startup.rs`:

```rust
// src/startup.rs
...
async fn run(
    listener: std::net::TcpListener,
    db_pool: sqlx::postgres::PgPool,
    settings: crate::settings::Settings,
) -> Result<actix_web::dev::Server, std::io::Error> {
    ...
    // For session
    let secret_key = actix_web::cookie::Key::from(settings.secret.hmac_secret.as_bytes());
    let server = actix_web::HttpServer::new(move || {
    actix_web::App::new()
        .wrap(if settings.debug {
            actix_session::SessionMiddleware::builder(
                actix_session::storage::CookieSessionStore::default(),
                secret_key.clone(),
            )
            .cookie_http_only(true)
            .cookie_same_site(actix_web::cookie::SameSite::None)
            .cookie_secure(true)
            .build()
        } else {
            actix_session::SessionMiddleware::new(
                actix_session::storage::CookieSessionStore::default(),
                secret_key.clone(),
            )
        })
        ...
}
...
```

We need some sort of secret key, preferably HMAC-compatible, to encrypt the cookie. Since we already had that in our settings, we just converted it to something cookie-compatible using `actix_web::cookie::Key::from()`. We then wrap our app with the `actix_session::SessionMiddleware`. We have different configurations for development and production environments for ease of development. Now, we can access and modify the session state in our request handlers using the `Session` extractor. That's what we'll do next in our login handler:

```rust
// src/routes/users/login.rs
use sqlx::Row;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
    email: String,
    password: String,
}

#[tracing::instrument(name = "Logging a user in", skip( pool, user, session), fields(user_email = %user.email))]
#[actix_web::post("/login/")]
async fn login_user(
    pool: actix_web::web::Data<sqlx::postgres::PgPool>,
    user: actix_web::web::Json<LoginUser>,
    session: actix_session::Session,
) -> actix_web::HttpResponse {
    match get_user_who_is_active(&pool, &user.email).await {
        Ok(loggedin_user) => match tokio::task::spawn_blocking(move || {
            crate::utils::verify_password(loggedin_user.password.as_ref(), user.password.as_bytes())
        })
        .await
        .expect("Unable to unwrap JoinError.")
        {
            Ok(_) => {
                tracing::event!(target: "backend", tracing::Level::INFO, "User logged in successfully.");
                session.renew();
                session
                    .insert(crate::types::USER_ID_KEY, loggedin_user.id)
                    .expect("`user_id` cannot be inserted into session");
                session
                    .insert(crate::types::USER_EMAIL_KEY, &loggedin_user.email)
                    .expect("`user_email` cannot be inserted into session");

                actix_web::HttpResponse::Ok().json(crate::types::UserVisible {
                    id: loggedin_user.id,
                    email: loggedin_user.email,
                    first_name: loggedin_user.first_name,
                    last_name: loggedin_user.last_name,
                    is_active: loggedin_user.is_active,
                    is_staff: loggedin_user.is_staff,
                    is_superuser: loggedin_user.is_superuser,
                    date_joined: loggedin_user.date_joined,
                    thumbnail: loggedin_user.thumbnail,
                })
            }
            Err(e) => {
                tracing::event!(target: "argon2",tracing::Level::ERROR, "Failed to authenticate user: {:#?}", e);
                actix_web::HttpResponse::BadRequest().json(crate::types::ErrorResponse {
                    error: "Email and password do not match".to_string(),
                })
            }
        },
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found:{:#?}", e);
            actix_web::HttpResponse::NotFound().json(crate::types::ErrorResponse {
                error: "A user with these details does not exist. If you registered with these details, ensure you activate your account by clicking on the link sent to your e-mail address".to_string(),
            })
        }
    }
}

#[tracing::instrument(name = "Getting a user from DB.", skip(pool, email),fields(user_email = %email))]
pub async fn get_user_who_is_active(
    pool: &sqlx::postgres::PgPool,
    email: &String,
) -> Result<crate::types::User, sqlx::Error> {
    match sqlx::query("SELECT id, email, password, first_name, last_name, is_staff, is_superuser, thumbnail, date_joined FROM users WHERE email = $1 AND is_active = TRUE")
        .bind(email)
        .map(|row: sqlx::postgres::PgRow| crate::types::User {
            id: row.get("id"),
            email: row.get("email"),
            password: row.get("password"),
            first_name: row.get("first_name"),
            last_name: row.get("last_name"),
            is_active: true,
            is_staff: row.get("is_staff"),
            is_superuser: row.get("is_superuser"),
            thumbnail: row.get("thumbnail"),
            date_joined: row.get("date_joined"),
        })
        .fetch_one(pool)
        .await
    {
        Ok(user) => Ok(user),
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found in DB: {:#?}", e);
            Err(e)
        }
    }
}
```

We created a new file, `src/routes/users/login.rs`. In the handler function, we expect the user to provide his/her email/password combination in JSON format. From there, we retrieve an ACTIVE user with that email address. If the user isn't active, an appropriate response will be returned. If otherwise, we verify the password supplied with the one saved using the `verify_password` util we wrote a while back. Notice that we put the whole verification block in `tokio::task::spawn_blocking`. This is because the verification process can take a while and it's blocking in nature. We don't want the user to notice the effect that much. If the password gets verified successfully, we keep the user's session alive and insert some of the user's data in the session. We then return the user's VISIBILITY-worthy data as an HTTP response. If a wrong password was supplied, an appropriate response was returned as well. Remember to create those types used, viz: `UserVisible`, `User` and co. Add the login handler to our `auth_routes_config`. Now, a user can log in! Yay 💃...

### Step 3: Logging user out

A logged-in user should be able to log out right? Then let's make that happen!

```rust
// src/routes/users/logout.rs

#[tracing::instrument(name = "Log out user", skip(session))]
#[actix_web::post("/logout/")]
pub async fn log_out(session: actix_session::Session) -> actix_web::HttpResponse {
    match session_user_id(&session).await {
        Ok(_) => {
            tracing::event!(target: "backend", tracing::Level::INFO, "Users retrieved from the DB.");
            session.purge();
            actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
                message: "You have successfully logged out".to_string(),
            })
        }
        Err(e) => {
            tracing::event!(target: "backend",tracing::Level::ERROR, "Failed to get user from session: {:#?}", e);
            actix_web::HttpResponse::BadRequest().json(crate::types::ErrorResponse {
                error:
                    "We currently have some issues. Kindly try again and ensure you are logged in"
                        .to_string(),
            })
        }
    }
}

#[tracing::instrument(name = "Get user_id from session.", skip(session))]
async fn session_user_id(session: &actix_session::Session) -> Result<uuid::Uuid, String> {
    match session.get(crate::types::USER_ID_KEY) {
        Ok(user_id) => match user_id {
            None => Err("You are not authenticated".to_string()),
            Some(id) => Ok(id),
        },
        Err(e) => Err(format!("{e}")),
    }
}
```

The handler resides in `src/routes/users/logout.rs` and what it does is check whether or not the requesting user has a valid session. If he/she does, we just `purge` the session. Purging a session means removing such a session from both the client and server. Pretty neat! Ensure you add the handler to our route config.

You can use Postman or, if you use VS code, [Thunder Client][4] extension, to test our endpoints so far. In the next article, we'll start consuming the endpoints with a proper front-end application. See you then...

## Outro

Enjoyed this article? Consider [contacting me for a job, something worthwhile or buying a coffee ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and [Twitter](https://twitter.com/Sirneij). It isn't bad if you help share this article for wider coverage. I will appreciate it...

[1]: https://github.com/Sirneij/rust-auth/pull/5/commits/00dac159eff028dbce77dfd62de53fd3a25fd1c7 "Confirm registration, login, session, and logout"
[2]: https://actix.rs/docs/extractors#query "Actix-web Query extractors"
[3]: https://crates.io/crates/actix-session "Session management for Actix Web"
[4]: https://marketplace.visualstudio.com/items?itemName=rangav.vscode-thunder-client "Thunder Client"