Authentication system using rust (actix-web) and sveltekit - User Registration

sirneij

John Owolabi Idogun

Posted on April 24, 2023

Authentication system using rust (actix-web) and sveltekit - User Registration

Introduction

Having set up our store and database of choice, it's now time to put them to work. This article does exactly that. We'll familiarize ourselves with more awesome crates in the Rust ecosystem to generate and verify secured tokens, hash and verify passwords, send multipart emails with a direct SMTP server, and feel more at home with some other actix-web features. This article will be a bit long as we'll be covering a handful of concepts. Let's get to it!

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: Install a couple of other dependencies

Since we'll be leveraging rust's awesome ecosystem a lot, let's bring the crates we'll be using into our project:

~/rust-auth/backend$ cargo add pasetors once_cell hex chrono argon2 

~/rust-auth/backend$ cargo add uuid --features="v4,serde"

~/rust-auth/backend$ cargo add serde_json --features="raw_value"

~/rust-auth/backend$ cargo add minijinja --features="source"

~/rust-auth/backend$ cargo add lettre --features="builder,tokio1-native-tls"
Enter fullscreen mode Exit fullscreen mode

That was quite some crates!!! Yeah but most of them are very common and popular. The table below summarizes what we'll be using them for:

Crate Feature(s) Contextual usage
pasetors all To generate and verify secure tokens
once_cell all To load template source once and make it available when needed
hex all To encode some randomly-generated 128 bytes of data which was used as session_key — a key used to temporarily store tokens in redis.
chrono all To effortlessly work with dates and time.
argon2 all To hash and verify passwords as well as generate cryptographically secure random data.
uuid v4, serde To generate and work with version 4 and serde-compatible UUIDs.
serde_json raw_value To convert tokenized data into JSON object.
minijinja source To render HTML or multipart emails sent to users with their proper context values.
lettre builder, tokio1-native-tls To asynchronously and securely deliver emails to users.

Step 2: Write token and hashing logic

Now to some meaty stuff! We want to write the logic to securely generate and verify tokens as well as hash and verify passwords based on some best practices. We'll be needing a new module, utils, to help achieve this. Create the folder and make it a module as we have been doing. To be more organized, you should create a submodule, auth, where our token and password logic shall live. If you haven't wrapped your head around modules, check out the complete code for this article here.

Let's start with passwords. Open src/utils/auth/password.rs in your editor and populate it with:

// src/utils/auth/password.rs

use argon2::{
    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};

#[tracing::instrument(name = "Hashing user password", skip(password))]
pub async fn hash(password: &[u8]) -> String {
    let salt = SaltString::generate(&mut OsRng);
    Argon2::default()
        .hash_password(password, &salt)
        .expect("Unable to hash password.")
        .to_string()
}

#[tracing::instrument(name = "Verifying user password", skip(password, hash))]
pub async fn verify_password(
    hash: &str,
    password: &[u8],
) -> Result<(), argon2::password_hash::Error> {
    let parsed_hash = PasswordHash::new(hash)?;
    Argon2::default().verify_password(password, &parsed_hash)
}
Enter fullscreen mode Exit fullscreen mode

Simple, huh?! Yeah. We are using the default setup of argon2 to create a hash and verify it. Default setup? Isn't that too naive? No, I can tell you. The default setting uses the argon2id algorithm, 19456kB (19MiB) memory cost, 2 number of iterations, and 1 degree of parallelism. Just as recommended by OWASP's Password Storage Cheat Sheet.

Let's write the logic for token generation and verification next:

// src/utils/auth/tokens.rs

use argon2::password_hash::rand_core::{OsRng, RngCore};
use core::convert::TryFrom;
use deadpool_redis::redis::AsyncCommands;
use hex;
use pasetors::claims::{Claims, ClaimsValidationRules};
use pasetors::keys::SymmetricKey;
use pasetors::token::UntrustedToken;
use pasetors::{local, version4::V4, Local};

/// Store the session key prefix as a const so it can't be typo'd anywhere it's used.
const SESSION_KEY_PREFIX: &str = "valid_session_key_for_{}";

/// Issues a pasetor token to a user. The token has the user's id encoded.
/// A session_key is also encoded. This key is used to destroy the token
/// as soon as it's been verified. Depending on its usage, the token issued
/// has at most an hour to live. Which means, it is destroyed after its time-to-live.
#[tracing::instrument(name = "Issue pasetors token", skip(redis_connection))]
pub async fn issue_confirmation_token_pasetors(
    user_id: uuid::Uuid,
    redis_connection: &mut deadpool_redis::redis::aio::Connection,
    is_for_password_change: Option<bool>,
) -> Result<String, deadpool_redis::redis::RedisError> {
    // I just generate 128 bytes of random data for the session key
    // from something that is cryptographically secure (rand::CryptoRng)
    //
    // You don't necessarily need a random value, but you'll want something
    // that is sufficiently not able to be guessed (you don't want someone getting
    // an old token that is supposed to not be live, and being able to get a valid
    // token from that).
    let session_key: String = {
        let mut buff = [0_u8; 128];
        OsRng.fill_bytes(&mut buff);
        hex::encode(buff)
    };

    let redis_key = {
        if is_for_password_change.is_some() {
            format!(
                "{}{}is_for_password_change",
                SESSION_KEY_PREFIX, session_key
            )
        } else {
            format!("{}{}", SESSION_KEY_PREFIX, session_key)
        }
    };

    redis_connection
        .set(
            redis_key.clone(),
            // I just want to validate that the key exists to indicate the session is "live".
            String::new(),
        )
        .await
        .map_err(|e| {
            tracing::event!(target: "backend", tracing::Level::ERROR, "RedisError (set): {}", e);
            e
        })?;

    let settings = crate::settings::get_settings().expect("Cannot load settings.");
    let current_date_time = chrono::Local::now();
    let dt = {
        if is_for_password_change.is_some() {
            current_date_time + chrono::Duration::hours(1)
        } else {
            current_date_time + chrono::Duration::minutes(settings.secret.token_expiration)
        }
    };

    let time_to_live = {
        if is_for_password_change.is_some() {
            chrono::Duration::hours(1)
        } else {
            chrono::Duration::minutes(settings.secret.token_expiration)
        }
    };

    redis_connection
        .expire(
            redis_key.clone(),
            time_to_live.num_seconds().try_into().unwrap(),
        )
        .await
        .map_err(|e| {
            tracing::event!(target: "backend", tracing::Level::ERROR, "RedisError (expiry): {}", e);
            e
        })?;

    let mut claims = Claims::new().unwrap();
    // Set custom expiration, default is 1 hour
    claims.expiration(&dt.to_rfc3339()).unwrap();
    claims
        .add_additional("user_id", serde_json::json!(user_id))
        .unwrap();
    claims
        .add_additional("session_key", serde_json::json!(session_key))
        .unwrap();

    let sk = SymmetricKey::<V4>::from(settings.secret.secret_key.as_bytes()).unwrap();
    Ok(local::encrypt(
        &sk,
        &claims,
        None,
        Some(settings.secret.hmac_secret.as_bytes()),
    )
    .unwrap())
}

/// Verifies and destroys a token. A token is destroyed immediately
/// it has successfully been verified and all encoded data extracted.
/// Redis is used for such destruction.
#[tracing::instrument(name = "Verify pasetors token", skip(token, redis_connection))]
pub async fn verify_confirmation_token_pasetor(
    token: String,
    redis_connection: &mut deadpool_redis::redis::aio::Connection,
    is_password: Option<bool>,
) -> Result<crate::types::ConfirmationToken, String> {
    let settings = crate::settings::get_settings().expect("Cannot load settings.");
    let sk = SymmetricKey::<V4>::from(settings.secret.secret_key.as_bytes()).unwrap();

    let validation_rules = ClaimsValidationRules::new();
    let untrusted_token = UntrustedToken::<Local, V4>::try_from(&token)
        .map_err(|e| format!("TokenValiation: {}", e))?;
    let trusted_token = local::decrypt(
        &sk,
        &untrusted_token,
        &validation_rules,
        None,
        Some(settings.secret.hmac_secret.as_bytes()),
    )
    .map_err(|e| format!("Pasetor: {}", e))?;
    let claims = trusted_token.payload_claims().unwrap();

    let uid = serde_json::to_value(claims.get_claim("user_id").unwrap()).unwrap();

    match serde_json::from_value::<String>(uid) {
        Ok(uuid_string) => match uuid::Uuid::parse_str(&uuid_string) {
            Ok(user_uuid) => {
                let sss_key =
                    serde_json::to_value(claims.get_claim("session_key").unwrap()).unwrap();
                let session_key = match serde_json::from_value::<String>(sss_key) {
                    Ok(session_key) => session_key,
                    Err(e) => return Err(format!("{}", e)),
                };

                let redis_key = {
                    if is_password.is_some() {
                        format!(
                            "{}{}is_for_password_change",
                            SESSION_KEY_PREFIX, session_key
                        )
                    } else {
                        format!("{}{}", SESSION_KEY_PREFIX, session_key)
                    }
                };

                if redis_connection
                    .get::<_, Option<String>>(redis_key.clone())
                    .await
                    .map_err(|e| format!("{}", e))?
                    .is_none()
                {
                    return Err("Token has been used or expired.".to_string());
                }
                redis_connection
                    .del(redis_key.clone())
                    .await
                    .map_err(|e| format!("{}", e))?;
                Ok(crate::types::ConfirmationToken { user_id: user_uuid })
            }
            Err(e) => Err(format!("{}", e)),
        },

        Err(e) => Err(format!("{}", e)),
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a lot! Yeah, I know but trust me, glossing over them will simplify everything.

What issue_confirmation_token_pasetors does is to help generate secure paseto tokens with users' ID encoded for every registered user. To ensure that these tokens get destroyed after a user verifies his/her email address or when a user fails to verify his/her email address maybe for whatever reason, we also store the generated token in redis and utilize Redis's "Time To Live (TTL)" feature. Each token's "Time To Live" can be programmatically set in our .yaml files or via environment variables as will be discussed soon. We encode the key with which we store the token in redis as well so that we won't destroy another user's tokens erroneously. This entire scenario was what played out in the verify_confirmation_token_pasetor function. Pretty simple. It looked daunting because of the design decisions of the crate we used.

Step 3: Create types and update settings

Your code will not compile for now due to a missing type, ConfirmationToken, and other setting variables. Let's update our settings:

# settings/base.yaml
...
email:
  host: "smtp.gmail.com"
  host_user: ""
  host_user_password: ""

# settings/development.yaml
...
secret:
  secret_key: "YkDU_%q({@QV&5-Z}SONy,7YO?[qF6F6"
  token_expiration: 30
  hmac_secret: "3daad17f50d3577ae06406213073aa28e5cda75b97f5f35170e63653bbb66d8d"

frontend_url: "https://localhost:3000"

# settings/production.yaml
...
secret:
  secret_key: ""
  token_expiration: 0
  hmac_secret: ""

frontend_url: ""
Enter fullscreen mode Exit fullscreen mode

We decided to make email credentials general. We'll be using Gmail as our SMTP server. I recommend you go through this tutorial to know how to generate your credentials if you haven't. The secret column stores some important strings heavily used for token generation. You should use different strings for dev and prod. You can generate them online and use environment variables to set them such as APP_SECRET__SECRET_KEY=... for setting secret_key and APP_SECRET__HMAC_SECRET=... for the hmac_secret. We also have token_expiration there which sets the number of minutes you want your generated tokens to expire. frontend_url is the URL of your frontend application.

As you must have known by now, we need to effect these settings in src/settings.rs:

// src/settings.rs
...

/// Global settings for the exposing all preconfigured variables
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
    ...
    pub secret: Secret,
    pub email: EmailSettings,
    pub frontend_url: String,
}

...
#[derive(serde::Deserialize, Clone)]
pub struct Secret {
    pub secret_key: String,
    pub token_expiration: i64,
    pub hmac_secret: String,
}

#[derive(serde::Deserialize, Clone)]
pub struct EmailSettings {
    pub host: String,
    pub host_user: String,
    pub host_user_password: String,
}
...
Enter fullscreen mode Exit fullscreen mode

Next is the creation of the outstanding type, ConfirmationToken. As a good culture, we need good organization here too. So create a types module and in it create a tokens.rs file:

// src/types/tokens.rs

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ConfirmationToken {
    pub user_id: uuid::Uuid,
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Load templates and write email logic

Now, we want our HTML-based emails to be loaded by just providing their names or brief paths. To do this, let's utilize once_cell and minijinja. Open up src/lib.rs and append:

// src/lib.rs
...
pub static ENV: once_cell::sync::Lazy<minijinja::Environment<'static>> =
    once_cell::sync::Lazy::new(|| {
        let mut env = minijinja::Environment::new();
        env.set_source(minijinja::Source::from_path("templates"));
        env
    });
Enter fullscreen mode Exit fullscreen mode

Those few lines of code lazily make available all files located in the templates folder at the root of our app. We will use it soon.

Let's now write the logic to send emails. Again, we are using the utils module we created then:

// src/utils/emails.rs
use lettre::AsyncTransport;

#[tracing::instrument(
    name = "Generic e-mail sending function.",
    skip(
        recipient_email,
        recipient_first_name,
        recipient_last_name,
        subject,
        html_content,
        text_content
    ),
    fields(
        recipient_email = %recipient_email,
        recipient_first_name = %recipient_first_name,
        recipient_last_name = %recipient_last_name
    )
)]
pub async fn send_email(
    sender_email: Option<String>,
    recipient_email: String,
    recipient_first_name: String,
    recipient_last_name: String,
    subject: impl Into<String>,
    html_content: impl Into<String>,
    text_content: impl Into<String>,
) -> Result<(), String> {
    let settings = crate::settings::get_settings().expect("Failed to read settings.");

    let email = lettre::Message::builder()
        .from(
            format!(
                "{} <{}>",
                "JohnWrites",
                if sender_email.is_some() {
                    sender_email.unwrap()
                } else {
                    settings.email.host_user.clone()
                }
            )
            .parse()
            .unwrap(),
        )
        .to(format!(
            "{} <{}>",
            [recipient_first_name, recipient_last_name].join(" "),
            recipient_email
        )
        .parse()
        .unwrap())
        .subject(subject)
        .multipart(
            lettre::message::MultiPart::alternative()
                .singlepart(
                    lettre::message::SinglePart::builder()
                        .header(lettre::message::header::ContentType::TEXT_PLAIN)
                        .body(text_content.into()),
                )
                .singlepart(
                    lettre::message::SinglePart::builder()
                        .header(lettre::message::header::ContentType::TEXT_HTML)
                        .body(html_content.into()),
                ),
        )
        .unwrap();

    let creds = lettre::transport::smtp::authentication::Credentials::new(
        settings.email.host_user,
        settings.email.host_user_password,
    );

    // Open a remote connection to gmail
    let mailer: lettre::AsyncSmtpTransport<lettre::Tokio1Executor> =
        lettre::AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(&settings.email.host)
            .unwrap()
            .credentials(creds)
            .build();

    // Send the email
    match mailer.send(email).await {
        Ok(_) => {
            tracing::event!(target: "backend", tracing::Level::INFO, "Email successfully sent!");
            Ok(())
        }
        Err(e) => {
            tracing::event!(target: "backend", tracing::Level::ERROR, "Could not send email: {:#?}", e);
            Err(format!("Could not send email: {:#?}", e))
        }
    }
}

#[tracing::instrument(
    name = "Generic multipart e-mail sending function.",
    skip(redis_connection),
    fields(
        recipient_user_id = %user_id,
        recipient_email = %recipient_email,
        recipient_first_name = %recipient_first_name,
        recipient_last_name = %recipient_last_name
    )
)]
pub async fn send_multipart_email(
    subject: String,
    user_id: uuid::Uuid,
    recipient_email: String,
    recipient_first_name: String,
    recipient_last_name: String,
    template_name: &str,
    redis_connection: &mut deadpool_redis::redis::aio::Connection,
) -> Result<(), String> {
    let settings = crate::settings::get_settings().expect("Unable to load settings.");
    let title = subject.clone();

    let issued_token = match crate::utils::issue_confirmation_token_pasetors(
        user_id,
        redis_connection,
        None,
    )
    .await
    {
        Ok(t) => t,
        Err(e) => {
            tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);
            return Err(format!("{}", e));
        }
    };
    let web_address = {
        if settings.debug {
            format!(
                "{}:{}",
                settings.application.base_url, settings.application.port,
            )
        } else {
            settings.application.base_url
        }
    };
    let confirmation_link = {
        if template_name == "password_reset_email.html" {
            format!(
                "{}/users/password/confirm/change_password?token={}",
                web_address, issued_token,
            )
        } else {
            format!(
                "{}/users/register/confirm/?token={}",
                web_address, issued_token,
            )
        }
    };
    let current_date_time = chrono::Local::now();
    let dt = current_date_time + chrono::Duration::minutes(settings.secret.token_expiration);

    let template = crate::ENV.get_template(template_name).unwrap();
    let ctx = minijinja::context! {
        title => &title,
        confirmation_link => &confirmation_link,
        domain => &settings.frontend_url,
        expiration_time => &settings.secret.token_expiration,
        exact_time => &dt.format("%A %B %d, %Y at %r").to_string()
    };
    let html_text = template.render(ctx).unwrap();

    let text = format!(
        r#"
        Tap the link below to confirm your email address.
        {}
        "#,
        confirmation_link
    );
    tokio::spawn(send_email(
        None,
        recipient_email,
        recipient_first_name,
        recipient_last_name,
        subject,
        html_text,
        text,
    ));
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Though some lines, skimming through the lines will simplify them. In the send_email function, we were just carefully formulating the multipart emails, with text-only fallback, and providing the necessary credentials to ensure a successful and secure email delivery. That function was a direct modification of this and this examples available in the crate's repo. send_multipart_email issues a token to the user, builds the confirmation link sent to such a user, renders a template with the context values sent into it, and then surrenders the sending of the email to tokio. It's like putting the email in a queue and sending it on a different thread.

For the email verification, we have this HTML:

<!--templates/verification_email.html-->

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ title }}</title>
  </head>

  <body>
    <table
      style="
        max-width: 555px;
        width: 100%;
        font-family: 'Open Sans', Segoe, 'Segoe UI', 'DejaVu Sans',
          'Trebuchet MS', Verdana, sans-serif;
        background: #fff;
        font-size: 13px;
        color: #323232;
      "
      cellspacing="0"
      cellpadding="0"
      border="0"
      bgcolor="#ffffff"
      align="center"
    >
      <tbody>
        <tr>
          <td align="left">
            <h1 style="text-align: center">
              <span style="font-size: 15px">
                <strong>{{ title }}</strong>
              </span>
            </h1>

            <p>Tap the button below to verify your email address.</p>

            <table
              style="
                max-width: 555px;
                width: 100%;
                font-family: 'Open Sans', arial, sans-serif;
                font-size: 13px;
                color: #323232;
              "
              cellspacing="0"
              cellpadding="0"
              border="0"
              bgcolor="#ffffff"
              align="center"
            >
              <tbody>
                <tr>
                  <td height="10">&nbsp;</td>
                </tr>
                <tr>
                  <td style="text-align: center">
                    <a
                      href="{{ confirmation_link }}"
                      style="
                        color: #fff;
                        background-color: hsla(199, 69%, 84%, 1);
                        width: 320px;
                        font-size: 16px;
                        border-radius: 3px;
                        line-height: 44px;
                        height: 44px;
                        font-family: 'Open Sans', Arial, helvetica, sans-serif;
                        text-align: center;
                        text-decoration: none;
                        display: inline-block;
                      "
                      target="_blank"
                      data-saferedirecturl="https://www.google.com/url?q={{ confirmation_link }}"
                    >
                      <span style="color: #000000">
                        <strong>Verify email address</strong>
                      </span>
                    </a>
                  </td>
                </tr>
              </tbody>
            </table>

            <table
              style="
                max-width: 555px;
                width: 100%;
                font-family: 'Open Sans', arial, sans-serif;
                font-size: 13px;
                color: #323232;
              "
              cellspacing="0"
              cellpadding="0"
              border="0"
              bgcolor="#ffffff"
              align="center"
            >
              <tbody>
                <tr>
                  <td height="10">&nbsp;</td>
                </tr>
                <tr>
                  <td align="left">
                    <p align="center">&nbsp;</p>
                    If the above button doesn't work, try copying and pasting
                    the link below into your browser. If you continue to
                    experience problems, please contact us.
                    <br />
                    {{ confirmation_link }}
                    <br />
                  </td>
                </tr>
                <tr>
                  <td>
                    <p align="center">&nbsp;</p>
                    <br />
                    <p style="padding-bottom: 15px; margin: 0">
                      Kindly note that this link will expire in
                      <strong>{{expiration_time}} minutes</strong>. The exact
                      expiration date and time is:
                      <strong>{{ exact_time }}</strong>.
                    </p>
                  </td>
                </tr>
              </tbody>
            </table>
          </td>
        </tr>
      </tbody>
    </table>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

If you have ever used templating engines, you will not be lost.

Step 5: Write registration route logic

Let's finally write our registration logic:

// src/routes/users/register.rs
use sqlx::Row;

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

#[derive(serde::Deserialize, serde::Serialize)]
pub struct CreateNewUser {
    email: String,
    password: String,
    first_name: String,
    last_name: String,
}

#[tracing::instrument(name = "Adding a new user",
skip( pool, new_user, redis_pool),
fields(
    new_user_email = %new_user.email,
    new_user_first_name = %new_user.first_name,
    new_user_last_name = %new_user.last_name
))]
#[actix_web::post("/register/")]
pub async fn register_user(
    pool: actix_web::web::Data<sqlx::postgres::PgPool>,
    new_user: actix_web::web::Json<NewUser>,
    redis_pool: actix_web::web::Data<deadpool_redis::Pool>,
) -> actix_web::HttpResponse {
    let mut transaction = match pool.begin().await {
        Ok(transaction) => transaction,
        Err(e) => {
            tracing::event!(target: "backend", tracing::Level::ERROR, "Unable to begin DB transaction: {:#?}", e);
            return actix_web::HttpResponse::InternalServerError().json(
                crate::types::ErrorResponse {
                    error: "Something unexpected happend. Kindly try again.".to_string(),
                },
            );
        }
    };
    let hashed_password = crate::utils::hash(new_user.0.password.as_bytes()).await;

    let create_new_user = CreateNewUser {
        password: hashed_password,
        email: new_user.0.email,
        first_name: new_user.0.first_name,
        last_name: new_user.0.last_name,
    };

    let user_id = match insert_created_user_into_db(&mut transaction, &create_new_user).await {
        Ok(id) => id,
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to insert user into DB: {:#?}", e);
            let error_message = if e
                .as_database_error()
                .unwrap()
                .code()
                .unwrap()
                .parse::<i32>()
                .unwrap()
                == 23505
            {
                crate::types::ErrorResponse {
                    error: "A user with that email address already exists".to_string(),
                }
            } else {
                crate::types::ErrorResponse {
                    error: "Error inserting user into the database".to_string(),
                }
            };
            return actix_web::HttpResponse::InternalServerError().json(error_message);
        }
    };

    // send confirmation email to the new user.
    let mut redis_con = redis_pool
        .get()
        .await
        .map_err(|e| {
            tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e);
            actix_web::HttpResponse::InternalServerError().json(crate::types::ErrorResponse {
                error: "We cannot activate your account at the moment".to_string(),
            })
        })
        .expect("Redis connection cannot be gotten.");

    crate::utils::send_multipart_email(
        "RustAuth - Let's get you verified".to_string(),
        user_id,
        create_new_user.email,
        create_new_user.first_name,
        create_new_user.last_name,
        "verification_email.html",
        &mut redis_con,
    )
    .await
    .unwrap();

    if transaction.commit().await.is_err() {
        return actix_web::HttpResponse::InternalServerError().finish();
    }

    tracing::event!(target: "backend", tracing::Level::INFO, "User created successfully.");
    actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
        message: "Your account was created successfully. Check your email address to activate your account as we just sent you an activation link. Ensure you activate your account before the link expires".to_string(),
    })
}

#[tracing::instrument(name = "Inserting new user into DB.", skip(transaction, new_user),fields(
    new_user_email = %new_user.email,
    new_user_first_name = %new_user.first_name,
    new_user_last_name = %new_user.last_name
))]
async fn insert_created_user_into_db(
    transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    new_user: &CreateNewUser,
) -> Result<uuid::Uuid, sqlx::Error> {
    let user_id = match sqlx::query(
        "INSERT INTO users (email, password, first_name, last_name) VALUES ($1, $2, $3, $4) RETURNING id",
    )
    .bind(&new_user.email)
    .bind(&new_user.password)
    .bind(&new_user.first_name)
    .bind(&new_user.last_name)
    .map(|row: sqlx::postgres::PgRow| -> uuid::Uuid{
        row.get("id")
   })
    .fetch_one(&mut *transaction)
    .await
    {
        Ok(id) => id,
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to insert user into DB: {:#?}", e);
            return Err(e);
        }
    };

    match sqlx::query(
        "INSERT INTO user_profile (user_id) 
                VALUES ($1) 
            ON CONFLICT (user_id) 
            DO NOTHING
            RETURNING user_id",
    )
    .bind(user_id)
    .map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("user_id") })
    .fetch_one(&mut *transaction)
    .await
    {
        Ok(id) => {
            tracing::event!(target: "sqlx",tracing::Level::INFO, "User profile created successfully {}.", id);
            Ok(id)
        }
        Err(e) => {
            tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to insert user's profile into DB: {:#?}", e);
            Err(e)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I am sorry if that overwhelms you. Again, looking through will calm your nerves. We expect a user to provide email, first_name, last_name, and password as JSON. We used actix-web's JSON extractors to get this data from the user. Since we'll be doing two operations at a go, that is creating the user and his/her profile, we want to ensure that if one fails, the previous ones should be reversed or rolled back. Hence the use of transaction. After that, we hashed the password provided by the user and called the insert_created_user_into_db to do what its name implies. In the insert_created_user_into_db, we used raw SQL to insert the user into the DB. If successful, the user's ID is returned and fed into the next query which tends to create the user's profile. If all is good, we return the user's ID. This ID is one of the things sent over to send_multipart_email, the function previously discussed. We then did:

...
if transaction.commit().await.is_err() {
        return actix_web::HttpResponse::InternalServerError().finish();
}
Enter fullscreen mode Exit fullscreen mode

To persist our data in the DB. If this is not done, our user data will not be persisted. Then we return a success message. In case an error is encountered at any point, an appropriate error message will be sent to the user. Thanks to Rust's awesome error-handling prowess. We will do this a lot in the coming articles.

Step 6: Connect the registration route to the application

The last bit of the work is making our application aware of our newly created route. To do this, I chose a rather organized way of doing things. We'll be using actix-web service configuration to split our routes into modules for modularity. Open up src/routes/users/mod.rs:

// src/routes/users/mod.rs
...
pub fn auth_routes_config(cfg: &mut actix_web::web::ServiceConfig) {
    cfg.service(actix_web::web::scope("/users").service(register::register_user));
}
Enter fullscreen mode Exit fullscreen mode

We scoped our users' module to have an endpoint that looks like /users/.... For registration, it'll be /users/register/. Next is src/routes/mod.rs:

// src/routes/mod.rs
...
pub use users::auth_routes_config;
Enter fullscreen mode Exit fullscreen mode

And then, src/startup.rs:

// src/startup.rs
...
    .service(crate::routes::health_check)
    // Authentication routes
    .configure(crate::routes::auth_routes_config)
...
Enter fullscreen mode Exit fullscreen mode

With this, we are good!!!

That was nothing short of a long ride... I personally think it was worth it. See you soon.

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!

💖 💪 🙅 🚩
sirneij
John Owolabi Idogun

Posted on April 24, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related