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:
After that, change directory into each subdirectory: backend and frontend in different terminals. Then following the instructions in each subdirectory to run them.
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)]pubstructParameters{token:String,}#[tracing::instrument(name="Activating a new user",skip(pool,parameters,redis_pool))]#[actix_web::get("/register/confirm/")]pubasyncfnconfirm(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{letsettings=crate::settings::get_settings().expect("Failed to read settings.");letmutredis_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.");letconfirmation_token=matchcrate::utils::verify_confirmation_token_pasetor(parameters.token.clone(),&mutredis_con,None,).await{Ok(token)=>token,Err(e)=>{tracing::event!(target:"backend",tracing::Level::ERROR,"{:#?}",e);returnactix_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(),});}};matchactivate_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))]pubasyncfnactivate_new_user(pool:&sqlx::postgres::PgPool,user_id:uuid::Uuid,)->Result<(),sqlx::Error>{matchsqlx::query("UPDATE users SET is_active=true WHERE id = $1").bind(user_id).execute(pool).await{Ok(_)=>Ok(()),Err(e)=>{tracing::error!("Failed to execute query: {:#?}",e);Err(e)}}}
Remember that the link embedded in the email sent to the user after registration has this pattern: .../users/register/confirm/?token=... where token is the issued paseto token. In the URL, token is a query parameter and Query extractors 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:
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...asyncfnrun(listener:std::net::TcpListener,db_pool:sqlx::postgres::PgPool,settings:crate::settings::Settings,)->Result<actix_web::dev::Server,std::io::Error>{...// For sessionletsecret_key=actix_web::cookie::Key::from(settings.secret.hmac_secret.as_bytes());letserver=actix_web::HttpServer::new(move||{actix_web::App::new().wrap(ifsettings.debug{actix_session::SessionMiddleware::builder(actix_session::storage::CookieSessionStore::default(),secret_key.clone(),).cookie_http_only(true).cookie_same_site(actix_web::cookie::SameSite::None).cookie_secure(true).build()}else{actix_session::SessionMiddleware::new(actix_session::storage::CookieSessionStore::default(),secret_key.clone(),)})...}...
We need some sort of secret key, preferably HMAC-compatible, to encrypt the cookie. Since we already had that in our settings, we just converted it to something cookie-compatible using actix_web::cookie::Key::from(). We then wrap our app with the actix_session::SessionMiddleware. We have different configurations for development and production environments for ease of development. Now, we can access and modify the session state in our request handlers using the Session extractor. That's what we'll do next in our login handler:
// src/routes/users/login.rsusesqlx::Row;#[derive(serde::Deserialize,Debug,serde::Serialize)]pubstructLoginUser{email:String,password:String,}#[tracing::instrument(name="Logging a user in",skip(pool,user,session),fields(user_email=%user.email))]#[actix_web::post("/login/")]asyncfnlogin_user(pool:actix_web::web::Data<sqlx::postgres::PgPool>,user:actix_web::web::Json<LoginUser>,session:actix_session::Session,)->actix_web::HttpResponse{matchget_user_who_is_active(&pool,&user.email).await{Ok(loggedin_user)=>matchtokio::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))]pubasyncfnget_user_who_is_active(pool:&sqlx::postgres::PgPool,email:&String,)->Result<crate::types::User,sqlx::Error>{matchsqlx::query("SELECT id, email, password, first_name, last_name, is_staff, is_superuser, thumbnail, date_joined FROM users WHERE email = $1 AND is_active = TRUE").bind(email).map(|row:sqlx::postgres::PgRow|crate::types::User{id:row.get("id"),email:row.get("email"),password:row.get("password"),first_name:row.get("first_name"),last_name:row.get("last_name"),is_active:true,is_staff:row.get("is_staff"),is_superuser:row.get("is_superuser"),thumbnail:row.get("thumbnail"),date_joined:row.get("date_joined"),}).fetch_one(pool).await{Ok(user)=>Ok(user),Err(e)=>{tracing::event!(target:"sqlx",tracing::Level::ERROR,"User not found in DB: {:#?}",e);Err(e)}}}
We created a new file, src/routes/users/login.rs. In the handler function, we expect the user to provide his/her email/password combination in JSON format. From there, we retrieve an ACTIVE user with that email address. If the user isn't active, an appropriate response will be returned. If otherwise, we verify the password supplied with the one saved using the verify_password util we wrote a while back. Notice that we put the whole verification block in tokio::task::spawn_blocking. This is because the verification process can take a while and it's blocking in nature. We don't want the user to notice the effect that much. If the password gets verified successfully, we keep the user's session alive and insert some of the user's data in the session. We then return the user's VISIBILITY-worthy data as an HTTP response. If a wrong password was supplied, an appropriate response was returned as well. Remember to create those types used, viz: UserVisible, User and co. Add the login handler to our auth_routes_config. Now, a user can log in! Yay 💃...
Step 3: Logging user out
A logged-in user should be able to log out right? Then let's make that happen!
// src/routes/users/logout.rs#[tracing::instrument(name="Log out user",skip(session))]#[actix_web::post("/logout/")]pubasyncfnlog_out(session:actix_session::Session)->actix_web::HttpResponse{matchsession_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))]asyncfnsession_user_id(session:&actix_session::Session)->Result<uuid::Uuid,String>{matchsession.get(crate::types::USER_ID_KEY){Ok(user_id)=>matchuser_id{None=>Err("You are not authenticated".to_string()),Some(id)=>Ok(id),},Err(e)=>Err(format!("{e}")),}}
The handler resides in src/routes/users/logout.rs and what it does is check whether or not the requesting user has a valid session. If he/she does, we just purge the session. Purging a session means removing such a session from both the client and server. Pretty neat! Ensure you add the handler to our route config.
You can use Postman or, if you use VS code, Thunder Client 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...