From 6e20a3c51756c2e84290da6ec53b89a5fc58c0fc Mon Sep 17 00:00:00 2001 From: Vika Date: Wed, 28 Sep 2022 03:55:48 +0300 Subject: Use tokens from the auth backend to authenticate for Micropub --- kittybox-rs/indieauth/src/lib.rs | 5 ++ kittybox-rs/src/frontend/onboarding.rs | 10 ++- kittybox-rs/src/indieauth/mod.rs | 72 ++++++++++++++++ kittybox-rs/src/lib.rs | 1 - kittybox-rs/src/main.rs | 15 ++-- kittybox-rs/src/media/mod.rs | 18 ++-- kittybox-rs/src/micropub/mod.rs | 148 ++++++++++++++++++--------------- kittybox-rs/src/micropub/util.rs | 52 +++++------- 8 files changed, 201 insertions(+), 120 deletions(-) diff --git a/kittybox-rs/indieauth/src/lib.rs b/kittybox-rs/indieauth/src/lib.rs index 22dcdbd..a60cc42 100644 --- a/kittybox-rs/indieauth/src/lib.rs +++ b/kittybox-rs/indieauth/src/lib.rs @@ -538,6 +538,11 @@ impl TokenData { std::time::UNIX_EPOCH + std::time::Duration::from_secs(time) }) } + + /// Check if a certain scope is allowed for this token. + pub fn check_scope(&self, scope: &Scope) -> bool { + self.scope.has(scope) + } } // I don't like this type, because it could've been represented diff --git a/kittybox-rs/src/frontend/onboarding.rs b/kittybox-rs/src/frontend/onboarding.rs index b498aed..b460e6a 100644 --- a/kittybox-rs/src/frontend/onboarding.rs +++ b/kittybox-rs/src/frontend/onboarding.rs @@ -54,8 +54,12 @@ async fn onboard( ) -> Result<(), FrontendError> { // Create a user to pass to the backend // At this point the site belongs to nobody, so it is safe to do - let user = - crate::tokenauth::User::new(user_uid.as_str(), "https://kittybox.fireburn.ru/", "create"); + let user = kittybox_indieauth::TokenData { + me: user_uid.clone(), + client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), + scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]), + iat: None, exp: None + }; if data.user["type"][0] != "h-card" || data.first_post["type"][0] != "h-entry" { return Err(FrontendError::with_code( @@ -96,7 +100,7 @@ async fn onboard( .map_err(FrontendError::from)?; } let (uid, post) = crate::micropub::normalize_mf2(data.first_post, &user); - crate::micropub::_post(user, uid, post, db, http) + crate::micropub::_post(&user, uid, post, db, http) .await .map_err(|e| FrontendError { msg: "Error while posting the first post".to_string(), diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs index 36f207c..6dc9ec6 100644 --- a/kittybox-rs/src/indieauth/mod.rs +++ b/kittybox-rs/src/indieauth/mod.rs @@ -1,3 +1,5 @@ +use std::marker::PhantomData; + use tracing::error; use serde::Deserialize; use axum::{ @@ -27,6 +29,76 @@ const REFRESH_TOKEN_VALIDITY: u64 = ACCESS_TOKEN_VALIDITY / 7 * 60; // 60 days /// Internal scope for accessing the token introspection endpoint. const KITTYBOX_TOKEN_STATUS: &str = "kittybox:token_status"; +pub(crate) struct User(pub(crate) TokenData, pub(crate) PhantomData); +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("User").field(&self.0).finish() + } +} +impl std::ops::Deref for User { + type Target = TokenData; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub enum IndieAuthResourceError { + InvalidRequest, + Unauthorized, + InvalidToken +} +impl axum::response::IntoResponse for IndieAuthResourceError { + fn into_response(self) -> axum::response::Response { + use IndieAuthResourceError::*; + + match self { + Unauthorized => ( + StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", "Bearer")] + ).into_response(), + InvalidRequest => ( + StatusCode::BAD_REQUEST, + Json(&serde_json::json!({"error": "invalid_request"})) + ).into_response(), + InvalidToken => ( + StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", "Bearer, error=\"invalid_token\"")], + Json(&serde_json::json!({"error": "unauthorized"})) + ).into_response() + } + } +} + +#[async_trait::async_trait] +impl axum::extract::FromRequest for User { + type Rejection = IndieAuthResourceError; + + async fn from_request(req: &mut axum::extract::RequestParts) -> Result { + let TypedHeader(Authorization(token)) = + TypedHeader::>::from_request(req) + .await + .map_err(|_| IndieAuthResourceError::Unauthorized)?; + + let axum::Extension(auth) = axum::Extension::::from_request(req) + .await + .unwrap(); + + let Host(host) = Host::from_request(req) + .await + .map_err(|_| IndieAuthResourceError::InvalidRequest)?; + + auth.get_token( + &format!("https://{host}/").parse().unwrap(), + token.token() + ) + .await + .unwrap() + .ok_or(IndieAuthResourceError::InvalidToken) + .map(|t| User(t, PhantomData)) + } +} + pub async fn metadata( Host(host): Host ) -> Metadata { diff --git a/kittybox-rs/src/lib.rs b/kittybox-rs/src/lib.rs index a75c3ea..7084601 100644 --- a/kittybox-rs/src/lib.rs +++ b/kittybox-rs/src/lib.rs @@ -4,7 +4,6 @@ /// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database. pub mod database; pub mod frontend; -pub mod tokenauth; pub mod media; pub mod micropub; pub mod indieauth; diff --git a/kittybox-rs/src/main.rs b/kittybox-rs/src/main.rs index 796903b..ad76042 100644 --- a/kittybox-rs/src/main.rs +++ b/kittybox-rs/src/main.rs @@ -123,11 +123,15 @@ async fn main() { )); let micropub = axum::Router::new() - .route("/.kittybox/micropub", kittybox::micropub::router(database.clone(), http.clone())) + .route("/.kittybox/micropub", kittybox::micropub::router( + database.clone(), + http.clone(), + auth_backend.clone() + )) .nest("/.kittybox/micropub/client", kittybox::companion::router()); let media = axum::Router::new() - .nest("/.kittybox/media", kittybox::media::router(blobstore).layer(axum::Extension(http))); + .nest("/.kittybox/media", kittybox::media::router(blobstore, auth_backend.clone())); let indieauth = kittybox::indieauth::router(auth_backend, database.clone()); @@ -165,13 +169,6 @@ async fn main() { .merge(media) .merge(indieauth) .merge(technical) - .layer( - axum::Extension( - kittybox::tokenauth::TokenEndpoint( - "https://tokens.indieauth.com/token".parse().unwrap() - ) - ) - ) .layer(tower::ServiceBuilder::new() .layer(tower_http::trace::TraceLayer::new_for_http()) .into_inner()) diff --git a/kittybox-rs/src/media/mod.rs b/kittybox-rs/src/media/mod.rs index a8ae6f9..0ce2ec9 100644 --- a/kittybox-rs/src/media/mod.rs +++ b/kittybox-rs/src/media/mod.rs @@ -1,9 +1,10 @@ use axum::{ - extract::{Extension, Host, multipart::{Multipart, MultipartError}, Path}, + extract::{Extension, Host, multipart::Multipart, Path}, response::{IntoResponse, Response}, headers::HeaderValue, }; use kittybox_util::error::{MicropubError, ErrorType}; -use crate::tokenauth::User; +use kittybox_indieauth::Scope; +use crate::indieauth::{User, backend::AuthBackend}; pub mod storage; use storage::{MediaStore, MediaStoreError, Metadata, ErrorKind}; @@ -19,12 +20,12 @@ impl From for MicropubError { } #[tracing::instrument(skip(blobstore))] -pub async fn upload( +pub(crate) async fn upload( mut upload: Multipart, Extension(blobstore): Extension, - user: User + user: User ) -> Response { - if !user.check_scope("media") { + if !user.check_scope(&Scope::Media) { return MicropubError { error: ErrorType::NotAuthorized, error_description: "Interacting with the media storage requires the \"media\" scope.".to_owned() @@ -61,7 +62,7 @@ pub async fn upload( } #[tracing::instrument(skip(blobstore))] -pub async fn serve( +pub(crate) async fn serve( Host(host): Host, Path(path): Path, Extension(blobstore): Extension @@ -103,9 +104,10 @@ pub async fn serve( } } -pub fn router(blobstore: S) -> axum::Router { +pub fn router(blobstore: S, auth: A) -> axum::Router { axum::Router::new() - .route("/", axum::routing::post(upload::)) + .route("/", axum::routing::post(upload::)) .route("/uploads/*file", axum::routing::get(serve::)) .layer(axum::Extension(blobstore)) + .layer(axum::Extension(auth)) } diff --git a/kittybox-rs/src/micropub/mod.rs b/kittybox-rs/src/micropub/mod.rs index d0aeae0..0f7441e 100644 --- a/kittybox-rs/src/micropub/mod.rs +++ b/kittybox-rs/src/micropub/mod.rs @@ -1,16 +1,16 @@ use crate::database::{MicropubChannel, Storage, StorageError}; -use crate::tokenauth::User; +use crate::indieauth::backend::AuthBackend; +use crate::indieauth::User; use crate::micropub::util::form_to_mf2_json; -use axum::extract::{BodyStream, Query}; +use axum::extract::{BodyStream, Query, Host}; use axum::headers::ContentType; use axum::response::{IntoResponse, Response}; use axum::TypedHeader; use axum::{http::StatusCode, Extension}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::fmt::Display; use tracing::{debug, error, info, warn}; - +use kittybox_indieauth::{Scope, TokenData}; use kittybox_util::{MicropubError, ErrorType}; #[derive(Serialize, Deserialize, Debug, PartialEq)] @@ -65,8 +65,7 @@ fn populate_reply_context( .iter() .find(|ctx| Some(ctx.url.as_str()) == i.as_str()) .and_then(|ctx| ctx.mf2["items"].get(0)) - .or(Some(i)) - .unwrap()) + .unwrap_or(i)) .collect::>()) }) } @@ -218,7 +217,7 @@ async fn background_processing( // TODO actually save the post to the database and schedule post-processing pub(crate) async fn _post( - user: User, + user: &TokenData, uid: String, mf2: serde_json::Value, db: D, @@ -234,7 +233,7 @@ pub(crate) async fn _post( // - The MF2-JSON document's author is set // Security check! Do we have an OAuth2 scope to proceed? - if !user.check_scope("create") { + if !user.check_scope(&Scope::Create) { return Err(MicropubError { error: ErrorType::InvalidScope, error_description: "Not enough privileges - try acquiring the \"create\" scope." @@ -292,7 +291,7 @@ pub(crate) async fn _post( db.update_post(chan, json!({"add": {"children": [uid]}})) .await?; } else if default_channels.iter().any(|i| chan == i) { - util::create_feed(&db, &uid, chan, &user).await?; + util::create_feed(&db, &uid, chan, user).await?; } else { warn!("Ignoring non-existent channel: {}", chan); } @@ -344,10 +343,10 @@ impl From for MicropubAction { } #[tracing::instrument(skip(db))] -async fn post_action( +async fn post_action( action: MicropubAction, db: D, - user: User, + user: User, ) -> Result<(), MicropubError> { let uri = if let Ok(uri) = action.url.parse::() { uri @@ -375,7 +374,7 @@ async fn post_action( match action.action { ActionType::Delete => { - if !user.check_scope("delete") { + if !user.check_scope(&Scope::Delete) { return Err(MicropubError { error: ErrorType::InvalidScope, error_description: "You need a \"delete\" scope for this.".to_owned(), @@ -385,7 +384,7 @@ async fn post_action( db.delete_post(&action.url).await? } ActionType::Update => { - if !user.check_scope("update") { + if !user.check_scope(&Scope::Update) { return Err(MicropubError { error: ErrorType::InvalidScope, error_description: "You need an \"update\" scope for this.".to_owned(), @@ -468,10 +467,10 @@ async fn dispatch_body( } #[tracing::instrument(skip(db, http))] -pub async fn post( +pub(crate) async fn post( Extension(db): Extension, Extension(http): Extension, - user: User, + user: User, body: BodyStream, TypedHeader(content_type): TypedHeader, ) -> axum::response::Response { @@ -482,7 +481,7 @@ pub async fn post( }, Ok(PostBody::MF2(mf2)) => { let (uid, mf2) = normalize_mf2(mf2, &user); - match _post(user, uid, mf2, db, http).await { + match _post(&user, uid, mf2, db, http).await { Ok(response) => response, Err(err) => err.into_response(), } @@ -492,30 +491,40 @@ pub async fn post( } #[tracing::instrument(skip(db))] -pub async fn query( +pub(crate) async fn query( Extension(db): Extension, query: Option>, - user: User, + Host(host): Host, + user: User, ) -> axum::response::Response { // We handle the invalid query case manually to return a // MicropubError instead of HTTP 422 - if query.is_none() { + let query = if let Some(Query(query)) = query { + query + } else { return MicropubError::new( ErrorType::InvalidRequest, "Invalid query provided. Try ?q=config to see what you can do." ).into_response(); - } - let query: MicropubQuery = query.unwrap().0; + }; - let host = axum::http::Uri::try_from(user.me.as_str()) + if axum::http::Uri::try_from(user.me.as_str()) .unwrap() .authority() .unwrap() - .clone(); + != &host + { + return MicropubError::new( + ErrorType::NotAuthorized, + "This website doesn't belong to you.", + ) + .into_response(); + } + match query.q { QueryType::Config => { - let channels: Vec = match db.get_channels(host.as_str()).await { + let channels: Vec = match db.get_channels(user.me.as_str()).await { Ok(chans) => chans, Err(err) => { return MicropubError::new( @@ -534,26 +543,15 @@ pub async fn query( QueryType::SyndicateTo ], "channels": channels, - "_kittybox_authority": host.as_str(), - "syndicate-to": [] + "_kittybox_authority": user.me.as_str(), + "syndicate-to": [], + "media_endpoint": user.me.join("/.kittybox/media").unwrap().as_str() })) .into_response() } QueryType::Source => { match query.url { Some(url) => { - if axum::http::Uri::try_from(&url) - .unwrap() - .authority() - .unwrap() - != &host - { - return MicropubError::new( - ErrorType::NotAuthorized, - "You are requesting a post from a website that doesn't belong to you.", - ) - .into_response(); - } match db.get_post(&url).await { Ok(some) => match some { Some(post) => axum::response::Json(&post).into_response(), @@ -582,7 +580,7 @@ pub async fn query( } } } - QueryType::Channel => match db.get_channels(host.as_str()).await { + QueryType::Channel => match db.get_channels(user.me.as_str()).await { Ok(chans) => axum::response::Json(json!({ "channels": chans })).into_response(), Err(err) => MicropubError::new( ErrorType::InternalServerError, @@ -596,9 +594,17 @@ pub async fn query( } } -pub fn router(storage: S, http: reqwest::Client) -> axum::routing::MethodRouter { - axum::routing::get(query::) - .post(post::) +pub fn router( + storage: S, + http: reqwest::Client, + auth: A +) -> axum::routing::MethodRouter +where + S: Storage + 'static, + A: AuthBackend +{ + axum::routing::get(query::) + .post(post::) .layer(tower_http::cors::CorsLayer::new() .allow_methods([ axum::http::Method::GET, @@ -607,6 +613,7 @@ pub fn router(storage: S, http: reqwest::Client) -> axum:: .allow_origin(tower_http::cors::Any)) .layer(axum::Extension(storage)) .layer(axum::Extension(http)) + .layer(axum::Extension(auth)) } #[cfg(test)] @@ -629,11 +636,13 @@ impl MicropubQuery { #[cfg(test)] mod tests { - use crate::{database::Storage, micropub::MicropubError, tokenauth::User}; + use crate::{database::Storage, micropub::MicropubError}; use hyper::body::HttpBody; use serde_json::json; use super::FetchedPostContext; + use kittybox_indieauth::{Scopes, Scope, TokenData}; + use axum::extract::Host; #[test] fn test_populate_reply_context() { @@ -682,14 +691,15 @@ mod tests { "content": ["Hello world!"] } }); - let user = User::new( - "https://localhost:8080/", - "https://kittybox.fireburn.ru/", - "profile", - ); + let user = TokenData { + me: "https://localhost:8080/".parse().unwrap(), + client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), + scope: Scopes::new(vec![Scope::Profile]), + iat: None, exp: None + }; let (uid, mf2) = super::normalize_mf2(post, &user); - let err = super::_post(user, uid, mf2, db.clone(), reqwest::Client::new()) + let err = super::_post(&user, uid, mf2, db.clone(), reqwest::Client::new()) .await .unwrap_err(); @@ -711,14 +721,15 @@ mod tests { "url": ["https://fireburn.ru/posts/hello"] } }); - let user = User::new( - "https://aaronparecki.com/", - "https://kittybox.fireburn.ru/", - "create update media", - ); + let user = TokenData { + me: "https://aaronparecki.com/".parse().unwrap(), + client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), + scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]), + iat: None, exp: None + }; let (uid, mf2) = super::normalize_mf2(post, &user); - let err = super::_post(user, uid, mf2, db.clone(), reqwest::Client::new()) + let err = super::_post(&user, uid, mf2, db.clone(), reqwest::Client::new()) .await .unwrap_err(); @@ -738,14 +749,15 @@ mod tests { "content": ["Hello world!"] } }); - let user = User::new( - "https://localhost:8080/", - "https://kittybox.fireburn.ru/", - "create", - ); + let user = TokenData { + me: "https://localhost:8080/".parse().unwrap(), + client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), + scope: Scopes::new(vec![Scope::Profile, Scope::Create]), + iat: None, exp: None + }; let (uid, mf2) = super::normalize_mf2(post, &user); - let res = super::_post(user, uid, mf2, db.clone(), reqwest::Client::new()) + let res = super::_post(&user, uid, mf2, db.clone(), reqwest::Client::new()) .await .unwrap(); @@ -765,11 +777,15 @@ mod tests { Some(axum::extract::Query(super::MicropubQuery::source( "https://aaronparecki.com/feeds/main", ))), - User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + Host("aaronparecki.com".to_owned()), + crate::indieauth::User::( + TokenData { + me: "https://fireburn.ru/".parse().unwrap(), + client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), + scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]), + iat: None, exp: None + }, std::marker::PhantomData + ) ) .await; diff --git a/kittybox-rs/src/micropub/util.rs b/kittybox-rs/src/micropub/util.rs index 7c6a0b1..5097878 100644 --- a/kittybox-rs/src/micropub/util.rs +++ b/kittybox-rs/src/micropub/util.rs @@ -1,5 +1,5 @@ use crate::database::Storage; -use crate::tokenauth::User; +use kittybox_indieauth::TokenData; use chrono::prelude::*; use core::iter::Iterator; use newbase60::num_to_sxg; @@ -33,7 +33,7 @@ fn reset_dt(post: &mut serde_json::Value) -> DateTime { chrono::DateTime::from(curtime) } -pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde_json::Value) { +pub fn normalize_mf2(mut body: serde_json::Value, user: &TokenData) -> (String, serde_json::Value) { // Normalize the MF2 object here. let me = &user.me; let folder = get_folder_from_type(body["type"][0].as_str().unwrap()); @@ -190,7 +190,7 @@ pub(crate) async fn create_feed( storage: &impl Storage, uid: &str, channel: &str, - user: &User, + user: &TokenData, ) -> crate::database::Result<()> { let path = url::Url::parse(channel).unwrap().path().to_string(); @@ -220,6 +220,16 @@ mod tests { use super::*; use serde_json::json; + fn token_data() -> TokenData { + TokenData { + me: "https://fireburn.ru/".parse().unwrap(), + client_id: "https://quill.p3k.io/".parse().unwrap(), + scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]), + exp: Some(u64::MAX), + iat: Some(0) + } + } + #[test] fn test_form_to_mf2() { assert_eq!( @@ -248,11 +258,7 @@ mod tests { let (uid, normalized) = normalize_mf2( mf2.clone(), - &User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + &token_data(), ); assert_eq!( normalized["properties"]["uid"][0], mf2["properties"]["uid"][0], @@ -277,11 +283,7 @@ mod tests { let (_, normalized) = normalize_mf2( mf2.clone(), - &User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + &token_data(), ); assert_eq!( @@ -303,11 +305,7 @@ mod tests { let (_, normalized) = normalize_mf2( mf2.clone(), - &User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + &token_data(), ); assert_eq!( @@ -327,11 +325,7 @@ mod tests { let (uid, post) = normalize_mf2( mf2, - &User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + &token_data(), ); assert_eq!( post["properties"]["published"] @@ -396,11 +390,7 @@ mod tests { let (_, post) = normalize_mf2( mf2, - &User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + &token_data(), ); assert!( post["properties"]["url"] @@ -429,11 +419,7 @@ mod tests { let (uid, post) = normalize_mf2( mf2, - &User::new( - "https://fireburn.ru/", - "https://quill.p3k.io/", - "create update media", - ), + &token_data(), ); assert_eq!( post["properties"]["uid"][0], uid, -- cgit 1.4.1