Production-ready microservice in Rust: 8. Authentication and authorization

Posted 2023-09-22 13:32:00 by sanyi ‐ 8 min read

A simple login endpoint and JWT based authentication, authorization

Up until now we managed to connect to a database, create a basic schema and insert an admin user into the database.

Now we will try to log in that user on a new POST /v1/login API endpoint and return a JWT token certifying his identity on success. An API consumer will be able to use that JWT token on subsequent calls to authenticate itself.

To test the JWT authentication we will create a POST /v1/dogs API endpoint in the next blog post, where the authorized users can submit new entries into the database.

If you do not have a database set up, please check the previous two articles.

First, we have to add some new dependencies in shelter_main/Cargo.toml:

[dependencies]
...
jsonwebtoken = "8.3.0"
chrono = "0.4.24"

We will use jsonwebtoken for JWT token generation and validation and chrono to get the current timestamp.

We have to extend our project structure: add a request and a response folder under shelter_main/src/api and the appropriate module declarations in shelter_main/src/api/mod.rs:

use crate::state::ApplicationState;
use axum::Router;
use std::sync::Arc;

mod handlers;
mod request;
mod response;
mod v1;

...

Create a new struct for the login request in shelter_main/src/api/request/login.rs:

use serde::Deserialize;

#[derive(Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

The Deseralize macro ensures that we can deserialize this request from JSON.

And the appropriate module declaration in shelter_main/src/api/request/mod.rs:

pub mod login;

Also create a new struct for the login response in shelter_main/src/api/response/login.rs:

use serde::Serialize;

#[derive(Serialize)]
pub struct LoginResponse {
    pub status: String,
    pub token: String,
}

The Serialize macro ensures that we can serialize this response into JSON.

And the appropriate module declaration in shelter_main/src/api/response/mod.rs:

pub mod login;

We will also need a struct to store the JWT token claims, I placed this into response/mod.rs too:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct TokenClaims {
    pub sub: String,
    pub iat: usize,
    pub exp: usize,
}

The sub field is the token subject (generally username or user id), the iat field will store the unix timestamp of the token generation and the exp field will store the unix timestamp of the token expiration time.

To create a login endpoint, we have to extend our api declaration in v1.rs:

use super::handlers;
use crate::state::ApplicationState;
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;

pub fn configure(state: Arc<ApplicationState>) -> Router {
    Router::new()
        .route(
            "/hello",
            get(handlers::hello::hello).with_state(state.clone()),
        )
        .route("/login", post(handlers::login::login).with_state(state))
}

And implement the login functionality in shelter_main/src/api/handlers/login.rs:

use crate::api::request::login::LoginRequest;
use crate::api::response::login::LoginResponse;
use crate::api::response::TokenClaims;
use crate::state::ApplicationState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use jsonwebtoken::{encode, EncodingKey, Header};
use std::sync::Arc;

pub async fn login(
    State(_state): State<Arc<ApplicationState>>,
    Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {

    ...

}

Do not forget to add the pub mod login; line to shelter_main/src/api/handlers/mod.rs!

Let's check the use statements: the LoginRequest and LoginResponse structs are for the request and response data respectively. I also explained TokenClaims earlier. We already used State, ApplicationState and StatusCode in the hello endpoint, so you may know them. One new thing is axum::Json, this is a Json extractor to convert the request body into a LoginRequest struct. I will tackle jsonwebtoken shortly.

I will implement a dummy login function first, always returning success without password checking.

We will need some data to populate the TokenClaims structure:

pub async fn login(
    State(_state): State<Arc<ApplicationState>>,
    Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {

    let now = chrono::Utc::now();
    let iat = now.timestamp() as usize;
    let exp = (now + chrono::Duration::minutes(60)).timestamp() as usize;
    let claims = TokenClaims {
        sub: payload.username,
        exp,
        iat,
    };

We use chrono to get the current timestamp and convert it into usize for the iat field. The exp field is similar, current timestamp plus 60 minutes (we will use a configurable timeout parameter for this in the final implementation). The token subject will store the username for now.

The next step is to encode the token:

    let secret = "secret";

    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .unwrap();

    let response = LoginResponse {
        status: "success".to_string(),
        token,
    };

The secret is not so secret in this case, we will read it from the configuration later.

Finally, return the response:

    Ok(Json(response))
}

We can compile and test the code at this stage (cargo build, ./target/debug/shelter_main serve).

Do not forget to set the environment variable for our database url configuration!

$ export SHELTER__DATABASE__URL="postgresql://postgres:[email protected]/shelter"

Now try to call the login API, for example:

$ curl -v -XPOST -d '{"username": "admin", "password": "pass"}' \
  -H 'Content-Type: application/json' \
  http://127.0.0.1:8080/v1/login

*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> POST /v1/login HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.81.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 41
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 178
< date: Fri, 22 Sep 2023 11:50:17 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
{"status":"success","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTY5NTM4MzQxNywiZXhwIjoxNjk1Mzg3MDE3fQ.XozZy7vZgk4UojpAY_13s2Pxp7bl4N5xh94n3LNq9rI"}

Now extend our configuration to add a token timeout and a token secret (shelter_main/src/settings.rs):

pub struct Settings {
    #[serde(default)]
    pub config: ConfigInfo,
    #[serde(default)]
    pub database: Database,
    #[serde(default)]
    pub logging: Logging,
    #[serde(default)]
    pub token_secret: String,
    #[serde(default)]
    pub token_timeout_seconds: i64,
}

and set these values in config.json:

{
    "database": {
        "url": "pgsql://"
    },
    "token_secret": "super secret string",
    "token_timeout_seconds": 3600
}

so we can use them in the login handler:

    let secret = &state.settings.load().token_secret;
    let timeout = state.settings.load().token_timeout_seconds;

    let now = chrono::Utc::now();
    let iat = now.timestamp() as usize;
    let exp = (now + chrono::Duration::seconds(timeout)).timestamp() as usize;

Next step: use the database to validate login credentials. First we need a database connection pool, so we will initialize one in serve.rs in the start_tokio method:

fn start_tokio(port: u16, settings: &Settings) -> anyhow::Result<()> {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async move {

            let db_url = settings.database.url.clone().unwrap_or("".to_string());
            let db_conn = Database::connect(db_url)
                .await
                .expect("Database connection failed");

            let state = Arc::new(ApplicationState::new(settings, db_conn)?);
    ...
}

We have to extend the ApplicationState struct also:

use crate::settings::Settings;
use arc_swap::ArcSwap;
use std::sync::Arc;
use sea_orm::DatabaseConnection;

pub struct ApplicationState {
    pub db_conn: ArcSwap<DatabaseConnection>,
    pub settings: ArcSwap<Settings>,
}

impl ApplicationState {
    pub fn new(settings: &Settings, db_conn: DatabaseConnection) -> anyhow::Result<Self> {

        Ok(Self {
            db_conn: ArcSwap::new(Arc::new(db_conn)),
            settings: ArcSwap::new(Arc::new((*settings).clone())),
        })
    }
}

Now we can check for the existence of the user in the login handler:

    match entity::user::Entity::find()
        .filter(entity::user::Column::Username.eq(&payload.username))
        .all(state.db_conn.load().as_ref())
        .await {
        Ok(admins) => {
            if admins.is_empty() {
                return Err(StatusCode::UNAUTHORIZED)
            }

            let admin = &admins[0];
            if validate_password(&payload.password, &admin.password).is_err() {
                return Err(StatusCode::UNAUTHORIZED)
            }
        }
        Err(_) => { return Err(StatusCode::UNAUTHORIZED) }
    }

Search for all the users whose username is equal to payload.username, return an HTTP 401 Unauthorized error when no user matches the criteria or the query returns with an error.

One more step to go: implement the validate_password function in the login handler:

fn validate_password(password: &str, hash: &str) -> anyhow::Result<String> {
    let argon2 = Argon2::default();
    let parsed_hash = PasswordHash::new(hash).map_err(|e| anyhow!(e.to_string()))?;

    argon2
        .verify_password(password.as_bytes(), &parsed_hash)
        .map_err(|_e| anyhow!("Failed to verify password"))
}

Those map_err calls are required, because the error types of the Argon2 library are not compatible with anyhow. We simply convert them into anyhow errors.

For the sake of completeness, the full list of use statements in handlers/login.rs:

use crate::api::request::login::LoginRequest;
use crate::api::response::login::LoginResponse;
use crate::api::response::TokenClaims;
use crate::state::ApplicationState;
use anyhow::anyhow;
use argon2::Argon2;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use jsonwebtoken::{encode, EncodingKey, Header};
use password_hash::{PasswordHash, PasswordVerifier};
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::QueryFilter;
use std::sync::Arc;

Now you should build and test the application (cargo build, ./target/debug/shelter_main serve).

A sample call with the default password:

$ curl -v -XPOST -d '{"username": "admin", "password": "Pa$$wd123"}' \
 -H 'Content-Type: application/json' \
 http://127.0.0.1:8080/v1/login

*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> POST /v1/login HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.81.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 46
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 178
< date: Fri, 22 Sep 2023 13:24:57 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
{"status":"success","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTY5NTM4OTA5NywiZXhwIjoxNjk1MzkyNjk3fQ.6JnaW9YaSMYLi6I12QKviUIZh0DlZpWDyWZzd_1S-sU"}

And a failed one:

$ curl -v -XPOST -d '{"username": "adminn", "password": "Pa$$wd123"}' \
 -H 'Content-Type: application/json' \
 http://127.0.0.1:8080/v1/login

*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> POST /v1/login HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.81.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 47
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 Unauthorized
< content-length: 0
< date: Fri, 22 Sep 2023 13:36:25 GMT
< 
* Connection #0 to host 127.0.0.1 left intact

We will create an authenticated POST /v1/dogs API endpoint in the next blog post to test the JWT authentication.

You can find the sample code on GitHub

Next article ยป

Tags:
rust microservice dog-shelter