Exigindo Autenticação

Agora que implementamos a lógica de login, precisamos aplicar ela ao nosso serviço. Atualmente nosso serviço possui 4 conjuntos de rotas, /auth/, /api/, /ping, /~/ready. Dessas rotas somente uma precisa de autenticação (api) enquanto as outras servem para fazer a autenticação (auth), ver a saúde do serviço (ping) e ver a disponibildiade de receber chamadas do serviço (ready). Assim, precisamos implementar "algo" que vai aplicar o sistema de login somente a rota /api. Esse algo será um middleware que nós vamos construir, ao contrário dos outros que já utilizamos, e lidará com a lógica de autenticação.

Middleware

Já utilizamos Middlewares anterioemente, mas como foram utilizações superficiais não foi preciso entender mais a fundo o que eram. Agora creio que seja um momento interessante de defini-los. Middlewares não são um conceito exclusivo de aplicações web, podendo ser utilizados tanto em sistemas operacionais como em programas do dia a dia. Os middlewares proveem um conjunto de serviços e capacidades comuns a uma aplicação que sua base não prove. Esses serviços e capacidades podem ser gerenciamento de dados, tratamento de mensagens, autenticação e logs. Assim, middlewares atual como um tecido conectivo de vários serviços da aplicação.

No caso de middlewares de aplicações web, geralmente sua funcionalidade é adicionar comportamentos aos processamento de request e de response. Eles consegue se conectar a um request, ou a um response, que chegou ao servidor e alterar este request, inclusive respondendo antes do esperado, ou alterando o response. Utilizamos o middleware de Logger para incluir logs ao processamento do nosso request e o middleware de DefaultHeaders para incluir um header em nosso response.

As alterações a seguir nos exigiram modificar o Cargo.toml para conter a crate futures e mover a crate actix-server para dependencies:

[dependencies]
actix = "0.9.0"
#...
jsonwebtokens = "1.2.0"
actix-service = "2.0.2"
futures = "0.3"

[dev-dependencies]
bytes = "0.5.3"

Estrutura de um middleware com Actix

Um middleware pode ser registrado em cada App, scope ou Resource do servidor e é executado em ordem oposta a seu registro. De modo geral, middlewares em Actix são um tipo, preferenciamente uma struct, que implementa as traits Service e Transform, da crate actix_service. Assim, cada um dos métodos da trait tem a capacidade de responder algo imediatamente ou através de uma future. O exemplo mais básico de Middleware seria um hello world no request (Hello from Request) e outro na response (Hello from Response), onde usamos wrap_fn para criar o middleware, conforme o código a seguir:

use actix_web::{dev::Service as _, web, App};
use futures_util::future::FutureExt;

#[actix_web::main]
async fn main() {
    let app = App::new()
        .wrap_fn(|req, srv| {
            println!("Hi from start. You requested: {}", req.path());
            srv.call(req).map(|res| {
                println!("Hi from response");
                res
            })
        })
        .route(
            "/index.html",
            web::get().to(|| async { "Hello, middleware!" }),
        );
}

Definindo o Middleware de autenticação

A primeira coisa que devemos fazer aqui é criar o módulo middleware em nosso código. Esse módulo estará contido em src/todo_api_web/middleware/mod.rs. A função authentication_middleware será passada como argumento para o wrap de App, App:new().wrap(from_fn(authentication_middleware)). Este bloco de código fica assim:

#![allow(unused)]
fn main() {
use crate::todo_api::{core::decode_jwt, model::core::JwtValue};
use actix_web_lab::middleware::Next;

use actix_web::Error;
use actix_web::{
    body::MessageBody,
    dev::{ServiceRequest, ServiceResponse},
    web::Data,
};

use super::model::http::Clients;
pub async fn authentication_middleware(
    mut req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    let data = req.extract::<Data<Clients>>().await.unwrap();
    let jwt = req.headers().get("x-auth");

    match jwt {
        None => Err(actix_web::error::ErrorInternalServerError(
            "Error in authentication middleware",
        )),
        Some(token) => {
            let decoded_jwt: JwtValue = serde_json::from_value(decode_jwt(token.to_str().unwrap()))
                .expect("Failed to parse Jwt");

            let valid_jwt = data.postgres.send(decoded_jwt);
            let fut = next.call(req).await?;

            match valid_jwt.await {
                Ok(true) => {
                    let (req, res) = fut.into_parts();
                    let res = ServiceResponse::new(req, res);
                    Ok(res)
                }
                _ => Err(actix_web::error::ErrorInternalServerError(
                    "Error in authentication middleware",
                )),
            }
        }
    }
}

}

A função call é o centro de nossa atenção, sendo ela responsável pela manipulação de dados que queremos fazer. O primeiro caso que vamos ver é o fato de querermos que este middleware atue somente nas rotas /api/, assim temos duas soluções para isso. A primeira seria adicionar este middleware diretamente em web::scope("api/") do arquivo de rotas:

#![allow(unused)]
fn main() {
pub fn app_routes(config: &mut web::ServiceConfig) {
    config.service(
        web::scope("")
            .service(
                web::scope("/api")
                    .service(create_todo)
                    .service(show_all_todo)
                    .wrap(from_fn(authentication_middleware)),
            )
            .service(
                web::scope("/auth")
                    .service(signup_user)
                    .service(login)
                    .service(logout),
            )
            .service(ping)
            .service(readiness)
            .default_service(web::to(|| HttpResponse::NotFound())),
    );
}

}

Não gosto muito desta alternativa pois ela impacta a testabilidade. Portanto, prefiro a segunda alternativa que é criar uma condicional que verifica se a rota do request começa com os scope que queremos. Caso a condicional for verdadeira, aplicamos nossa lógica, senão, simplesmente damos sequência ao request:

#![allow(unused)]
fn main() {
pub async fn authentication_middleware(
    mut req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
        if req.path().starts_with("/api/") {
         // ...
        } else {
        let fut = next.call(req).await?;
        let (req, res) = fut.into_parts();
        let res = ServiceResponse::new(req, res);
        Ok(res)
    }
}
}

O if que definimos extrai o path, rota, de ServiceRequest, e aplica a função starts_with com o início da rota que queremos, /api/. Com isso, toda as rotas do serviço que começarem com /api/ serão alteradas por este middleware. O próximo passo é extrairmos o header x-auth dos headers do request:

#![allow(unused)]
fn main() {
pub async fn authentication_middleware(
    mut req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    if req.path().starts_with("/api/") {
        let data = req.extract::<Data<Clients>>().await.unwrap();
        let jwt = req.headers().get("x-auth");

        // ...
    }
}
}

Exatraimos o header x-auth aplicando a função headers a request, que obtém todos os headers, e posteriormente escolhendo um header específico com get. O retorno desta função é um Option<String> contendo a String de Jwt. Como este campo é obrigatório, fazemos um match em jwt e no caso None retornamos um BadRequest com a informação que x-auth é requerido, x-auth is required. Depois disso precisamos de duas coisas, decodificar o token Jwt e enviar a resposta decodificada para validar ela no banco de dados, assim precisaremos da função decode_jwt para decodificar o jwt e de Clients armazenado em data para comunicar com o banco de dados:

#![allow(unused)]
fn main() {
pub async fn authentication_middleware(
    mut req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    if req.path().starts_with("/api/") {
        let data = req.extract::<Data<Clients>>().await.unwrap();
        let jwt = req.headers().get("x-auth");

        match jwt {
            None => Err(actix_web::error::ErrorInternalServerError(
                "Error in authentication middleware",
            )),
            Some(token) => {
                let decoded_jwt: JwtValue =
                    serde_json::from_value(decode_jwt(token.to_str().unwrap()))
                        .expect("Failed to parse Jwt");

                let valid_jwt = data.postgres.send(decoded_jwt);
                let fut = next.call(req).await?;

                match valid_jwt.await {
                    Ok(true) => {
                        let (req, res) = fut.into_parts();
                        let res = ServiceResponse::new(req, res);
                        Ok(res)
                    }
                    _ => Err(actix_web::error::ErrorInternalServerError(
                        "Error in authentication middleware",
                    )),
                }
            }
        }
    } else {
    // ...
}
}

Para recordar decode_jwt

#![allow(unused)]
fn main() {
pub fn decode_jwt(jwt: &str) -> Value {
   use jsonwebtokens::raw::{decode_json_token_slice, split_token, TokenSlices};

   let TokenSlices { claims, .. } = split_token(jwt).unwrap();
   let claims = decode_json_token_slice(claims).expect("Failed to decode token");
   claims
}
}

Nossa função call transforma o token em uma struct JwtValue, que corresponde aos campos presentes no token, através da função serde_json::from_value. O valor de JwtValue é enviado para o banco de dados através de data.postgres.send(decoded_jwt). Com o resultado da troca de mensagens com DbExecutor via data.postgres.send recebemos um tipo Result<bool, MailBoxError> e fazemos match. O único caso que nos interessa é o Ok(true), para todos os outros lançamos uma erro. Este erro retornará InternalServerError, pois não podemos reutilizar o conteúdo de req já que foi utilizado em let fut = self.service.call(req); para conretizar o request. Caso o resultado do match seja Ok(true), deixamos o request prosseguir. Agora, vamos a implementação de JwtValue:

#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Debug)]
pub struct JwtValue {
    pub id: String,
    pub email: String,
    pub expires_at: chrono::NaiveDateTime,
}

impl Message for JwtValue {
    type Result = bool;
}

impl Handler<JwtValue> for DbExecutor {
    type Result = bool;

    fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result {
        use crate::todo_api::db::auth::token_is_valid;

        let user = token_is_valid(&msg, &self.0.get().expect("Failed to open connection"));
        match user {
            Err(_) => false,
            Ok(user) => {
                match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) {
                    (true, true, true) => true,
                    _ => false
                }
            }
        }
    }
}
}

Nosso token Jwt possui 3 campos em seu claim id, email, expires_at, assim a implementação de JwtValue possui estes 3 campos, definidos como String, String, chrono::NaiveDateTime, respectivamente. Depois definimos a trait Message, com o tipo type Result = bool;. Para Handler, procuramos o User com token_is_valid(&msg, &self.0.get().expect("Failed to open connection")) e depois aplicamos match a sua resposta. Em caso de Err, retornamos false, e em caso de Ok, verificamos todas as condições que queremos em outro match (poderia ser um if/else, mas creio que o match ficou mais elegante devido ao uso da tupla). Caso todos os itens da tupla, (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) sejam verdadeiros, retornamos true, senão false. Os itens da tupla são:

  1. Usuário está ativo com user.is_active.
  2. Data atual é inferior a data expires_at do token com validate_jwt_date(user.expires_at).
  3. O id do token é o mesmo do usuário cadastrado com user.id.to_string() == msg.id.

Quanto a nossa função token_is_valid:

#![allow(unused)]
fn main() {
pub fn token_is_valid(token: &JwtValue, conn: &PgConnection) -> Result<User, DbError> {
    use crate::schema::auth_user::dsl::*;

    let items = auth_user.filter(email.eq(&token.email)).load::<User>(conn);

    match items {
        Ok(users) if users.len() > 1 => Err(DbError::DatabaseConflit),
        Ok(users) if users.len() < 1 => Err(DbError::CannotFindUser),
        Ok(users) => Ok(users.first().unwrap().clone().to_owned()),
        Err(_) => Err(DbError::CannotFindUser),
    }
}
}

Note que ela é praticamente igual a função scan_user. Assim, podemos substituir ela por scan user, obtendo o campo email de JwtValue:

#![allow(unused)]
fn main() {
impl Handler<JwtValue> for DbExecutor {
    type Result = bool;

    fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result {
        use crate::todo_api::db::auth::scan_user;

        let user = scan_user(String::from(&msg.email), &self.0.get().expect("Failed to open connection"));
        match user {
            Err(_) => false,
            Ok(user) => {
                match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) {
                    (true, true, true) => true,
                    _ => false
                }
            }
        }
    }
}
}

Com tudo isso pronto, basta adicionar o middleware em App::new():

#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
    std::env::set_var("RUST_LOG", "actix_web=debug");
    env_logger::init();

    let client = Clients::new().await;
    create_table(&client.clone()).await;

    HttpServer::new(move|| {
        App::new()
            .app_data(Data::new(client.clone()))
            .wrap(DefaultHeaders::new().add(("x-request-id", Uuid::new_v4().to_string())))
            .wrap(Logger::new("IP:%a DATETIME:%t REQUEST:\"%r\" STATUS: %s DURATION:%D X-REQUEST-ID:%{x-request-id}o"))
            .wrap(from_fn(authentication_middleware))
            .configure(app_routes)
    })
    .workers(num_cpus::get() - 2)
    .max_connections(30000)
    .bind(("0.0.0.0", 4000))
    .unwrap()
    .run()
    .await
}

Testando o middleware

Nosso middleware funciona bem, e basta executar o comando make run para se divertir com ele, porém não temos nenhum teste que garante o comportamento do middleware. Assim, podemos criar pelo menos dois testes. O primeiro teste é não enviar um header x-auth para uma rota /api/ e o segundo teste é enviar um token aleatório. Como decode_token possui uma versão para feature db-test, o resultado será sempre um user válido. Assim, vamos ao primeiro teste:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod middleware {
    use actix_web::{test, App, http::StatusCode};
    use dotenv::dotenv;
    use todo_server::todo_api_web::model::http::Clients;
    use todo_server::todo_api_web::routes::app_routes;

    use crate::helpers::read_json;

    #[actix_rt::test]
    async fn bad_request_todo_post() {
        dotenv().ok();
        let mut app =
            test::init_service(
                App::new()
                .data(Clients::new())
                .wrap(todo_server::todo_api_web::middleware::Authentication)
                .configure(app_routes)
            ).await;

        let req = test::TestRequest::post()
            .uri("/api/create")
            .header("Content-Type", "application/json")
            .set_payload(read_json("post_todo.json").as_bytes().to_owned())
            .to_request();

        let resp = test::call_service(&mut app, req).await;
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }
}
}

Este teste é práticamente igual ao teste que criamos uma todo_card, porém possui a função .wrap(todo_server::todo_api_web::middleware::Authentication) associada a App e em vez de validar a resposta valida o status como BAD_REQUEST. Depois disso, podemos criar um teste que adiciona um header x-auth com um Jwt contendo valores aleatórios para nossos campos:

{
  "id": "7562bf53-6156-433b-a201-90bbc74b0127",
  "email": "my@email.com",
  "expires_at": "2014-11-28T12:00:09"
}

Algoritmo HS256 com chave `your-256-bit-==secret`

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijc1NjJiZjUzLTYxNTYtNDMzYi1hMjAxLTkwYmJjNzRiMDEyNyIsImVtYWlsIjoibXlAZW1haWwuY29tIiwiZXhwaXJlc19hdCI6IjMwMjAtMTEtMjhUMTI6MDA6MDkifQ.hom6KvmmLIuu3dLCSUrOK9KBWyUb0fvdX4hIay52UIY

O teste para estes valores é:

#![allow(unused)]
fn main() {
#[actix_rt::test]
async fn good_token_todo_post() {
    dotenv().ok();
    let mut app =
        test::init_service(
            App::new()
            .data(Clients::new())
            .wrap(todo_server::todo_api_web::middleware::Authentication)
            .configure(app_routes)
        ).await;

    let req = test::TestRequest::post()
        .uri("/api/create")
        .header("Content-Type", "application/json")
        .header("x-auth", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijc1NjJiZjUzLTYxNTYtNDMzYi1hMjAxLTkwYmJjNzRiMDEyNyIsImVtYWlsIjoibXlAZW1haWwuY29tIiwiZXhwaXJlc19hdCI6IjMwMjAtMTEtMjhUMTI6MDA6MDkifQ.hom6KvmmLIuu3dLCSUrOK9KBWyUb0fvdX4hIay52UIY")
        .set_payload(read_json("post_todo.json").as_bytes().to_owned())
        .to_request();

    let resp = test::call_service(&mut app, req).await;
    println!("{:?}", resp);
    assert_eq!(resp.status(), StatusCode::CREATED);
}
}

Se executarmos este teste vamos receber como resposta um panic!, pois o middleware vai conter um is_active false que vai se encadear para um Err. Assim, precisaremos fazer algumas modificações em scan_user, User.from e handle de JwtValue. Com isso, as modificações serão em ordem de encadeamento:

#![allow(unused)]
fn main() {
// src/todo_api/model/core.rs
impl Handler<JwtValue> for DbExecutor {
    type Result = bool;

    #[cfg(not(feature = "dbtest"))]
    fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result {
        use crate::todo_api::db::auth::scan_user;

        let user = scan_user(String::from(&msg.email), &self.0.get().expect("Failed to open connection"));
        match user {
            Err(_) => false,
            Ok(user) => {
                match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) {
                    (true, true, true) => true,
                    _ => false
                }
            }
        }
    }

    #[cfg(feature = "dbtest")]
    fn handle(&mut self, msg: JwtValue, _: &mut Self::Context) -> Self::Result {
        use crate::todo_api::db::auth::test_scan_user;

        let user = test_scan_user(String::from(&msg.email), String::from(&msg.id), &self.0.get().expect("Failed to open connection"));
        match user {
            Err(_) => false,
            Ok(user) => {
                match (user.is_active, validate_jwt_date(user.expires_at), user.id.to_string() == msg.id) {
                    (true, true, true) => true,
                    _ => false
                }
            }
        }
    }
}

// src/todo_api/db/auth.rs
#[cfg(not(feature = "dbtest"))]
pub fn scan_user(user_email: String, conn: &PgConnection) -> Result<User, DbError> {
    use crate::schema::auth_user::dsl::*;

    let items = auth_user.filter(email.eq(&user_email)).load::<User>(conn);

    match items {
        Ok(users) if users.len() > 1 => Err(DbError::DatabaseConflit),
        Ok(users) if users.len() < 1 => Err(DbError::CannotFindUser),
        Ok(users) => Ok(users.first().unwrap().clone().to_owned()),
        Err(_) => Err(DbError::CannotFindUser),
    }
}

#[cfg(feature = "dbtest")]
pub fn scan_user(user_email: String, _conn: &PgConnection) -> Result<User, DbError> {
    use crate::schema::auth_user::dsl::*;
    use diesel::debug_query;
    use diesel::pg::Pg;
    let query = auth_user.filter(email.eq(&user_email));
    let expected = "SELECT \"auth_user\".\"email\", \"auth_user\".\"id\", \"auth_user\".\"password\", \"auth_user\".\"expires_at\", \"auth_user\".\"is_active\" FROM \"auth_user\" WHERE \"auth_user\".\"email\" = $1 -- binds: [\"my@email.com\"]".to_string();

    assert_eq!(debug_query::<Pg, _>(&query).to_string(), expected);
    Ok(User::from(user_email, "this is a hash".to_string()))
}

#[cfg(feature = "dbtest")]
pub fn test_scan_user(user_email: String, auth_id: String, _conn: &PgConnection) -> Result<User, DbError> {
    Ok(User::test_from(user_email, "this is a hash".to_string(), auth_id))
}

// src/todo_api/model/auth.rs
impl User {
    pub fn from(email: String, password: String) -> Self {
        let utc = crate::todo_api::db::helpers::one_day_from_now();

        Self {
            email: email,
            id: uuid::Uuid::new_v4(),
            password: password,
            expires_at: utc.naive_utc(),
            is_active: false,
        }
    }

    #[cfg(feature = "dbtest")]
    pub fn test_from(email: String, password: String, id: String) -> Self {
        let utc = crate::todo_api::db::helpers::one_day_from_now();

        Self {
            email: email,
            id: uuid::Uuid::parse_str(&id).unwrap(),
            password: password,
            expires_at: utc.naive_utc(),
            is_active: true,
        }
    }
    // ...
}
}

Precisamos modificar handle pois precisamos enviar id como argumento para validar seu valor posteriormente. Como enviamos id, precisamos modificar scan_user para criar um test_scan_user que receba o id como argumento e passe para um User::from que também suporte configurar id e definir is_active como true. Com isso, todos nossos testes passam e podemos prosseguir para os últimos passos, criar um CI, obter um todo pelo seu id e fazer update de um todo.