Part 1 saw us implement the login process and many other utility functions and setups with which the implementation was made seamless. In this part, we will build on that to allow comprehensive user management as promised. We'll write some middleware to help check whether or not a request is authenticated as well as enable user registration, email verification via token (a 6-digit cryptographically random token will be used), and user logout.
Source code
The source code for this series is hosted on GitHub via:
A Q&A web application to demostrate how to build a secured and scalable client-server application with axum and sveltekit
CryptoFlow
CryptoFlow is a full-stack web application built with Axum and SvelteKit. It's a Q&A system tailored towards the world of cryptocurrency!
I also have the application live. You can interact with it here. Please note that the backend was deployed on Render which:
Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.
Its building process is explained in this series of articles.
In almost any reasonable system, some services shouldn't be available to anonymous users. These kinds of services are protected by some sort of authentication and authorization mechanism. In our system, we already set up a mechanism to authenticate users. However, we haven't figured out a way to intercept every request coming into our system and check whether or not they should be allowed in. That's what we'll do here! We want to extract certain data from a request and make the decision to reject or accept such a request depending on the data extracted from it. To achieve this, we will roll out a middleware:
// backend/src/utils/middleware.rsusecrate::startup::AppState;usecrate::utils::get_user_id_from_session;useaxum::{extract::Request,middleware::Next};useaxum::{extract::State,response::{IntoResponse,Response},};useaxum_extra::extract::PrivateCookieJar;#[tracing::instrument(name="validate_authentication_session",skip(cookies,state,req,next))]pubasyncfnvalidate_authentication_session(cookies:PrivateCookieJar,State(state):State<AppState>,req:Request,next:Next,)->Result<implIntoResponse,Response>{// Use the utility function to get the user ID from the sessionmatchget_user_id_from_session(&cookies,&state.redis_store,false).await{Ok(_user_id)=>Ok(next.run(req).await),Err(error)=>Err(error.into_response()),}}
One major selling point of axum is its ease of writing middleware. Just look at what we have up there! It's just a beauty!!! Though some things have been abstracted away (especially retrieving the cookie and processing it), it is still unmatched! That said, writing middleware in axum can take different shapes, we decided to stick to using the axum::middleware::from_fn (axum::middleware::from_fn_with_state precisely) because our middleware is just for this system, built with axum, to extract request cookies and make decisions. In axum, a function regarded as middleware should take the Request and Next types (in v0.6, these types were required to be bounded by <B> generics) alongside other arguments and return something that implements IntoResponse. If all requirements are met, returning next.run(req).await allows the request to proceed to the main service it wanted.
An integral part of the middleware above is the get_user_id_from_session and it has this definition:
// backend/src/utils/user.rsusecrate::utils::{CustomAppError,ErrorContext};useaxum_extra::extract::PrivateCookieJar;usebb8_redis::bb8;useuuid::Uuid;#[tracing::instrument(name="get_user_id_from_session",skip(cookies,redis_store,is_logout))]pubasyncfnget_user_id_from_session(cookies:&PrivateCookieJar,redis_store:&bb8::Pool<bb8_redis::RedisConnectionManager>,is_logout:bool,)->Result<(Uuid,String),CustomAppError>{letsession_id=cookies.get("sessionid").map(|cookie|cookie.value().to_owned()).ok_or_else(||{CustomAppError::from(("Session ID not found because you are not authenticated".to_string(),ErrorContext::UnauthorizedAccess,))})?;letmutredis_con=redis_store.get().await.map_err(|_|{CustomAppError::from(("Failed to get redis connection".to_string(),ErrorContext::InternalServerError,))})?;tracing::debug!("Session ID: {}",session_id);letuser_id:String=bb8_redis::redis::cmd("GET").arg(&session_id).query_async(&mut*redis_con).await.map_err(|_|{CustomAppError::from(("You are not authorized since you don't seem to have been authenticated".to_string(),ErrorContext::UnauthorizedAccess,))})?;letuser_uuid=Uuid::parse_str(&user_id).map_err(|_|{CustomAppError::from(("Invalid user ID format".to_string(),ErrorContext::InternalServerError,))})?;ifis_logout{bb8_redis::redis::cmd("DEL").arg(&session_id).query_async::<_,i64>(&mut*redis_con).await.map_err(|_|{CustomAppError::from(("Failed to delete session ID from redis".to_string(),ErrorContext::InternalServerError,))})?;}Ok((user_uuid,session_id))}
The function first tries retrieving the session_id stored in the cookies (as done in the login_user in part 1). If successful, it then goes to do the same from the redis. This is the second layer of security. In case the session_id isn't found in either, appropriate errors are returned. The function takes a flag, is_logout, which determines whether or not the session_id will be deleted from the redis (which is the case for the logout operation).
With that concluded, let's use it. But before then, let's write the logout handler.
Step 2: Logging users out
We'll create a new file, backend/src/routes/users/logout.rs, and fill it with this:
usecrate::startup::AppState;usecrate::utils::CustomAppError;usecrate::utils::SuccessResponse;useaxum::{extract::State,http::StatusCode,response::IntoResponse};useaxum_extra::extract::cookie::{Cookie,PrivateCookieJar};#[axum::debug_handler]#[tracing::instrument(name="logout_user",skip(cookies,state))]pubasyncfnlogout_user(cookies:PrivateCookieJar,State(state):State<AppState>,)->Result<(PrivateCookieJar,implIntoResponse),CustomAppError>{// Get user_id and session_id from cookie and delete itlet(_,_)=crate::utils::get_user_id_from_session(&cookies,&state.redis_store,true).await?;Ok((cookies.remove(Cookie::from("sessionid")),SuccessResponse{message:"The unauthentication process was successful.".to_string(),status_code:StatusCode::OK.as_u16(),}.into_response(),))}
It is very simple. We utilized the get_user_id_from_session utility function, passing is_logout=true to delete the session from redis in case everything goes as planned. Remember that to propagate any action on PrivateCookieJar, it must be returned with your response.
users_routes now needs to take AppState as an argument (you need to pass this in backend/src/startup.rs accordingly). This is because from_fn_with_state needs an instance of it to work. Our middleware was also added up. This ensures that only authenticated users can access /api/users/logout route. As demonstrated, all routes before the application of a middleware have that middleware applied to them while those after are free from the middleware's requirements.
You can now test the login/logout process and everything should be great.
Step 3: Registering users
Before users can log in (not to mention logout), they need a (verified) account. It's time to start the process of getting such an account:
// backend/src/routes/users/register.rsusecrate::models::NewUser;usecrate::startup::AppState;usecrate::utils::SuccessResponse;usecrate::utils::{CustomAppError,CustomAppJson,ErrorContext};useargon2::password_hash::rand_core::{OsRng,RngCore};useaxum::{extract::State,http::StatusCode,response::IntoResponse};usesha2::{Digest,Sha256};#[axum::debug_handler]#[tracing::instrument(name="register_user",skip(state,new_user),fields(user_email=new_user.email,user_first_name=new_user.first_name,user_last_name=new_user.last_name))]pubasyncfnregister_user(State(state):State<AppState>,CustomAppJson(new_user):CustomAppJson<NewUser>,)->Result<implIntoResponse,CustomAppError>{lethashed_password=crate::utils::hash_password(&new_user.password.as_bytes()).await;letuser=state.db_store.create_user(&new_user.first_name,&new_user.last_name,&new_user.email,&hashed_password,).await?;// Generate a truly random activation code for the user using argon2::password_hash::rand_core::OsRngletactivation_code=(OsRng.next_u32()%900000+100000).to_string();// Hash the activation codeletmuthasher=Sha256::new();hasher.update(activation_code.as_bytes());lethashed_activation_code=format!("{:x}",hasher.finalize());// Save activation code in redisletmutredis_con=state.redis_store.get().await.map_err(|_|{CustomAppError::from(("Failed to get redis connection".to_string(),ErrorContext::InternalServerError,))})?;letsettings=crate::settings::get_settings().map_err(|_|{CustomAppError::from(("Failed to read settings".to_string(),ErrorContext::InternalServerError,))})?;letactivation_code_expiration_in_seconds=settings.secret.token_expiration*60;bb8_redis::redis::cmd("SET").arg(user.id.to_string()).arg(hashed_activation_code).arg("EX").arg(activation_code_expiration_in_seconds).query_async::<_,String>(&mut*redis_con).await.map_err(|_|{CustomAppError::from(("Failed to save activation code".to_string(),ErrorContext::InternalServerError,))})?;// Send activation code to user's emailcrate::utils::send_multipart_email("Welcome to CryptoFlow with Rust (axum) and SvelteKit".to_string(),user,state.clone(),"user_welcome.html",activation_code,).await.map_err(|_|{CustomAppError::from(("Failed to send activation email".to_string(),ErrorContext::InternalServerError,))})?;Ok(SuccessResponse{message:"Registration complete! Check your email for a verification code to activate your account.".to_string(),status_code:StatusCode::CREATED.as_u16(),}.into_response())}
As usual, error handling took a bunch of space but it's okay! We just started by hashing the provided password and went straight to create the user in our database using the create_user method on db_store (was written in part 1). This creation sets the is_active to false pending when the user's email is confirmed. Then, a 6-digit cryptographically random string is generated and its sha256 hash (we don't want to save the plain token) is saved temporarily (for token_expiration * 60 seconds, 15 * 60 by default) in redis (the reason for sending a token instead of sending a verification link was explained here). The token is then sent to the email used in registration. Let's take a look at the email-sending utility:
// backend/src/utils/email.rsuselettre::AsyncTransport;#[tracing::instrument(name="Generic e-mail sending function.",skip(subject,html_content,text_content),fields(recipient_email=%user.email,recipient_first_name=%user.first_name,recipient_last_name=%user.last_name))]pubasyncfnsend_email(user:crate::models::UserVisible,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!("{} <{}>","CryptoFlow with axum and SvelteKit",settings.email.host_user.clone()).parse().map_err(|e|{tracing::error!("Could not parse 'from' email address: {:#?}",e);format!("Could not parse 'from' email address: {:#?}",e)})?,).to(format!("{} <{}>",[user.first_name,user.last_name].join(" "),user.email).parse().map_err(|e|{tracing::error!("Could not parse 'to' email address: {:#?}",e);format!("Could not parse 'to' email address: {:#?}",e)})?).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::info!("Email sent successfully.");Ok(())}Err(e)=>{tracing::error!("Could not send email: {:#?}",e);Err(format!("Could not send email: {:#?}",e))}}}#[tracing::instrument(name="Generic multipart e-mail sending function.",skip(user,state,template_name),fields(recipient_user_id=%user.id,recipient_email=%user.email,recipient_first_name=%user.first_name,recipient_last_name=%user.last_name))]pubasyncfnsend_multipart_email(subject:String,user:crate::models::UserVisible,state:crate::startup::AppState,template_name:&str,issued_token:String,)->Result<(),String>{letsettings=crate::settings::get_settings().expect("Unable to load settings.");lettitle=subject.clone();letnow=chrono::Local::now();letexpiration_time=now+chrono::Duration::minutes(settings.secret.token_expiration);letexact_time=expiration_time.format("%A %B %d, %Y at %r").to_string();lettemplate=state.env.get_template(template_name).unwrap();letctx=minijinja::context!{title=>&title,user_id=>&user.id,domain=>&settings.frontend_url,token=>&issued_token,expiration_time=>&settings.secret.token_expiration,exact_time=>&exact_time,};lethtml_text=template.render(ctx).unwrap();lettext=format!(r#"
Thanks for signing up for a CryptoFlow with Rust (axum) and SvelteKit. We're excited to have you on board!
For future reference, your user ID number is {}.
Please visit {}/auth/activate/{} and input the token below to activate your account:
{}
Please note that this is a one-time use token and it will expire in {} minutes ({}).
Thanks,
CryptoFlow with Rust (axum) and SvelteKit Team
"#,user.id,settings.frontend_url,user.id,issued_token,settings.secret.token_expiration,exact_time);tokio::spawn(send_email(user,subject,html_text,text));Ok(())}
Briefly (a detailed explanation can be found here), it uses the lettre crate to build and send emails. The emails support HTML and have plaintext fallback in case the email server doesn't support HTML. For templating, we used minijinja which we will set up next!
Kindly create the templates folder at the root of the backend folder. In it, put this HTML file:
<!--backend/templates/user_welcome.html--><!DOCTYPE html><html><head><metaname="viewport"content="width=device-width"/><metahttp-equiv="Content-Type"content="text/html; charset=UTF-8"/><title>{{ title }}</title></head><body><tablestyle="background: #ffffff; border-radius: 1rem; padding: 30px 0px"><tbody><tr><tdstyle="padding: 0px 30px"><h3style="margin-bottom: 0px; color: #000000">Hello,</h3><p>
Thanks for signing up for a CryptoFlow with Rust (axum) and
SvelteKit. We're excited to have you on board!
</p></td></tr><tr><tdstyle="padding: 0px 30px"><p>For future reference, your user ID is #{{ user_id }}.</p><p>
Please visit
<ahref="{{ domain }}/auth/activate/{{ user_id }}">
{{ domain }}/auth/activate/{{ user_id }}
</a>
and input the OTP below to activate your account:
</p></td></tr><tr><tdstyle="padding: 10px 30px; text-align: center"><strongstyle="display: block; color: #00a856">
One Time Password (OTP)
</strong><tablestyle="margin: 10px 0px"width="100%"><tbody><tr><tdstyle="
padding: 25px;
background: #faf9f5;
border-radius: 1rem;
"><strongstyle="
letter-spacing: 8px;
font-size: 24px;
color: #000000;
">
{{ token }}
</strong></td></tr></tbody></table><smallstyle="display: block; color: #6c757d; line-height: 19px"><strong>
Please note that this is a one-time use token and it will expire
in {{ expiration_time }} minutes ({{ exact_time }}).
</strong></small></td></tr><tr><tdstyle="padding: 0px 30px"><hrstyle="margin: 0"/></td></tr><tr><tdstyle="padding: 30px 30px"><table><tbody><tr><td><strong>
Kind Regards,<br/>
CryptoFlow with Rust (axum) and SvelteKit Team
</strong></td><td></td></tr></tbody></table></td></tr></tbody></table></body></html>
It's a simple but nice-looking email template built in this series.
To wrap up, let's write the user activation or token verification handler.
Step 4: User activation and token verification
Currently, the registration process isn't complete yet as the registered users cannot log in to our system. Their email addresses must be verified before such can be allowed. Here is the function that handles the verification:
// backend/src/routes/users/activate_account.rsusecrate::{models::ActivateUser,startup::AppState,utils::{CustomAppError,CustomAppJson,ErrorContext,SuccessResponse},};useaxum::{extract::State,http::StatusCode,response::IntoResponse};usesha2::{Digest,Sha256};#[axum::debug_handler]#[tracing::instrument(name="activate_user_account",skip(state,acc_user))]pubasyncfnactivate_user_account(State(state):State<AppState>,CustomAppJson(acc_user):CustomAppJson<ActivateUser>,)->Result<implIntoResponse,CustomAppError>{letmutredis_con=state.redis_store.get().await.map_err(|_|{CustomAppError::from(("Failed to get redis connection".to_string(),ErrorContext::InternalServerError,))})?;letmuthasher=Sha256::new();hasher.update(acc_user.token.as_bytes());lethashed_token=format!("{:x}",hasher.finalize());lethashed_activation_code:String=bb8_redis::redis::cmd("GET").arg(&acc_user.id.to_string()).query_async(&mut*redis_con).await.map_err(|_|{CustomAppError::from(("This activation has been used or expired".to_string(),ErrorContext::BadRequest,))})?;ifhashed_activation_code==hashed_token{state.db_store.activate_user(&acc_user.id).await?;// Delete activation code from redisbb8_redis::redis::cmd("DEL").arg(&acc_user.id.to_string()).query_async::<_,i64>(&mut*redis_con).await.map_err(|_|{CustomAppError::from(("Failed to delete activation code from Redis".to_string(),ErrorContext::InternalServerError,))})?;Ok(SuccessResponse{message:"The activation process was successful.".to_string(),status_code:StatusCode::OK.as_u16(),}.into_response())}else{returnErr(CustomAppError::from(("Activation code not found or expired".to_string(),ErrorContext::BadRequest,)));}}
It's simple. We require that the user provides the token sent. We then find its hash and compare it to what we have in redis for that user. If they are the same, the user gets activated. Otherwise, we send an error. Though not covered in this series, a nice feature to have is allowing users to regenerate tokens so that they won't be locked out of our system forever (we want users!!!).
NOTE: I also implemented a handler that retrieves the currently logged-in user. It's simple and can be seen here.
With that, we are done with user management stuff. It's not feature-complete though (I gave suggestions). Let's move on to the Q&A service in the next few articles. See ya!!!
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!