start introducing new error messages

This commit is contained in:
Tamo 2024-06-24 19:00:53 +02:00
parent 558b66e535
commit a74fb87d1e
2 changed files with 200 additions and 43 deletions

View File

@ -12,6 +12,8 @@ use futures::Future;
use meilisearch_auth::{AuthController, AuthFilter}; use meilisearch_auth::{AuthController, AuthFilter};
use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::error::{Code, ResponseError};
use self::policies::AuthError;
pub struct GuardedData<P, D> { pub struct GuardedData<P, D> {
data: D, data: D,
filters: AuthFilter, filters: AuthFilter,
@ -35,12 +37,12 @@ impl<P, D> GuardedData<P, D> {
let missing_master_key = auth.get_master_key().is_none(); let missing_master_key = auth.get_master_key().is_none();
match Self::authenticate(auth, token, index).await? { match Self::authenticate(auth, token, index).await? {
Some(filters) => match data { Ok(filters) => match data {
Some(data) => Ok(Self { data, filters, _marker: PhantomData }), Some(data) => Ok(Self { data, filters, _marker: PhantomData }),
None => Err(AuthenticationError::IrretrievableState.into()), None => Err(AuthenticationError::IrretrievableState.into()),
}, },
None if missing_master_key => Err(AuthenticationError::MissingMasterKey.into()), Err(_) if missing_master_key => Err(AuthenticationError::MissingMasterKey.into()),
None => Err(AuthenticationError::InvalidToken.into()), Err(e) => Err(ResponseError::from_msg(e.to_string(), Code::InvalidApiKey)),
} }
} }
@ -51,12 +53,12 @@ impl<P, D> GuardedData<P, D> {
let missing_master_key = auth.get_master_key().is_none(); let missing_master_key = auth.get_master_key().is_none();
match Self::authenticate(auth, String::new(), None).await? { match Self::authenticate(auth, String::new(), None).await? {
Some(filters) => match data { Ok(filters) => match data {
Some(data) => Ok(Self { data, filters, _marker: PhantomData }), Some(data) => Ok(Self { data, filters, _marker: PhantomData }),
None => Err(AuthenticationError::IrretrievableState.into()), None => Err(AuthenticationError::IrretrievableState.into()),
}, },
None if missing_master_key => Err(AuthenticationError::MissingMasterKey.into()), Err(_) if missing_master_key => Err(AuthenticationError::MissingMasterKey.into()),
None => Err(AuthenticationError::MissingAuthorizationHeader.into()), Err(_) => Err(AuthenticationError::MissingAuthorizationHeader.into()),
} }
} }
@ -64,7 +66,7 @@ impl<P, D> GuardedData<P, D> {
auth: Data<AuthController>, auth: Data<AuthController>,
token: String, token: String,
index: Option<String>, index: Option<String>,
) -> Result<Option<AuthFilter>, ResponseError> ) -> Result<Result<AuthFilter, AuthError>, ResponseError>
where where
P: Policy + 'static, P: Policy + 'static,
{ {
@ -127,7 +129,7 @@ pub trait Policy {
auth: Data<AuthController>, auth: Data<AuthController>,
token: &str, token: &str,
index: Option<&str>, index: Option<&str>,
) -> Option<AuthFilter>; ) -> Result<AuthFilter, policies::AuthError>;
} }
pub mod policies { pub mod policies {
@ -144,11 +146,32 @@ pub mod policies {
enum TenantTokenOutcome { enum TenantTokenOutcome {
NotATenantToken, NotATenantToken,
Invalid,
Expired,
Valid(Uuid, SearchRules), Valid(Uuid, SearchRules),
} }
#[derive(thiserror::Error, Debug)]
pub enum AuthError {
#[error("Tenant token expired. Was valid up to `{exp}` and we're now `{now}`")]
ExpiredTenantToken { exp: i64, now: i64 },
#[error("The provided API key is invalid.")]
InvalidApiKey,
#[error("The provided tenant token is invalid.")]
InvalidTenantToken,
#[error("Could not decode tenant token, {0}")]
CouldNotDecodeTenantToken(jsonwebtoken::errors::Error),
}
impl From<jsonwebtoken::errors::Error> for AuthError {
fn from(error: jsonwebtoken::errors::Error) -> Self {
use jsonwebtoken::errors::ErrorKind;
match error.kind() {
ErrorKind::InvalidToken => AuthError::InvalidTenantToken,
_ => AuthError::CouldNotDecodeTenantToken(error),
}
}
}
fn tenant_token_validation() -> Validation { fn tenant_token_validation() -> Validation {
let mut validation = Validation::default(); let mut validation = Validation::default();
validation.validate_exp = false; validation.validate_exp = false;
@ -158,15 +181,15 @@ pub mod policies {
} }
/// Extracts the key id used to sign the payload, without performing any validation. /// Extracts the key id used to sign the payload, without performing any validation.
fn extract_key_id(token: &str) -> Option<Uuid> { fn extract_key_id(token: &str) -> Result<Uuid, AuthError> {
let mut validation = tenant_token_validation(); let mut validation = tenant_token_validation();
validation.insecure_disable_signature_validation(); validation.insecure_disable_signature_validation();
let dummy_key = DecodingKey::from_secret(b"secret"); let dummy_key = DecodingKey::from_secret(b"secret");
let token_data = decode::<Claims>(token, &dummy_key, &validation).ok()?; let token_data = decode::<Claims>(token, &dummy_key, &validation)?;
// get token fields without validating it. // get token fields without validating it.
let Claims { api_key_uid, .. } = token_data.claims; let Claims { api_key_uid, .. } = token_data.claims;
Some(api_key_uid) Ok(api_key_uid)
} }
fn is_keys_action(action: u8) -> bool { fn is_keys_action(action: u8) -> bool {
@ -187,76 +210,78 @@ pub mod policies {
auth: Data<AuthController>, auth: Data<AuthController>,
token: &str, token: &str,
index: Option<&str>, index: Option<&str>,
) -> Option<AuthFilter> { ) -> Result<AuthFilter, AuthError> {
// authenticate if token is the master key. // authenticate if token is the master key.
// Without a master key, all routes are accessible except the key-related routes. // Without a master key, all routes are accessible except the key-related routes.
if auth.get_master_key().map_or_else(|| !is_keys_action(A), |mk| mk == token) { if auth.get_master_key().map_or_else(|| !is_keys_action(A), |mk| mk == token) {
return Some(AuthFilter::default()); return Ok(AuthFilter::default());
} }
let (key_uuid, search_rules) = let (key_uuid, search_rules) =
match ActionPolicy::<A>::authenticate_tenant_token(&auth, token) { match ActionPolicy::<A>::authenticate_tenant_token(&auth, token) {
TenantTokenOutcome::Valid(key_uuid, search_rules) => { Ok(TenantTokenOutcome::Valid(key_uuid, search_rules)) => {
(key_uuid, Some(search_rules)) (key_uuid, Some(search_rules))
} }
TenantTokenOutcome::Expired => return None, Ok(TenantTokenOutcome::NotATenantToken)
TenantTokenOutcome::Invalid => return None, | Err(AuthError::InvalidTenantToken) => (
TenantTokenOutcome::NotATenantToken => { auth.get_optional_uid_from_encoded_key(token.as_bytes())
(auth.get_optional_uid_from_encoded_key(token.as_bytes()).ok()??, None) .map_err(|_e| AuthError::InvalidApiKey)?
} .ok_or(AuthError::InvalidApiKey)?,
None,
),
Err(e) => return Err(e),
}; };
// check that the indexes are allowed // check that the indexes are allowed
let action = Action::from_repr(A)?; let action = Action::from_repr(A).ok_or(AuthError::InvalidTenantToken)?;
let auth_filter = auth.get_key_filters(key_uuid, search_rules).ok()?; let auth_filter = auth
.get_key_filters(key_uuid, search_rules)
.map_err(|_e| AuthError::InvalidTenantToken)?;
if auth.is_key_authorized(key_uuid, action, index).unwrap_or(false) if auth.is_key_authorized(key_uuid, action, index).unwrap_or(false)
&& index.map(|index| auth_filter.is_index_authorized(index)).unwrap_or(true) && index.map(|index| auth_filter.is_index_authorized(index)).unwrap_or(true)
{ {
return Some(auth_filter); return Ok(auth_filter);
} }
None Err(AuthError::InvalidApiKey)
} }
} }
impl<const A: u8> ActionPolicy<A> { impl<const A: u8> ActionPolicy<A> {
fn authenticate_tenant_token(auth: &AuthController, token: &str) -> TenantTokenOutcome { fn authenticate_tenant_token(
auth: &AuthController,
token: &str,
) -> Result<TenantTokenOutcome, AuthError> {
// Only search action can be accessed by a tenant token. // Only search action can be accessed by a tenant token.
if A != actions::SEARCH { if A != actions::SEARCH {
return TenantTokenOutcome::NotATenantToken; return Ok(TenantTokenOutcome::NotATenantToken);
} }
let uid = if let Some(uid) = extract_key_id(token) { let uid = extract_key_id(token)?;
uid
} else {
return TenantTokenOutcome::NotATenantToken;
};
// Check if tenant token is valid. // Check if tenant token is valid.
let key = if let Some(key) = auth.generate_key(uid) { let key = if let Some(key) = auth.generate_key(uid) {
key key
} else { } else {
return TenantTokenOutcome::Invalid; /// Only happens when no master key has been set
return Err(AuthError::InvalidTenantToken);
}; };
let data = if let Ok(data) = decode::<Claims>( let data = decode::<Claims>(
token, token,
&DecodingKey::from_secret(key.as_bytes()), &DecodingKey::from_secret(key.as_bytes()),
&tenant_token_validation(), &tenant_token_validation(),
) { )?;
data
} else {
return TenantTokenOutcome::Invalid;
};
// Check if token is expired. // Check if token is expired.
if let Some(exp) = data.claims.exp { if let Some(exp) = data.claims.exp {
if OffsetDateTime::now_utc().unix_timestamp() > exp { let now = OffsetDateTime::now_utc().unix_timestamp();
return TenantTokenOutcome::Expired; if now > exp {
return Err(AuthError::ExpiredTenantToken { exp, now });
} }
} }
TenantTokenOutcome::Valid(uid, data.claims.search_rules) Ok(TenantTokenOutcome::Valid(uid, data.claims.search_rules))
} }
} }

View File

@ -1,7 +1,10 @@
use actix_web::test;
use http::StatusCode;
use jsonwebtoken::{EncodingKey, Header};
use meili_snap::*; use meili_snap::*;
use uuid::Uuid; use uuid::Uuid;
use crate::common::Server; use crate::common::{Server, Value};
use crate::json; use crate::json;
#[actix_rt::test] #[actix_rt::test]
@ -436,3 +439,132 @@ async fn patch_api_keys_unknown_field() {
} }
"###); "###);
} }
async fn send_request_with_custom_auth(
app: impl actix_web::dev::Service<
actix_http::Request,
Response = actix_web::dev::ServiceResponse<impl actix_web::body::MessageBody>,
Error = actix_web::Error,
>,
url: &str,
auth: &str,
) -> (Value, StatusCode) {
let req = test::TestRequest::get().uri(url).insert_header(("Authorization", auth)).to_request();
let res = test::call_service(&app, req).await;
let status_code = res.status();
let body = test::read_body(res).await;
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
(response, status_code)
}
#[actix_rt::test]
async fn invalid_auth_format() {
let server = Server::new_auth().await;
let app = server.init_web_app().await;
let req = test::TestRequest::get().uri("/indexes/dog/documents").to_request();
let res = test::call_service(&app, req).await;
let status_code = res.status();
let body = test::read_body(res).await;
let response: Value = serde_json::from_slice(&body).unwrap_or_default();
snapshot!(status_code, @"401 Unauthorized");
snapshot!(response, @r###"
{
"message": "The Authorization header is missing. It must use the bearer authorization method.",
"code": "missing_authorization_header",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#missing_authorization_header"
}
"###);
let (response, status_code) =
send_request_with_custom_auth(&app, "/indexes/dog/documents", "Bearer").await;
snapshot!(status_code, @"403 Forbidden");
snapshot!(response, @r###"
{
"message": "The provided API key is invalid.",
"code": "invalid_api_key",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
}
"###);
let (response, status_code) =
send_request_with_custom_auth(&app, "/indexes/dog/documents", "Bearer kefir").await;
snapshot!(status_code, @"403 Forbidden");
snapshot!(response, @r###"
{
"message": "The provided API key is invalid.",
"code": "invalid_api_key",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
}
"###);
// The tenant token won't be recognized at all if we're not on a search route
let claims = json!({ "tamo": "kefir" });
let jwt = jsonwebtoken::encode(&Header::default(), &claims, &EncodingKey::from_secret(b"tamo"))
.unwrap();
let (response, status_code) =
send_request_with_custom_auth(&app, "/indexes/dog/documents", &format!("Bearer {jwt}"))
.await;
snapshot!(status_code, @"403 Forbidden");
snapshot!(response, @r###"
{
"message": "The provided API key is invalid.",
"code": "invalid_api_key",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
}
"###);
let claims = json!({ "tamo": "kefir" });
let jwt = jsonwebtoken::encode(&Header::default(), &claims, &EncodingKey::from_secret(b"tamo"))
.unwrap();
let (response, status_code) =
send_request_with_custom_auth(&app, "/indexes/dog/search", &format!("Bearer {jwt}")).await;
snapshot!(status_code, @"403 Forbidden");
snapshot!(response, @r###"
{
"message": "Could not decode tenant token, JSON error: missing field `searchRules` at line 1 column 16",
"code": "invalid_api_key",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
}
"###);
// The error messages are not ideal but that's expected since we cannot _yet_ use deserr
let claims = json!({ "searchRules": "kefir" });
let jwt = jsonwebtoken::encode(&Header::default(), &claims, &EncodingKey::from_secret(b"tamo"))
.unwrap();
let (response, status_code) =
send_request_with_custom_auth(&app, "/indexes/dog/search", &format!("Bearer {jwt}")).await;
snapshot!(status_code, @"403 Forbidden");
snapshot!(response, @r###"
{
"message": "Could not decode tenant token, JSON error: data did not match any variant of untagged enum SearchRules at line 1 column 23",
"code": "invalid_api_key",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
}
"###);
let uuid = Uuid::nil();
let claims = json!({ "searchRules": ["kefir"], "apiKeyUid": uuid.to_string() });
let jwt = jsonwebtoken::encode(&Header::default(), &claims, &EncodingKey::from_secret(b"tamo"))
.unwrap();
let (response, status_code) =
send_request_with_custom_auth(&app, "/indexes/dog/search", &format!("Bearer {jwt}")).await;
snapshot!(status_code, @"403 Forbidden");
snapshot!(response, @r###"
{
"message": "Could not decode tenant token, InvalidSignature",
"code": "invalid_api_key",
"type": "auth",
"link": "https://docs.meilisearch.com/errors#invalid_api_key"
}
"###);
// ~~ For the next tests we first need to retrieve an API key
}