Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 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 crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ bcrypt = { workspace = true }
unicode-properties = "0.1.4"
base64 = { workspace = true }
time = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
axum-server = { workspace = true }
rustls = { workspace = true }
Expand Down
142 changes: 104 additions & 38 deletions crates/server/src/console/pages/auth_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ use axum::Form;
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use extenddb_storage::management_store::RateLimitStore;
use extenddb_storage::management_store::{PasswordHashStore, RateLimitStore};
use serde::Deserialize;

use crate::console::ConsoleState;
use crate::console::html;
use crate::console::session::CredentialStamp;
use crate::management::CallerIdentity;
use crate::management::auth::log_password_check_error;
use crate::rate_limit::{self, LoginAttempt, LoginRefusal};
Expand Down Expand Up @@ -47,15 +48,16 @@ pub async fn login_submit(

// Try admin auth first, then IAM user auth. A storage error (including a
// timed-out wait for a bcrypt slot) is answered 500 and recorded as
// nothing: no password was checked.
// nothing: no password was checked. A match carries the stamp of the
// password hash it was checked against, and the session is bound to it.
let checked = check_credentials(&form.username, &form.password, &*state.catalog_store).await;
let identity =
let (identity, stamp) =
match settle_attempt(attempt, checked, &*state.catalog_store, &state.listen_url).await {
Ok(identity) => identity,
Ok(login) => login,
Err(page) => return page,
};

let (token, _csrf) = state.sessions.create(identity).await;
let (token, _csrf) = state.sessions.create(identity, stamp).await;
let cookie = session_cookie(&token, 28800, state.secure_cookies);

(
Expand Down Expand Up @@ -126,7 +128,7 @@ IAM users: enter <code>account_id/user_name</code>.
/// Result of an admin login attempt — three-state to distinguish "not found"
/// (fall through to IAM) from "wrong password" (reject immediately).
enum AdminLoginResult {
Authenticated(CallerIdentity),
Authenticated(CallerIdentity, CredentialStamp),
WrongPassword,
NotFound,
}
Expand Down Expand Up @@ -166,16 +168,18 @@ fn refusal_page(refusal: LoginRefusal, listen_url: &str) -> Response {
}
}

/// Check the form's credentials, admin first and then IAM user. `Ok(None)`
/// is a wrong password or an unknown principal; `Err(())` is a storage
/// error, including a timed-out wait for a bcrypt slot.
/// Check the form's credentials, admin first and then IAM user. A match
/// returns the identity with the stamp of the password hash it was checked
/// against. `Ok(None)` is a wrong password or an unknown principal;
/// `Err(())` is a storage error, including a timed-out wait for a bcrypt
/// slot.
async fn check_credentials(
username: &str,
password: &str,
store: &dyn extenddb_storage::CatalogStore,
) -> Result<Option<CallerIdentity>, ()> {
) -> Result<Option<(CallerIdentity, CredentialStamp)>, ()> {
match try_admin_login(username, password, store).await? {
AdminLoginResult::Authenticated(id) => Ok(Some(id)),
AdminLoginResult::Authenticated(id, stamp) => Ok(Some((id, stamp))),
// Admin exists, wrong password: do NOT fall through.
AdminLoginResult::WrongPassword => Ok(None),
// Admin not found: try IAM user auth.
Expand All @@ -189,12 +193,12 @@ async fn check_credentials(
/// the attempt releases its reservation.
async fn settle_attempt(
attempt: LoginAttempt,
checked: Result<Option<CallerIdentity>, ()>,
checked: Result<Option<(CallerIdentity, CredentialStamp)>, ()>,
rate_limiter: &dyn RateLimitStore,
listen_url: &str,
) -> Result<CallerIdentity, Response> {
) -> Result<(CallerIdentity, CredentialStamp), Response> {
match checked {
Ok(Some(identity)) => Ok(identity),
Ok(Some(login)) => Ok(login),
Ok(None) => {
attempt.record_failure(rate_limiter).await;
Err(invalid_credentials(listen_url))
Expand All @@ -207,16 +211,20 @@ async fn settle_attempt(
async fn try_admin_login(
username: &str,
password: &str,
store: &dyn extenddb_storage::management_store::AdminStore,
store: &dyn PasswordHashStore,
) -> Result<AdminLoginResult, ()> {
let result = store
.verify_admin_password(username, password)
let hash = store
.get_admin_password_hash(username)
.await
.map_err(|e| log_password_check_error("Console login: DB error during admin auth", &e))?;
Ok(match result {
Some(true) => AdminLoginResult::Authenticated(CallerIdentity::Admin(username.to_owned())),
Some(false) => AdminLoginResult::WrongPassword,
None => AdminLoginResult::NotFound,
let Some(hash) = hash else {
return Ok(AdminLoginResult::NotFound);
};
Ok(match verify_login(password, hash).await? {
Some(stamp) => {
AdminLoginResult::Authenticated(CallerIdentity::Admin(username.to_owned()), stamp)
}
None => AdminLoginResult::WrongPassword,
})
}

Expand All @@ -225,34 +233,90 @@ async fn try_admin_login(
async fn try_iam_login(
username: &str,
password: &str,
store: &dyn extenddb_storage::management_store::ManagementStore,
) -> Result<Option<CallerIdentity>, ()> {
store: &dyn PasswordHashStore,
) -> Result<Option<(CallerIdentity, CredentialStamp)>, ()> {
let Some((acct_id, uname)) = username.split_once('/') else {
return Ok(None);
};
let ok = store
.verify_iam_user_password(acct_id, uname, password)
// `None` when the user does not exist or has no console password.
let hash = store
.get_user_password_hash(acct_id, uname)
.await
.map_err(|e| {
log_password_check_error("Console login: DB error during IAM user auth", &e)
})?;
Ok(ok.then(|| CallerIdentity::IamUser {
let Some(hash) = hash else {
return Ok(None);
};
let Some(stamp) = verify_login(password, hash).await? else {
return Ok(None);
};
let identity = CallerIdentity::IamUser {
account_id: acct_id.to_owned(),
user_name: uname.to_owned(),
}))
};
Ok(Some((identity, stamp)))
}

/// Verify a login password against the hash read from the catalog. On a
/// match, returns the stamp of that same hash, which the new session is bound
/// to. The hash is read once and used for both, so a password change that
/// lands during the login either fails the login or ends the new session at
/// its first request.
///
/// All console login verification goes through this function, and through
/// the bcrypt slots of [`extenddb_storage::password::verify_password`].
/// `Ok(None)` is a wrong password. `Err(())` is a verification that did not
/// run (no bcrypt slot within the wait, or a failed blocking task); the
/// caller answers 500 and records nothing.
async fn verify_login(password: &str, hash: String) -> Result<Option<CredentialStamp>, ()> {
let stamp = CredentialStamp::of(&hash);
let matched = extenddb_storage::password::verify_password(password.to_owned(), hash)
.await
.map_err(|e| log_password_check_error("Console login: password verification failed", &e))?;
Ok(matched.then_some(stamp))
}

#[cfg(test)]
mod tests {
use extenddb_storage::management_store::{OpError, OpResult};
use extenddb_storage::password::VERIFY_OVERLOADED;
use futures::future::BoxFuture;

use super::*;
use crate::management::auth::tests::FailingAdmins;
use crate::rate_limit::PRINCIPAL_LIMITED;
use crate::rate_limit::tests::{MemoryStore, capture_logs, peer};

const LISTEN_URL: &str = "https://127.0.0.1:8443";

/// A password hash store whose reads fail with `OpError::Internal`
/// carrying `message`. Logins read nothing else from it.
struct FailingHashes {
message: &'static str,
}

impl PasswordHashStore for FailingHashes {
fn get_admin_password_hash(&self, _: &str) -> BoxFuture<'_, OpResult<Option<String>>> {
let error = OpError::Internal(self.message.to_owned());
Box::pin(async move { Err(error) })
}

fn get_user_password_hash(
&self,
_: &str,
_: &str,
) -> BoxFuture<'_, OpResult<Option<String>>> {
let error = OpError::Internal(self.message.to_owned());
Box::pin(async move { Err(error) })
}
}

/// A password made at run time. The store fails before any check, so its
/// value does not matter, and no literal reaches the password check.
fn any_password() -> String {
format!("pw-{}", std::process::id())
}

async fn body_text(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
Expand All @@ -278,11 +342,12 @@ mod tests {

#[tokio::test]
async fn a_store_error_in_the_admin_check_is_an_error_not_an_unknown_admin() {
let admins = FailingAdmins {
let hashes = FailingHashes {
message: "store is down",
};
let password = any_password();
let (logs, _guard) = capture_logs();
assert!(try_admin_login("admin", "pw", &admins).await.is_err());
assert!(try_admin_login("admin", &password, &hashes).await.is_err());
let text = logs.text();
assert!(
text.contains("ERROR") && text.contains("Console login: DB error during admin auth"),
Expand All @@ -292,11 +357,12 @@ mod tests {

#[tokio::test]
async fn a_bcrypt_slot_timeout_is_an_error_and_is_not_logged_as_a_store_error() {
let admins = FailingAdmins {
let hashes = FailingHashes {
message: VERIFY_OVERLOADED,
};
let password = any_password();
let (logs, _guard) = capture_logs();
assert!(try_admin_login("admin", "pw", &admins).await.is_err());
assert!(try_admin_login("admin", &password, &hashes).await.is_err());
let text = logs.text();
assert!(!text.contains("DB error"), "{text}");
}
Expand All @@ -310,9 +376,9 @@ mod tests {
let attempt = rate_limit::begin_login(&attempts, "admin", peer("198.51.100.90"))
.await
.unwrap();
let page = settle_attempt(attempt, Err(()), &attempts, LISTEN_URL)
.await
.unwrap_err();
let Err(page) = settle_attempt(attempt, Err(()), &attempts, LISTEN_URL).await else {
panic!("a storage error was settled as a login");
};
assert_eq!(page.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(attempts.row_count(), 0);
}
Expand All @@ -323,9 +389,9 @@ mod tests {
let attempt = rate_limit::begin_login(&attempts, "admin", peer("198.51.100.91"))
.await
.unwrap();
let page = settle_attempt(attempt, Ok(None), &attempts, LISTEN_URL)
.await
.unwrap_err();
let Err(page) = settle_attempt(attempt, Ok(None), &attempts, LISTEN_URL).await else {
panic!("a wrong password was settled as a login");
};
assert_eq!(page.status(), StatusCode::OK);
assert!(body_text(page).await.contains("Invalid credentials"));
assert_eq!(attempts.rows_for("admin", "198.51.100.91"), 1);
Expand Down
79 changes: 68 additions & 11 deletions crates/server/src/console/pages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! Console page handlers.
//!
//! Each handler queries the catalog DB directly and renders server-side HTML.
//! Authentication is via session cookies validated against the in-memory store.
//! Authentication is via session cookies validated against the in-memory store
//! and, on every request, against the principal's password hash in the catalog.
//! All state-changing POST forms include a CSRF token validated on submission.

mod access_key_pages;
Expand Down Expand Up @@ -42,11 +43,12 @@ pub use user_pages::{create_user, delete_user, new_user_form, user_detail};

use std::sync::Arc;

use axum::http::HeaderMap;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};

use crate::console::ConsoleState;
use crate::console::html;
use crate::console::session::SessionCheck;
use crate::management::CallerIdentity;

/// Session data returned by `require_session`: identity + CSRF token.
Expand All @@ -55,24 +57,39 @@ pub struct SessionData {
pub csrf_token: String,
}

/// Extract session identity and CSRF token from the cookie header. Returns
/// the session data or a redirect to the login page.
/// Extract the session from the cookie header and check it against the
/// catalog. Returns the session data; a redirect to the login page when there
/// is no session, it expired, or its principal was deleted or had its
/// password changed; or 503 when the catalog cannot be read.
#[allow(clippy::result_large_err)]
async fn require_session(
headers: &HeaderMap,
state: &Arc<ConsoleState>,
) -> Result<SessionData, Response> {
let token = extract_session_token(headers);
let result = match token {
Some(t) => state.sessions.get(&t).await,
None => None,
let Some(token) = extract_session_token(headers) else {
return Err(Redirect::to("/console/login").into_response());
};
match result {
Some((identity, csrf_token)) => Ok(SessionData {
session_response(state.sessions.validate(&token, &*state.catalog_store).await)
}

/// The session data for a session that passed its check, or the response a
/// console request gets when it did not.
#[allow(clippy::result_large_err)]
fn session_response(check: SessionCheck) -> Result<SessionData, Response> {
match check {
SessionCheck::Valid {
identity,
csrf_token,
} => Ok(SessionData {
identity,
csrf_token,
}),
None => Err(Redirect::to("/console/login").into_response()),
SessionCheck::Invalid => Err(Redirect::to("/console/login").into_response()),
SessionCheck::Unavailable => Err((
StatusCode::SERVICE_UNAVAILABLE,
"The catalog is unavailable. Try again.",
)
.into_response()),
}
}

Expand Down Expand Up @@ -168,3 +185,43 @@ fn op_error_message(e: extenddb_storage::management_store::OpError) -> String {
OpError::Database { .. } => "Database error".to_owned(),
}
}

#[cfg(test)]
mod tests {
use axum::http::header::LOCATION;

use super::*;

#[test]
fn a_passed_check_returns_the_session() {
let Ok(session) = session_response(SessionCheck::Valid {
identity: CallerIdentity::Admin("alice".to_owned()),
csrf_token: "csrf".to_owned(),
}) else {
panic!("a session that passed its check was refused");
};
assert!(matches!(session.identity, CallerIdentity::Admin(ref n) if n == "alice"));
assert_eq!(session.csrf_token, "csrf");
}

#[test]
fn an_ended_session_gets_the_login_redirect() {
let Err(response) = session_response(SessionCheck::Invalid) else {
panic!("an ended session was accepted");
};
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get(LOCATION).map(|v| v.as_bytes()),
Some(&b"/console/login"[..])
);
}

#[test]
fn an_unreadable_catalog_gets_503() {
let Err(response) = session_response(SessionCheck::Unavailable) else {
panic!("a request was accepted while the catalog could not be read");
};
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(response.headers().get(LOCATION).is_none());
}
}
Loading
Loading