Add machinery for seeding RNG for tests and start testing the auth flow

Signed-off-by: Olivier 'reivilibre <olivier@librepush.net>
This commit is contained in:
Olivier 'reivilibre' 2024-07-06 21:04:01 +01:00
parent 6cd72b4177
commit f2b0a64fb0
13 changed files with 268 additions and 37 deletions

17
Cargo.lock generated
View File

@ -1656,11 +1656,13 @@ dependencies = [
"hornbeam",
"insta",
"josekit",
"maplit",
"metrics",
"metrics-exporter-prometheus",
"metrics-process",
"pgtemp",
"rand",
"rand_xoshiro",
"rstest",
"serde",
"serde_json",
@ -1983,6 +1985,12 @@ dependencies = [
"libc",
]
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchers"
version = "0.1.0"
@ -2658,6 +2666,15 @@ dependencies = [
"getrandom",
]
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa"
dependencies = [
"rand_core",
]
[[package]]
name = "raw-cpuid"
version = "10.7.0"

View File

@ -27,6 +27,7 @@ metrics = "0.21.1"
metrics-exporter-prometheus = "0.12.1"
metrics-process = "1.0.12"
rand = "0.8.5"
rand_xoshiro = "0.6.0"
serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.108"
serde_urlencoded = "0.7.1"
@ -44,5 +45,7 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
assert_matches2 = "0.1.2"
axum-test-helper = "0.3.0"
insta = { version = "1.39.0", features = ["serde", "yaml"] }
maplit = "1.0.2"
pgtemp = "0.3.0"
rand_xoshiro = "0.6.0"
rstest = "0.21.0"

View File

@ -74,6 +74,9 @@
# Test coverage. Vaguely useful but not definitive.
pkgs.cargo-tarpaulin
# Snapshot testing
pkgs.cargo-insta
pkgs.grass-sass
pkgs.entr

View File

@ -9,6 +9,7 @@ pub mod cli;
pub mod config;
pub mod passwords;
pub mod store;
pub mod utils;
pub mod web;
#[cfg(test)]

View File

@ -4,11 +4,14 @@ use axum::Router;
use confique::{Config, Partial};
use josekit::jwk::alg::rsa::RsaKeyPair;
use pgtemp::PgTempDB;
use rand::SeedableRng;
use rand_xoshiro::Xoshiro256StarStar;
use serde_json::json;
use crate::{
config::{Configuration, SecretConfig},
store::IdCoopStore,
utils::{Clock, RandGen},
web::make_router,
};
@ -23,6 +26,7 @@ const RSA_KEY_PAIR_PEM: &[u8] = include_bytes!("tests/keypair.pem");
const RSA_PUBLIC_KEY_PEM: &[u8] = include_bytes!("tests/publickey.crt");
mod test_cli;
mod test_oidc_auth_flow;
async fn basic_system() -> TestSystem {
let temp_db = pgtemp::PgTempDBBuilder::new()
@ -40,6 +44,7 @@ async fn basic_system() -> TestSystem {
// Not useful, not actually used in the tests
"bind": "127.0.0.1:1",
"public_base_uri": "http://idcoop.example.com",
"client_ip_source": "RightmostXForwardedFor",
},
"postgres": {
"connect": "postgres://not-used-in-tests"
@ -80,9 +85,17 @@ async fn basic_system() -> TestSystem {
let config = Arc::new(config);
let store = Arc::new(store);
let router = make_router(store.clone(), config.clone(), Arc::new(secrets))
.await
.expect("failed to make router");
let clock = Clock::Fake();
let randgen = RandGen(Xoshiro256StarStar::seed_from_u64(424242));
let router = make_router(
store.clone(),
config.clone(),
Arc::new(secrets),
clock,
randgen,
)
.await
.expect("failed to make router");
TestSystem {
database: temp_db,

View File

@ -0,0 +1,9 @@
---
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
---
- content-length: "238"
content-type: text/html; charset=utf-8
set-cookie: __Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000
x-frame-options: DENY
- "<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='HL4qRFKUlBqkrPTvAQ6z-w'><button type='submit'>click here to login</button> (temporary form)</form>"

View File

@ -0,0 +1,10 @@
---
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
---
- content-length: "55"
content-type: text/plain; charset=utf-8
location: "/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge=challenging&code_challenge_method=S256&nonce=noncey"
set-cookie: __Host-LoginSession=HL4qRFKUlBqkrPTvAQ6z-xpYf2uo9sbO68miVnnz7KE; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000
x-frame-options: DENY
- Logged in. Redirecting you back to what you were doing.

View File

@ -0,0 +1,9 @@
---
source: src/tests/test_oidc_auth_flow.rs
expression: "(headers, text)"
---
- content-length: "238"
content-type: text/html; charset=utf-8
set-cookie: __Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=43200000
x-frame-options: DENY
- "<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='HL4qRFKUlBqkrPTvAQ6z-w'><button type='submit'>click here to login</button> (temporary form)</form>"

View File

@ -0,0 +1,91 @@
//! Tests the OpenID Connect auth flow
use std::collections::BTreeMap;
use axum::http::StatusCode;
use axum_test_helper::{TestClient, TestResponse};
use insta::assert_yaml_snapshot;
use maplit::btreemap;
use crate::{passwords::create_password_hash, store::CreateUser, tests::basic_system};
async fn dump_resp_text(
req_name: &str,
resp: TestResponse,
) -> (StatusCode, BTreeMap<String, String>, String) {
let status = resp.status();
// convert headers to a simple B-Tree map so they can be serialised in snapshots
// easily
let mut headers: BTreeMap<String, String> = resp
.headers()
.clone()
.into_iter()
.map(|(k, v)| (k.unwrap().to_string(), v.to_str().unwrap().to_owned()))
.collect();
// Remove date because it's not stable across tests!
headers.remove("date");
let text = resp.text().await;
eprintln!("=== Response for {req_name} ===");
eprintln!("Status: {status:?}");
eprintln!("Headers: {headers:#?}");
eprintln!("Body: {text:?}");
eprintln!("=== End of response ===");
(status, headers, text)
}
/// Tests the full flow...
#[tokio::test]
async fn test_todo() {
let sys = basic_system().await;
let pwhash = create_password_hash("secret", &sys.config.password_hashing).unwrap();
let _: () = sys
.store
.txn(|mut txn| {
Box::pin(async move {
txn.create_user(CreateUser {
user_login_name: "robert".to_owned(),
password_hash: Some(pwhash),
locked: false,
})
.await
.unwrap();
Ok(())
})
})
.await
.unwrap();
let client = TestClient::new(sys.web);
// 1. /auth request
const LOGIN_URL: &str = "/login?then=%2Foidc%2Fauth%3Fscope%3Dopenid%26client_id%3Daclient%26response_type%3Dcode%26state%3Dwombat%26redirect_uri%3Dhttp%3A%252F%252Faclient.example.com%252Fredirect%26code_challenge%3Dchallenging%26code_challenge_method%3DS256%26nonce%3Dnoncey";
let resp = client.get("/oidc/auth?scope=openid&client_id=aclient&response_type=code&state=wombat&redirect_uri=http:%2F%2Faclient.example.com%2Fredirect&code_challenge=challenging&code_challenge_method=S256&nonce=noncey").send().await;
let (status, headers, _text) = dump_resp_text("1. /auth request", resp).await;
assert_eq!(status, 302);
assert_eq!(headers.get("location").unwrap(), LOGIN_URL);
// 2. /login request
let resp = client.get(LOGIN_URL).send().await;
let (status, headers, text) = dump_resp_text("2. /login request", resp).await;
assert_eq!(status, 200);
assert_yaml_snapshot!("2/login", (headers, text));
// 3. /login request with credentials
let resp = client
.post(LOGIN_URL)
.form(&btreemap! {
"username" => "robert",
"password" => "secret",
"xsrf" => "HL4qRFKUlBqkrPTvAQ6z-w",
})
.header("Cookie", "__Host-SessionlessXsrf=HL4qRFKUlBqkrPTvAQ6z-w")
// /login is rate-limited by IP source and needs an IP
.header("X-Forwarded-For", "0.0.0.0")
.send()
.await;
let (status, headers, text) = dump_resp_text("3. /login request with credentials", resp).await;
assert_eq!(status, 302);
assert_yaml_snapshot!("3/login", (headers, text));
}

66
src/utils.rs Normal file
View File

@ -0,0 +1,66 @@
//! Miscellaneous utilities
#[cfg(not(test))]
mod real_utils {
use rand::{thread_rng, RngCore};
/// A source of random numbers that can be faked for tests.
#[derive(Clone)]
pub struct RandGen;
impl RngCore for RandGen {
fn next_u32(&mut self) -> u32 {
thread_rng().next_u32()
}
fn next_u64(&mut self) -> u64 {
thread_rng().next_u64()
}
fn fill_bytes(&mut self, dest: &mut [u8]) {
thread_rng().fill_bytes(dest)
}
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> {
thread_rng().try_fill_bytes(dest)
}
}
}
#[cfg(test)]
mod test_utils {
use std::ops::{Deref, DerefMut};
use rand_xoshiro::Xoshiro256StarStar;
#[derive(Clone)]
pub struct RandGen(pub Xoshiro256StarStar);
impl Deref for RandGen {
type Target = Xoshiro256StarStar;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for RandGen {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
}
#[cfg(not(test))]
pub use self::real_utils::RandGen;
#[cfg(test)]
pub use self::test_utils::RandGen;
/// A source of time that can be faked for tests.
#[derive(Clone)]
pub enum Clock {
/// Use real time
Real,
/// Fake time for use in tests
Fake(),
}

View File

@ -24,16 +24,16 @@ use axum::{
routing::{get, post},
Extension, Router,
};
use eyre::Context;
use governor::{clock::QuantaClock, state::keyed::DashMapStateStore, RateLimiter};
use hornbeam::{initialise_template_manager, make_template_manager};
use tower_cookies::CookieManagerLayer;
use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use tracing::{error, info};
use tracing::error;
use crate::{
config::{Configuration, RatelimiterConfig, RatelimitsConfig, SecretConfig},
store::IdCoopStore,
utils::{Clock, RandGen},
web::{
login::{get_login, post_login, PasswordHashInflightLimiter},
oauth_openid::{
@ -61,6 +61,8 @@ pub(crate) async fn make_router(
store: Arc<IdCoopStore>,
config: Arc<Configuration>,
secrets: Arc<SecretConfig>,
clock: Clock,
randgen: RandGen,
) -> eyre::Result<Router> {
initialise_template_manager!(TEMPLATING);
@ -123,20 +125,26 @@ pub(crate) async fn make_router(
.layer(Extension(Arc::new(PasswordHashInflightLimiter::new(1))))
.layer(client_ip_source.into_extension())
.layer(Extension(Arc::new(ratelimiters)))
.layer(Extension(VolatileCodeStore::default()));
.layer(Extension(VolatileCodeStore::default()))
.layer(Extension(clock))
.layer(Extension(randgen));
Ok(router)
}
/// Serves, on the bind address specified, the HTTP service
/// including a user interface and any OAuth, OpenID Connect and custom APIs.
#[cfg(not(test))]
pub async fn serve(
bind: SocketAddr,
store: Arc<IdCoopStore>,
config: Arc<Configuration>,
secrets: Arc<SecretConfig>,
) -> eyre::Result<()> {
let router = make_router(store, config, secrets).await?;
use eyre::Context;
use tracing::info;
let router = make_router(store, config, secrets, Clock::Real, RandGen).await?;
info!("Listening on {bind:?}");
axum::Server::try_bind(&bind)

View File

@ -9,8 +9,8 @@ use std::{
use async_trait::async_trait;
use axum::{
extract::{FromRequestParts, Query},
headers::Cookie,
http::{request::Parts, uri::PathAndQuery, HeaderValue, StatusCode},
headers::Cookie as CookieHeader,
http::{request::Parts, uri::PathAndQuery, StatusCode},
response::{Html, IntoResponse, Response},
Extension, Form, TypedHeader,
};
@ -25,13 +25,14 @@ use rand::{thread_rng, Rng};
use serde::Deserialize;
use sqlx::types::Uuid;
use tokio::sync::Semaphore;
use tower_cookies::Cookies;
use tower_cookies::{Cookie, Cookies};
use tracing::error;
use crate::{
config::{Configuration, PasswordHashingConfig},
passwords::{check_hash, create_password_hash},
store::IdCoopStore,
utils::RandGen,
};
use super::{sessionless_xsrf, Ratelimiters, WebResult};
@ -203,17 +204,15 @@ where
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Ok(cookies) = TypedHeader::<Cookie>::from_request_parts(parts, state).await else {
let Ok(cookies) = TypedHeader::<CookieHeader>::from_request_parts(parts, state).await
else {
return Err((StatusCode::UNAUTHORIZED, "No login session."));
};
let Some(cookie_val) = cookies.get("__Host-LoginSession").map(str::to_owned) else {
return Err((StatusCode::UNAUTHORIZED, "No login session."));
};
let Ok(login_session_token) = BASE64_URL_SAFE_NO_PAD.decode(&cookie_val) else {
return Err((
StatusCode::UNAUTHORIZED,
"Invalid login session token."
));
return Err((StatusCode::UNAUTHORIZED, "Invalid login session token."));
};
if login_session_token.len() != LOGIN_SESSION_TOKEN_BYTES {
return Err((StatusCode::UNAUTHORIZED, "Invalid login session token."));
@ -268,11 +267,12 @@ pub async fn get_login(
current_session: Option<LoginSession>,
Query(query): Query<LoginQuery>,
cookies: Cookies,
Extension(mut randgen): Extension<RandGen>,
) -> Response {
match current_session {
Some(_session) => make_post_login_redirect(query.then),
None => {
let xsrf_token = sessionless_xsrf::get_token(&cookies);
let xsrf_token = sessionless_xsrf::get_token(&cookies, &mut randgen);
Html(format!("<form method='POST'>UN<input type='text' name='username'> PW<input type='password' name='password'> <input type='hidden' name='xsrf' value='{}'><button type='submit'>click here to login</button> (temporary form)</form>", xsrf_token)).into_response()
}
}
@ -335,6 +335,7 @@ pub async fn post_login(
Extension(phil): Extension<Arc<PasswordHashInflightLimiter>>,
SecureClientIp(src_ip): SecureClientIp,
Extension(ratelimiters): Extension<Arc<Ratelimiters>>,
Extension(mut randgen): Extension<RandGen>,
Form(form): Form<PostLoginForm>,
) -> WebResult<Response> {
ratelimiters.housekeeping();
@ -344,7 +345,7 @@ pub async fn post_login(
.await;
if !sessionless_xsrf::check_token(&cookies, &form.xsrf) {
// Invalid XSRF token: try again
return Ok(get_login(None, Query(query), cookies).await);
return Ok(get_login(None, Query(query), cookies, Extension(randgen)).await);
}
// retrieve user details
@ -400,11 +401,11 @@ pub async fn post_login(
};
// Generate a login session token and store the hash in our database
let login_session_token = thread_rng().gen::<[u8; LOGIN_SESSION_TOKEN_BYTES]>();
let login_session_token = randgen.gen::<[u8; LOGIN_SESSION_TOKEN_BYTES]>();
let login_session_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(login_session_token);
let login_session_token_hash: [u8; LOGIN_SESSION_TOKEN_HASH_BYTES] =
Blake2s256::digest(login_session_token).into();
let xsrf_secret = thread_rng().gen::<[u8; LOGIN_SESSION_XSRF_SECRET_BYTES]>();
let xsrf_secret = randgen.gen::<[u8; LOGIN_SESSION_XSRF_SECRET_BYTES]>();
// store session in the database
store
@ -417,20 +418,16 @@ pub async fn post_login(
.await
.context("failed to store session in database")?;
let expiry_date = chrono::Utc::now() + chrono::Duration::days(500);
let expiry_date_rfc1123 = expiry_date.format("%a, %d %b %Y %H:%M:%S GMT");
Ok((
[(
"Set-Cookie",
HeaderValue::from_str(&format!(
"__Host-LoginSession={}; Path=/; HttpOnly; SameSite=Strict; Secure; Expires={}",
login_session_token_b64, expiry_date_rfc1123
))
.expect("no reason we should fail to make a cookie"),
)],
make_post_login_redirect(query.then),
)
.into_response())
cookies.add(
Cookie::build("__Host-LoginSession", login_session_token_b64.clone())
.path("/")
.http_only(true)
.secure(true)
.same_site(tower_cookies::cookie::SameSite::Strict)
.max_age(time::Duration::days(500))
.finish(),
);
Ok(make_post_login_redirect(query.then))
}
/// Make a redirect for once the user has logged in.

View File

@ -7,20 +7,22 @@
//! Even on older browsers, that type of attack is rare, so this 'naïve' scheme is fine for the purpose (rather than having to do anything complicated).
use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
use rand::{thread_rng, Rng};
use rand::Rng;
use subtle::ConstantTimeEq;
use time::Duration;
use tower_cookies::{Cookie, Cookies};
use crate::utils::RandGen;
/// Name of the cookie for the sessionless cross-site request forgery prevention cookie.
pub const COOKIE_NAME: &str = "__Host-SessionlessXsrf";
/// Gets the Sessionless XSRF token to put into a form request
pub fn get_token(cookies: &Cookies) -> String {
pub fn get_token(cookies: &Cookies, randgen: &mut RandGen) -> String {
if let Some(xsrf_cookie) = cookies.get(COOKIE_NAME) {
xsrf_cookie.value().to_owned()
} else {
let new_token = thread_rng().gen::<[u8; 16]>();
let new_token = randgen.gen::<[u8; 16]>();
let new_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(new_token);
cookies.add(
Cookie::build(COOKIE_NAME, new_token_b64.clone())
@ -37,7 +39,9 @@ pub fn get_token(cookies: &Cookies) -> String {
/// Checks a Sessionless XSRF token obtained from a form request
pub fn check_token(cookies: &Cookies, token: &str) -> bool {
let Some(xsrf_token) = cookies.get(COOKIE_NAME) else { return false };
let Some(xsrf_token) = cookies.get(COOKIE_NAME) else {
return false;
};
bool::from(xsrf_token.value().as_bytes().ct_eq(token.as_bytes()))
}