diff --git a/services/reports/src/http/handlers.rs b/services/reports/src/http/handlers.rs index 7ffbc9b5a..35f6a7152 100644 --- a/services/reports/src/http/handlers.rs +++ b/services/reports/src/http/handlers.rs @@ -1,92 +1,93 @@ use actix_web::{get, post, web, HttpResponse}; use comm_lib::http::auth::get_comm_authentication_middleware as auth_middleware; +use comm_lib::http::auth_service::Authenticated; use http::header; use serde::Deserialize; use super::NotFoundHandler; use crate::report_types::ReportInput; use crate::service::ReportsService; /// POST endpoint accepts either a single report Object /// or an array of reports #[derive(Debug, Deserialize)] #[serde(untagged)] enum PostReportsPayload { Single(ReportInput), Multiple(Vec), } impl PostReportsPayload { pub fn into_vec(self) -> Vec { match self { Self::Single(report) => vec![report], Self::Multiple(reports) => reports, } } } #[post("")] async fn post_reports( payload: web::Json, - service: ReportsService, + service: Authenticated, ) -> actix_web::Result { use serde_json::json; let payload = payload.into_inner(); let ids = service.save_reports(payload.into_vec()).await?; let response = HttpResponse::Created().json(json!({ "reportIDs": ids })); Ok(response) } #[derive(Debug, Deserialize)] struct QueryOptions { cursor: Option, page_size: Option, // there can be more options here in the future // e.g. filter by platform, report type, user, etc. } #[get("", wrap = "auth_middleware()")] async fn query_reports( query: web::Query, - service: ReportsService, + service: Authenticated, ) -> actix_web::Result { let QueryOptions { cursor, page_size } = query.into_inner(); let page = service.list_reports(cursor, page_size).await?; let response = HttpResponse::Ok().json(page); Ok(response) } #[get("/{report_id}", wrap = "auth_middleware()")] async fn get_single_report( path: web::Path, - service: ReportsService, + service: Authenticated, ) -> actix_web::Result { let report_id = path.into_inner(); let report = service .get_report(report_id.into()) .await? .unwrap_or_404()?; let response = HttpResponse::Ok().json(report); Ok(response) } #[get("/{report_id}/redux-devtools.json", wrap = "auth_middleware()")] async fn redux_devtools_import( path: web::Path, - service: ReportsService, + service: Authenticated, ) -> actix_web::Result { let report_id = path.into_inner(); let devtools_json = service .get_redux_devtools_import(report_id.clone().into()) .await? .unwrap_or_404()?; let response = HttpResponse::Ok() .insert_header(( header::CONTENT_DISPOSITION, format!("attachment; filename=report-{}.json", report_id), )) .json(devtools_json); Ok(response) } diff --git a/services/reports/src/http/service.rs b/services/reports/src/http/service.rs index 64e2ccafd..5f5b7a34e 100644 --- a/services/reports/src/http/service.rs +++ b/services/reports/src/http/service.rs @@ -1,82 +1,14 @@ -use actix_web::FromRequest; -use comm_lib::auth::{ - is_csat_verification_disabled, AuthService, AuthorizationCredential, +use comm_lib::{ + auth::AuthorizationCredential, http::auth_service::HttpAuthenticatedService, }; -use std::{future::Future, pin::Pin}; -use tracing::{error, warn}; use crate::service::ReportsService; -impl FromRequest for ReportsService { - type Error = actix_web::Error; - type Future = Pin>>>; - - #[inline] - fn from_request( - req: &actix_web::HttpRequest, - payload: &mut actix_web::dev::Payload, - ) -> Self::Future { - use actix_web::error::{ErrorForbidden, ErrorInternalServerError}; - - let base_service = - req.app_data::().cloned().ok_or_else(|| { - tracing::error!( - "FATAL! Failed to extract ReportsService from actix app_data. \ - Check HTTP server configuration" - ); - ErrorInternalServerError("Internal server error") - }); - - let auth_service = AuthService::from_request(req, payload).into_inner(); - let request_auth_value = - AuthorizationCredential::from_request(req, payload).into_inner(); - - Box::pin(async move { - let auth_service = auth_service?; - let base_service = base_service?; - - let credential = request_auth_value.ok(); - - // This is Some if the request contains valid Authorization header - let auth_token = match credential { - Some(token @ AuthorizationCredential::UserToken(_)) => { - let token_valid = auth_service - .verify_auth_credential(&token) - .await - .map_err(|err| { - error!("Failed to verify access token: {err}"); - ErrorInternalServerError("Internal server error") - })?; - if token_valid || is_csat_verification_disabled() { - token - } else { - warn!("Posting report with invalid credentials! Defaulting to ServicesToken..."); - get_services_token_credential(&auth_service).await? - } - } - Some(_) => { - // Reports service shouldn't be called by other services - warn!("Reports service requires user authorization"); - return Err(ErrorForbidden("Forbidden")); - } - None => { - // Unauthenticated requests get a service-to-service token - get_services_token_credential(&auth_service).await? - } - }; - let service = base_service.with_authentication(auth_token); - Ok(service) - }) +impl HttpAuthenticatedService for ReportsService { + fn make_authenticated( + self, + auth_credential: AuthorizationCredential, + ) -> Self { + self.with_authentication(auth_credential) } } - -async fn get_services_token_credential( - auth_service: &AuthService, -) -> Result { - let services_token = - auth_service.get_services_token().await.map_err(|err| { - error!("Failed to get services token: {err}"); - actix_web::error::ErrorInternalServerError("Internal server error") - })?; - Ok(AuthorizationCredential::ServicesToken(services_token)) -} diff --git a/shared/comm-lib/src/auth/service.rs b/shared/comm-lib/src/auth/service.rs index 7d0c79942..7a9d087a9 100644 --- a/shared/comm-lib/src/auth/service.rs +++ b/shared/comm-lib/src/auth/service.rs @@ -1,164 +1,170 @@ use aws_sdk_secretsmanager::Client as SecretsManagerClient; use chrono::{DateTime, Duration, Utc}; use grpc_clients::identity::unauthenticated::client as identity_client; -use super::{AuthorizationCredential, ServicesAuthToken, UserIdentity}; +use super::{ + is_csat_verification_disabled, AuthorizationCredential, ServicesAuthToken, + UserIdentity, +}; const SECRET_NAME: &str = "servicesToken"; /// duration for which we consider previous token valid /// after rotation const ROTATION_PROTECTION_PERIOD: i64 = 3; // seconds // AWS managed version tags for secrets const AWSCURRENT: &str = "AWSCURRENT"; const AWSPREVIOUS: &str = "AWSPREVIOUS"; // Identity service gRPC clients require a code version and device type. // We can supply some placeholder values for services for the time being, since // this metadata is only relevant for devices. const PLACEHOLDER_CODE_VERSION: u64 = 0; const DEVICE_TYPE: &str = "service"; #[derive( Debug, derive_more::Display, derive_more::Error, derive_more::From, )] pub enum AuthServiceError { SecretManagerError(aws_sdk_secretsmanager::Error), GrpcError(grpc_clients::error::Error), Unexpected, } type AuthServiceResult = Result; /// This service is responsible for handling request authentication. /// For HTTP services, it should be added as app data to the server: /// ```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) /// // ... /// ``` #[derive(Clone)] pub struct AuthService { secrets_manager: SecretsManagerClient, identity_service_url: String, } impl AuthService { pub fn new( aws_cfg: &aws_config::SdkConfig, identity_service_url: impl Into, ) -> Self { let secrets_client = SecretsManagerClient::new(aws_cfg); AuthService { secrets_manager: secrets_client, identity_service_url: identity_service_url.into(), } } /// Obtains a service-to-service token which can be used to authenticate /// when calling other services endpoints. It should be only used when /// no [`UserIdentity`] is provided from client pub async fn get_services_token( &self, ) -> AuthServiceResult { get_services_token_version(&self.secrets_manager, AWSCURRENT) .await .map_err(AuthServiceError::from) } /// Verifies the provided [`AuthorizationCredential`]. Returns `true` if - /// authentication was successful. + /// authentication was successful or CSAT verification is disabled. pub async fn verify_auth_credential( &self, credential: &AuthorizationCredential, ) -> AuthServiceResult { + if is_csat_verification_disabled() { + return Ok(true); + } match credential { AuthorizationCredential::UserToken(user) => { let UserIdentity { user_id, device_id, access_token, } = user; identity_client::verify_user_access_token( &self.identity_service_url, user_id, device_id, access_token, PLACEHOLDER_CODE_VERSION, DEVICE_TYPE.to_string(), ) .await .map_err(AuthServiceError::from) } AuthorizationCredential::ServicesToken(token) => { verify_services_token(&self.secrets_manager, token) .await .map_err(AuthServiceError::from) } } } } async fn get_services_token_version( client: &SecretsManagerClient, version: impl Into, ) -> Result { let result = client .get_secret_value() .secret_id(SECRET_NAME) .version_stage(version) .send() .await?; let token = result .secret_string() .expect("Services token secret is not a string. This should not happen"); Ok(ServicesAuthToken::new(token.to_string())) } async fn time_since_rotation( client: &SecretsManagerClient, ) -> Result, aws_sdk_secretsmanager::Error> { let result = client .describe_secret() .secret_id(SECRET_NAME) .send() .await?; let duration = result .last_rotated_date() .and_then(|date| date.to_millis().ok()) .and_then(DateTime::from_timestamp_millis) .map(|last_rotated| Utc::now().signed_duration_since(last_rotated)); Ok(duration) } async fn verify_services_token( client: &SecretsManagerClient, token_to_verify: &ServicesAuthToken, ) -> Result { let actual_token = get_services_token_version(client, AWSCURRENT).await?; // we need to always get it to achieve constant time eq let last_rotated = time_since_rotation(client).await?; let was_recently_rotated = last_rotated .filter(|rotation_time| { *rotation_time < Duration::seconds(ROTATION_PROTECTION_PERIOD) }) .is_some(); let is_valid = *token_to_verify == actual_token; // token might have just been rotated. In this case check the previous token // this case makes the function non-constant time, but it happens very rarely if !is_valid && was_recently_rotated { let previous_token = get_services_token_version(client, AWSPREVIOUS).await?; let previous_valid = *token_to_verify == previous_token; return Ok(previous_valid); } Ok(is_valid) } diff --git a/shared/comm-lib/src/http.rs b/shared/comm-lib/src/http.rs index a65985f2e..f0d629692 100644 --- a/shared/comm-lib/src/http.rs +++ b/shared/comm-lib/src/http.rs @@ -1,34 +1,35 @@ pub mod auth; +pub mod auth_service; pub mod multipart; use crate::tools::BoxedError; use actix_cors::Cors; use actix_web::web::Bytes; use futures_core::Stream; pub fn cors_config(is_sandbox: bool) -> Cors { // For local development, use relaxed CORS config if is_sandbox { // All origins, methods, request headers and exposed headers allowed. // Credentials supported. Max age 1 hour. Does not send wildcard. return Cors::permissive(); } Cors::default() .allowed_origin("https://web.comm.app") // for local development using prod service .allowed_origin("http://localhost:3000") .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) .allow_any_header() .expose_any_header() } // Trait type aliases aren't supported in Rust, but // we can workaround this by creating an empty trait // that extends the traits we want to alias. #[rustfmt::skip] pub trait ByteStream: Stream> {} #[rustfmt::skip] impl ByteStream for T where T: Stream> {} diff --git a/shared/comm-lib/src/http/auth_service.rs b/shared/comm-lib/src/http/auth_service.rs new file mode 100644 index 000000000..afded75ac --- /dev/null +++ b/shared/comm-lib/src/http/auth_service.rs @@ -0,0 +1,182 @@ +use std::any::type_name; +use std::pin::Pin; +use std::{future::Future, ops::Deref}; + +use actix_web::HttpRequest; +use actix_web::{ + error::{ErrorForbidden, ErrorInternalServerError}, + web, FromRequest, +}; +use tracing::{error, warn}; + +use crate::auth::{AuthService, AuthorizationCredential}; + +/// Service that can be stored in HTTP server app data: `App::app_data()` +/// and requires request authentication to work. The app data +/// should contain the default (unauthenticated) instance of the service. +/// The service is cloned with each request and fed with authentication token. +/// +/// Services of this type can be retrieved in request handlers +/// with the `Authenticated` extractor. See ['Authenticated'] +/// for more details. +pub trait HttpAuthenticatedService: Clone { + /// Supplies base (unauthenticated) service with [`AuthorizationCredential`] + /// and returns new authenticated instance. + fn make_authenticated(self, auth_credential: AuthorizationCredential) + -> Self; + + /// Whether service should accept requests authenticated with + /// a service-to-service token (callable by other services). + /// If false, service can only be called with `UserIdentity` token. + fn accepts_services_token(&self, _req: &HttpRequest) -> bool { + false + } + + /// If the service should fall back to service-to-service token, + /// when user credentials are invalid. Default is `true`. + /// If you want to fail requests, prefer hiding endpoints behind + /// [`super::auth::get_comm_authentication_middleware()`] instead. + fn fallback_to_services_token(&self, _req: &HttpRequest) -> bool { + true + } +} + +/// Extractor for services that require HTTP authentication to work. +/// If the endpoint is authenticated, given `UserIdentity` credential is +/// passed to the service. For unauthenticated endpoints, a service-to-service +/// token is retrieved. +/// +/// Note that this does not require making the endpoint authenticated. +/// It only supplies authorization credential to the wrapped service. +/// +/// Wrapped service must be specified in HTTP app data (`App::app_data()`), +/// either directly or via [`web::Data`]. See [`web::Data`] documentation +/// to decide whether to use it or not. +/// +/// # Example +/// ```ignore +/// pub async fn request_handler( +/// some_service: Authenticated, +/// ) -> Result { +/// Ok(HttpResponse::Ok().finish()) +/// } +/// ``` +#[derive(Clone)] +pub struct Authenticated { + inner: S, +} + +impl Authenticated { + /// Retrieves inner authenticated service + pub fn into_inner(self) -> S { + self.inner + } +} + +impl Deref for Authenticated { + type Target = S; + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for Authenticated +where + as Deref>::Target: AsRef, +{ + fn as_ref(&self) -> &S { + self.deref() + } +} + +impl FromRequest for Authenticated { + type Error = actix_web::Error; + type Future = Pin>>>; + + fn from_request( + req: &HttpRequest, + payload: &mut actix_web::dev::Payload, + ) -> Self::Future { + let base_service = req + .app_data::() + .or_else(|| { + // fallback to web::Data for compatibility with existing code + req.app_data::>().map(|it| it.as_ref()) + }) + .cloned() + .ok_or_else(|| { + tracing::error!( + "FATAL! Failed to extract `{}` from actix `App::app_data()`. \ + Check HTTP server configuration!", + type_name::() + ); + ErrorInternalServerError("Internal server error") + }); + + let auth_service = AuthService::from_request(req, payload).into_inner(); + let request_auth_value = + AuthorizationCredential::from_request(req, payload).into_inner(); + + let req = req.clone(); + Box::pin(async move { + let auth_service = auth_service?; + let base_service = base_service?; + + let credential = request_auth_value.ok(); + + // This is Some if the request contains valid Authorization header + let auth_token = match credential { + Some(token @ AuthorizationCredential::UserToken(_)) => { + let token_valid = auth_service + .verify_auth_credential(&token) + .await + .map_err(|err| { + error!("Failed to verify access token: {err}"); + ErrorInternalServerError("Internal server error") + })?; + + match token_valid { + true => token, + false if S::fallback_to_services_token(&base_service, &req) => { + warn!( + "Got {1} request with invalid credentials! {0}", + "Defaulting to ServicesToken...", + type_name::() + ); + get_services_token_credential(&auth_service).await? + } + false => { + return Err(ErrorForbidden("invalid credentials")); + } + } + } + Some(token @ AuthorizationCredential::ServicesToken(_)) => { + if S::accepts_services_token(&base_service, &req) { + token + } else { + // This service shouldn't be called by other services + warn!("{} requests requires user authorization", type_name::()); + return Err(ErrorForbidden("Forbidden")); + } + } + None => { + // Unauthenticated requests get a service-to-service token + get_services_token_credential(&auth_service).await? + } + }; + let service = base_service.make_authenticated(auth_token); + Ok(Authenticated { inner: service }) + }) + } +} + +async fn get_services_token_credential( + auth_service: &AuthService, +) -> Result { + let services_token = + auth_service.get_services_token().await.map_err(|err| { + error!("Failed to get services token: {err}"); + actix_web::error::ErrorInternalServerError("Internal server error") + })?; + Ok(AuthorizationCredential::ServicesToken(services_token)) +}