diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs new file mode 100644 index 0000000000..d634825878 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/api_or_web_session_user_record.rs @@ -0,0 +1,21 @@ +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +/// A user authenticated by either an API key or a web session cookie. +/// +/// The two lookup queries (`get_api_or_web_session_user_by_api_key` and +/// `get_api_or_web_session_user_by_session_token`) select congruent user fields so both can +/// produce this one uniform record. +pub struct ApiOrWebSessionUserRecord { + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_address: String, + + pub user_role_slug: String, + pub is_banned: bool, + pub can_ban_users: bool, + + /// Only set when the lookup was by API key. + pub maybe_api_key_token: Option, +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs new file mode 100644 index 0000000000..4495edf0b6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_api_key.rs @@ -0,0 +1,70 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use artcraft_api_keys::ArtcraftApiKey; +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false}; +use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord; + +pub struct GetApiOrWebSessionUserByApiKeyArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + /// The actual API key secret presented by the client. + pub api_key: &'e ArtcraftApiKey, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up the user behind an API key secret, selecting the same user fields as +/// `get_api_or_web_session_user_by_session_token` so both auth paths share one record shape. +/// +/// Returns `Ok(None)` when no row matches, when the matching API key is soft-deleted, or when the +/// owning user is missing or deleted. The full secret is never echoed back. +pub async fn get_api_or_web_session_user_by_api_key<'e, 'c: 'e, E>( + args: GetApiOrWebSessionUserByApiKeyArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + users.token as `user_token: UserToken`, + users.username, + users.display_name, + users.email_address, + users.user_role_slug, + users.is_banned, + user_roles.can_ban_users, + api_keys.token as `api_key_token: ApiKeyToken` +FROM api_keys +INNER JOIN users + ON users.token = api_keys.owner_user_token +LEFT OUTER JOIN user_roles + ON users.user_role_slug = user_roles.slug +WHERE api_keys.api_key = ? + AND api_keys.maybe_deleted_at IS NULL + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL +LIMIT 1 + "#, + args.api_key.as_str_be_careful(), + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| ApiOrWebSessionUserRecord { + user_token: r.user_token, + username: r.username, + display_name: r.display_name, + email_address: r.email_address, + user_role_slug: r.user_role_slug, + is_banned: i8_to_bool(r.is_banned), + can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users), + maybe_api_key_token: Some(r.api_key_token), + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs new file mode 100644 index 0000000000..7a3ae609e4 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/get_api_or_web_session_user_by_session_token.rs @@ -0,0 +1,67 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::users::UserToken; + +use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false}; +use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord; + +pub struct GetApiOrWebSessionUserBySessionTokenArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + /// The session token decoded from the session cookie. + pub session_token: &'e str, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up the user behind a web session token, selecting the same user fields as +/// `get_api_or_web_session_user_by_api_key` so both auth paths share one record shape. +/// +/// Returns `Ok(None)` when no row matches, when the session is deleted, or when the owning user +/// is missing or deleted. +pub async fn get_api_or_web_session_user_by_session_token<'e, 'c: 'e, E>( + args: GetApiOrWebSessionUserBySessionTokenArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + users.token as `user_token: UserToken`, + users.username, + users.display_name, + users.email_address, + users.user_role_slug, + users.is_banned, + user_roles.can_ban_users +FROM user_sessions +INNER JOIN users + ON users.token = user_sessions.user_token +LEFT OUTER JOIN user_roles + ON users.user_role_slug = user_roles.slug +WHERE user_sessions.token = ? + AND user_sessions.deleted_at IS NULL + AND users.user_deleted_at IS NULL + AND users.mod_deleted_at IS NULL +LIMIT 1 + "#, + args.session_token, + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| ApiOrWebSessionUserRecord { + user_token: r.user_token, + username: r.username, + display_name: r.display_name, + email_address: r.email_address, + user_role_slug: r.user_role_slug, + is_banned: i8_to_bool(r.is_banned), + can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users), + maybe_api_key_token: None, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs new file mode 100644 index 0000000000..6751f3c647 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/api_or_web_sessions/mod.rs @@ -0,0 +1,3 @@ +pub mod api_or_web_session_user_record; +pub mod get_api_or_web_session_user_by_api_key; +pub mod get_api_or_web_session_user_by_session_token; diff --git a/crates/schema/database/mysql_queries/src/queries/users/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/mod.rs index 24f32c6b17..339dbb2da3 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/mod.rs @@ -1,3 +1,4 @@ +pub mod api_or_web_sessions; pub mod user; pub mod user_badges; pub mod user_bookmarks; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/create_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/create_character_handler.rs index 1ece6fde04..275343c060 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/create_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/create_character_handler.rs @@ -24,6 +24,7 @@ use tokens::tokens::generic_inference_jobs::InferenceJobToken; use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions}; use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session; use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format; use crate::state::server_state::ServerState; use crate::util::http_download_url_to_bytes::http_download_url_to_bytes; @@ -58,19 +59,12 @@ pub async fn create_character_handler( .acquire() .await?; - let maybe_user_session = server_state - .session_checker - .maybe_get_user_session_from_connection(&http_request, &mut mysql_connection) - .await - .map_err(|e| { - warn!("Session checker error: {:?}", e); - CommonWebError::from(e) - })?; - - let user_session = match maybe_user_session { - Some(session) => session, - None => return Err(CommonWebError::NotAuthorized), - }; + let user_session = require_api_or_web_session( + &http_request, + &server_state.session_checker, + &server_state.avt_cookie_manager, + &mut mysql_connection, + ).await?; let user_token = &user_session.user_token; let ip_address = get_request_ip(&http_request); @@ -194,7 +188,7 @@ pub async fn create_character_handler( kinovi_character_id: &gen_result.character_id, maybe_model_type: None, maybe_creator_user_token: Some(user_token), - maybe_avt_token: None, + maybe_avt_token: user_session.maybe_avt_token.as_ref(), creator_ip_address: &ip_address, creator_set_visibility: Visibility::Hidden, mysql_executor: &mut *transaction, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs index 28ae40e930..6b6d40351c 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/delete_character_handler.rs @@ -10,7 +10,7 @@ use mysql_queries::queries::characters::delete_character::delete_character; use mysql_queries::queries::characters::get_character_by_token_including_deleted::get_character_by_token_including_deleted; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session; use crate::state::server_state::ServerState; /// Delete a character. @@ -40,7 +40,12 @@ pub async fn delete_character_handler( let mut mysql_connection = server_state.mysql_pool.acquire().await?; - let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?; + let user_session = require_api_or_web_session( + &http_request, + &server_state.session_checker, + &server_state.avt_cookie_manager, + &mut mysql_connection, + ).await?; let user_token = &user_session.user_token; let is_mod = user_session.is_mod(); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs index 5cbfb8dca3..67d190beda 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/edit_character_handler.rs @@ -11,7 +11,7 @@ use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession; use seedance2pro_client::requests::update_character::update_character::{update_character, UpdateCharacterArgs}; use crate::http_server::common_responses::common_web_error::CommonWebError; -use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session; use crate::state::server_state::ServerState; /// Edit a character's name or description. @@ -38,7 +38,12 @@ pub async fn edit_character_handler( let mut mysql_connection = server_state.mysql_pool.acquire().await?; - let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?; + let user_session = require_api_or_web_session( + &http_request, + &server_state.session_checker, + &server_state.avt_cookie_manager, + &mut mysql_connection, + ).await?; let user_token = &user_session.user_token; let is_mod = user_session.is_mod(); diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs index d65b245f47..60161b5412 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/characters/list_characters_handler.rs @@ -16,7 +16,7 @@ use crate::http_server::common_responses::common_web_error::CommonWebError; use crate::http_server::common_responses::media::media_domain::MediaDomain; use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder; use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain; -use crate::http_server::user_lookup::user_session::require_user_session::require_user_session; +use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session; use crate::state::server_state::ServerState; /// List characters for the current session. @@ -39,7 +39,12 @@ pub async fn list_characters_handler( let mut mysql_connection = server_state.mysql_pool.acquire().await?; - let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *mysql_connection).await?; + let user_session = require_api_or_web_session( + &http_request, + &server_state.session_checker, + &server_state.avt_cookie_manager, + &mut mysql_connection, + ).await?; let user_token = &user_session.user_token; diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs new file mode 100644 index 0000000000..0dfb97943d --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/mod.rs @@ -0,0 +1 @@ +pub mod require_api_or_web_session; diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/require_api_or_web_session.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/require_api_or_web_session.rs new file mode 100644 index 0000000000..2cbcaf7484 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/api_or_web_session/require_api_or_web_session.rs @@ -0,0 +1,183 @@ +use std::marker::PhantomData; + +use actix_web::http::header; +use actix_web::HttpRequest; +use log::warn; +use sqlx::MySqlConnection; + +use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager; +use mysql_queries::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord; +use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_api_key::{ + get_api_or_web_session_user_by_api_key, GetApiOrWebSessionUserByApiKeyArgs, +}; +use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_session_token::{ + get_api_or_web_session_user_by_session_token, GetApiOrWebSessionUserBySessionTokenArgs, +}; +use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken; +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker; +use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key; + +const ADMIN_ROLE_SLUG: &str = "admin"; + +/// The authenticated identity behind a request that may come from either an API client +/// (`Authorization` header API key) or the website (session cookie). Fields common to both auth +/// paths are populated uniformly, so handlers don't need to care which path authenticated the +/// user. +pub struct ApiOrWebSession { + pub session_type: SessionType, + pub user_token: UserToken, + pub username: String, + pub display_name: String, + pub email_address: String, + + pub user_role_slug: String, + pub can_ban_users: bool, + + /// Only present for API-key sessions. + pub maybe_api_key_token: Option, + + /// Anonymous visitor tracking cookie. Only ever present for web sessions. + pub maybe_avt_token: Option, +} + +/// Which auth path authenticated the request. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum SessionType { + Api, + WebSession, +} + +impl ApiOrWebSession { + pub fn is_mod(&self) -> bool { + self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG + } +} + +/// Authenticate a request as either an API-key user or a web-session (cookie) user. +/// +/// If an `Authorization` header is present, ONLY the API-key lookup is attempted — a malformed or +/// unknown key is a 401, never a fallthrough to cookies. Otherwise the session cookie is decoded +/// and looked up, and the AVT (anonymous visitor tracking) cookie is captured alongside it. +/// +/// Neither path is cached: every request performs a fresh MySQL lookup. Pass an already-open +/// connection (`&mut *connection`) rather than acquiring a fresh one. +pub async fn require_api_or_web_session( + http_request: &HttpRequest, + session_checker: &SessionChecker, + avt_cookie_manager: &AvtCookieManager, + mysql_connection: &mut MySqlConnection, +) -> Result { + if http_request.headers().contains_key(header::AUTHORIZATION) { + authenticate_by_api_key(http_request, mysql_connection).await + } else { + authenticate_by_web_session(http_request, session_checker, avt_cookie_manager, mysql_connection).await + } +} + +async fn authenticate_by_api_key( + http_request: &HttpRequest, + mysql_connection: &mut MySqlConnection, +) -> Result { + let api_key = match get_authorization_header_api_key(http_request) { + Some(api_key) => api_key, + None => { + warn!("Authorization header present but not a usable API key"); + return Err(CommonWebError::NotAuthorized); + } + }; + + let maybe_record = get_api_or_web_session_user_by_api_key(GetApiOrWebSessionUserByApiKeyArgs { + api_key: &api_key, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await + .map_err(|err| { + warn!("API key user lookup error: {:?}", err); + CommonWebError::from_error(err) + })?; + + // A missing or soft-deleted key (or a key whose owner no longer exists) is a 401, not a leak of + // which case occurred. + let record = match maybe_record { + Some(record) => record, + None => { + warn!("No live API key user for presented key: {:?}", api_key); + return Err(CommonWebError::NotAuthorized); + } + }; + + into_session_rejecting_banned(record, SessionType::Api, None) +} + +async fn authenticate_by_web_session( + http_request: &HttpRequest, + session_checker: &SessionChecker, + avt_cookie_manager: &AvtCookieManager, + mysql_connection: &mut MySqlConnection, +) -> Result { + let maybe_session_token = session_checker + .get_session_token(http_request) + .map_err(|err| { + warn!("Session cookie decode error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let session_token = match maybe_session_token { + Some(session_token) => session_token, + None => { + warn!("not logged in"); + return Err(CommonWebError::NotAuthorized); + } + }; + + let maybe_record = get_api_or_web_session_user_by_session_token(GetApiOrWebSessionUserBySessionTokenArgs { + session_token: &session_token, + mysql_executor: &mut *mysql_connection, + phantom: PhantomData, + }) + .await + .map_err(|err| { + warn!("Web session user lookup error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let record = match maybe_record { + Some(record) => record, + None => { + warn!("Valid cookie; invalid session: {}", session_token); + return Err(CommonWebError::NotAuthorized); + } + }; + + let maybe_avt_token = avt_cookie_manager.get_avt_token_from_request(http_request); + + into_session_rejecting_banned(record, SessionType::WebSession, maybe_avt_token) +} + +fn into_session_rejecting_banned( + record: ApiOrWebSessionUserRecord, + session_type: SessionType, + maybe_avt_token: Option, +) -> Result { + if record.is_banned { + warn!("user is banned: {:?}", record.user_token.as_str()); + return Err(CommonWebError::NotAuthorized); + } + + Ok(ApiOrWebSession { + session_type, + user_token: record.user_token, + username: record.username, + display_name: record.display_name, + email_address: record.email_address, + user_role_slug: record.user_role_slug, + can_ban_users: record.can_ban_users, + maybe_api_key_token: record.maybe_api_key_token, + maybe_avt_token, + }) +} diff --git a/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs b/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs index 0043e41ef5..11127bc4da 100644 --- a/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/user_lookup/mod.rs @@ -1,2 +1,3 @@ pub mod api_keys; +pub mod api_or_web_session; pub mod user_session;