Working Through Rust Web Programming pt 4: Authentication and Authorization

Matthew MacFarquhar
9 min readMar 28, 2024

--

Introduction

In this series, I will be working through this book on Rust Web Programming. The book is quite large and in depth, but in these articles I will be distilling the big picture pieces of the book into bite size tutorials and walk throughs.

In this section, we will be locking down our endpoints, so only authorized users can access them, we will be utilizing our JSON Web Token we set up in part 1 to enable a user to get a token on login and then use that token to access the endpoints in our web app.

The backend work will be tracked in this github project https://github.com/mattmacf98/web_app. This specific work is achieved in this commit. We will also make some front end changes to add a user login and token management, at this commit.

Creating a User

Before we can do any Authentication, we need to actually have a user in the database to authenticate against. The first section of the chapter gives us some good practice on our end-to-end database-model-view integration abilities.

Making the Data Models

Like with our item, we are going to split this data model up into a new_user for inserting into the database, and a user for querying from the database.

Since our users have passwords we will have an extra step when creating — to hash the password — and when querying — to verify the password.

new_user.rs

use uuid::Uuid;
use diesel::Insertable;
use bcrypt::{DEFAULT_COST, hash};

use crate::schema::users;

#[derive(Insertable, Clone)]
#[table_name="users"]
pub struct NewUser {
pub username: String,
pub email: String,
pub password: String,
pub unique_id: String
}

impl NewUser {
pub fn new(username: String, email: String, password: String) -> NewUser {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
let uuid = Uuid::new_v4().to_string();
return NewUser {
username,
email,
password: hashed_password,
unique_id: uuid
}
}
}

if we didn’t need to create a uuid and hash the password, we could just plug in the data that was passed in the new function.

user.rs

extern crate bcrypt;

use diesel::{Queryable, Identifiable};
use bcrypt::verify;

use crate::schema::users;

#[derive(Queryable, Clone, Identifiable)]
#[table_name="users"]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub password: String,
pub unique_id: String
}

impl User {
pub fn verify(&self, password: String) -> bool {
verify(password.as_str(), &self.password).unwrap()
}
}

Our user item is pretty standard, except we implement a function called verify which will take in a given password and verify if it hashes to the one stored in our user DB — indicating the password is correct.

Connecting with the Item Model

The book goes over a couple of options for associating two tables. In the end, the most practical approach is to create a foreign key in the item database to reference the sequential id of the user who owns the todo item.

We update our todo item data model to have a user_id field and we update our schema.rs with our new field in todo and our new user table.

We then generate the migrations for creating users and update the up and down sql for our table.

up.sql

CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE,
email VARCHAR NOT NULL UNIQUE,
password VARCHAR NOT NULL,
unique_id VARCHAR NOT NULL
);

INSERT INTO users (username, email, password, unique_id) VALUES ('placeholder', 'placeholder email', 'placeholder password', 'placeholder unique id');

ALTER TABLE to_do ADD user_id integer default 1 CONSTRAINT user_id REFERENCES users NOT NULL;

down.sql

ALTER TABLE to_do DROP COLUMN user_id;

DROP TABLE users;

Running this migration will automatically update our schema.rs and add the lines below.

diesel::joinable!(to_do -> users (user_id));

diesel::allow_tables_to_appear_in_same_query!(
to_do,
users,
);

to tell diesel that the to_do table is joinable to the users table by its user_id field.

Create User View

Finally, we will create a view to allow us to create new users.

use crate::diesel;
use diesel::prelude::*;

use actix_web::{web, HttpResponse, Responder};
use actix_web::HttpResponseBuilder;
use diesel::{QueryResult, RunQueryDsl};

use crate::database::DB;
use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser;
use crate::schema::users;

pub async fn create(new_user: web::Json<NewUserSchema>, db: DB) -> impl Responder {
let new_user = NewUser::new(new_user.name.clone(), new_user.email.clone(), new_user.password.clone());
let insert_result = diesel::insert_into(users::table).values(&new_user).execute(&db.connection);

match insert_result {
Ok(_) => HttpResponse::Created(),
Err(_) => HttpResponse::Conflict()
}
}

This view will take in our new user which is provided in the body of the HttpRequest. Then it will insert the user — which has our hashed password — into our users table. This view is exposed into our app using the view factory method we went over in part 1.

Summary

  • Create data models for new_users for inserting and users for querying
  • Hash passwords before they go into the database
  • Connect two tables using a foreign key

Authenticating and Authorizing

We now have a user in the database, let’s put it to use and add authentication and authorization into our app.

Authentication

Authentication is the act of logging in or signing up, we provided a username and password to the server so that it knows who we are.

We will allow users to login using our login view.

use std::collections::HashMap;
use crate::diesel;
use diesel::prelude::*;
use actix_web::{web, HttpResponse, Responder};

use crate::database::DB;
use crate::models::user::user::User;
use crate::json_serialization::login::Login;
use crate::json_serialization::login_response::LoginResponse;
use crate::schema::users;
use crate::jwt::JwToken;

pub async fn login(credentials: web::Json<Login>, db: DB) -> HttpResponse {
let password = credentials.password.clone();
let users = users::table
.filter(users::columns::username.eq_all(credentials.username.clone()))
.load::<User>(&db.connection).unwrap();

if users.len() == 0 {
return HttpResponse::NotFound().await.unwrap()
} else if users.len() > 1 {
return HttpResponse::Conflict().await.unwrap()
}

return match users[0].verify(password) {
true => {
let token = JwToken::new(users[0].id);
let raw_token = token.encode();
let response = LoginResponse{token: raw_token.clone()};
let body = serde_json::to_string(&response).unwrap();
HttpResponse::Ok().append_header(("token", raw_token)).json(&body)
},
false => HttpResponse::Unauthorized().finish()
};
}

This view takes in the password and username provided, and then tries to find a user with the matching username. If it can, it then calls verify against the provided password to see if it hashes to the one we have stored.

If the password is a match, we want to give the user a token of some sort, so that they don’t have to provide a username and password every time they need to access a page and can instead just provide the token. We build and encode the token and send it back to the user — the book sends the token in the body and header so we can read it easier on the front end, common practice is just to send the token in the header.

JwToken

Lets go over how we changed the JwToken class.

use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use actix_web::error::ErrorUnauthorized;
use futures::future::{Ready, ok, err};

use serde::{Deserialize, Serialize};
use jsonwebtoken::{encode, decode, Algorithm, Header, EncodingKey, DecodingKey, Validation};

use chrono::{DateTime, Utc};
use chrono::serde::ts_seconds;
use diesel::dsl::min;
use crate::config::Config;

#[derive(Debug, Serialize, Deserialize)]
pub struct JwToken {
pub user_id: i32,
pub exp: usize,
}

impl JwToken {

pub fn get_key() -> String {
let config = Config::new();
let key_str = config.map.get("SECRET_KEY").unwrap().as_str().unwrap();
return key_str.to_owned();
}

pub fn encode(self) -> String {
let key = EncodingKey::from_secret(JwToken::get_key().as_ref());
let token = encode(&Header::default(), &self, &key).unwrap();
return token;
}

pub fn new(user_id: i32) -> Self {
let config = Config::new();
let minutes = config.map.get("EXPIRE_MINUTES").unwrap().as_i64().unwrap();
let expiration = Utc::now().checked_add_signed(chrono::Duration::try_minutes(minutes).expect("valid timestamp")).unwrap().timestamp();
return JwToken {user_id, exp: expiration as usize};
}

pub fn from_token(token: String) -> Result<Self, String> {
let key = DecodingKey::from_secret(JwToken::get_key().as_ref());
let token_result = decode::<JwToken>(&token, &key, &Validation::new(Algorithm::HS256));
return match token_result {
Ok(data) => Ok(data.claims),
Err(error) => Err(format!("{}", error))
};
}
}

impl FromRequest for JwToken {
type Error = Error;
type Future = Ready<Result<JwToken, Error>>;

fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
match req.headers().get("token") {
Some(data) => {
let raw_token = data.to_str().unwrap().to_string();
let token_result = JwToken::from_token(raw_token);
match token_result {
Ok(token) => ok(token),
Err(message) => {
if message == "ExpiredSignature".to_owned() {
err(ErrorUnauthorized("Token expired"))
} else {
err(ErrorUnauthorized("Token can't be decoded"))
}
}
}
},
None => {
let error = ErrorUnauthorized("Token not in header under key 'token'");
err(error)
}
}
}
}

A token is made up of a user id and an expiration time. When we get a request to create a new token for a given user id, we will get our EXPIRE_MINUTES from our config.yml and mint a new token to provide to our logged in user. We expire this token so that even if a bad actor gets their hands on it, the token will eventually not work — which wold not be the case if they got their hands on our password.

Before we can send this token over the internet, we will need to encode it using a secret key (stored in our config.yml for now) so that it leaves our server as a string of characters with no inherent meaning.

When we receive the token in a request, we use that same secret key to decode the non-sensical string back into the user id and expiration time. From which point we can then throw an error — if the string cannot be decoded or the token is expired — or return the valid token decoded so we can use the user id encoded in it.

Authorization

Now that we have a safe way of telling the server who we are using our JwToken, we can restrict views to only authorized users and only return data pertaining to the logged in user.

For example,

pub async fn edit(to_do_item: web::Json<ToDoItem>, token: JwToken, db: DB) -> HttpResponse {
let results = to_do::table.filter(to_do::title.eq_all(&to_do_item.title)).filter(to_do::user_id.eq_all(token.user_id));

let _ = diesel::update(results)
.set(to_do::status.eq_all("DONE"))
.execute(&db.connection);

return HttpResponse::Ok().json(ToDoItems::get_state(Some(token.user_id.clone())));
}

our edit view now checks that the to_do item has a user_id that matches the one in the token, so we can only edit the todo items we own.

Also,

pub fn get_state(user_id: Option<i32>) -> ToDoItems {
let connection = DBCONNECTION.db_connection.get().unwrap();


let items = match user_id {
None => to_do::table.order(to_do::columns::id.asc()).load::<Item>(&connection).unwrap(),
Some(user_id) => to_do::table.filter(to_do::columns::user_id.eq_all(user_id)).order(to_do::columns::id.asc()).load::<Item>(&connection).unwrap()
};

let mut array_buffer = Vec::with_capacity(items.len());
for item in items {
let status = TaskStatus::from_string(item.status);
let item = to_do_factory(&item.title, status);
array_buffer.push(item);
}

return ToDoItems::new(array_buffer);
}

Our get_state function inside our to_do_items.rs, takes in a user_id, which we can then use to restrict the to_do items we return to only the ones owned by this user.

Summary

  • Authentication takes in a username and password and spits out an expiring token
  • The token can be provided in future requests to authorize a request to take some action
  • The server will create and encode the token using a secret key on login and use the same secret key to decode the token and extract the original encoded information

Front End

We’ve done all this work in the backend to create and verify these tokens, now lets update the front end to hold onto the token and provide it during our requests.

Authentication Component

LoginForm.js

import {Component} from "react";
import axios from "axios";
import "../css/LoginForm.css";

class LoginForm extends Component {
state = {
username: "",
password: ""
}

submitLogin = (e) => {
e.preventDefault();
axios.post("http://localhost:8000/v1/auth/login",
{
"username": this.state.username,
"password": this.state.password
},
{headers: {"Access-Control-Allow-Origin": "*"}}
).then(response => {
this.props.handleLogin(JSON.parse(response.data)["token"]);
}).catch(error => {
alert(error);
}).finally(() => {
this.setState({username: "", password: ""});
});
}

handlePasswordChange = (e) => {
this.setState({password: e.target.value});
}

handleUsernameChange = (e) => {
this.setState({username: e.target.value});
}

render() {
return(
<form className="login" onSubmit={this.submitLogin}>
<h1 className="login-title">Login</h1>
<input type="text" className="login-input" placeholder="Username" autoFocus onChange={this.handleUsernameChange} value={this.state.username}/>
<input type="password" className="login-input" placeholder="Password" autoFocus onChange={this.handlePasswordChange} value={this.state.password}/>
<input type="submit" value="Lets Go" className="login-button"/>
</form>
);
}
}

export default LoginForm;

The login form is essentially a pretty form that takes in a callback function to call if the login request is successful.

handleLogin = (token) => {
localStorage.setItem("user-token", token);
this.setState({"login_status": true});
this.getItems();
}

In our App.js, this is our handleLogin function, which will take in the token we get from the server and save it in local storage for us to use in requests.

Using the Token

Now that we have the token, we just need to add it to all our requests to our web app.

Below is an example of how get changes.

getItems() {
axios.get("http://127.0.0.1:8000/v1/item/get", {headers: {"token": localStorage.getItem("user-token")}})
.then(response => {
let pending_items = response.data["pending_items"];
let done_items = response.data["done_items"];

this.setState({
"pending_items": this.processItemValues(pending_items),
"done_items": this.processItemValues(done_items),
"pending_items_count": response.data["pending_item_count"],
"done_items_count": response.data["done_item_count"]
})
}).catch(error => {
if (error.response.status === 401) {
this.logout();
}
});
}

The main difference is that we pass in our token header, we get the token value from our localStorage. If there is an error we logout, which is just a function to clear the user-token key from localStorage and set our page state to logged out so we can try logging back in.

Summary

  • When the front end gets a token back from the server, it stores it in local storage for later use
  • The front end provides the token on future requests to the server when needed

Conclusion

We have a pretty good todo app now! We can support multiple users with their own lists and we are using good standards for JwTokens and hashed passwords. In the next section we will be introducing a few additional features of rust web programming to make our app more RESTful.

--

--

Matthew MacFarquhar
Matthew MacFarquhar

Written by Matthew MacFarquhar

I am a software engineer working for Amazon living in SF/NYC.

No responses yet