refactor(authentication): authenticate merchant by API keys from API keys table (#712)

This commit is contained in:
Sanchith Hegde
2023-03-08 14:34:22 +05:30
committed by GitHub
parent 1a27facaa7
commit afd08d42c7
8 changed files with 129 additions and 47 deletions

View File

@ -1,10 +1,8 @@
use common_utils::{date_time, errors::CustomResult, fp_utils}; use common_utils::date_time;
use error_stack::{report, IntoReport, ResultExt}; use error_stack::{report, IntoReport, ResultExt};
use masking::{PeekInterface, Secret, StrongSecret}; use masking::{PeekInterface, StrongSecret};
use router_env::{instrument, tracing}; use router_env::{instrument, tracing};
#[cfg(feature = "kms")]
use crate::services::kms;
use crate::{ use crate::{
configs::settings, configs::settings,
consts, consts,
@ -14,6 +12,8 @@ use crate::{
types::{api, storage, transformers::ForeignInto}, types::{api, storage, transformers::ForeignInto},
utils, utils,
}; };
#[cfg(feature = "kms")]
use crate::{routes::metrics, services::kms};
pub static HASH_KEY: tokio::sync::OnceCell<StrongSecret<[u8; PlaintextApiKey::HASH_KEY_LEN]>> = pub static HASH_KEY: tokio::sync::OnceCell<StrongSecret<[u8; PlaintextApiKey::HASH_KEY_LEN]>> =
tokio::sync::OnceCell::const_new(); tokio::sync::OnceCell::const_new();
@ -28,6 +28,10 @@ pub async fn get_hash_key(
api_key_config.kms_encrypted_hash_key.clone(), api_key_config.kms_encrypted_hash_key.clone(),
) )
.await .await
.map_err(|error| {
metrics::AWS_KMS_FAILURES.add(&metrics::CONTEXT, 1, &[]);
error
})
.change_context(errors::ApiErrorResponse::InternalServerError) .change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to KMS decrypt API key hashing key")?; .attach_printable("Failed to KMS decrypt API key hashing key")?;
@ -49,11 +53,13 @@ pub async fn get_hash_key(
// Defining new types `PlaintextApiKey` and `HashedApiKey` in the hopes of reducing the possibility // Defining new types `PlaintextApiKey` and `HashedApiKey` in the hopes of reducing the possibility
// of plaintext API key being stored in the data store. // of plaintext API key being stored in the data store.
pub struct PlaintextApiKey(Secret<String>); pub struct PlaintextApiKey(StrongSecret<String>);
#[derive(Debug, PartialEq, Eq)]
pub struct HashedApiKey(String); pub struct HashedApiKey(String);
impl PlaintextApiKey { impl PlaintextApiKey {
pub const HASH_KEY_LEN: usize = 32; const HASH_KEY_LEN: usize = 32;
const PREFIX_LEN: usize = 12; const PREFIX_LEN: usize = 12;
@ -107,22 +113,6 @@ impl PlaintextApiKey {
.to_string(), .to_string(),
) )
} }
pub fn verify_hash(
&self,
key: &[u8; Self::HASH_KEY_LEN],
stored_api_key: &HashedApiKey,
) -> CustomResult<(), errors::ApiKeyError> {
// Converting both hashes to `blake3::Hash` since it provides constant-time equality checks
let provided_api_key_hash = blake3::keyed_hash(key, self.0.peek().as_bytes());
let stored_api_key_hash = blake3::Hash::from_hex(&stored_api_key.0)
.into_report()
.change_context(errors::ApiKeyError::FailedToReadHashFromHex)?;
fp_utils::when(provided_api_key_hash != stored_api_key_hash, || {
Err(errors::ApiKeyError::HashVerificationFailed).into_report()
})
}
} }
#[instrument(skip_all)] #[instrument(skip_all)]
@ -165,7 +155,7 @@ pub async fn retrieve_api_key(
key_id: &str, key_id: &str,
) -> RouterResponse<api::RetrieveApiKeyResponse> { ) -> RouterResponse<api::RetrieveApiKeyResponse> {
let api_key = store let api_key = store
.find_api_key_optional(key_id) .find_api_key_by_key_id_optional(key_id)
.await .await
.change_context(errors::ApiErrorResponse::InternalServerError) // If retrieve failed .change_context(errors::ApiErrorResponse::InternalServerError) // If retrieve failed
.attach_printable("Failed to retrieve new API key")? .attach_printable("Failed to retrieve new API key")?
@ -224,12 +214,30 @@ pub async fn list_api_keys(
Ok(ApplicationResponse::Json(api_keys)) Ok(ApplicationResponse::Json(api_keys))
} }
impl From<&str> for PlaintextApiKey {
fn from(s: &str) -> Self {
Self(s.to_owned().into())
}
}
impl From<String> for PlaintextApiKey {
fn from(s: String) -> Self {
Self(s.into())
}
}
impl From<HashedApiKey> for storage::HashedApiKey { impl From<HashedApiKey> for storage::HashedApiKey {
fn from(hashed_api_key: HashedApiKey) -> Self { fn from(hashed_api_key: HashedApiKey) -> Self {
hashed_api_key.0.into() hashed_api_key.0.into()
} }
} }
impl From<storage::HashedApiKey> for HashedApiKey {
fn from(hashed_api_key: storage::HashedApiKey) -> Self {
Self(hashed_api_key.into_inner())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)] #![allow(clippy::expect_used, clippy::unwrap_used)]
@ -251,8 +259,7 @@ mod tests {
hashed_api_key.0.as_bytes() hashed_api_key.0.as_bytes()
); );
plaintext_api_key let new_hashed_api_key = plaintext_api_key.keyed_hash(hash_key.peek());
.verify_hash(hash_key.peek(), &hashed_api_key) assert_eq!(hashed_api_key, new_hashed_api_key)
.unwrap();
} }
} }

View File

@ -414,11 +414,3 @@ pub enum WebhooksFlowError {
#[error("Resource not found")] #[error("Resource not found")]
ResourceNotFound, ResourceNotFound,
} }
#[derive(Debug, thiserror::Error)]
pub enum ApiKeyError {
#[error("Failed to read API key hash from hexadecimal string")]
FailedToReadHashFromHex,
#[error("Failed to verify provided API key hash against stored API key hash")]
HashVerificationFailed,
}

View File

@ -22,11 +22,16 @@ pub trait ApiKeyInterface {
async fn revoke_api_key(&self, key_id: &str) -> CustomResult<bool, errors::StorageError>; async fn revoke_api_key(&self, key_id: &str) -> CustomResult<bool, errors::StorageError>;
async fn find_api_key_optional( async fn find_api_key_by_key_id_optional(
&self, &self,
key_id: &str, key_id: &str,
) -> CustomResult<Option<storage::ApiKey>, errors::StorageError>; ) -> CustomResult<Option<storage::ApiKey>, errors::StorageError>;
async fn find_api_key_by_hash_optional(
&self,
hashed_api_key: storage::HashedApiKey,
) -> CustomResult<Option<storage::ApiKey>, errors::StorageError>;
async fn list_api_keys_by_merchant_id( async fn list_api_keys_by_merchant_id(
&self, &self,
merchant_id: &str, merchant_id: &str,
@ -69,7 +74,7 @@ impl ApiKeyInterface for Store {
.into_report() .into_report()
} }
async fn find_api_key_optional( async fn find_api_key_by_key_id_optional(
&self, &self,
key_id: &str, key_id: &str,
) -> CustomResult<Option<storage::ApiKey>, errors::StorageError> { ) -> CustomResult<Option<storage::ApiKey>, errors::StorageError> {
@ -80,6 +85,17 @@ impl ApiKeyInterface for Store {
.into_report() .into_report()
} }
async fn find_api_key_by_hash_optional(
&self,
hashed_api_key: storage::HashedApiKey,
) -> CustomResult<Option<storage::ApiKey>, errors::StorageError> {
let conn = pg_connection(&self.master_pool).await?;
storage::ApiKey::find_optional_by_hashed_api_key(&conn, hashed_api_key)
.await
.map_err(Into::into)
.into_report()
}
async fn list_api_keys_by_merchant_id( async fn list_api_keys_by_merchant_id(
&self, &self,
merchant_id: &str, merchant_id: &str,
@ -118,7 +134,7 @@ impl ApiKeyInterface for MockDb {
Err(errors::StorageError::MockDbError)? Err(errors::StorageError::MockDbError)?
} }
async fn find_api_key_optional( async fn find_api_key_by_key_id_optional(
&self, &self,
_key_id: &str, _key_id: &str,
) -> CustomResult<Option<storage::ApiKey>, errors::StorageError> { ) -> CustomResult<Option<storage::ApiKey>, errors::StorageError> {
@ -126,6 +142,14 @@ impl ApiKeyInterface for MockDb {
Err(errors::StorageError::MockDbError)? Err(errors::StorageError::MockDbError)?
} }
async fn find_api_key_by_hash_optional(
&self,
_hashed_api_key: storage::HashedApiKey,
) -> CustomResult<Option<storage::ApiKey>, errors::StorageError> {
// [#172]: Implement function for `MockDb`
Err(errors::StorageError::MockDbError)?
}
async fn list_api_keys_by_merchant_id( async fn list_api_keys_by_merchant_id(
&self, &self,
_merchant_id: &str, _merchant_id: &str,

View File

@ -5,11 +5,12 @@ use router_env::opentelemetry::{
Context, Context,
}; };
use crate::create_counter;
pub static CONTEXT: Lazy<Context> = Lazy::new(Context::current); pub static CONTEXT: Lazy<Context> = Lazy::new(Context::current);
static GLOBAL_METER: Lazy<Meter> = Lazy::new(|| global::meter("ROUTER_API")); static GLOBAL_METER: Lazy<Meter> = Lazy::new(|| global::meter("ROUTER_API"));
pub(crate) static HEALTH_METRIC: Lazy<Counter<u64>> = create_counter!(HEALTH_METRIC, GLOBAL_METER); // No. of health API hits
Lazy::new(|| GLOBAL_METER.u64_counter("HEALTH_API").init()); create_counter!(KV_MISS, GLOBAL_METER); // No. of KV misses
#[cfg(feature = "kms")]
pub(crate) static KV_MISS: Lazy<Counter<u64>> = create_counter!(AWS_KMS_FAILURES, GLOBAL_METER); // No. of AWS KMS API failures
Lazy::new(|| GLOBAL_METER.u64_counter("KV_MISS").init());

View File

@ -11,6 +11,7 @@ static PT_METER: Lazy<Meter> = Lazy::new(|| global::meter("PROCESS_TRACKER"));
pub(crate) static CONSUMER_STATS: Lazy<Histogram<f64>> = pub(crate) static CONSUMER_STATS: Lazy<Histogram<f64>> =
Lazy::new(|| PT_METER.f64_histogram("CONSUMER_OPS").init()); Lazy::new(|| PT_METER.f64_histogram("CONSUMER_OPS").init());
#[macro_export]
macro_rules! create_counter { macro_rules! create_counter {
($name:ident, $meter:ident) => { ($name:ident, $meter:ident) => {
pub(crate) static $name: Lazy<Counter<u64>> = pub(crate) static $name: Lazy<Counter<u64>> =

View File

@ -1,11 +1,16 @@
use actix_web::http::header::HeaderMap; use actix_web::http::header::HeaderMap;
use api_models::{payment_methods::PaymentMethodListRequest, payments::PaymentsRequest}; use api_models::{payment_methods::PaymentMethodListRequest, payments::PaymentsRequest};
use async_trait::async_trait; use async_trait::async_trait;
use common_utils::date_time;
use error_stack::{report, IntoReport, ResultExt}; use error_stack::{report, IntoReport, ResultExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use masking::PeekInterface;
use crate::{ use crate::{
core::errors::{self, RouterResult}, core::{
api_keys,
errors::{self, RouterResult},
},
db::StorageInterface, db::StorageInterface,
routes::{app::AppStateInfo, AppState}, routes::{app::AppStateInfo, AppState},
services::api, services::api,
@ -38,11 +43,45 @@ where
request_headers: &HeaderMap, request_headers: &HeaderMap,
state: &A, state: &A,
) -> RouterResult<storage::MerchantAccount> { ) -> RouterResult<storage::MerchantAccount> {
let api_key = let api_key = get_api_key(request_headers)
get_api_key(request_headers).change_context(errors::ApiErrorResponse::Unauthorized)?; .change_context(errors::ApiErrorResponse::Unauthorized)?
.trim();
if api_key.is_empty() {
return Err(errors::ApiErrorResponse::Unauthorized)
.into_report()
.attach_printable("API key is empty");
}
let api_key = api_keys::PlaintextApiKey::from(api_key);
let hash_key = {
let config = state.conf();
api_keys::HASH_KEY
.get_or_try_init(|| api_keys::get_hash_key(&config.api_keys))
.await?
};
let hashed_api_key = api_key.keyed_hash(hash_key.peek());
let stored_api_key = state
.store()
.find_api_key_by_hash_optional(hashed_api_key.into())
.await
.change_context(errors::ApiErrorResponse::InternalServerError) // If retrieve failed
.attach_printable("Failed to retrieve API key")?
.ok_or(report!(errors::ApiErrorResponse::Unauthorized)) // If retrieve returned `None`
.attach_printable("Merchant not authenticated")?;
if stored_api_key
.expires_at
.map(|expires_at| expires_at < date_time::now())
.unwrap_or(false)
{
return Err(report!(errors::ApiErrorResponse::Unauthorized))
.attach_printable("API key has expired");
}
state state
.store() .store()
.find_merchant_account_by_api_key(api_key) .find_merchant_account_by_merchant_id(&stored_api_key.merchant_id)
.await .await
.map_err(|e| { .map_err(|e| {
if e.current_context().is_db_not_found() { if e.current_context().is_db_not_found() {

View File

@ -81,6 +81,12 @@ impl From<ApiKeyUpdate> for ApiKeyUpdateInternal {
#[diesel(sql_type = diesel::sql_types::Text)] #[diesel(sql_type = diesel::sql_types::Text)]
pub struct HashedApiKey(String); pub struct HashedApiKey(String);
impl HashedApiKey {
pub fn into_inner(self) -> String {
self.0
}
}
impl From<String> for HashedApiKey { impl From<String> for HashedApiKey {
fn from(hashed_api_key: String) -> Self { fn from(hashed_api_key: String) -> Self {
Self(hashed_api_key) Self(hashed_api_key)

View File

@ -3,7 +3,7 @@ use router_env::{instrument, tracing};
use super::generics; use super::generics;
use crate::{ use crate::{
api_keys::{ApiKey, ApiKeyNew, ApiKeyUpdate, ApiKeyUpdateInternal}, api_keys::{ApiKey, ApiKeyNew, ApiKeyUpdate, ApiKeyUpdateInternal, HashedApiKey},
errors, errors,
schema::api_keys::dsl, schema::api_keys::dsl,
PgPooledConn, StorageResult, PgPooledConn, StorageResult,
@ -65,6 +65,18 @@ impl ApiKey {
.await .await
} }
#[instrument(skip(conn))]
pub async fn find_optional_by_hashed_api_key(
conn: &PgPooledConn,
hashed_api_key: HashedApiKey,
) -> StorageResult<Option<Self>> {
generics::generic_find_one_optional::<<Self as HasTable>::Table, _, _>(
conn,
dsl::hashed_api_key.eq(hashed_api_key),
)
.await
}
#[instrument(skip(conn))] #[instrument(skip(conn))]
pub async fn find_by_merchant_id( pub async fn find_by_merchant_id(
conn: &PgPooledConn, conn: &PgPooledConn,