diff --git a/Cargo.lock b/Cargo.lock index 01b0795..bf7de12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,12 +90,31 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "diesel" version = "2.1.6" @@ -179,6 +198,7 @@ dependencies = [ "anyhow", "bytes", "chrono", + "cookie", "diesel", "ft-derive", "ft-sys", @@ -358,6 +378,12 @@ dependencies = [ "include_dir", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -385,6 +411,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -530,6 +562,37 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/Cargo.toml b/Cargo.toml index c0c3eee..8a353d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" chrono = { version = "0.4", default-features = false, features = ["serde"] } +cookie = "0.18" diesel = { version = ">=2, <2.2", features = ["serde_json"] } serde_urlencoded = "0.7" include_dir = "0.7" @@ -43,6 +44,7 @@ uuid = { version = "1.8", default-features = false, features = ["v8"] } ft-sys-shared = { path = "ft-sys-shared", version = "0.1.2" } ft-derive = { path = "ft-derive", version = "0.1.1" } ft-sys = { path = "ft-sys", version = "0.1.3" } +ft-sdk = { path = "ft-sdk", version = "0.1.12" } [workspace.dependencies.rusqlite] diff --git a/ft-sdk/Cargo.toml b/ft-sdk/Cargo.toml index c0c1aa7..27eaf6e 100644 --- a/ft-sdk/Cargo.toml +++ b/ft-sdk/Cargo.toml @@ -33,6 +33,7 @@ diesel = { workspace = true, optional = true } include_dir.workspace = true rand_core.workspace = true uuid.workspace = true +cookie.workspace = true [dev-dependencies] pretty_assertions = "1" diff --git a/ft-sdk/src/auth/mod.rs b/ft-sdk/src/auth/mod.rs index 9709f06..ce6028e 100644 --- a/ft-sdk/src/auth/mod.rs +++ b/ft-sdk/src/auth/mod.rs @@ -6,7 +6,9 @@ mod utils; pub use ft_sys_shared::SESSION_KEY; pub use schema::{fastn_session, fastn_user}; -pub use session::SessionID; +#[cfg(feature = "auth-provider")] +pub use session::{expire_session_cookie, set_session_cookie}; +pub use session::{SessionID, SessionIDError}; pub use utils::{user_data_by_query, Counter}; #[derive(Clone, Debug)] @@ -73,29 +75,52 @@ pub fn session_providers() -> Vec { todo!() } +/// Fetches user data based on a given session cookie. +/// +/// This function fetches user data based on a given session cookie if the +/// session cookie is found and is valid and user data is found. +/// If the session cookie not found, it returns `None`. #[cfg(feature = "field-extractors")] pub fn ud( cookie: ft_sdk::Cookie, conn: &mut ft_sdk::Connection, ) -> Result, UserDataError> { - if let Some(v) = ft_sys::env::var("DEBUG_LOGGED_IN".to_string()) { - let mut v = v.splitn(4, ' '); - return Ok(Some(ft_sys::UserData { - id: v.next().unwrap().parse().unwrap(), - identity: v.next().unwrap_or_default().to_string(), - name: v.next().map(|v| v.to_string()).unwrap_or_default(), - email: v.next().map(|v| v.to_string()).unwrap_or_default(), - verified_email: true, - })); + ft_sdk::println!("session cookie: {cookie}"); + // Check if debug user data is available, return it if found. + if let Some(ud) = get_debug_ud() { + return Ok(Some(ud)); } - ft_sdk::println!("sid: {cookie}"); - - let sid = match cookie.0 { + // Extract the session ID from the cookie. + let session_id = match cookie.0 { Some(v) => v, None => return Ok(None), }; + ud_from_session_key(conn, &ft_sdk::auth::SessionID(session_id)) +} + +/// Fetches user data based on a given session id. +/// +/// This function fetches user data based on a given session id if the +/// session is valid and user data is found. +/// If the session cookie not found, it returns `None`. +#[cfg(feature = "field-extractors")] +pub fn ud_from_session_key( + conn: &mut ft_sdk::Connection, + session_id: &ft_sdk::auth::SessionID, +) -> Result, UserDataError> { + // Check if debug user data is available, return it if found. + if let Some(ud) = get_debug_ud() { + return Ok(Some(ud)); + } + + ft_sdk::println!("sid: {}", session_id.0); + + // Validate the session using the extracted session ID. + session_id.validate_session(conn)?; + + // Query the database to get the user data associated with the session ID. let (UserId(id), identity, data) = match utils::user_data_by_query( conn, r#" @@ -107,13 +132,14 @@ pub fn ud( fastn_session.id = $1 AND fastn_user.id = fastn_session.uid "#, - sid.as_str(), + session_id.0.as_str(), ) { Ok(v) => v, Err(UserDataError::NoDataFound) => return Ok(None), Err(e) => return Err(e), }; + // Extract the primary email from the user data, prefer verified emails. let email = data .verified_emails .first() @@ -122,19 +148,46 @@ pub fn ud( Ok(Some(ft_sys::UserData { id, - identity: identity.expect("user fetched from session cookie must have identity"), + identity: identity.ok_or_else(|| UserDataError::IdentityNotFound)?, name: data.name.unwrap_or_default(), email, verified_email: !data.verified_emails.is_empty(), })) } +/// Check if debug user data is available, return it if found. +fn get_debug_ud() -> Option { + match ft_sys::env::var("DEBUG_LOGGED_IN".to_string()) { + Some(debug_logged_in) => { + let mut debug_logged_in = debug_logged_in.splitn(4, ' '); + Some(ft_sys::UserData { + id: debug_logged_in.next().unwrap().parse().unwrap(), + identity: debug_logged_in.next().unwrap_or_default().to_string(), + name: debug_logged_in + .next() + .map(|v| v.to_string()) + .unwrap_or_default(), + email: debug_logged_in + .next() + .map(|v| v.to_string()) + .unwrap_or_default(), + verified_email: true, + }) + } + None => None, + } +} + #[derive(Debug, thiserror::Error)] pub enum UserDataError { #[error("no data found for the provider")] NoDataFound, #[error("multiple rows found")] MultipleRowsFound, + #[error("session error: {0:?}")] + SessionError(#[from] ft_sdk::auth::SessionIDError), + #[error("user fetched from session cookie must have identity")] + IdentityNotFound, #[error("db error: {0:?}")] DatabaseError(#[from] diesel::result::Error), #[error("failed to deserialize data from db: {0:?}")] diff --git a/ft-sdk/src/auth/provider.rs b/ft-sdk/src/auth/provider.rs index 948f41a..d7312a9 100644 --- a/ft-sdk/src/auth/provider.rs +++ b/ft-sdk/src/auth/provider.rs @@ -290,23 +290,57 @@ pub fn create_user( Ok(ft_sdk::auth::UserId(user_id)) } -/// persist the user in session and redirect to `next` +/// Logs in a user and manages session creation or update. /// -/// `identity`: Eg for GitHub, it could be the username. This is stored in the cookie so can be -/// retrieved without a db call to show a user identifiable information. +/// # Arguments +/// +/// * `conn` - Mutable reference to a `ft_sdk::Connection` to interact with the database. +/// * `user_id` - Reference to a `ft_sdk::UserId` representing the user's ID. +/// * `session_id` - Optional `ft_sdk::auth::SessionID` representing an existing session ID. +/// +/// # Returns +/// +/// A `Result` containing a `ft_sdk::auth::SessionID` if the login operation is successful, +/// or a `LoginError` if there's an issue with the login process. pub fn login( conn: &mut ft_sdk::Connection, - ft_sdk::UserId(user_id): &ft_sdk::UserId, + user_id: &ft_sdk::UserId, session_id: Option, +) -> Result { + login_with_custom_session_expiration(conn, user_id, session_id, None) +} + +/// Logs in a user with customizable session expiration and manages session creation or update. +/// +/// # Arguments +/// +/// * `conn` - Mutable reference to a `ft_sdk::Connection` to interact with the database. +/// * `user_id` - Reference to a `ft_sdk::UserId` representing the user's ID. +/// * `session_id` - Optional `ft_sdk::auth::SessionID` representing an existing session ID. +/// * `session_expiration_duration` - Optional `chrono::Duration` for custom session expiration. +/// +/// # Returns +/// +/// A `Result` containing a `ft_sdk::auth::SessionID` if the login operation is successful, +/// or a `LoginError` if there's an issue with the login process. +pub fn login_with_custom_session_expiration( + conn: &mut ft_sdk::Connection, + user_id: &ft_sdk::UserId, + session_id: Option, + session_expiration_duration: Option, ) -> Result { match session_id { - Some(session_id) if session_id.0 == "hello" => { - Ok(ft_sdk::auth::session::create_with_user(conn, *user_id)?) - } - Some(session_id) => Ok(ft_sdk::auth::session::set_user_id( - conn, session_id, *user_id, + Some(session_id) if session_id.0 == "hello" => Ok(ft_sdk::auth::session::create_with_user( + conn, + user_id, + session_expiration_duration, + )?), + _ => Ok(ft_sdk::auth::session::set_user_id( + conn, + session_id, + user_id, + session_expiration_duration, )?), - None => Ok(ft_sdk::auth::session::create_with_user(conn, *user_id)?), } } @@ -316,7 +350,6 @@ pub enum LoginError { DatabaseError(#[from] diesel::result::Error), #[error("set user id for session {0}")] SetUserIDError(#[from] ft_sdk::auth::session::SetUserIDError), - #[error("json error: {0}")] JsonError(#[from] serde_json::Error), } diff --git a/ft-sdk/src/auth/schema.rs b/ft-sdk/src/auth/schema.rs index c632e49..d06c7f0 100644 --- a/ft-sdk/src/auth/schema.rs +++ b/ft-sdk/src/auth/schema.rs @@ -20,6 +20,7 @@ diesel::table! { data -> Text, updated_at -> Timestamptz, created_at -> Timestamptz, + expires_at -> Nullable, } } diff --git a/ft-sdk/src/auth/session.rs b/ft-sdk/src/auth/session.rs index 120f2f8..3c2d017 100644 --- a/ft-sdk/src/auth/session.rs +++ b/ft-sdk/src/auth/session.rs @@ -4,10 +4,8 @@ pub struct SessionID(pub String); #[cfg(feature = "auth-provider")] #[derive(Debug, thiserror::Error)] pub enum SetUserIDError { - #[error("session not found")] - SessionNotFound, - #[error("multiple sessions found")] - MultipleSessionsFound, + #[error("session id error: {0:?}")] + SessionIDError(#[from] ft_sdk::auth::SessionIDError), #[error("failed to query db: {0:?}")] DatabaseError(#[from] diesel::result::Error), } @@ -15,38 +13,94 @@ pub enum SetUserIDError { #[cfg(feature = "auth-provider")] pub fn set_user_id( conn: &mut ft_sdk::Connection, - SessionID(session_id): SessionID, - user_id: i64, + session_id: Option, + user_id: &ft_sdk::UserId, + session_expiration_duration: Option, ) -> Result { use diesel::prelude::*; use ft_sdk::auth::fastn_session; - match diesel::update(fastn_session::table.filter(fastn_session::id.eq(session_id.as_str()))) - .set(fastn_session::uid.eq(Some(user_id))) - .execute(conn)? - { - 0 => Ok(create_with_user(conn, user_id)?), - 1 => Ok(SessionID(session_id)), - _ => Err(SetUserIDError::MultipleSessionsFound), + let now = ft_sdk::env::now(); + + // Query to check if the session exists and get its expiration time + let session = match session_id { + Some(session_id) => { + let existing_session_expires_at = fastn_session::table + .select(fastn_session::expires_at.nullable()) + .filter(fastn_session::id.eq(session_id.0.as_str())) + .first::>>(conn) + .optional()?; + + match existing_session_expires_at { + Some(Some(expires_at)) if expires_at < now => Some((session_id.0.clone(), true)), + Some(_) => Some((session_id.0.clone(), false)), + None => None, + } + } + None => match ft_sdk::auth::SessionID::from_user_id(conn, user_id) { + Ok(session_id) => Some((session_id.0.clone(), false)), + Err(ft_sdk::auth::SessionIDError::SessionExpired(session_id)) => { + Some((session_id, true)) + } + Err(ft_sdk::auth::SessionIDError::SessionNotFound) => None, + Err(e) => return Err(e.into()), + }, + }; + + match session { + Some((session_id, true)) => { + // Session is expired, delete it and create a new one + diesel::delete(fastn_session::table.filter(fastn_session::id.eq(session_id.as_str()))) + .execute(conn)?; + Ok(create_with_user( + conn, + user_id, + session_expiration_duration, + )?) + } + Some((session_id, false)) => { + // Session is not expired, update the user ID + diesel::update(fastn_session::table.filter(fastn_session::id.eq(session_id.as_str()))) + .set(( + fastn_session::uid.eq(Some(user_id.0)), + fastn_session::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(SessionID(session_id)) + } + None => { + // Session does not exist, create a new one + Ok(create_with_user( + conn, + user_id, + session_expiration_duration, + )?) + } } } #[cfg(feature = "auth-provider")] pub fn create_with_user( conn: &mut ft_sdk::Connection, - user_id: i64, + ft_sdk::UserId(user_id): &ft_sdk::UserId, + session_expiration_duration: Option, ) -> Result { use diesel::prelude::*; use ft_sdk::auth::fastn_session; + use std::ops::Add; let session_id = generate_new_session_id(); + let session_expires_at = + session_expiration_duration.map(|duration| ft_sdk::env::now().add(duration)); diesel::insert_into(fastn_session::table) .values(( fastn_session::id.eq(&session_id), - fastn_session::uid.eq(Some(user_id)), + fastn_session::uid.eq(Some(*user_id)), fastn_session::created_at.eq(ft_sdk::env::now()), fastn_session::updated_at.eq(ft_sdk::env::now()), + fastn_session::expires_at.eq(session_expires_at), fastn_session::data.eq("{}"), )) .execute(conn)?; @@ -54,6 +108,168 @@ pub fn create_with_user( Ok(ft_sdk::auth::SessionID(session_id)) } +/// Sets a session cookie with an expiration time based on the session's expiration time +/// in the database. If the session has an expiration time in the future, the cookie's +/// max age is set to the remaining duration until that time. Otherwise, a default max age +/// of 400 days is set. +/// +/// # Arguments +/// +/// * `conn` - A mutable reference to the database connection. +/// * `session_id` - A string slice representing the session ID. +/// * `host` - The host for which the cookie is valid. +/// +/// # Errors +/// +/// This function will return an error if: +/// * The session ID is not found in the database. +/// * The session ID is found but has expired. +/// * There is an issue querying the database. +/// * There is an error creating the `http::HeaderValue`. +#[cfg(feature = "auth-provider")] +pub fn set_session_cookie( + conn: &mut ft_sdk::Connection, + ft_sdk::auth::SessionID(session_id): ft_sdk::auth::SessionID, + host: ft_sdk::Host, +) -> Result { + use diesel::prelude::*; + use ft_sdk::auth::fastn_session; + + let now = ft_sdk::env::now(); + + // Query to check if the session exists and get its expiration time. + let max_age = match fastn_session::table + .select(fastn_session::expires_at.nullable()) + .filter(fastn_session::id.eq(&session_id)) + .first::>>(conn) + { + // If the session has an expiration time and it is in the future. + Ok(Some(session_expires_at)) if session_expires_at > now => { + let duration = session_expires_at - now; + cookie::time::Duration::new(duration.num_seconds(), duration.subsec_nanos()) + } + // If the session does not have an expiration time. + Ok(None) => cookie::time::Duration::seconds(34560000), + // If the session has an expiration time and it is in the past. + Ok(_) => return Err(SessionIDError::SessionExpired(session_id.clone()).into()), + // If the session is not found. + Err(diesel::NotFound) => return Err(SessionIDError::SessionNotFound.into()), + // If there is an error querying the database. + Err(e) => return Err(e.into()), + }; + + // Build the cookie with the determined max age + let cookie = cookie::Cookie::build((ft_sdk::auth::SESSION_KEY, session_id)) + .domain(host.without_port()) + .path("/") + .max_age(max_age) + .same_site(cookie::SameSite::Strict) + .build(); + + // Convert the cookie to an HTTP header value and return it + Ok(http::HeaderValue::from_str(cookie.to_string().as_str())?) +} + +/// Expires the session cookie immediately by setting its expiration time to the current time. +#[cfg(feature = "auth-provider")] +pub fn expire_session_cookie(host: ft_sdk::Host) -> Result { + let cookie = cookie::Cookie::build((ft_sdk::auth::SESSION_KEY, "")) + .domain(host.without_port()) + .path("/") + .expires(convert_now_to_offsetdatetime()) + .build(); + + Ok(http::HeaderValue::from_str(cookie.to_string().as_str())?) +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionIDError { + #[error("session not found")] + SessionNotFound, + #[error("session expired")] + SessionExpired(String), + #[error("failed to query db: {0:?}")] + DatabaseError(#[from] diesel::result::Error), +} + +impl SessionID { + /// Retrieves a session ID based on a given user ID. + /// + /// This method queries the database to find a session associated with the + /// given user ID. + pub fn from_user_id( + conn: &mut ft_sdk::Connection, + user_id: &ft_sdk::auth::UserId, + ) -> Result { + use diesel::prelude::*; + use ft_sdk::auth::fastn_session; + + // Get the current time. + let now = ft_sdk::env::now(); + + // Query to find the session ID and its expiration time for the given user ID. + match fastn_session::table + .select((fastn_session::id, fastn_session::expires_at.nullable())) + .filter(fastn_session::uid.eq(user_id.0)) + .first::<(String, Option>)>(conn) + { + // If a session is found and it is expired, return a `SessionExpired` error. + Ok((id, Some(expires_at))) if expires_at < now => { + return Err(SessionIDError::SessionExpired(id)) + } + // If a valid session is found, return the session ID. + Ok((id, _)) => Ok(SessionID(id)), + // If no session is found for the user ID, return a `SessionNotFound` error. + Err(diesel::NotFound) => return Err(SessionIDError::SessionNotFound), + // If any other error occurs during the query, return it. + Err(e) => return Err(e.into()), + } + } + + /// Validates if the session is active and not expired. + /// + /// This function checks the validity of the session associated with the session ID. + /// If the session is found and is not expired, it returns `Ok(())`. + /// If the session is expired or not found, it returns an appropriate error. + pub fn validate_session(&self, conn: &mut ft_sdk::Connection) -> Result<(), SessionIDError> { + use diesel::prelude::*; + use ft_sdk::auth::fastn_session; + + // Get the current time. + let now = ft_sdk::env::now(); + + // Query to find the session ID and its expiration time for the given user ID. + match fastn_session::table + .select((fastn_session::id, fastn_session::expires_at.nullable())) + .filter(fastn_session::id.eq(&self.0)) + .first::<(String, Option>)>(conn) + { + // If a session is found and it is expired, return a `SessionExpired` error. + Ok((id, Some(expires_at))) if expires_at < now => { + return Err(SessionIDError::SessionExpired(id)) + } + // If a valid session is found, return. + Ok((_, _)) => Ok(()), + // If no session is found for the user ID, return a `SessionNotFound` error. + Err(diesel::NotFound) => return Err(SessionIDError::SessionNotFound), + // If any other error occurs during the query, return it. + Err(e) => return Err(e.into()), + } + } +} + +/// Converts the current time to `cookie::time::OffsetDateTime`. +#[cfg(feature = "auth-provider")] +fn convert_now_to_offsetdatetime() -> cookie::time::OffsetDateTime { + let now = ft_sdk::env::now(); + let timestamp = now.timestamp(); + let nanoseconds = now.timestamp_subsec_nanos(); + cookie::time::OffsetDateTime::from_unix_timestamp_nanos( + (timestamp * 1_000_000_000 + nanoseconds as i64) as i128, + ) + .unwrap() +} + #[cfg(feature = "auth-provider")] fn generate_new_session_id() -> String { use rand_core::RngCore;