~whbboyd/russet

2b89574c620c625d3e0654a5d900a4153f4bb281 — Will Boyd 3 months ago 26ccbae
Remove unwrap and handle unauthenticated errors by redirecting to login page
M src/domain/user.rs => src/domain/user.rs +4 -1
@@ 65,7 65,10 @@ where Persistence: RussetUserPersistenceLayer {
			}
			None => {
				// Hash the password anyway to resist user enumeration via side channels
				let parsed_hash = argon2::PasswordHash::new("$argon2id$v=19$m=19456,t=2,p=1$DFhnniX1Kn3JoEKD5e9qbQ$IxgxUYNYPTvPTjez280uFJh166f+eNkCXntlVe5NaZQ").unwrap();
				let parsed_hash = argon2::PasswordHash::new(
						"$argon2id$v=19$m=19456,t=2,p=1$DFhnniX1Kn3JoEKD5e9qbQ$\
						IxgxUYNYPTvPTjez280uFJh166f+eNkCXntlVe5NaZQ"
					).expect("hardcoded password hash should parse");
				let _ = password_hash.verify_password(&password_bytes, &parsed_hash);
				info!("User {:?} not found", user_name);
				Ok(None)

M src/feed/rss.rs => src/feed/rss.rs +3 -1
@@ 23,7 23,9 @@ impl RussetFeedReader for RssFeedReader {
		let title = rss.title;
		let entries = rss.items.into_iter().map(|item| {
			Entry {
				internal_id: item.guid.map_or_else(|| item.link.clone().unwrap(), |guid| guid.value().to_string()),
				internal_id: item.guid.map_or_else(
					|| item.link.clone().expect("TODO: every item might not have either a guid or a link"),
					|guid| guid.value().to_string()),
				url: item
					.link
					.map_or(None, |url| Url::parse(&url).ok()),

M src/http/error.rs => src/http/error.rs +9 -3
@@ 1,5 1,5 @@
use axum::http::StatusCode;
use axum::response::{ Html, IntoResponse, Response  };
use axum::response::{ Html, IntoResponse, Redirect, Response };
use crate::Err;
use crate::persistence::model::User;
use sailfish::RenderError;


@@ 9,7 9,7 @@ use ulid::Ulid;

pub enum HttpError {
	BadRequest { description: String },
	Unauthenticated,
	Unauthenticated { redirect_to: Option<String> },
	NotFound,
	InternalError { description: String },
}


@@ 41,7 41,13 @@ impl IntoResponse for HttpError {
	fn into_response(self) -> Response {
		let (status, description) = match self {
			HttpError::BadRequest { description } => (StatusCode::BAD_REQUEST, description),
			HttpError::Unauthenticated => (StatusCode::UNAUTHORIZED, "You must <a href=\"/login\">log in</a>.".to_string()),
			HttpError::Unauthenticated { redirect_to } => {
				let redirect = format!("/login{}", redirect_to.map_or(
						"".to_string(),
						|redirect| format!("?redirect_to={redirect}"),
					) );
				return Redirect::to(&redirect).into_response();
			},
			HttpError::NotFound => (StatusCode::NOT_FOUND, "No resource exists at this URL.".to_string()),
			HttpError::InternalError { description } => {
				let correlation_id = Ulid::new();

M src/http/login.rs => src/http/login.rs +1 -1
@@ 86,6 86,6 @@ where Persistence: RussetPersistenceLayer {
				Redirect::to(&login.redirect_to.unwrap_or("/".to_string())),
			))
		},
		None => Err(HttpError::Unauthenticated),
		None => Err(HttpError::Unauthenticated { redirect_to: login.redirect_to }),
	}
}

M src/http/mod.rs => src/http/mod.rs +17 -3
@@ 26,8 26,20 @@ pub fn russet_router<Persistence>(
	login_concurrent_limit: u32,
) -> Router<AppState<Persistence>>
where Persistence: RussetPersistenceLayer {
	let global_limit_semaphore = Arc::new(Semaphore::new(global_concurrent_limit.try_into().unwrap()));
	let login_limit_sempahore = Arc::new(Semaphore::new(login_concurrent_limit.try_into().unwrap()));
	let global_limit_semaphore = Arc::new(
		Semaphore::new(
			global_concurrent_limit
				.try_into()
				.expect("global concurrency limit should fit in a usize")
		)
	);
	let login_limit_sempahore = Arc::new(
		Semaphore::new(
			login_concurrent_limit
				.try_into()
				.expect("login concurrency limit should *definitely* fit in a usize")
		)
	);
	Router::new()
		.route("/login", post(login::login_user))
		.layer(GlobalConcurrencyLimitLayer::with_semaphore(login_limit_sempahore))


@@ 50,7 62,9 @@ async fn csp_header<B>(mut response: Response<B>) -> Response<B> {
		.headers_mut()
		.insert(
			"Content-Security-Policy",
			"script-source none".parse().unwrap(),
			"script-source none"
				.parse()
				.expect("hard-coded header should be encoded correctly"),
		);
	response
}

M src/http/root.rs => src/http/root.rs +2 -1
@@ 91,7 91,8 @@ impl EditUserEntriesRequest {
				},
				"select-all" => select_all = true,
				key if key.starts_with("select-") => {
					let suffix = key.strip_prefix("select-").unwrap();
					let suffix = key.strip_prefix("select-")
						.expect("a string with starts with a given prefix has that prefix");
					let id = ulid::Ulid::from_string(suffix)?;
					selected_ids.push(EntryId(id));
				},

M src/http/session.rs => src/http/session.rs +8 -8
@@ 2,11 2,11 @@ use axum::{ async_trait, RequestPartsExt };
use axum::extract::{ FromRef, FromRequestParts, State };
use axum_extra::extract::cookie::CookieJar;
use axum::http::request::Parts;
use axum::response::{ IntoResponse, Redirect, Response };
use core::marker::PhantomData;
use crate::http::AppState;
use crate::http::error::HttpError;
use crate::persistence::model::User;
use crate::persistence::RussetPersistenceLayer;
use std::marker::PhantomData;

#[derive(Debug)]
pub struct AuthenticatedUser<Persistence> {


@@ 20,27 20,27 @@ where
	Persistence: RussetPersistenceLayer,
	AppState<Persistence>: FromRef<S>,
{
	type Rejection = Response;
	type Rejection = HttpError;
	async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
		let State(state): State<AppState<Persistence>> = State::from_request_parts(parts, state)
			.await
			.map_err(|err| err.into_response())?;
			.expect("Infallible is");
		let cookies = parts.extract::<CookieJar>()
			.await
			.map_err(|err| err.into_response())?;
			.expect("Infallible is");

		let session_cookie = cookies.get("session_id");
		match session_cookie {
			Some(session_cookie) => {
				let user = state.domain_service.auth_user(session_cookie.value()).await.unwrap();
				let user = state.domain_service.auth_user(session_cookie.value()).await?;
				match user {
					Some(user) => Ok(AuthenticatedUser { user, phantom: PhantomData }),
					// Session cookie is present but invalid; user needs to reauthenticate
					None => Err(Redirect::to("/login").into_response()),
					None => Err(HttpError::Unauthenticated { redirect_to: None }),
				}
			},
			// Session cookies is missing: user needs to authenticate
			None => Err(Redirect::to("/login").into_response()),
			None => Err(HttpError::Unauthenticated { redirect_to: None }),
		}
	}
}

M src/http/static_routes.rs => src/http/static_routes.rs +2 -2
@@ 13,6 13,6 @@ pub async fn styles() -> Response<String> {
	Response::builder()
		.status(StatusCode::OK)
		.header(header::CONTENT_TYPE, "text/css")
		.body(Css{}.render_once().unwrap())
		.unwrap()
		.body(Css{}.render_once().expect("rendering a static asset should work"))
		.expect("building a response with a static asset should work")
}

M src/main.rs => src/main.rs +4 -4
@@ 43,7 43,7 @@ pub type Result<T> = std::result::Result<T, Err>;

#[tokio::main]
async fn main() -> Result<()> {
	init_tracing();
	init_tracing()?;

	// Hierarchy of configs
	let config = {


@@ 143,15 143,15 @@ async fn main() -> Result<()> {
	Ok(())
}

fn init_tracing() {
fn init_tracing() -> Result<()> {
	let filter = EnvFilter::builder()
		.with_default_directive(LevelFilter::INFO.into())
		.from_env()
		.unwrap();
		.from_env()?;
	let subscriber = tracing_subscriber::fmt::layer();
//`		.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE);
	tracing_subscriber::registry()
		.with(filter)
		.with(subscriber)
		.init();
	Ok(())
}

M src/persistence/sql/mod.rs => src/persistence/sql/mod.rs +9 -1
@@ 43,7 43,15 @@ impl TryFrom<Timestamp> for i64 {
	type Error = Err;
	fn try_from(value: Timestamp) -> Result<i64> {
		Ok(value.0.duration_since(SystemTime::UNIX_EPOCH).map_or_else(
			|_| SystemTime::UNIX_EPOCH.duration_since(value.0).unwrap().as_millis().try_into().map(|i: i64| -i),
			// Err case: value is prior to epoch, so do the comparison in the
			// other direction and negate the result
			|_| SystemTime::UNIX_EPOCH
					.duration_since(value.0)
					.expect("duration_since should only return Err if the SystemTimes are in reverse order")
					.as_millis()
					.try_into()
					.map(|i: i64| -i),
			// Ok case: just pull out the milliseconds
			|duration| duration.as_millis().try_into(),
		)?)
	}

M src/persistence/sql/user.rs => src/persistence/sql/user.rs +1 -2
@@ 208,8 208,7 @@ impl RussetUserPersistenceLayer for SqlDatabase {
			.execute(&self.pool)
			.await?
			.rows_affected()
			.try_into()
			.unwrap();
			.try_into()?;
		Ok(rows)
	}