Two of the most important aspects of a web application are authentication and authorization. In this chapter, we are going to learn how to implement simple authentication and authorization systems. After we have created these systems, we are going to learn how to create a simple Application Programming Interface (API) and how to protect the API endpoint using a JSON Web Token (JWT).
At the end of this chapter, you will be able to create an authentication system, with functionality such as logging in and logging out and setting access rights for logged-in users. You will also be able to create an API server and know how to secure the API endpoints.
In this chapter, we are going to cover these main topics:
For this chapter, we have the usual requirements: a Rust compiler, a text editor, a web browser, and a PostgreSQL database server, along with the FFmpeg command line. We are going to learn about JSON and APIs in this chapter. Install cURL or any other HTTP testing client.
You can find the source code for this chapter at https://github.com/PacktPublishing/Rust-Web-Development-with-Rocket/tree/main/Chapter11.
One of the most common tasks of a web application is handling registration and logging in. By logging in, users can tell the web server that they really are who they say they are.
We already created a sign-up system when we implemented CRUD for the user model. Now, let's implement a login system using the existing user model.
The idea for login is simple: the user can fill in their username and password. The application then verifies that the username and password are valid. After that, the application can generate a cookie with the user's information and return the cookie to the web browser. Every time there's a request from the browser, the cookie is sent back from the browser to the server, and we validate the content of the cookie.
To make sure we don't have to implement the cookie for every request, we can create a request guard that validates the cookie automatically if we use the request guard in a route handling function.
Let's start implementing a user login system by following these steps:
pub mod guards;
pub mod auth;
use crate::fairings::db::DBConnection;
use crate::models::user::User;
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::serde::Serialize;
use rocket_db_pools::{sqlx::Acquire, Connection};
#[derive(Serialize)]
pub struct CurrentUser {
pub user: User,
}
pub const LOGIN_COOKIE_NAME: &str = "user_uuid";
#[rocket::async_trait]
impl<'r> FromRequest<'r> for CurrentUser {
type Error = ();
async fn from_request(req: &'r Request<'_>) ->
Outcome<Self, Self::Error> {
}
}
let error = Outcome::Failure((Status::Unauthorized, ()));
let parsed_cookie = req.cookies().get_private(LOGIN_COOKIE_NAME);
if parsed_cookie.is_none() {
return error;
}
let cookie = parsed_cookie.unwrap();
let uuid = cookie.value();
let parsed_db = req.guard::<Connection<DBConnection>>().await;
if !parsed_db.is_success() {
return error;
}
let mut db = parsed_db.unwrap();
let parsed_connection = db.acquire().await;
if parsed_connection.is_err() {
return error;
}
let connection = parsed_connection.unwrap();
let found_user = User::find(connection, uuid).await;
if found_user.is_err() {
return error;
}
let user = found_user.unwrap();
Outcome::Success(CurrentUser { user })
{% extends "template" %}
{% block body %}
<form accept-charset="UTF-8" action="login"
autocomplete="off" method="POST">
<input type="hidden" name="authenticity_token"
value="{{ csrf_token }}"/>
<fieldset>
<legend>Login</legend>
<div class="row">
<div class="col-sm-12 col-md-3">
<label for="username">Username:</label>
</div>
<div class="col-sm-12 col-md">
<input name="username" type="text" value=""
/>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-md-3">
<label for="password">Password:</label>
</div>
<div class="col-sm-12 col-md">
<input name="password" type="password" />
</div>
</div>
<button type="submit" value="Submit">
Submit</button>
</fieldset>
</form>
{% endblock %}
#[derive(FromForm)]
pub struct Login<'r> {
pub username: &'r str,
pub password: &'r str,
pub authenticity_token: &'r str,
}
fn verify_password(ag: &Argon2, reference: &str, password: &str) -> Result<(), OurError> {
let reference_hash = PasswordHash::new(
reference).map_err(|e| {
OurError::new_internal_server_error(
String::from("Input error"), Some(
Box::new(e)))
})?;
Ok(ag
.verify_password(password.as_bytes(),
&reference_hash)
.map_err(|e| {
OurError::new_internal_server_error(
String::from("Cannot verify
password"),
Some(Box::new(e)),
)
})?)
}
let old_password_hash = PasswordHash::new(&old_user.password_hash).map_err(|e| {
OurError::new_internal_server_error(
String::from("Input error"), Some(Box::new(e)))
})?;
let argon2 = Argon2::default();
argon2
.verify_password(user.old_password.as_bytes(),
&old_password_hash)
.map_err(|e| {
OurError::new_internal_server_error(
String::from("Cannot confirm old
password"),
Some(Box::new(e)),
)
})?;
And, change it to the following lines:
let argon2 = Argon2::default();
verify_password(&argon2, &old_user.password_hash, user.old_password)?;
pub async fn find_by_login<'r>(
connection: &mut PgConnection,
login: &'r Login<'r>,
) -> Result<Self, OurError> {
let query_str = "SELECT * FROM users WHERE
username = $1";
let user = sqlx::query_as::<_, Self>(query_str)
.bind(&login.username)
.fetch_one(connection)
.await
.map_err(OurError::from_sqlx_error)?;
let argon2 = Argon2::default();
verify_password(&argon2, &user.password_hash,
&login.password)?;
Ok(user)
}
pub mod session;
Then, create a new file called src/routes/session.rs.
use super::HtmlResponse;
use crate::fairings::csrf::Token as CsrfToken;
use rocket::request::FlashMessage;
use rocket_dyn_templates::{context, Template};
#[get("/login", format = "text/html")]
pub async fn new<'r>(flash: Option<FlashMessage<'_>>, csrf_token: CsrfToken) -> HtmlResponse {
let flash_string = flash
.map(|fl| format!("{}", fl.message()))
.unwrap_or_else(|| "".to_string());
let context = context! {
flash: flash_string,
csrf_token: csrf_token,
};
Ok(Template::render("sessions/new", context))
}
use crate::fairings::db::DBConnection;
use crate::guards::auth::LOGIN_COOKIE_NAME;
use crate::models::user::{Login, User};
use rocket::form::{Contextual, Form};
use rocket::http::{Cookie, CookieJar};
use rocket::response::{Flash, Redirect};
use rocket_db_pools::{sqlx::Acquire, Connection};
...
#[post("/login", format = "application/x-www-form-urlencoded", data = "<login_context>")]
pub async fn create<'r>(
mut db: Connection<DBConnection>,
login_context: Form<Contextual<'r, Login<'r>>>,
csrf_token: CsrfToken,
cookies: &CookieJar<'_>,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
let login_error = || Flash::error(
Redirect::to("/login"), "Cannot login");
if login_context.value.is_none() {
return Err(login_error());
}
let login = login_context.value.as_ref().unwrap();
csrf_token
.verify(&login.authenticity_token)
.map_err(|_| login_error())?;
let connection = db.acquire().await.map_err(|_|
login_error())?;
let user = User::find_by_login(connection, login)
.await
.map_err(|_| login_error())?;
cookies.add_private(Cookie::new(LOGIN_COOKIE_NAME,
user.uuid.to_string()));
Ok(Flash::success(Redirect::to("/users"), "Login
successfully"))
}
#[post("/logout", format = "application/x-www-form-urlencoded")]
pub async fn delete(cookies: &CookieJar<'_>) -> Flash<Redirect> {
cookies.remove_private(
Cookie::named(LOGIN_COOKIE_NAME));
Flash::success(Redirect::to("/users"), "Logout
successfully")
}
use our_application::routes::{self, post, session, user};
...
async fn rocket() -> Rocket<Build> {
...
routes![
...
session::new,
session::create,
session::delete,
]
...
}
pub async fn edit_user(
mut db: Connection<DBConnection>,
...
) -> HtmlResponse {
let connection = db
.acquire()
.await
.map_err(|_| Status::InternalServerError)?;
let user = User::find(connection,
uuid).await.map_err(|e| e.status)?;
...
}
use crate::guards::auth::CurrentUser;
...
pub async fn edit_user(...
current_user: CurrentUser,
) -> HtmlResponse {
...
let context = context! {
form_url: format!("/users/{}", uuid),
...
user: ¤t_user.user,
current_user: ¤t_user,
...
};
...
}
...
pub async fn update_user<'r>(...
current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
...
match user_value.method {
"PUT" => put_user(db, uuid, user_context,
csrf_token, current_user).await,
"PATCH" => patch_user(db, uuid, user_context,
csrf_token, current_user).await,
...
}
}
...
pub async fn put_user<'r>(...
_current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
...
pub async fn patch_user<'r>(...
current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
put_user(db, uuid, user_context, csrf_token,
current_user).await
}
...
pub async fn delete_user_entry_point(...
current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
delete_user(db, uuid, current_user).await
}
...
pub async fn delete_user(...
_current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
crate::guards::auth::CurrentUser;
...
pub async fn create_post<'r>(...
_current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
...
pub async fn delete_post(...
_current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {...}
Before we implemented authentication, we could edit and delete any user or post. Now try editing or deleting something without logging in. Then, try logging in and deleting and editing.
One problem still exists: after logging in, users can edit and delete other users' information. We will learn how to prevent this problem by implementing authorization in the next section.
Authentication and authorization are two of the main concepts of information security. If authentication is a way to prove that an entity is who they say they are, then authorization is a way to give rights to the entity. One entity might be able to modify some resources, one entity might be able to modify all resources, one entity might only be able to see limited resources, and so on.
In the previous section, we implemented authentication concepts such as login and CurrentUser; now it's time to implement authorization. The idea is that we make sure logged-in users can only modify their own information and posts.
Please keep in mind that this example is very simple. In more advanced information security, there are more advanced concepts, such as role-based access control. For example, we can create a role called admin, we can set a certain user as admin, and admin can do everything without restrictions.
Let's try implementing simple authorization by following these steps:
impl CurrentUser {
pub fn is(&self, uuid: &str) -> bool {
self.user.uuid.to_string() == uuid
}
pub fn is_not(&self, uuid: &str) -> bool {
!self.is(uuid)
}
}
pub fn new_unauthorized_error(debug: Option<Box<dyn Error>>) -> Self {
Self::new_error_with_status(Status::Unauthorized,
String::from("unauthorized"), debug)
}
<body>
<header>
<a href="/users" class="button">Home</a>
{% if current_user %}
<form accept-charset="UTF-8" action="/logout"
autocomplete="off" method="POST" id="logout"
class="hidden"></form>
<button type="submit" value="Submit" form="
logout">Logout</button>
{% else %}
<a href="/login" class="button">Login</a>
<a href="/users/new" class="button">Signup</a>
{% endif %}
</header>
<div class="container">
<a href="/users/new" class="button">New user</a>
Find this line:
<a href="/users/edit/{{ user.uuid }}" class="button">Edit User</a>
Modify it into the following lines:
{% if current_user and current_user.user.uuid == user.uuid %}
<a href="/users/edit/{{user.uuid}}" class="
button">Edit User</a>
{% endif %}
<a href="/users/edit/{{user.uuid}}" class="button">Edit User</a>
<form accept-charset="UTF-8" action="/users/delete/{{user.uuid}}" autocomplete="off" method="POST" id="deleteUser" class="hidden"></form>
<button type="submit" value="Submit" form="deleteUser">Delete</button>
And, surround those lines with conditional checking as follows:
{% if current_user and current_user.user.uuid == user.uuid %}
<a href...
...
</button>
{% endif %}
<form action="/users/{{ user.uuid }}/posts" enctype="multipart/form-data" method="POST">
...
</form>
Surround the form lines with the following conditional:
{% if current_user %}
<form action="/users/{{ user.uuid }}/posts" enctype="multipart/form-data" method="POST">
...
</form>
{% endif %}
<form accept-charset="UTF-8" action="/users/{{user.uuid}}/posts/delete/{{post.uuid}}" autocomplete="off" method="POST" id="deletePost" class="hidden"></form>
<button type="submit" value="Submit" form="deletePost">Delete</button>
Surround them with the following lines:
{% if current_user and current_user.user.uuid == user.uuid %}
<form...
...
</button>
{% endif %}
Let's convert route handling functions, starting from src/routes/post.rs. Modify the get_post() function as follows:
pub async fn get_post(...
current_user: Option<CurrentUser>,
) -> HtmlResponse {
...
let context = context! {user, current_user, post:
&(post.to_show_post())};
Ok(Template::render("posts/show", context))
}
pub async fn get_posts(...
current_user: Option<CurrentUser>,
) -> HtmlResponse {
let context = context! {
...
current_user,
};
Ok(Template::render("posts/index", context))
}
pub async fn create_post<'r>(...
current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
...
if current_user.is_not(user_uuid) {
return Err(create_err());
}
...
}
pub async fn delete_post(...
current_user: CurrentUser,
) -> Result<Flash<Redirect>, Flash<Redirect>> {
...
if current_user.is_not(user_uuid) {
return Err(delete_err());
}
...
}
One of the common tasks of web applications is handling APIs. APIs can return a lot of different formats, but modern APIs have converged into two common formats: JSON and XML.
Building an endpoint that returns JSON is pretty simple in the Rocket web framework. For handling the request body in JSON format, we can use rocket::serde::json::Json<T> as a data guard. The generic T type must implement the serde::Deserialize trait or else the Rust compiler will refuse to compile.
For responding, we can do the same thing by responding with rocket::serde::json::Json<T>. The generic T type must only implement the serde::Serialize trait when used as a response.
Let's see an example of how to handle JSON requests and responses. We want to create a single API endpoint, /api/users. This endpoint can receive a JSON body similar to the structure of our_application::models::pagination::Pagination, as follows:
{"next":"2022-02-22T22:22:22.222222Z","limit":10}
Follow these steps to implement the API endpoint:
use rocket::serde::{Serialize, Serializer};
use serde::ser::SerializeStruct;
...
impl Serialize for OurError {
fn serialize<S>(&self, serializer: S) ->
Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.
serialize_struct("OurError", 2)?;
state.serialize_field("status", &self
.status.code)?;
state.serialize_field("message", &self
.message)?;
state.end()
}
}
use rocket::serde::{Deserialize, Serialize};
...
#[derive(Debug, sqlx::Type, Clone, Serialize, Deserialize)]
#[sqlx(transparent)]
pub struct OurDateTime(pub DateTime<Utc>);
use rocket::serde::{Deserialize, Serialize};
...
#[derive(FromForm, Serialize, Deserialize)]
pub struct Pagination {...}
pub struct User {
...
#[serde(skip_serializing)]
pub password_hash: String,
...
}
#[derive(Serialize)]
pub struct UsersWrapper {
pub users: Vec<User>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub pagination: Option<Pagination>,
}
Note that we are skipping the pagination field if it's None.
Then, create a new file in src/routes/api.rs.
use crate::errors::our_error::OurError;
use crate::fairings::db::DBConnection;
use crate::models::{
pagination::Pagination,
user::{User, UsersWrapper},
};
use rocket_db_pools::Connection;
use rocket::serde::json::Json;
#[get("/users", format = "json", data = "<pagination>")]
pub async fn users(
mut db: Connection<DBConnection>,
pagination: Option<Json<Pagination>>,
) -> Result<Json<UsersWrapper>, Json<OurError>> {}
let parsed_pagination = pagination.map(|p| p.into_inner());
let (users, new_pagination) = User::find_all(&mut db, parsed_pagination)
.await
.map_err(|_| OurError::new_internal_server_
error(String::from("Internal Error"), None))?;
Because we have implemented the Serialize trait for OurError, we can return the type automatically.
Ok(Json(UsersWrapper {
users,
pagination: new_pagination,
}))
use our_application::routes::{self, api, post, session, user};
...
.mount("/", ...)
.mount("/assets", FileServer::from(relative!("static")))
.mount("/api", routes![api::users])
curl -X GET -H "Content-Type: application/json" -d "{"next":"2022-02-22T22:22:22.222222Z","limit":1}" http://127.0.0.1:8000/api/users
The application should return something similar to the following output:
{"users":[{"uuid":"8faa59d6-1079-424a-8eb9-09ceef1969c8","username":"example","email":"[email protected]","description":"example","status":"Inactive","created_at":"2021-11-06T06:09:09.534864Z","updated_at":"2021-11-06T06:09:09.534864Z"}],"pagination":{"next":"2021-11-06T06:09:09.534864Z","limit":1}}
Now that we have finished creating an API endpoint, let's try securing the endpoint in the next section.
One common task we want to do is protect the API endpoints from unauthorized access. There are a lot of reasons why API endpoints have to be protected, such as wanting to protect sensitive data, conducting financial services, or offering subscription services.
In the web browser, we can protect server endpoints by making a session, assigning a cookie to the session, and returning the session to the web browser, but an API client is not always a web browser. API clients can be mobile applications, other web applications, hardware monitors, and many more. This raises the question, how can we protect the API endpoint?
There are a lot of ways to protect the API endpoint, but one industry standard is by using a JWT. According to IETF RFC7519, a JWT is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT can be either JSON objects or special plaintext representations of said JSON objects.
One flow to use a JWT is as follows:
Let's try implementing API endpoint protection by following these steps:
hmac = "0.12.1"
jwt = "0.16.0"
sha2 = "0.10.2"
jwt_secret = "fill with your own secret"
pub struct JWToken {
pub secret: String,
}
use our_application::states::JWToken;
...
struct Config {...
jwt_secret: String,
}
...
async fn rocket() -> Rocket<Build> {
...
let config: Config = our_rocket...
let jwt_secret = JWToken {
secret: String::from(config.jwt_
secret.clone()),
};
let final_rocket = our_rocket.manage(jwt_secret);
...
final_rocket
}
use rocket::serde::{Deserialize, Serialize};
Add the following structs:
#[derive(Deserialize)]
pub struct JWTLogin<'r> {
pub username: &'r str,
pub password: &'r str,
}
#[derive(Serialize)]
pub struct Auth {
pub token: String,
}
impl<'r> JWTLogin<'r> {
pub async fn authenticate(
&self,
connection: &mut PgConnection,
secret: &'r str,
) -> Result<Auth, OurError> {}
}
let auth_error =
|| OurError::new_bad_request_error(
String::from("Cannot verify password"), None);
let user = User::find_by_login(
connection,
&Login {
username: self.username,
password: self.password,
authenticity_token: "",
},
)
.await
.map_err(|_| auth_error())?;
verify_password(&Argon2::default(), &user.password_hash, self.password)?;
use hmac::{Hmac, Mac};
use jwt::{SignWithKey};
use sha2::Sha256;
use std::collections::BTreeMap;
Continue the following inside authenticate to generate a token from the user's UUID and return the token:
let user_uuid = &user.uuid.to_string();
let key: Hmac<Sha256> =
Hmac::new_from_slice(secret.as_bytes()
).map_err(|_| auth_error())?;
let mut claims = BTreeMap::new();
claims.insert("user_uuid", user_uuid);
let token = claims.sign_with_key(&key).map_err(|_| auth_error())?;
Ok(Auth {
token: token.as_str().to_string(),
})
use crate::models::user::{Auth, JWTLogin, User, UsersWrapper};
use crate::states::JWToken;
use rocket::State;
use rocket_db_pools::{sqlx::Acquire, Connection};
#[post("/login", format = "json", data = "<jwt_login>")]
pub async fn login<'r>(
mut db: Connection<DBConnection>,
jwt_login: Option<Json<JWTLogin<'r>>>,
jwt_secret: &State<JWToken>,
) -> Result<Json<Auth>, Json<OurError>> {
let connection = db
.acquire()
.await
.map_err(|_| OurError::new_internal_server_
error(String::from("Cannot login"), None))?;
let parsed_jwt_login = jwt_login
.map(|p| p.into_inner())
.ok_or_else(|| OurError::new_bad_request_
error(String::from("Cannot login"), None))?;
Ok(Json(
parsed_jwt_login
.authenticate(connection, &jwt_secret
.secret)
.await
.map_err(|_| OurError::new_internal_
server_error(String::from("Cannot login"),
None))?,
))
}
use crate::states::JWToken;
use hmac::{Hmac, Mac};
use jwt::{Header, Token, VerifyWithKey};
use sha2::Sha256;
use std::collections::BTreeMap;
pub struct APIUser {
pub user: User,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for APIUser {
type Error = ();
async fn from_request(req: &'r Request<'_>) ->
Outcome<Self, Self::Error> {}
}
let error = || Outcome::Failure ((Status::Unauthorized, ()));
let parsed_header = req.headers().get_one("Authorization");
if parsed_header.is_none() {
return error();
}
let token_str = parsed_header.unwrap();
let parsed_secret = req.rocket().state::<JWToken>();
if parsed_secret.is_none() {
return error();
}
let secret = &parsed_secret.unwrap().secret;
let parsed_key: Result<Hmac<Sha256>, _> = Hmac::new_from_slice(secret.as_bytes());
if parsed_key.is_err() {
return error();
}
let key = parsed_key.unwrap();
let parsed_token: Result<Token<Header, BTreeMap<String, String>, _>, _> = token_str.verify_with_key(&key);
if parsed_token.is_err() {
return error();
}
let token = parsed_token.unwrap();
let claims = token.claims();
let parsed_user_uuid = claims.get("user_uuid");
if parsed_user_uuid.is_none() {
return error();
}
let user_uuid = parsed_user_uuid.unwrap();
let parsed_db = req.guard::<Connection<DBConnection>>().await;
if !parsed_db.is_success() {
return error();
}
let mut db = parsed_db.unwrap();
let parsed_connection = db.acquire().await;
if parsed_connection.is_err() {
return error();
}
let connection = parsed_connection.unwrap();
let found_user = User::find(connection, &user_uuid).await;
if found_user.is_err() {
return error();
}
let user = found_user.unwrap();
Outcome::Success(APIUser { user })
use crate::guards::auth::APIUser;
...
#[get("/protected_users", format = "json", data = "<pagination>")]
pub async fn authenticated_users(
db: Connection<DBConnection>,
pagination: Option<Json<Pagination>>,
_authorized_user: APIUser,
) -> Result<Json<UsersWrapper>, Json<OurError>> {
users(db, pagination).await
}
...
.mount("/api", routes![api::users, api::login,
api::authenticated_users])
...
Now, try accessing the new endpoint. Here is an example when using the cURL command line:
curl -X GET -H "Content-Type: application/json"
http://127.0.0.1:8000/api/protected_users
The response will be an error. Now try sending a request to get the access token. Here is an example:
curl -X POST -H "Content-Type: application/json"
-d "{"username":"example", "password": "password"}"
http://127.0.0.1:8000/api/login
There's a token returned, as shown in this example:
{"token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX3V1aWQiOiJmMGMyZDM4Yy0zNjQ5LTRkOWQtYWQ4My0wZGE4ZmZlY2 E2MDgifQ.XJIaKlIfrBEUw_Ho2HTxd7hQkowTzHkx2q_xKy8HMKA"}
Use the token to send the request, as in this example:
curl -X GET -H "Content-Type: application/json" T -H "Content-Type: application/json"
-H "Authorization: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX3V1aWQiOiJmMGMyZDM4Yy0zNjQ5LTRkOWQtYWQ4My0wZGE4ZmZlY2 E2MDgifQ.XJIaKlIfrBEUw_Ho2HTxd7hQkowTzHkx2q_xKy8HMKA"
http://127.0.0.1:8000/api/protected_users
Then, the correct response will be returned. JWT is a good way to protect API endpoints, so use the technique that we have learned when necessary.
In this chapter, we learned about authenticating users and then creating a cookie to store logged-in user information. We also introduced CurrentUser as a request guard that works as authorization for certain parts of the application.
After creating authentication and authorization systems, we also learned about API endpoints. We parsed the incoming request body as a request guard in an API and then created an API response.
Finally, we learned a little bit about the JWT and how to use it to protect API endpoints.
In the next chapter, we are going to learn how to test the code that we have created.