diff --git a/services/docker-compose.tests.yml b/services/docker-compose.tests.yml index 96b423a2c..e1c27b26f 100644 --- a/services/docker-compose.tests.yml +++ b/services/docker-compose.tests.yml @@ -1,86 +1,88 @@ version: '3.9' volumes: localstack: commtest_build_artifacts: services: commtest: depends_on: - tunnelbroker-server - backup-server - blob-server - identity-server # There are no tests for these services: # - feature-flags-server # - reports-server build: dockerfile: services/commtest/Dockerfile context: ../ platform: '${PLATFORM:-linux/amd64}' volumes: # This one caches build directory and allows to run tests multiple times without rebuilding - commtest_build_artifacts:/home/root/app/commtest/target env_file: test-commons.env environment: # tested services endpoints TUNNELBROKER_WS_ENDPOINT: 'ws://tunnelbroker-server:51001' TUNNELBROKER_GRPC_ENDPOINT: 'http://tunnelbroker-server:${COMM_SERVICES_PORT_TUNNELBROKER}' BACKUP_SERVICE_URL: 'http://backup-server:${COMM_SERVICES_PORT_BACKUP}' BLOB_SERVICE_URL: 'http://blob-server:${COMM_SERVICES_PORT_BLOB}' IDENTITY_GRPC_ENDPOINT: 'http://identity-server:${COMM_SERVICES_PORT_IDENTITY}' # override localstack endpoint in terraform setup TF_VAR_localstack_endpoint: 'http://localstack:4566' # others COMM_NUMBER_OF_THREADS: '4' BLOB_SERVICE_EXECUTABLE: /shared/bin/blob RUST_LOG: blob=trace,comm_lib=debug tunnelbroker-server: image: tunnelbroker pull_policy: build platform: '${PLATFORM:-linux/amd64}' restart: on-failure env_file: test-commons.env environment: COMM_TUNNELBROKER_IDENTITY_ENDPOINT: 'http://identity-server:50054' AMQP_URI: 'amqp://comm:comm@rabbitmq:5672' RUST_LOG: tunnelbroker=trace backup-server: image: backup pull_policy: build platform: '${PLATFORM:-linux/amd64}' env_file: test-commons.env environment: BLOB_SERVICE_URL: 'http://blob-server:50053' + COMM_SERVICES_DISABLE_CSAT_VERIFICATION: 'true' blob-server: image: blob pull_policy: build # Until blob cleanup is supported in tests, enable auto-deletion command: ['blob', 'server', '--instant-delete'] platform: '${PLATFORM:-linux/amd64}' env_file: test-commons.env environment: RUST_LOG: blob=trace,comm_lib=debug + COMM_SERVICES_DISABLE_CSAT_VERIFICATION: 'true' identity-server: image: identity pull_policy: build platform: '${PLATFORM:-linux/amd64}' env_file: test-commons.env environment: TUNNELBROKER_GRPC_ENDPOINT: 'http://tunnelbroker-server:50051' build: args: - generate_keypair=true localstack: environment: - PERSISTENCE=0 rabbitmq: healthcheck: test: rabbitmq-diagnostics -q ping interval: 15s timeout: 10s retries: 5 diff --git a/shared/comm-lib/src/auth/types.rs b/shared/comm-lib/src/auth/types.rs index 1b104d8f4..b51606520 100644 --- a/shared/comm-lib/src/auth/types.rs +++ b/shared/comm-lib/src/auth/types.rs @@ -1,197 +1,210 @@ use base64::{prelude::BASE64_STANDARD, Engine}; use constant_time_eq::constant_time_eq; use derive_more::{Display, Error, From}; use serde::{Deserialize, Serialize}; use std::{str::FromStr, string::FromUtf8Error}; /// This implements [`actix_web::FromRequest`], so it can be used to extract user /// identity information from HTTP requests. /// # Example /// ```ignore /// pub async fn request_handler( /// principal: AuthorizationCredential, /// ) -> Result { /// Ok(HttpResponse::Ok().finish()) /// } /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(untagged)] pub enum AuthorizationCredential { UserToken(UserIdentity), ServicesToken(ServicesAuthToken), } impl AuthorizationCredential { /// Gets the access token value, usable in bearer authorization /// /// # Example /// ```ignore /// reqwest::get("url").beaerer_auth(credential.as_authorization_token()?).send().await?; /// ``` pub fn as_authorization_token(&self) -> Result { match self { AuthorizationCredential::UserToken(user) => user.as_authorization_token(), AuthorizationCredential::ServicesToken(token) => { token.as_authorization_token() } } } } +impl std::fmt::Display for AuthorizationCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthorizationCredential::UserToken(UserIdentity { user_id, .. }) => { + write!(f, "UserTokenCredential(user_id={})", user_id) + } + AuthorizationCredential::ServicesToken(_) => { + write!(f, "ServicesTokenCredential") + } + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, derive_more::Constructor)] pub struct ServicesAuthToken { #[serde(rename = "servicesToken")] token_value: String, } impl ServicesAuthToken { /// Gets the raw token value pub fn into_inner(self) -> String { self.token_value } /// Gets the raw token value pub fn as_str(&self) -> &str { self.token_value.as_str() } /// Gets the access token value, usable in bearer authorization /// /// # Example /// ```ignore /// reqwest::get("url").beaerer_auth(token.as_authorization_token()?).send().await?; /// ``` pub fn as_authorization_token(&self) -> Result { let json = serde_json::to_string(self)?; let base64_str = BASE64_STANDARD.encode(json); Ok(base64_str) } } impl PartialEq for ServicesAuthToken { fn eq(&self, other: &Self) -> bool { constant_time_eq(self.token_value.as_bytes(), other.token_value.as_bytes()) } } /// This implements [`actix_web::FromRequest`], so it can be used to extract user /// identity information from HTTP requests. /// # Example /// ```ignore /// pub async fn request_handler( /// user: UserIdentity, /// ) -> Result { /// Ok(HttpResponse::Ok().finish()) /// } /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct UserIdentity { #[serde(rename = "userID")] pub user_id: String, #[serde(rename = "accessToken")] pub access_token: String, #[serde(rename = "deviceID")] pub device_id: String, } impl UserIdentity { /// Gets the access token value, usable in bearer authorization /// /// # Example /// ```ignore /// reqwest::get("url").beaerer_auth(user.as_authorization_token()?).send().await?; /// ``` pub fn as_authorization_token(&self) -> Result { let json = serde_json::to_string(self)?; let base64_str = BASE64_STANDARD.encode(json); Ok(base64_str) } } #[derive(Debug, Display, Error, From)] pub enum AuthorizationCredentialParseError { Base64DecodeError(base64::DecodeError), Utf8DecodeError(FromUtf8Error), JsonParseError(serde_json::Error), } /// Parsing of [UserIdentity] from bearer token impl FromStr for UserIdentity { type Err = AuthorizationCredentialParseError; fn from_str(s: &str) -> Result { let bytes = BASE64_STANDARD.decode(s)?; let text = String::from_utf8(bytes)?; let user = serde_json::from_str(&text)?; Ok(user) } } /// Parsing of [AuthorizationCredential] from bearer token impl FromStr for AuthorizationCredential { type Err = AuthorizationCredentialParseError; fn from_str(s: &str) -> Result { let bytes = BASE64_STANDARD.decode(s)?; let text = String::from_utf8(bytes)?; let credential = serde_json::from_str(&text)?; Ok(credential) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_user_identity_parsing() { let identity = UserIdentity { user_id: "user".to_string(), access_token: "token".to_string(), device_id: "device".to_string(), }; let json = r#"{"userID": "user", "accessToken": "token", "deviceID": "device"}"#; let encoded = BASE64_STANDARD.encode(json); let parsed_identity = encoded.parse::(); assert!(parsed_identity.is_ok(), "Parse error: {parsed_identity:?}"); assert_eq!(parsed_identity.unwrap(), identity); } #[test] fn test_user_credential_parsing() { let identity = UserIdentity { user_id: "user".to_string(), access_token: "token".to_string(), device_id: "device".to_string(), }; let json = r#"{"userID": "user", "accessToken": "token", "deviceID": "device"}"#; let encoded = BASE64_STANDARD.encode(json); let parsed_identity = encoded.parse::(); assert!(parsed_identity.is_ok(), "Parse error: {parsed_identity:?}"); assert_eq!( parsed_identity.unwrap(), AuthorizationCredential::UserToken(identity) ); } #[test] fn test_services_token_parsing() { let token = ServicesAuthToken::new("hello".to_string()); let json = r#"{"servicesToken": "hello"}"#; let encoded = BASE64_STANDARD.encode(json); let parsed_identity = encoded.parse::(); assert!(parsed_identity.is_ok(), "Parse error: {parsed_identity:?}"); assert_eq!( parsed_identity.unwrap(), AuthorizationCredential::ServicesToken(token) ); } } diff --git a/shared/comm-lib/src/http/auth.rs b/shared/comm-lib/src/http/auth.rs index 8df83bac2..22e7c5897 100644 --- a/shared/comm-lib/src/http/auth.rs +++ b/shared/comm-lib/src/http/auth.rs @@ -1,176 +1,194 @@ use actix_web::{ body::{EitherBody, MessageBody}, dev::{Service, ServiceRequest, ServiceResponse, Transform}, + error::{ErrorForbidden, ErrorInternalServerError}, FromRequest, HttpMessage, }; use actix_web_httpauth::{ extractors::{bearer::BearerAuth, AuthenticationError}, headers::www_authenticate::bearer::Bearer, middleware::HttpAuthentication, }; use futures_util::future::{ready, Ready}; use http::StatusCode; use std::str::FromStr; use tracing::debug; use crate::auth::{ - is_csat_verification_disabled, AuthorizationCredential, UserIdentity, + is_csat_verification_disabled, AuthService, AuthorizationCredential, + UserIdentity, }; impl FromRequest for AuthorizationCredential { type Error = actix_web::Error; type Future = Ready>; fn from_request( req: &actix_web::HttpRequest, _: &mut actix_web::dev::Payload, ) -> Self::Future { if let Some(credential) = req.extensions().get::() { return ready(Ok(credential.clone())); } let f = || { let bearer = BearerAuth::extract(req).into_inner()?; let credential = match AuthorizationCredential::from_str(bearer.token()) { Ok(credential) => credential, Err(err) => { debug!("HTTP authorization error: {err}"); return Err(AuthenticationError::new(Bearer::default()).into()); } }; Ok(credential) }; ready(f()) } } impl FromRequest for UserIdentity { type Error = actix_web::Error; type Future = Ready>; fn from_request( req: &actix_web::HttpRequest, payload: &mut actix_web::dev::Payload, ) -> Self::Future { use futures_util::future::{err, ok}; match AuthorizationCredential::from_request(req, payload).into_inner() { Ok(AuthorizationCredential::UserToken(user)) => ok(user.clone()), Ok(_) => { debug!("Authorization provided, but it's not UserIdentity"); let mut error = AuthenticationError::new(Bearer::default()); *error.status_code_mut() = StatusCode::FORBIDDEN; err(error.into()) } Err(e) => err(e), } } } /// Counterpart of [`actix_web_httpauth::extractors::bearer::BearerAuth`] that /// handles parsing Authorization header into [`AuthorizationCredential`]. /// The value can be `None` when CSAT verification is disabled. #[derive(Clone, Debug)] struct CommServicesBearerAuth { credential: Option, } impl FromRequest for CommServicesBearerAuth { type Error = actix_web::Error; type Future = Ready>; fn from_request( req: &actix_web::HttpRequest, _payload: &mut actix_web::dev::Payload, ) -> Self::Future { use futures_util::future::{err, ok}; if is_csat_verification_disabled() { return ok(Self { credential: None }); } match AuthorizationCredential::extract(req).into_inner() { Ok(credential) => ok(Self { credential: Some(credential), }), Err(e) => err(e), } } } /// Function used by auth middleware to validate authenticated requests. async fn middleware_validation_function( req: ServiceRequest, auth: CommServicesBearerAuth, ) -> Result { let Some(credential) = auth.credential else { return if is_csat_verification_disabled() { Ok(req) } else { // This branch should be normally unreachable. If this happens, // it means that `MiddlewareCredentialExtractor::from_request()` // implementation is incorrect. tracing::error!( "CSAT verification enabled, but no credential was extracted!" ); let mut error = AuthenticationError::new(Bearer::default()); *error.status_code_mut() = StatusCode::INTERNAL_SERVER_ERROR; Err((error.into(), req)) }; }; - // TODO: call identity service, for now just allow every request - req.extensions_mut().insert(credential); + let auth_service = req + .app_data::() + .expect("FATAL: missing AuthService app data. Check HTTP server config."); + match auth_service.verify_auth_credential(&credential).await { + Ok(true) => tracing::trace!("Request is authenticated with {credential}"), + Ok(false) => { + tracing::trace!("Request is not authenticated. Token: {credential:?}"); + // allow for invalid tokens if verification is disabled + if !is_csat_verification_disabled() { + return Err((ErrorForbidden("invalid credentials"), req)); + } + } + Err(err) => { + tracing::error!("Error verifying auth credential: {err}"); + return Err((ErrorInternalServerError("internal error"), req)); + } + }; + req.extensions_mut().insert(credential); Ok(req) } /// Use this to add Authentication Middleware. It's going to parse Authorization /// header and call the identity service to check if the provided credentials /// are correct. If not it's going to reject the request. /// Note that this requires `AuthService` to be present in the app data. /// /// # Example /// ```ignore /// let auth_service = AuthService::new(&aws_config, &config.identity_endpoint); /// let auth_middleware = get_comm_authentication_middleware(); /// App::new() /// .app_data(auth_service.clone()) /// .wrap(auth_middleware) /// ``` /// If you don't want all of the routes to require authentication you can wrap /// individual resources or scopes: /// ```ignore /// App::new().service( /// web::resource("/endpoint").route(web::get().to(handler)).wrap(auth_middleware), /// ) /// ``` // This type is very complicated, but unfortunately typing this directly // requires https://github.com/rust-lang/rust/issues/99697 to be merged. // The issue is that we can't specify the second generic argument of // HttpAuthentication, because it look something like this: // ``` // impl Fn(ServiceRequest, BearerAuth) -> impl Future< // Output = Result, // > // `` // which isn't valid (until the linked issue is merged). pub fn get_comm_authentication_middleware() -> impl Transform< S, ServiceRequest, Response = ServiceResponse>, Error = actix_web::Error, InitError = (), > + Clone + 'static where B: MessageBody + 'static, S: Service< ServiceRequest, Response = ServiceResponse, Error = actix_web::Error, > + 'static, { HttpAuthentication::with_fn(middleware_validation_function) }