From e39a617d37350f6482b9ecc4b466896be259f138 Mon Sep 17 00:00:00 2001 From: Claas Date: Fri, 29 Nov 2024 00:05:55 +0100 Subject: [PATCH] Implement cookie authentication --- Cargo.lock | 109 +++++++++++++++++++++++++++++++++ Cargo.toml | 9 ++- src/auth/authenticated_user.rs | 74 ++++++++++++++++++++++ src/auth/cookie.rs | 74 ++++++++++++++++++++++ src/auth/mod.rs | 56 ++++++++++++----- src/index.rs | 5 +- src/main.rs | 7 ++- 7 files changed, 313 insertions(+), 21 deletions(-) create mode 100644 src/auth/authenticated_user.rs create mode 100644 src/auth/cookie.rs diff --git a/Cargo.lock b/Cargo.lock index fac1648..8293d41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -29,6 +39,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.11" @@ -684,6 +708,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "cobs" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" + [[package]] name = "combine" version = "4.6.7" @@ -706,7 +736,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64 0.22.1", "percent-encoding", + "rand", + "subtle", "time", "version_check", ] @@ -768,9 +802,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.10" @@ -899,6 +943,18 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1087,6 +1143,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2061,6 +2127,12 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.1.5" @@ -2229,6 +2301,30 @@ dependencies = [ "spki", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "postcard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63d01def49fc815900a83e7a4a5083d2abc81b7ddd569a3fa0477778ae9b3ec" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3012,10 +3108,13 @@ dependencies = [ "askama_axum", "axum 0.7.9", "axum-extra", + "base64 0.22.1", "bitwarden", + "cookie", "dotenvy", "libsql", "nanoid", + "postcard", "serde", "thiserror 2.0.3", "time", @@ -3514,6 +3613,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 7004eb6..ba870e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,15 +6,18 @@ edition = "2021" [dependencies] askama = "0.12.1" askama_axum = "0.4.0" -axum = "0.7.9" -axum-extra = { version = "0.9.6", features = ["cookie"] } +axum = { version = "0.7.9", features = [] } +axum-extra = { version = "0.9.6", features = ["cookie", "cookie-private"] } +base64 = "0.22.1" bitwarden = "1.0.0" +cookie = "0.18.1" dotenvy = "0.15.7" libsql = "0.6.0" nanoid = "0.4.0" +postcard = { version = "1.1.0", features = ["alloc"], default-features = false } serde = { version = "1.0.215", features = ["derive"] } thiserror = "2.0.3" -time = "0.3.36" +time = { version = "0.3.36", features = ["serde"] } tokio = { version = "1.41.1", features = ["full"] } tower-http = { version = "0.6.2", features = ["fs"] } tracing = "0.1.40" diff --git a/src/auth/authenticated_user.rs b/src/auth/authenticated_user.rs new file mode 100644 index 0000000..33e547c --- /dev/null +++ b/src/auth/authenticated_user.rs @@ -0,0 +1,74 @@ +use axum::{ + async_trait, + extract::{FromRef, FromRequestParts}, + http::{request::Parts, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use axum_extra::extract::{cookie::Key, PrivateCookieJar}; +use thiserror::Error; +use time::OffsetDateTime; + +use crate::AppState; + +use super::cookie; + +pub(crate) struct AuthenticatedUser; + +#[derive(Error, Debug)] +pub(crate) enum Error { + #[error("No cookie")] + NoCookie, + #[error(transparent)] + BadCookie(#[from] cookie::Error), + #[error("Expired cookie")] + ExpiredCookie, +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Error::NoCookie | Error::BadCookie(_) | Error::ExpiredCookie => { + StatusCode::UNAUTHORIZED.into_response() + } + } + } +} + +#[async_trait] +impl FromRequestParts for AuthenticatedUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = Error; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + + let key = Key::from_ref(&app_state); + // Infallible 🙂 + let Ok(headers) = HeaderMap::from_request_parts(parts, state).await; + let jar = PrivateCookieJar::from_headers(&headers, key); + + let cookie = jar.get(cookie::NAME).ok_or(Error::NoCookie)?; + + // This can be set by users. + // So additional validation is required when not expired. + // The worst case here is the user sets it lower and is signed out earlier but that is their fault. + // But they should not be allowed to set it higher and stay signed in. + let is_expired = cookie + .expires_datetime() + .is_some_and(|datetime| OffsetDateTime::now_utc() > datetime); + + if is_expired { + return Err(Error::ExpiredCookie); + } + + let value = cookie::Session::try_from(cookie)?; + if value.is_expired() { + return Err(Error::ExpiredCookie); + } + + Ok(AuthenticatedUser) + } +} diff --git a/src/auth/cookie.rs b/src/auth/cookie.rs new file mode 100644 index 0000000..0c26126 --- /dev/null +++ b/src/auth/cookie.rs @@ -0,0 +1,74 @@ +use axum::extract::FromRef; +use axum_extra::extract::cookie::{Cookie, Key as AxumKey}; +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use cookie::CookieBuilder; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; + +use super::AppState; + +#[derive(Clone)] +pub(crate) struct Key(AxumKey); + +pub(super) const NAME: &str = "session"; +impl Key { + pub(crate) fn new() -> Option { + AxumKey::try_generate().map(Self) + } +} + +impl FromRef for AxumKey { + fn from_ref(state: &AppState) -> Self { + state.key.0.clone() + } +} + +#[derive(Deserialize, Serialize)] +pub(super) struct Session { + #[serde(with = "time::serde::timestamp")] + expires_at: OffsetDateTime, +} + +impl Session { + const NAME: &str = "session"; + const LIFETIME: time::Duration = time::Duration::days(30); + + pub(super) fn build<'a>() -> Result, postcard::Error> { + let expires_at = OffsetDateTime::now_utc() + Self::LIFETIME; + let cookie = Self::new(expires_at); + let serialized = postcard::to_allocvec(&cookie)?; + + //TODO this does not need to be valid and printable characters. + // Just UTF-8 as it is encrypted and base64 encoded again by the private cookie jar + let encoded = BASE64_URL_SAFE_NO_PAD.encode(serialized); + Ok(Cookie::build((Self::NAME, encoded)).expires(expires_at)) + } + + fn new(expires_at: OffsetDateTime) -> Self { + Self { expires_at } + } + + pub(super) fn is_expired(&self) -> bool { + OffsetDateTime::now_utc() > self.expires_at + } +} + +#[derive(Error, Debug)] +pub(super) enum Error { + #[error("Bad cookie encoding: {0}")] + BadCookieEncoding(#[from] base64::DecodeError), + #[error("Bad cookie: {0}")] + BadCookieFormat(#[from] postcard::Error), +} + +impl TryFrom> for Session { + type Error = Error; + + fn try_from(cookie: Cookie) -> Result { + let encoded = cookie.value(); + let serialied = BASE64_URL_SAFE_NO_PAD.decode(encoded)?; + let value = postcard::from_bytes(&serialied)?; + Ok(value) + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c188ab2..dca83f3 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,13 +1,23 @@ +mod authenticated_user; +pub(crate) mod cookie; + +pub(super) use authenticated_user::AuthenticatedUser; + use std::sync::Arc; -use askama_axum::{IntoResponse, Template}; -use axum::{extract::State, http::StatusCode, response::Redirect, Form}; +use askama_axum::Template; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, + Form, +}; use axum_extra::extract::{ - cookie::{Cookie, SameSite}, - CookieJar, + cookie::{Key, SameSite}, + PrivateCookieJar, }; -use nanoid::nanoid; use serde::Deserialize; +use thiserror::Error; use crate::AppState; @@ -24,30 +34,44 @@ pub(super) struct SignInRequest { secret: Arc, } -const SESSION_LIFETIME: time::Duration = time::Duration::days(30); +#[derive(Error, Debug)] +pub(super) enum CreateSignInError { + #[error("Bad secret")] + BadSecret, + #[error("Error building cookie {0}")] + BuildCookieError(#[from] postcard::Error), +} + +impl IntoResponse for CreateSignInError { + fn into_response(self) -> Response { + match self { + CreateSignInError::BadSecret => StatusCode::FORBIDDEN.into_response(), + CreateSignInError::BuildCookieError(_) => { + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + } +} pub(super) async fn create_sign_in( - State(AppState { secrets }): State, - jar: CookieJar, + State(AppState { secrets, .. }): State, + jar: PrivateCookieJar, Form(request): Form, -) -> impl IntoResponse { +) -> Result { if request.secret != secrets.user_secret { - return StatusCode::FORBIDDEN.into_response(); + return Err(CreateSignInError::BadSecret); } - let session_id = nanoid!(); - let expires_at = time::OffsetDateTime::now_utc() + SESSION_LIFETIME; // Set session cookie // The cookie does not need to be encrypted as it doesn't contain any sensitive information - let cookie = Cookie::build(("session", session_id)) + let cookie = cookie::Session::build()? .path("/") .secure(true) // Tell browsers to not allow JavaScript to access the cookie. Prevents some XSS attacks // (JS can still indirectly find out if user is authenticated by trying to access authenticated endpoints) .http_only(true) // Prevents CRSF attack - .same_site(SameSite::Strict) - .expires(expires_at); + .same_site(SameSite::Strict); - (jar.add(cookie), Redirect::to("/")).into_response() + Ok((jar.add(cookie), Redirect::to("/")).into_response()) } diff --git a/src/index.rs b/src/index.rs index b3e38d0..9d3ded1 100644 --- a/src/index.rs +++ b/src/index.rs @@ -1,9 +1,12 @@ use askama_axum::Template; +use crate::auth::AuthenticatedUser; + #[derive(Template)] #[template(path = "index.html")] pub(super) struct IndexTemplate; -pub(super) async fn get() -> IndexTemplate { +pub(super) async fn get(user: Option) -> IndexTemplate { + tracing::debug!("User is authenticated? {}", user.is_some()); IndexTemplate } diff --git a/src/main.rs b/src/main.rs index 6acde88..07f83cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod index; use std::net::Ipv4Addr; +use auth::cookie::{self, Key}; use axum::routing::get; use axum::Router; use dotenvy::dotenv; @@ -17,6 +18,7 @@ use tracing_subscriber::util::SubscriberInitExt; #[derive(Clone)] pub(crate) struct AppState { pub(crate) secrets: Secrets, + pub(crate) key: cookie::Key, } #[tokio::main] @@ -32,7 +34,10 @@ async fn main() { dotenv().ok(); let secrets = secrets::setup().await.unwrap(); - let state = AppState { secrets }; + let state = AppState { + secrets, + key: Key::new().expect("Error accessing random"), + }; let app = Router::new() .route("/", get(index::get)) -- 2.51.2