DEV Community

Cover image for Authentication system using rust (actix-web) and sveltekit - Login and Logout
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

Authentication system using rust (actix-web) and sveltekit - Login and Logout

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 logo Sirneij / rust-auth

A fullstack authentication system using rust, sveltekit, and Typescript

rust-auth

A full-stack secure and performant authentication system using rust's Actix web and JavaScript's SvelteKit.

This application resulted from this series of articles and it's currently live here(I have disabled the backend from running live).

Run locally

You can run the application locally by first cloning it:

~/$ git clone https://github.com/Sirneij/rust-auth.git
Enter fullscreen mode Exit fullscreen mode

After that, change directory into each subdirectory: backend and frontend in different terminals. Then following the instructions in each subdirectory to run them.




Implementation

You can get the overview of the code for this article on github.

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:

// 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 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:

~/rust-auth/backend$ cargo add actix-session --features cookie-session
Enter fullscreen mode Exit fullscreen mode

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:

// 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(),
            )
        })
        ...
}
...
Enter fullscreen mode Exit fullscreen mode

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:

// 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 until 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!

// 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}")),
    }
}
Enter fullscreen mode Exit fullscreen mode

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 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? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and Twitter.

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

Top comments (6)

Collapse
 
khannz profile image
Pavel Fiskovich • Edited

Hi there, @sirneij ! Great series, one of the best actually I saw for a long time... so huuge thanks for sharing!

I bet someone could already point that in other ways, but I can't see anything here, so let me gentle share my experience after reading this publication and repo code:

  1. for src/routes/users/login.rs it seems like pub visibility modifier is misplaced: I assume it should be at login_user(), not get_user_who_is_active() so we can use it for src/routes/users/mod.rs for example;
  2. with current code for login.rs, compiler is not happy about match of tokio::task::spawn_blocking(): Image description

Once again — thank you for such an effort 💯

Collapse
 
sirneij profile image
John Owolabi Idogun

Hi @khannz I am really humbled that you found my article useful.

To the reviews, the source code actually has some updated code, a bit different than the one in this article: github.com/Sirneij/rust-auth/blob/...

You can check it out.

Thank you once again.

Collapse
 
mrcigar profile image
Jay Jones

Hi John,

I'm trying to follow along as well and get stuck on the same login block:

cannot return value referencing local data loggedin_user.password
returns a value referencing data owned by the current function
login.rs(18, 59): loggedin_user.password is borrowed here
cannot return value referencing local data user
returns a value referencing data owned by the current function

The move for the loggedin_user to the blocking thread prevents us from returning the result of the password hash. I looked at the updated code and it looks to be the same. What am I missing?

Thanks for the help.
Jay.

Thread Thread
 
mrcigar profile image
Jay Jones

Nevermind,

For those stuck, Mario's post below was the solution, you have to update the password hashing function to not take ownership of the password during the check, see branch:
branch origin/confirm-login-session-logout

Thanks again for a great series.

Collapse
 
koakh profile image
Mário Monteiro

you are right @khannz! this series are pretty awesome,

I'm following the tutorial series right now and only fail here,
in this part of login and logout.....

I bring the files from repository branch origin/confirm-login-session-logout and keep going

congratulations for your awesome work @sirneij

Collapse
 
sirneij profile image
John Owolabi Idogun

Thank you @koakh