Authentication system using rust (actix-web) and sveltekit - User Registration
John Owolabi Idogun
Posted on April 24, 2023
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:
After that, change directory into each subdirectory: backend and frontend in different terminals. Then following the instructions in each subdirectory to run them.
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.rsuseargon2::{password_hash::{rand_core::OsRng,PasswordHash,PasswordHasher,PasswordVerifier,SaltString},Argon2,};#[tracing::instrument(name="Hashing user password",skip(password))]pubasyncfnhash(password:&[u8])->String{letsalt=SaltString::generate(&mutOsRng);Argon2::default().hash_password(password,&salt).expect("Unable to hash password.").to_string()}#[tracing::instrument(name="Verifying user password",skip(password,hash))]pubasyncfnverify_password(hash:&str,password:&[u8],)->Result<(),argon2::password_hash::Error>{letparsed_hash=PasswordHash::new(hash)?;Argon2::default().verify_password(password,&parsed_hash)}
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.rsuseargon2::password_hash::rand_core::{OsRng,RngCore};usecore::convert::TryFrom;usedeadpool_redis::redis::AsyncCommands;usehex;usepasetors::claims::{Claims,ClaimsValidationRules};usepasetors::keys::SymmetricKey;usepasetors::token::UntrustedToken;usepasetors::{local,version4::V4,Local};/// Store the session key prefix as a const so it can't be typo'd anywhere it's used.constSESSION_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))]pubasyncfnissue_confirmation_token_pasetors(user_id:uuid::Uuid,redis_connection:&mutdeadpool_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).letsession_key:String={letmutbuff=[0_u8;128];OsRng.fill_bytes(&mutbuff);hex::encode(buff)};letredis_key={ifis_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})?;letsettings=crate::settings::get_settings().expect("Cannot load settings.");letcurrent_date_time=chrono::Local::now();letdt={ifis_for_password_change.is_some(){current_date_time+chrono::Duration::hours(1)}else{current_date_time+chrono::Duration::minutes(settings.secret.token_expiration)}};lettime_to_live={ifis_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})?;letmutclaims=Claims::new().unwrap();// Set custom expiration, default is 1 hourclaims.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();letsk=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))]pubasyncfnverify_confirmation_token_pasetor(token:String,redis_connection:&mutdeadpool_redis::redis::aio::Connection,is_password:Option<bool>,)->Result<crate::types::ConfirmationToken,String>{letsettings=crate::settings::get_settings().expect("Cannot load settings.");letsk=SymmetricKey::<V4>::from(settings.secret.secret_key.as_bytes()).unwrap();letvalidation_rules=ClaimsValidationRules::new();letuntrusted_token=UntrustedToken::<Local,V4>::try_from(&token).map_err(|e|format!("TokenValiation: {}",e))?;lettrusted_token=local::decrypt(&sk,&untrusted_token,&validation_rules,None,Some(settings.secret.hmac_secret.as_bytes()),).map_err(|e|format!("Pasetor: {}",e))?;letclaims=trusted_token.payload_claims().unwrap();letuid=serde_json::to_value(claims.get_claim("user_id").unwrap()).unwrap();matchserde_json::from_value::<String>(uid){Ok(uuid_string)=>matchuuid::Uuid::parse_str(&uuid_string){Ok(user_uuid)=>{letsss_key=serde_json::to_value(claims.get_claim("session_key").unwrap()).unwrap();letsession_key=matchserde_json::from_value::<String>(sss_key){Ok(session_key)=>session_key,Err(e)=>returnErr(format!("{}",e)),};letredis_key={ifis_password.is_some(){format!("{}{}is_for_password_change",SESSION_KEY_PREFIX,session_key)}else{format!("{}{}",SESSION_KEY_PREFIX,session_key)}};ifredis_connection.get::<_,Option<String>>(redis_key.clone()).await.map_err(|e|format!("{}",e))?.is_none(){returnErr("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)),}}
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:
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)]pubstructSettings{...pubsecret:Secret,pubemail:EmailSettings,pubfrontend_url:String,}...#[derive(serde::Deserialize,Clone)]pubstructSecret{pubsecret_key:String,pubtoken_expiration:i64,pubhmac_secret:String,}#[derive(serde::Deserialize,Clone)]pubstructEmailSettings{pubhost:String,pubhost_user:String,pubhost_user_password:String,}...
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:
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:
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.rsuselettre::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))]pubasyncfnsend_email(sender_email:Option<String>,recipient_email:String,recipient_first_name:String,recipient_last_name:String,subject:implInto<String>,html_content:implInto<String>,text_content:implInto<String>,)->Result<(),String>{letsettings=crate::settings::get_settings().expect("Failed to read settings.");letemail=lettre::Message::builder().from(format!("{} <{}>","JohnWrites",ifsender_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();letcreds=lettre::transport::smtp::authentication::Credentials::new(settings.email.host_user,settings.email.host_user_password,);// Open a remote connection to gmailletmailer:lettre::AsyncSmtpTransport<lettre::Tokio1Executor>=lettre::AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(&settings.email.host).unwrap().credentials(creds).build();// Send the emailmatchmailer.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))]pubasyncfnsend_multipart_email(subject:String,user_id:uuid::Uuid,recipient_email:String,recipient_first_name:String,recipient_last_name:String,template_name:&str,redis_connection:&mutdeadpool_redis::redis::aio::Connection,)->Result<(),String>{letsettings=crate::settings::get_settings().expect("Unable to load settings.");lettitle=subject.clone();letissued_token=matchcrate::utils::issue_confirmation_token_pasetors(user_id,redis_connection,None,).await{Ok(t)=>t,Err(e)=>{tracing::event!(target:"backend",tracing::Level::ERROR,"{}",e);returnErr(format!("{}",e));}};letweb_address={ifsettings.debug{format!("{}:{}",settings.application.base_url,settings.application.port,)}else{settings.application.base_url}};letconfirmation_link={iftemplate_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,)}};letcurrent_date_time=chrono::Local::now();letdt=current_date_time+chrono::Duration::minutes(settings.secret.token_expiration);lettemplate=crate::ENV.get_template(template_name).unwrap();letctx=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()};lethtml_text=template.render(ctx).unwrap();lettext=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(())}
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><htmllang="en"><head><metacharset="UTF-8"/><metaname="viewport"content="width=device-width, initial-scale=1.0"/><title>{{ title }}</title></head><body><tablestyle="
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><tdalign="left"><h1style="text-align: center"><spanstyle="font-size: 15px"><strong>{{ title }}</strong></span></h1><p>Tap the button below to verify your email address.</p><tablestyle="
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><tdheight="10"> </td></tr><tr><tdstyle="text-align: center"><ahref="{{ 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 }}"><spanstyle="color: #000000"><strong>Verify email address</strong></span></a></td></tr></tbody></table><tablestyle="
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><tdheight="10"> </td></tr><tr><tdalign="left"><palign="center"> </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><palign="center"> </p><br/><pstyle="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>
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.rsusesqlx::Row;#[derive(serde::Deserialize,Debug,serde::Serialize)]pubstructNewUser{email:String,password:String,first_name:String,last_name:String,}#[derive(serde::Deserialize,serde::Serialize)]pubstructCreateNewUser{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/")]pubasyncfnregister_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{letmuttransaction=matchpool.begin().await{Ok(transaction)=>transaction,Err(e)=>{tracing::event!(target:"backend",tracing::Level::ERROR,"Unable to begin DB transaction: {:#?}",e);returnactix_web::HttpResponse::InternalServerError().json(crate::types::ErrorResponse{error:"Something unexpected happend. Kindly try again.".to_string(),},);}};lethashed_password=crate::utils::hash(new_user.0.password.as_bytes()).await;letcreate_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,};letuser_id=matchinsert_created_user_into_db(&muttransaction,&create_new_user).await{Ok(id)=>id,Err(e)=>{tracing::event!(target:"sqlx",tracing::Level::ERROR,"Failed to insert user into DB: {:#?}",e);leterror_message=ife.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(),}};returnactix_web::HttpResponse::InternalServerError().json(error_message);}};// send confirmation email to the new user.letmutredis_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",&mutredis_con,).await.unwrap();iftransaction.commit().await.is_err(){returnactix_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))]asyncfninsert_created_user_into_db(transaction:&mutsqlx::Transaction<'_,sqlx::Postgres>,new_user:&CreateNewUser,)->Result<uuid::Uuid,sqlx::Error>{letuser_id=matchsqlx::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);returnErr(e);}};matchsqlx::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)}}}
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:
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:
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!