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
Loggerpara incluir logs ao processamento do nosso request e o middleware deDefaultHeaderspara 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:
- Usuário está ativo com
user.is_active. - Data atual é inferior a data
expires_atdo token comvalidate_jwt_date(user.expires_at). - 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.