Trading card city builder game?

implement auth layer, with fake auth

eldridge.cam 6c0e696d e5f0fec0

verified
Waiting for spindle ...
+280 -57
+50 -1
Cargo.lock
··· 239 239 ] 240 240 241 241 [[package]] 242 + name = "axum-extra" 243 + version = "0.12.5" 244 + source = "registry+https://github.com/rust-lang/crates.io-index" 245 + checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" 246 + dependencies = [ 247 + "axum 0.8.8", 248 + "axum-core 0.5.6", 249 + "bytes", 250 + "futures-core", 251 + "futures-util", 252 + "headers", 253 + "http", 254 + "http-body", 255 + "http-body-util", 256 + "mime", 257 + "pin-project-lite", 258 + "tower-layer", 259 + "tower-service", 260 + "tracing", 261 + ] 262 + 263 + [[package]] 242 264 name = "base64" 243 265 version = "0.22.1" 244 266 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 292 314 dependencies = [ 293 315 "anyhow", 294 316 "axum 0.8.8", 317 + "axum-extra 0.12.5", 295 318 "cartography-macros", 296 319 "clap", 297 320 "derive_more", ··· 838 861 ] 839 862 840 863 [[package]] 864 + name = "headers" 865 + version = "0.4.1" 866 + source = "registry+https://github.com/rust-lang/crates.io-index" 867 + checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" 868 + dependencies = [ 869 + "base64", 870 + "bytes", 871 + "headers-core", 872 + "http", 873 + "httpdate", 874 + "mime", 875 + "sha1", 876 + ] 877 + 878 + [[package]] 879 + name = "headers-core" 880 + version = "0.3.0" 881 + source = "registry+https://github.com/rust-lang/crates.io-index" 882 + checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" 883 + dependencies = [ 884 + "http", 885 + ] 886 + 887 + [[package]] 841 888 name = "heck" 842 889 version = "0.5.0" 843 890 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1742 1789 checksum = "7b3efdac145ec337b9db75e6d1d8732980551c07490ef9db72947e2af592f051" 1743 1790 dependencies = [ 1744 1791 "axum 0.7.9", 1745 - "axum-extra", 1792 + "axum-extra 0.9.6", 1746 1793 "rust-embed", 1747 1794 "serde_json", 1748 1795 "tokio", ··· 2363 2410 source = "registry+https://github.com/rust-lang/crates.io-index" 2364 2411 checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 2365 2412 dependencies = [ 2413 + "base64", 2366 2414 "bitflags", 2367 2415 "bytes", 2368 2416 "http", 2369 2417 "http-body", 2418 + "mime", 2370 2419 "pin-project-lite", 2371 2420 "tower-layer", 2372 2421 "tower-service",
+2 -1
packages/cartography/Cargo.toml
··· 13 13 [dependencies] 14 14 anyhow = "1.0.101" 15 15 axum = { version = "0.8.8", features = ["ws"] } 16 + axum-extra = { version = "0.12.5", features = ["typed-header"] } 16 17 clap = { version = "4.5.58", features = ["derive"] } 17 18 derive_more = { version = "2.1.1", features = ["error", "display"] } 18 19 futures = "0.3.31" ··· 28 29 time = { version = "0.3.47", features = ["serde", "serde-human-readable"] } 29 30 tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } 30 31 tokio-stream = { version = "0.1.18", features = ["sync"] } 31 - tower-http = { version = "0.6.8", features = ["trace"] } 32 + tower-http = { version = "0.6.8", features = ["auth", "trace"] } 32 33 tracing = "0.1.44" 33 34 tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } 34 35 utoipa = { git = "https://github.com/foxfriends/utoipa", features = ["time"] } # { version = "5.4.0", features = ["time"] }
packages/cartography/src/api/errors/banner_not_found.rs packages/cartography/src/api/errors/banner_not_found_error.rs
+14
packages/cartography/src/api/errors/forbidden_error.rs
··· 1 + use super::ApiError; 2 + use axum::http::StatusCode; 3 + 4 + #[derive(Debug, derive_more::Display, derive_more::Error)] 5 + #[display("permission required")] 6 + pub struct ForbiddenError; 7 + 8 + impl ApiError for ForbiddenError { 9 + const STATUS: StatusCode = StatusCode::FORBIDDEN; 10 + const CODE: &str = "Forbidden"; 11 + type Detail = (); 12 + 13 + fn detail(&self) -> Self::Detail {} 14 + }
+33
packages/cartography/src/api/errors/internal_server_error.rs
··· 1 + use super::{ApiError, JsonError}; 2 + use axum::http::StatusCode; 3 + 4 + #[derive(Debug, derive_more::Display, derive_more::Error)] 5 + #[display("{_0}")] 6 + pub struct InternalServerError(anyhow::Error); 7 + 8 + #[allow(unused_macros)] 9 + macro_rules! respond_internal_server_error { 10 + ($($fmt:tt)+) => { 11 + return Err(internal_server_error(anyhow::anyhow!($($fmt)+)).into()) 12 + }; 13 + () => { 14 + return Err(internal_server_error(anyhow::anyhow!("Unexpected server error")).into()) 15 + }; 16 + } 17 + 18 + pub(crate) use respond_internal_server_error; 19 + 20 + pub fn internal_server_error<T>(error: T) -> JsonError<InternalServerError> 21 + where 22 + anyhow::Error: From<T>, 23 + { 24 + JsonError(InternalServerError(error.into())) 25 + } 26 + 27 + impl ApiError for InternalServerError { 28 + const STATUS: StatusCode = StatusCode::INTERNAL_SERVER_ERROR; 29 + const CODE: &str = "Internal server error"; 30 + type Detail = (); 31 + 32 + fn detail(&self) -> Self::Detail {} 33 + }
+15 -33
packages/cartography/src/api/errors/mod.rs
··· 1 1 use axum::body::Body; 2 2 use axum::http::{Response, StatusCode}; 3 - use axum::response::{IntoResponse, Json}; 3 + use axum::response::{AppendHeaders, IntoResponse, Json}; 4 4 use serde_json::Value; 5 5 use std::error::Error; 6 6 7 - mod banner_not_found; 7 + mod banner_not_found_error; 8 + mod forbidden_error; 9 + mod internal_server_error; 10 + mod unauthorized_error; 8 11 9 - pub use banner_not_found::BannerNotFoundError; 12 + pub use banner_not_found_error::BannerNotFoundError; 13 + pub use forbidden_error::ForbiddenError; 14 + #[allow(unused_imports)] 15 + pub(crate) use internal_server_error::{internal_server_error, respond_internal_server_error}; 16 + pub use unauthorized_error::UnauthorizedError; 10 17 11 18 pub trait ApiError: Error { 12 19 const STATUS: StatusCode; 13 20 const CODE: &str; 14 21 type Detail: serde::Serialize; 15 22 16 - fn detail(&self) -> Self::Detail; 17 - } 18 - 19 - #[derive(Debug, derive_more::Display, derive_more::Error)] 20 - #[display("{_0}")] 21 - pub struct InternalServerError(anyhow::Error); 22 - 23 - macro_rules! respond_internal_server_error { 24 - ($($fmt:tt)+) => { 25 - return Err(internal_server_error(anyhow::anyhow!($($fmt)+)).into()) 26 - }; 27 - () => { 28 - return Err(internal_server_error(anyhow::anyhow!("Unexpected server error")).into()) 29 - }; 30 - } 31 - 32 - pub(crate) use respond_internal_server_error; 23 + fn headers(&self) -> AppendHeaders<Vec<(String, String)>> { 24 + AppendHeaders(vec![]) 25 + } 33 26 34 - pub fn internal_server_error<T>(error: T) -> JsonError<InternalServerError> 35 - where 36 - anyhow::Error: From<T>, 37 - { 38 - JsonError(InternalServerError(error.into())) 39 - } 40 - 41 - impl ApiError for InternalServerError { 42 - const STATUS: StatusCode = StatusCode::INTERNAL_SERVER_ERROR; 43 - const CODE: &str = "Internal server error"; 44 - type Detail = (); 45 - 46 - fn detail(&self) -> Self::Detail {} 27 + fn detail(&self) -> Self::Detail; 47 28 } 48 29 49 30 pub struct JsonError<T>(pub T); ··· 76 57 77 58 ( 78 59 T::STATUS, 60 + self.0.headers(), 79 61 Json(ErrorData::<T> { 80 62 code: T::CODE, 81 63 message: self.0.to_string(),
+18
packages/cartography/src/api/errors/unauthorized_error.rs
··· 1 + use super::ApiError; 2 + use axum::{http::StatusCode, response::AppendHeaders}; 3 + 4 + #[derive(Debug, derive_more::Display, derive_more::Error)] 5 + #[display("authentication required")] 6 + pub struct UnauthorizedError; 7 + 8 + impl ApiError for UnauthorizedError { 9 + const STATUS: StatusCode = StatusCode::UNAUTHORIZED; 10 + const CODE: &str = "Unauthorized"; 11 + type Detail = (); 12 + 13 + fn headers(&self) -> AppendHeaders<Vec<(String, String)>> { 14 + AppendHeaders(vec![("WWW-Authenticate".to_owned(), "Trust".to_owned())]) 15 + } 16 + 17 + fn detail(&self) -> Self::Detail {} 18 + }
+82
packages/cartography/src/api/middleware/authorization.rs
··· 1 + use crate::api::errors::{ForbiddenError, JsonError, UnauthorizedError}; 2 + use crate::dto::AccountIdOrMe; 3 + use axum::extract::Request; 4 + use axum::http::HeaderValue; 5 + use axum::middleware::Next; 6 + use axum::response::Response; 7 + use axum_extra::TypedHeader; 8 + use axum_extra::headers; 9 + use axum_extra::headers::authorization::Credentials; 10 + 11 + #[derive(Clone, Debug)] 12 + pub enum Authorization { 13 + Unauthorized, 14 + AccountOwner(String), 15 + } 16 + 17 + impl Authorization { 18 + pub fn authorized_account_id(&self) -> Result<&str, JsonError<UnauthorizedError>> { 19 + match self { 20 + Authorization::Unauthorized => Err(JsonError(UnauthorizedError)), 21 + Authorization::AccountOwner(account_id) => Ok(account_id), 22 + } 23 + } 24 + 25 + pub fn resolve_account_id<'a>( 26 + &'a self, 27 + account_id: &'a AccountIdOrMe, 28 + ) -> Result<&'a str, JsonError<UnauthorizedError>> { 29 + match account_id { 30 + AccountIdOrMe::Me => self.authorized_account_id(), 31 + AccountIdOrMe::AccountId(account_id) => Ok(account_id), 32 + } 33 + } 34 + 35 + #[expect(clippy::result_large_err)] 36 + pub fn require_authorization(&self, account_id: &str) -> axum::response::Result<()> { 37 + let authorized_for = self.authorized_account_id()?; 38 + if authorized_for == account_id { 39 + Ok(()) 40 + } else { 41 + Err(JsonError(ForbiddenError).into()) 42 + } 43 + } 44 + } 45 + 46 + pub struct Trust(pub String); 47 + 48 + impl Credentials for Trust { 49 + const SCHEME: &'static str = "Trust"; 50 + 51 + fn decode(value: &HeaderValue) -> Option<Self> { 52 + Some(Self( 53 + value.to_str().ok()?["Trust ".len()..] 54 + .trim_start() 55 + .to_owned(), 56 + )) 57 + } 58 + 59 + /// Encode the credentials to a `HeaderValue`. 60 + /// 61 + /// The `SCHEME` must be the first part of the `value`. 62 + fn encode(&self) -> HeaderValue { 63 + format!("Trust {}", self.0).try_into().unwrap() 64 + } 65 + } 66 + 67 + /// DEV ONLY implementation of "authorization", in which we just trust that the caller 68 + /// is not lying about who they are. 69 + pub async fn trust( 70 + authorization: Option<TypedHeader<headers::Authorization<Trust>>>, 71 + mut request: Request, 72 + next: Next, 73 + ) -> Response { 74 + if let Some(TypedHeader(headers::Authorization(Trust(account_id)))) = authorization { 75 + request 76 + .extensions_mut() 77 + .insert(Authorization::AccountOwner(account_id)); 78 + } else { 79 + request.extensions_mut().insert(Authorization::Unauthorized); 80 + } 81 + next.run(request).await 82 + }
+1
packages/cartography/src/api/middleware/mod.rs
··· 1 + pub mod authorization;
+1
packages/cartography/src/api/mod.rs
··· 1 1 mod errors; 2 2 3 + pub mod middleware; 3 4 pub mod operations; 4 5 pub mod ws;
+9 -9
packages/cartography/src/api/operations/list_fields.rs
··· 1 - use axum::Json; 2 - use axum::extract::Path; 3 - 4 - use crate::api::errors::{internal_server_error, respond_internal_server_error}; 1 + use crate::api::errors::internal_server_error; 2 + use crate::api::middleware::authorization::Authorization; 5 3 use crate::dto::*; 4 + use axum::extract::Path; 5 + use axum::{Extension, Json}; 6 6 7 7 #[derive(serde::Serialize, utoipa::ToSchema)] 8 8 pub struct ListFieldsResponse { ··· 23 23 )] 24 24 pub async fn list_fields( 25 25 db: axum::Extension<sqlx::PgPool>, 26 - Path(player_id): Path<AccountIdOrMe>, 26 + Extension(authorization): Extension<Authorization>, 27 + Path(account_id): Path<AccountIdOrMe>, 27 28 ) -> axum::response::Result<Json<ListFieldsResponse>> { 28 - dbg!(&player_id); 29 - let AccountIdOrMe::AccountId(account_id) = player_id else { 30 - respond_internal_server_error!("unimplemented"); 31 - }; 29 + let account_id = authorization.resolve_account_id(&account_id)?; 30 + authorization.require_authorization(account_id)?; 31 + 32 32 let mut conn = db.acquire().await.map_err(internal_server_error)?; 33 33 let fields = sqlx::query_as!( 34 34 Field,
+26 -9
packages/cartography/src/api/operations/pull_banner.rs
··· 1 1 use crate::api::errors::{ 2 2 BannerNotFoundError, ErrorDetailResponse, JsonError, internal_server_error, 3 3 }; 4 + use crate::api::middleware::authorization::Authorization; 4 5 use crate::db::CardClass; 5 6 use crate::dto::*; 6 - use axum::Json; 7 7 use axum::extract::Path; 8 + use axum::{Extension, Json}; 8 9 use rand::distr::weighted::WeightedIndex; 9 10 use rand::rngs::Xoshiro256PlusPlus; 10 11 use rand::{Rng, RngExt, SeedableRng, rng}; ··· 32 33 pub async fn pull_banner( 33 34 db: axum::Extension<sqlx::PgPool>, 34 35 Path(banner_id): Path<String>, 36 + Extension(authorization): Extension<Authorization>, 35 37 ) -> axum::response::Result<Json<PullBannerResponse>> { 38 + let account_id = authorization.authorized_account_id()?; 39 + 36 40 let mut conn = db.begin().await.map_err(internal_server_error)?; 37 41 38 42 let banner = sqlx::query!( ··· 149 153 inserted_pack.pack_banner_id, 150 154 inserted_pack.opened_at 151 155 "#, 152 - "foxfriends", 156 + account_id, 153 157 banner_id, 154 158 seed as i64, 155 159 &tile_type_ids, ··· 179 183 mod tests { 180 184 use crate::test::prelude::*; 181 185 use axum::body::Body; 182 - use axum::http::Request; 186 + use axum::http::{Request, StatusCode}; 183 187 use sqlx::PgPool; 184 188 185 189 use super::PullBannerResponse; ··· 188 192 migrator = "MIGRATOR", 189 193 fixtures(path = "../../../fixtures", scripts("seed", "account")) 190 194 )] 191 - async fn pull_banner_standard(pool: PgPool) { 195 + async fn pull_banner_ok(pool: PgPool) { 192 196 let app = crate::app::Config::test(pool).into_router(); 193 197 194 198 let request = Request::post("/api/v1/banners/base-standard/pull") 199 + .header("Authorization", "Trust foxfriends") 195 200 .body(Body::empty()) 196 201 .unwrap(); 197 202 198 203 let Ok(response) = app.oneshot(request).await; 199 - assert!( 200 - response.status().is_success(), 201 - "{}", 202 - response.json::<serde_json::Value>().await.unwrap() 203 - ); 204 + assert_success!(response); 205 + 204 206 let response: PullBannerResponse = response.json().await.unwrap(); 205 207 assert_eq!(response.pack.pack_banner_id, "base-standard"); 206 208 assert_eq!(response.pack.account_id, "foxfriends"); 207 209 assert_eq!(response.pack.opened_at, None); 208 210 assert_eq!(response.pack_cards.len(), 5); 211 + } 212 + 213 + #[sqlx::test( 214 + migrator = "MIGRATOR", 215 + fixtures(path = "../../../fixtures", scripts("seed", "account")) 216 + )] 217 + async fn pull_banner_requires_authorization(pool: PgPool) { 218 + let app = crate::app::Config::test(pool).into_router(); 219 + 220 + let request = Request::post("/api/v1/banners/base-standard/pull") 221 + .body(Body::empty()) 222 + .unwrap(); 223 + 224 + let Ok(response) = app.oneshot(request).await; 225 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 209 226 } 210 227 }
+2 -1
packages/cartography/src/app.rs
··· 1 - use crate::api::{operations, ws}; 1 + use crate::api::{middleware, operations, ws}; 2 2 use crate::bus::Bus; 3 3 use axum::Router; 4 4 use kameo::actor::Spawn as _; ··· 84 84 axum::routing::get(axum::response::Json(ApiDoc::openapi())), 85 85 ) 86 86 // .merge(scalar_api_reference::axum::router("/docs", &scalar_config)) // TODO: waiting on scalar_api_reference to re-publish 87 + .layer(axum::middleware::from_fn(middleware::authorization::trust)) 87 88 .layer(axum::Extension(bus)) 88 89 .layer(axum::Extension(self.pool)) 89 90 }
+3 -2
packages/cartography/src/main.rs
··· 1 + #[cfg(test)] 2 + mod test; 3 + 1 4 mod actor; 2 5 mod api; 3 6 mod app; 4 7 mod bus; 5 8 mod db; 6 9 mod dto; 7 - #[cfg(test)] 8 - mod test; 9 10 10 11 use clap::Parser as _; 11 12 use tracing_subscriber::prelude::*;
+24 -1
packages/cartography/src/test.rs
··· 2 2 3 3 pub trait ResponseExt { 4 4 async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T>; 5 + async fn text(self) -> anyhow::Result<String>; 5 6 } 6 7 7 8 impl ResponseExt for axum::response::Response { 8 9 async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T> { 9 10 Ok(serde_json::from_slice( 10 - &self.into_body().collect().await.unwrap().to_bytes(), 11 + &self.into_body().collect().await?.to_bytes(), 12 + )?) 13 + } 14 + 15 + async fn text(self) -> anyhow::Result<String> { 16 + Ok(String::from_utf8( 17 + self.into_body().collect().await?.to_bytes().to_vec(), 11 18 )?) 12 19 } 13 20 } 14 21 22 + macro_rules! assert_success { 23 + ($response:expr) => { 24 + assert!( 25 + $response.status().is_success(), 26 + "{}", 27 + $response 28 + .text() 29 + .await 30 + .expect("response body was not valid utf-8") 31 + ) 32 + }; 33 + } 34 + 35 + pub(crate) use assert_success; 36 + 15 37 pub mod prelude { 16 38 pub use super::ResponseExt as _; 17 39 pub use tower::ServiceExt as _; 18 40 19 41 pub const MIGRATOR: sqlx::migrate::Migrator = 20 42 cartography_macros::graphile_migrate!("migrations/committed"); 43 + pub(crate) use super::assert_success; 21 44 }