Skip to content
Open
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 @@ -38,6 +38,7 @@ unicode-properties = "0.1.4"
base64 = { workspace = true }
time = { workspace = true }
aes-gcm = { workspace = true }
sha2 = { workspace = true }
rand = { workspace = true }
axum-server = { workspace = true }
rustls = { workspace = true }
Expand Down
77 changes: 45 additions & 32 deletions crates/server/src/console/pages/auth_pages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ use axum::Form;
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use extenddb_storage::management_store::PasswordHashStore;
use serde::Deserialize;

use crate::console::ConsoleState;
use crate::console::html;
use crate::console::session::CredentialStamp;
use crate::management::CallerIdentity;
use crate::management::password::verify_password;
use crate::rate_limit;

use super::extract_session_token;
Expand Down Expand Up @@ -50,17 +53,16 @@ pub async fn login_submit(
}

// Try admin auth first.
let identity =
match try_admin_login(&form.username, &form.password, &*state.catalog_store).await {
AdminLoginResult::Authenticated(id) => Some(id),
AdminLoginResult::WrongPassword => None, // Admin exists, wrong password — do NOT fall through.
AdminLoginResult::NotFound => {
// Admin not found — try IAM user auth.
try_iam_login(&form.username, &form.password, &*state.catalog_store).await
}
};

let Some(identity) = identity else {
let login = match try_admin_login(&form.username, &form.password, &*state.catalog_store).await {
AdminLoginResult::Authenticated(id, stamp) => Some((id, stamp)),
AdminLoginResult::WrongPassword => None, // Admin exists, wrong password: do NOT fall through.
AdminLoginResult::NotFound => {
// Admin not found: try IAM user auth.
try_iam_login(&form.username, &form.password, &*state.catalog_store).await
}
};

let Some((identity, stamp)) = login else {
rate_limit::record_failed_login(&*state.catalog_store, &form.username, Some(&source_ip))
.await;
return (
Expand All @@ -70,7 +72,7 @@ pub async fn login_submit(
.into_response();
};

let (token, _csrf) = state.sessions.create(identity).await;
let (token, _csrf) = state.sessions.create(identity, stamp).await;
let cookie = format!(
"extenddb_session={token}; Path=/console; HttpOnly; SameSite=Strict; Max-Age=28800"
);
Expand Down Expand Up @@ -134,42 +136,53 @@ 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,
}

async fn try_admin_login(
username: &str,
password: &str,
store: &dyn extenddb_storage::management_store::AdminStore,
store: &dyn PasswordHashStore,
) -> AdminLoginResult {
let Ok(result) = store.verify_admin_password(username, password).await else {
let Ok(Some(hash)) = store.get_admin_password_hash(username).await else {
return AdminLoginResult::NotFound;
};
match result {
Some(true) => AdminLoginResult::Authenticated(CallerIdentity::Admin(username.to_owned())),
Some(false) => AdminLoginResult::WrongPassword,
None => AdminLoginResult::NotFound,
match verify_login(password, hash).await {
Some(stamp) => {
AdminLoginResult::Authenticated(CallerIdentity::Admin(username.to_owned()), stamp)
}
None => AdminLoginResult::WrongPassword,
}
}

async fn try_iam_login(
username: &str,
password: &str,
store: &dyn extenddb_storage::management_store::ManagementStore,
) -> Option<CallerIdentity> {
store: &dyn PasswordHashStore,
) -> Option<(CallerIdentity, CredentialStamp)> {
let (acct_id, uname) = username.split_once('/')?;
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.ok()??;
let stamp = verify_login(password, hash).await?;
let identity = CallerIdentity::IamUser {
account_id: acct_id.to_owned(),
user_name: uname.to_owned(),
};
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.
async fn verify_login(password: &str, hash: String) -> Option<CredentialStamp> {
let stamp = CredentialStamp::of(&hash);
verify_password(password.to_owned(), hash)
.await
.ok()?;
if ok {
Some(CallerIdentity::IamUser {
account_id: acct_id.to_owned(),
user_name: uname.to_owned(),
})
} else {
None
}
.then_some(stamp)
}
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 @@ -41,10 +42,11 @@ 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::session::SessionCheck;
use crate::management::CallerIdentity;

/// Session data returned by `require_session`: identity + CSRF token.
Expand All @@ -53,24 +55,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 @@ -148,3 +165,43 @@ fn op_error_message(e: extenddb_storage::management_store::OpError) -> String {
| OpError::Internal(m) => m,
}
}

#[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