Skip to content

Commit

Permalink
fix: force admin REST endpoints to authenticate
Browse files Browse the repository at this point in the history
  • Loading branch information
Zoe Spellman committed Mar 8, 2024
1 parent 2da8a83 commit 106d5b2
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 41 deletions.
2 changes: 2 additions & 0 deletions .env_files/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ APP_NAME=ratings
APP_PORT=8080
APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_ADMIN_USER=shadow
APP_ADMIN_PASSWORD=maria

DOCKER_POSTGRES_USER=postgres
DOCKER_POSTGRES_PASSWORD=@1234
Expand Down
3 changes: 3 additions & 0 deletions .env_files/test-server.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ APP_PORT=8080
# Update this with some real PostgreSQL details
APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_ADMIN_USER=shadow
APP_ADMIN_PASSWORD=maria


DOCKER_POSTGRES_USER=postgres
DOCKER_POSTGRES_PASSWORD=@1234
Expand Down
2 changes: 2 additions & 0 deletions .env_files/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ APP_PORT=8080
# Update this with some real PostgreSQL details
APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings
APP_ADMIN_USER=shadow
APP_ADMIN_PASSWORD=maria

DOCKER_POSTGRES_USER=postgres
DOCKER_POSTGRES_PASSWORD=@1234
Expand Down
23 changes: 15 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ edition = "2021"
[dependencies]
argon2 = "0.5.3"
axum = "0.6.20" # this *must* be pinned because 0.7.x relies on hyper 1.x causing a ton of type conversion issues
base64 = "0.22.0"
chrono = { version = "0.4.34", default-features = false, features = [
"std",
"clock",
Expand Down
146 changes: 146 additions & 0 deletions src/app/interfaces/authentication/admin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! An authenticator for our admin endpoints. Technically should work for any Basic auth
//! with some very minor modifications, but that's all we use it for now.
use std::convert::Infallible;

use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2, PasswordHash, PasswordVerifier,
};
use base64::prelude::*;
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use thiserror::Error;
use tracing::error;

use super::CredentialVerifier;

/// Errors that can occur while verifying authentication for the admin REST endpoints
#[derive(Error, Debug)]
#[allow(missing_docs)]
pub enum AdminAuthError {
#[error("basic auth: could not retrieve secret fron environment: {0}")]
EnvError(#[from] envy::Error),
#[error("basic auth: an error occurred while hashing the password")]
PasswordHashError,
#[error("basic auth: this expected basic authentication, but another type was used")]
WrongAuthType,
#[error("basic auth: the auth type was correct, but the header was malformed")]
MalformedAuth,
}

impl From<AdminAuthError> for tonic::Status {
fn from(value: AdminAuthError) -> Self {
match value {
AdminAuthError::EnvError(_) | AdminAuthError::PasswordHashError => {
tonic::Status::internal("internal service error")
}
AdminAuthError::WrongAuthType | AdminAuthError::MalformedAuth => {
tonic::Status::unauthenticated("Basic realm = \"admin\"")
}
}
}
}

impl From<argon2::password_hash::Error> for AdminAuthError {
fn from(_: argon2::password_hash::Error) -> Self {
Self::PasswordHashError
}
}

#[derive(Clone)]
/// Authenticates the admin REST endpoints, works in principle for any Basic auth,
/// though you'd need to modify it to pass in the `realm` for the errors.
pub struct AdminAuthVerifier {
/// Our hashing algorithm
algo: Argon2<'static>,
/// The hashed, base64 auth password
hashed: SecretString,
}

impl AdminAuthVerifier {
/// Creates a new [`AdminAuthVerifier`] from the secrets set in environment variables.
pub fn from_env() -> Result<Self, AdminAuthError> {
let config = AdminAuthConfig::from_env()?;
let encoded = config.into_base64();

let salt = SaltString::generate(&mut OsRng);

let algo = Argon2::default();
let hashed = SecretString::new(
algo.hash_password(encoded.expose_secret().as_bytes(), &salt)
.inspect_err(|e| error!("error hashing env password {e}"))?
.to_string(),
);

Ok(Self { algo, hashed })
}
}

impl CredentialVerifier for AdminAuthVerifier {
// We don't pass anything like a claim, so we just use an unconstructable type
// if `!` ever gets stabilized, switch to that.
type Extension = Infallible;

type Error = AdminAuthError;

fn verify(
&self,
credential: &axum::http::HeaderValue,
) -> Result<Option<Self::Extension>, Self::Error> {
let mut credential = credential.to_str().unwrap_or("").split_ascii_whitespace();

if credential.next().filter(|v| *v == "Basic").is_none() {
return Err(AdminAuthError::WrongAuthType);
}

if let Some(credential) = credential.next() {
let hash =
PasswordHash::new(self.hashed.expose_secret()).expect("password hash is broken");
self.algo.verify_password(credential.as_bytes(), &hash)?;

Ok(None)
} else {
Err(AdminAuthError::MalformedAuth)
}
}
}

/// A config that parses admin secrets from environment variables and then shreds them when done.
#[derive(Deserialize)]
pub struct AdminAuthConfig {
/// The admin's username
admin_user: SecretString,
/// The admin's password
admin_password: SecretString,
}

impl AdminAuthConfig {
/// Loads the secret from the environment
pub fn from_env() -> Result<AdminAuthConfig, envy::Error> {
dotenvy::dotenv().ok();

envy::prefixed("APP_").from_env::<AdminAuthConfig>()
}

/// Converts this into a [`base64`] encoded secret. This *ideally* will
/// shred all intermediate data, but that can never be guaranteed. It tries its best,
/// though.
fn into_base64(self) -> SecretString {
let mut secret = String::with_capacity(
self.admin_password.expose_secret().len() + 1 + self.admin_user.expose_secret().len(),
);

secret.push_str(self.admin_user.expose_secret());
secret.push(':');
secret.push_str(self.admin_password.expose_secret());
let secret = SecretString::new(secret);

SecretString::new(BASE64_STANDARD.encode(secret.expose_secret()))
}

/// Gets the inner values for other uses, mostly for tests
#[allow(dead_code)]
pub fn into_inner(self) -> (SecretString, SecretString) {
(self.admin_user, self.admin_password)
}
}
1 change: 1 addition & 0 deletions src/app/interfaces/authentication/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use hyper::header::{self, HeaderValue};
use tonic::Status;
use tracing::error;

pub mod admin;
pub mod jwt;

/// Verifies the given credentials, this is only really ever used by [`Authenticator`]
Expand Down
15 changes: 8 additions & 7 deletions src/app/interfaces/servers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
pub mod grpc;
pub mod rest;

use std::convert::Infallible;
use std::pin::Pin;

use axum::body::Bytes;
Expand All @@ -17,17 +16,17 @@ pub use rest::{RestService, RestServiceBuilder};
use thiserror::Error;
use tower::Service;

use self::grpc::GrpcError;
use self::{grpc::GrpcError, rest::RestError};

/// Any error that can occur internally to our service
#[derive(Debug, Error)]
pub enum AppCenterRatingsError {
/// An error from the GRPC endpoints
#[error("an error from the GRPC service occurred: {0}")]
#[error(transparent)]
GrpcError(#[from] GrpcError),
/// Technically, an error from the Rest endpoints, but they're infallible
#[error("cannot happen")]
RestError(#[from] Infallible),
/// Technically, an error from the Rest endpoints
#[error(transparent)]
RestError(#[from] RestError),
}

/// The general service for our app, containing all our endpoints
Expand All @@ -49,7 +48,9 @@ impl AppCenterRatingsService {
.with_default_routes()
.build(),
grpc_ready: false,
rest_service: RestServiceBuilder::default().build(),
rest_service: RestServiceBuilder::default()
.build()
.expect("could not create REST service from environment"),
rest_ready: false,
}
}
Expand Down
Loading

0 comments on commit 106d5b2

Please sign in to comment.