mirror of
https://github.com/aljazceru/pubky-core.git
synced 2026-01-30 11:24:31 +01:00
feat(homeserver): add PubkyHost and Authz layers
This commit is contained in:
@@ -2,9 +2,7 @@ use heed::{
|
||||
types::{Bytes, Str},
|
||||
Database,
|
||||
};
|
||||
use pkarr::PublicKey;
|
||||
use pubky_common::session::Session;
|
||||
use tower_cookies::Cookies;
|
||||
|
||||
use crate::core::database::DB;
|
||||
|
||||
@@ -14,61 +12,31 @@ pub type SessionsTable = Database<Str, Bytes>;
|
||||
pub const SESSIONS_TABLE: &str = "sessions";
|
||||
|
||||
impl DB {
|
||||
pub fn get_session(
|
||||
&mut self,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
) -> anyhow::Result<Option<Session>> {
|
||||
if let Some(bytes) = self.get_session_bytes(cookies, public_key)? {
|
||||
pub fn get_session(&self, session_secret: &str) -> anyhow::Result<Option<Session>> {
|
||||
let rtxn = self.env.read_txn()?;
|
||||
|
||||
let session = self
|
||||
.tables
|
||||
.sessions
|
||||
.get(&rtxn, session_secret)?
|
||||
.map(|s| s.to_vec());
|
||||
|
||||
rtxn.commit()?;
|
||||
|
||||
if let Some(bytes) = session {
|
||||
return Ok(Some(Session::deserialize(&bytes)?));
|
||||
};
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn get_session_bytes(
|
||||
&mut self,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
if let Some(cookie) =
|
||||
cookies.get(&public_key.to_string().chars().take(8).collect::<String>())
|
||||
{
|
||||
let rtxn = self.env.read_txn()?;
|
||||
pub fn delete_session(&mut self, secret: &str) -> anyhow::Result<bool> {
|
||||
let mut wtxn = self.env.write_txn()?;
|
||||
|
||||
let session = self
|
||||
.tables
|
||||
.sessions
|
||||
.get(&rtxn, cookie.value())?
|
||||
.map(|s| s.to_vec());
|
||||
let deleted = self.tables.sessions.delete(&mut wtxn, secret)?;
|
||||
|
||||
rtxn.commit()?;
|
||||
wtxn.commit()?;
|
||||
|
||||
return Ok(session);
|
||||
};
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn delete_session(
|
||||
&mut self,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
) -> anyhow::Result<bool> {
|
||||
// TODO: Set expired cookie to delete the cookie on client side.
|
||||
|
||||
if let Some(cookie) =
|
||||
cookies.get(&public_key.to_string().chars().take(8).collect::<String>())
|
||||
{
|
||||
let mut wtxn = self.env.write_txn()?;
|
||||
|
||||
let deleted = self.tables.sessions.delete(&mut wtxn, cookie.value())?;
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
return Ok(deleted);
|
||||
};
|
||||
|
||||
Ok(false)
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,48 +10,38 @@ use axum::{
|
||||
|
||||
use pkarr::PublicKey;
|
||||
|
||||
use crate::core::error::{Error, Result};
|
||||
use crate::core::error::Result;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Pubky {
|
||||
Host(PublicKey),
|
||||
PubkyHost(PublicKey),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PubkyHost(pub(crate) PublicKey);
|
||||
|
||||
impl Pubky {
|
||||
impl PubkyHost {
|
||||
pub fn public_key(&self) -> &PublicKey {
|
||||
match self {
|
||||
Pubky::Host(p) => p,
|
||||
Pubky::PubkyHost(p) => p,
|
||||
}
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for Pubky
|
||||
impl<S> FromRequestParts<S> for PubkyHost
|
||||
where
|
||||
S: Send + Sync,
|
||||
S: Sync + Send,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let headers_to_check = ["host", "pubky-host"];
|
||||
let pubky_host = parts
|
||||
.extensions
|
||||
.get::<PubkyHost>()
|
||||
.cloned()
|
||||
.ok_or((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Can't extract PubkyHost. Is `PubkyHostLayer` enabled?",
|
||||
))
|
||||
.map_err(|e| e.into_response())?;
|
||||
|
||||
for header in headers_to_check {
|
||||
if let Some(Ok(pubky_host)) = parts.headers.get(header).map(|h| h.to_str()) {
|
||||
if let Ok(public_key) = PublicKey::try_from(pubky_host) {
|
||||
tracing::debug!(?pubky_host);
|
||||
tracing::debug!(?pubky_host);
|
||||
|
||||
if header == "host" {
|
||||
return Ok(Pubky::Host(public_key));
|
||||
}
|
||||
|
||||
return Ok(Pubky::PubkyHost(public_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::new(StatusCode::NOT_FOUND, "Pubky host not found".into()).into_response())
|
||||
Ok(pubky_host)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
172
pubky-homeserver/src/core/layers/authz.rs
Normal file
172
pubky-homeserver/src/core/layers/authz.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use axum::http::{header, HeaderMap, Method};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use futures_util::future::BoxFuture;
|
||||
use pkarr::PublicKey;
|
||||
use std::{convert::Infallible, task::Poll};
|
||||
use tower::{Layer, Service};
|
||||
use tower_cookies::Cookies;
|
||||
|
||||
use crate::core::{
|
||||
error::{Error, Result},
|
||||
extractors::PubkyHost,
|
||||
AppState,
|
||||
};
|
||||
|
||||
/// A Tower Layer to handle authorization for write operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorizationLayer {
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl AuthorizationLayer {
|
||||
pub fn new(state: AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for AuthorizationLayer {
|
||||
type Service = AuthorizationMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
AuthorizationMiddleware {
|
||||
inner,
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware that performs authorization checks for write operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorizationMiddleware<S> {
|
||||
inner: S,
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
impl<S> Service<Request<Body>> for AuthorizationMiddleware<S>
|
||||
where
|
||||
S: Service<Request<Body>, Response = axum::response::Response, Error = Infallible>
|
||||
+ Send
|
||||
+ 'static
|
||||
+ Clone,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx).map_err(|_| unreachable!()) // `Infallible` conversion
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<Body>) -> Self::Future {
|
||||
let state = self.state.clone();
|
||||
let mut inner = self.inner.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let path = req.uri().path();
|
||||
|
||||
// Verify the path
|
||||
if let Err(e) = verify(path) {
|
||||
return Ok(e.into_response());
|
||||
}
|
||||
|
||||
let pubky = match req.extensions().get::<PubkyHost>() {
|
||||
Some(pk) => pk,
|
||||
None => {
|
||||
return Ok(
|
||||
Error::new(StatusCode::NOT_FOUND, "Pubky Host is missing".into())
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Authorize the request
|
||||
if let Err(e) = authorize(
|
||||
&state,
|
||||
req.method(),
|
||||
req.headers(),
|
||||
pubky.public_key(),
|
||||
path,
|
||||
) {
|
||||
return Ok(e.into_response());
|
||||
}
|
||||
|
||||
// If authorized, proceed to the inner service
|
||||
inner.call(req).await.map_err(|_| unreachable!())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the path.
|
||||
fn verify(path: &str) -> Result<()> {
|
||||
if !path.starts_with("/pub/") {
|
||||
return Err(Error::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Writing to directories other than '/pub/' is forbidden".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorize write (PUT or DELETE) for Public paths.
|
||||
fn authorize(
|
||||
state: &AppState,
|
||||
method: &Method,
|
||||
headers: &HeaderMap,
|
||||
public_key: &PublicKey,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
if path.starts_with("/pub/") && method == Method::GET {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session_secret = session_secret_from_headers(headers, public_key)
|
||||
.ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
let session = state
|
||||
.db
|
||||
.get_session(&session_secret)?
|
||||
.ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
if session.pubky() == public_key
|
||||
&& session.capabilities().iter().any(|cap| {
|
||||
path.starts_with(&cap.scope)
|
||||
&& cap
|
||||
.actions
|
||||
.contains(&pubky_common::capabilities::Action::Write)
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(Error::with_status(StatusCode::FORBIDDEN))
|
||||
}
|
||||
|
||||
fn cookie_name(public_key: &PublicKey) -> String {
|
||||
public_key.to_string().chars().take(8).collect::<String>()
|
||||
}
|
||||
|
||||
pub fn session_secret_from_cookies(cookies: Cookies, public_key: &PublicKey) -> Option<String> {
|
||||
cookies
|
||||
.get(&cookie_name(public_key))
|
||||
.map(|c| c.value().to_string())
|
||||
}
|
||||
|
||||
// TODO: unit test this
|
||||
fn session_secret_from_headers(headers: &HeaderMap, public_key: &PublicKey) -> Option<String> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|h| h.to_str().ok())
|
||||
.find(|h| h.starts_with(&cookie_name(public_key)))
|
||||
.and_then(|h| {
|
||||
h.split(';')
|
||||
.next()
|
||||
.and_then(|key_value| key_value.split('=').last())
|
||||
})
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
2
pubky-homeserver/src/core/layers/mod.rs
Normal file
2
pubky-homeserver/src/core/layers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod authz;
|
||||
pub mod pubky_host;
|
||||
64
pubky-homeserver/src/core/layers/pubky_host.rs
Normal file
64
pubky-homeserver/src/core/layers/pubky_host.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use pkarr::PublicKey;
|
||||
|
||||
use crate::core::extractors::PubkyHost;
|
||||
|
||||
use axum::{body::Body, http::Request};
|
||||
use futures_util::future::BoxFuture;
|
||||
use std::{convert::Infallible, task::Poll};
|
||||
use tower::{Layer, Service};
|
||||
|
||||
use crate::core::error::Result;
|
||||
|
||||
/// A Tower Layer to handle authorization for write operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PubkyHostLayer;
|
||||
|
||||
impl<S> Layer<S> for PubkyHostLayer {
|
||||
type Service = PubkyHostLayerMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
PubkyHostLayerMiddleware { inner }
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware that performs authorization checks for write operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PubkyHostLayerMiddleware<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> Service<Request<Body>> for PubkyHostLayerMiddleware<S>
|
||||
where
|
||||
S: Service<Request<Body>, Response = axum::response::Response, Error = Infallible>
|
||||
+ Send
|
||||
+ 'static
|
||||
+ Clone,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = Infallible;
|
||||
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx).map_err(|_| unreachable!()) // `Infallible` conversion
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<Body>) -> Self::Future {
|
||||
let mut inner = self.inner.clone();
|
||||
let mut req = req;
|
||||
|
||||
Box::pin(async move {
|
||||
let headers_to_check = ["host", "pubky-host"];
|
||||
|
||||
for header in headers_to_check {
|
||||
if let Some(Ok(pubky_host)) = req.headers().get(header).map(|h| h.to_str()) {
|
||||
if let Ok(public_key) = PublicKey::try_from(pubky_host) {
|
||||
req.extensions_mut().insert(PubkyHost(public_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner.call(req).await.map_err(|_| unreachable!())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
use anyhow::Result;
|
||||
use axum::{extract::Request, response::Response, Router};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
http::{header, Method},
|
||||
response::Response,
|
||||
Router,
|
||||
};
|
||||
use pkarr::{Keypair, PublicKey};
|
||||
use pubky_common::{
|
||||
auth::AuthVerifier, capabilities::Capability, crypto::random_bytes, session::Session,
|
||||
timestamp::Timestamp,
|
||||
auth::{AuthToken, AuthVerifier},
|
||||
capabilities::Capability,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
use tower_cookies::{cookie::SameSite, Cookie};
|
||||
|
||||
mod config;
|
||||
mod database;
|
||||
mod error;
|
||||
mod extractors;
|
||||
mod layers;
|
||||
mod routes;
|
||||
|
||||
use database::{tables::users::User, DB};
|
||||
use database::DB;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
@@ -28,7 +34,6 @@ pub(crate) struct AppState {
|
||||
/// A side-effect-free Core of the [Homeserver].
|
||||
pub struct HomeserverCore {
|
||||
config: Config,
|
||||
pub(crate) state: AppState,
|
||||
pub(crate) router: Router,
|
||||
}
|
||||
|
||||
@@ -49,7 +54,6 @@ impl HomeserverCore {
|
||||
let router = routes::create_app(state.clone());
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
router,
|
||||
config: config.clone(),
|
||||
})
|
||||
@@ -79,42 +83,28 @@ impl HomeserverCore {
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
// TODO: move this logic to a common place.
|
||||
pub fn create_user(&mut self, public_key: &PublicKey) -> Result<Cookie> {
|
||||
let mut wtxn = self.state.db.env.write_txn()?;
|
||||
pub async fn create_root_user(&mut self, keypair: &Keypair) -> Result<String> {
|
||||
let auth_token = AuthToken::sign(keypair, vec![Capability::root()]);
|
||||
|
||||
self.state.db.tables.users.put(
|
||||
&mut wtxn,
|
||||
public_key,
|
||||
&User {
|
||||
created_at: Timestamp::now().as_u64(),
|
||||
},
|
||||
)?;
|
||||
let response = self
|
||||
.call(
|
||||
Request::builder()
|
||||
.uri("/signup")
|
||||
.header("host", keypair.public_key().to_string())
|
||||
.method(Method::POST)
|
||||
.body(Body::from(auth_token.serialize()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes::<16>());
|
||||
let header_value = response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.expect("should return a set-cookie header")
|
||||
.to_string();
|
||||
|
||||
let session = Session::new(public_key, &[Capability::root()], None).serialize();
|
||||
|
||||
self.state
|
||||
.db
|
||||
.tables
|
||||
.sessions
|
||||
.put(&mut wtxn, &session_secret, &session)?;
|
||||
|
||||
wtxn.commit()?;
|
||||
|
||||
let mut cookie = Cookie::new(
|
||||
public_key.to_string().chars().take(8).collect::<String>(),
|
||||
session_secret,
|
||||
);
|
||||
|
||||
cookie.set_path("/");
|
||||
|
||||
cookie.set_secure(true);
|
||||
cookie.set_same_site(SameSite::None);
|
||||
cookie.set_http_only(true);
|
||||
|
||||
Ok(cookie)
|
||||
Ok(header_value)
|
||||
}
|
||||
|
||||
pub async fn call(&self, request: Request) -> Result<Response> {
|
||||
|
||||
@@ -12,7 +12,8 @@ use pubky_common::{crypto::random_bytes, session::Session, timestamp::Timestamp}
|
||||
use crate::core::{
|
||||
database::tables::users::User,
|
||||
error::{Error, Result},
|
||||
extractors::Pubky,
|
||||
extractors::PubkyHost,
|
||||
layers::authz::session_secret_from_cookies,
|
||||
AppState,
|
||||
};
|
||||
|
||||
@@ -29,14 +30,16 @@ pub async fn signup(
|
||||
}
|
||||
|
||||
pub async fn session(
|
||||
State(mut state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
) -> Result<impl IntoResponse> {
|
||||
if let Some(session) = state.db.get_session(cookies, pubky.public_key())? {
|
||||
// TODO: add content-type
|
||||
return Ok(session.serialize());
|
||||
};
|
||||
if let Some(secret) = session_secret_from_cookies(cookies, pubky.public_key()) {
|
||||
if let Some(session) = state.db.get_session(&secret)? {
|
||||
// TODO: add content-type
|
||||
return Ok(session.serialize());
|
||||
};
|
||||
}
|
||||
|
||||
Err(Error::with_status(StatusCode::NOT_FOUND))
|
||||
}
|
||||
@@ -44,9 +47,13 @@ pub async fn session(
|
||||
pub async fn signout(
|
||||
State(mut state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
) -> Result<impl IntoResponse> {
|
||||
state.db.delete_session(cookies, pubky.public_key())?;
|
||||
// TODO: Set expired cookie to delete the cookie on client side.
|
||||
|
||||
if let Some(secret) = session_secret_from_cookies(cookies, pubky.public_key()) {
|
||||
state.db.delete_session(&secret)?;
|
||||
}
|
||||
|
||||
// Idempotent Success Response (200 OK)
|
||||
Ok(())
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The controller part of the [crate::HomeserverCore]
|
||||
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, head, post, put},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
@@ -10,12 +9,14 @@ use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
|
||||
use crate::core::AppState;
|
||||
|
||||
use super::layers::pubky_host::PubkyHostLayer;
|
||||
|
||||
mod auth;
|
||||
mod feed;
|
||||
mod public;
|
||||
mod root;
|
||||
|
||||
fn base(state: AppState) -> Router {
|
||||
fn base() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(root::handler))
|
||||
.route("/signup", post(auth::signup))
|
||||
@@ -27,22 +28,19 @@ fn base(state: AppState) -> Router {
|
||||
.route("/session", get(auth::session))
|
||||
.route("/session", delete(auth::signout))
|
||||
// - Data routes
|
||||
.route("/pub/", get(public::read::list_root))
|
||||
.route("/pub/*path", get(public::read::get))
|
||||
.route("/pub/*path", head(public::read::head))
|
||||
.route("/pub/*path", put(public::write::put))
|
||||
.route("/pub/*path", delete(public::write::delete))
|
||||
// Events
|
||||
.route("/events/", get(feed::feed))
|
||||
.layer(CookieManagerLayer::new())
|
||||
// TODO: revisit if we enable streaming big payloads
|
||||
// TODO: maybe add to a separate router (drive router?).
|
||||
.layer(DefaultBodyLimit::max(100 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
// TODO: add size limit
|
||||
// TODO: revisit if we enable streaming big payloads
|
||||
// TODO: maybe add to a separate router (drive router?).
|
||||
}
|
||||
|
||||
pub fn create_app(state: AppState) -> Router {
|
||||
base(state.clone())
|
||||
base()
|
||||
.merge(public::data_store_router(state.clone()))
|
||||
.layer(CorsLayer::very_permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(PubkyHostLayer)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,21 @@
|
||||
use axum::http::StatusCode;
|
||||
use pkarr::PublicKey;
|
||||
use tower_cookies::Cookies;
|
||||
|
||||
use crate::core::{
|
||||
error::{Error, Result},
|
||||
AppState,
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, head, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::core::{layers::authz::AuthorizationLayer, AppState};
|
||||
|
||||
pub mod read;
|
||||
pub mod write;
|
||||
|
||||
/// Authorize write (PUT or DELETE) for Public paths.
|
||||
fn authorize(
|
||||
state: &mut AppState,
|
||||
cookies: Cookies,
|
||||
public_key: &PublicKey,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
// TODO: can we move this logic to the extractor or a layer
|
||||
// to perform this validation?
|
||||
let session = state
|
||||
.db
|
||||
.get_session(cookies, public_key)?
|
||||
.ok_or(Error::with_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
if session.pubky() == public_key
|
||||
&& session.capabilities().iter().any(|cap| {
|
||||
path.starts_with(&cap.scope[1..])
|
||||
&& cap
|
||||
.actions
|
||||
.contains(&pubky_common::capabilities::Action::Write)
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(Error::with_status(StatusCode::FORBIDDEN))
|
||||
}
|
||||
|
||||
fn verify(path: &str) -> Result<()> {
|
||||
if !path.starts_with("pub/") {
|
||||
return Err(Error::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Writing to directories other than '/pub/' is forbidden".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// TODO: should we forbid paths ending with `/`?
|
||||
|
||||
Ok(())
|
||||
pub fn data_store_router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/pub/", get(read::list_root))
|
||||
.route("/pub/*path", get(read::get))
|
||||
.route("/pub/*path", head(read::head))
|
||||
.route("/pub/*path", put(write::put))
|
||||
.route("/pub/*path", delete(write::delete))
|
||||
.layer(DefaultBodyLimit::max(100 * 1024 * 1024))
|
||||
.layer(AuthorizationLayer::new(state.clone()))
|
||||
}
|
||||
|
||||
@@ -11,20 +11,16 @@ use std::str::FromStr;
|
||||
use crate::core::{
|
||||
database::tables::entries::Entry,
|
||||
error::{Error, Result},
|
||||
extractors::{EntryPath, ListQueryParams, Pubky},
|
||||
extractors::{EntryPath, ListQueryParams, PubkyHost},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::verify;
|
||||
|
||||
pub async fn head(
|
||||
State(state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
headers: HeaderMap,
|
||||
path: EntryPath,
|
||||
) -> Result<impl IntoResponse> {
|
||||
verify(path.as_str())?;
|
||||
|
||||
let rtxn = state.db.env.read_txn()?;
|
||||
|
||||
get_entry(
|
||||
@@ -38,7 +34,7 @@ pub async fn head(
|
||||
|
||||
pub async fn list_root(
|
||||
State(state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
params: ListQueryParams,
|
||||
) -> Result<impl IntoResponse> {
|
||||
list(state, pubky.public_key(), "pub/", params)
|
||||
@@ -47,12 +43,10 @@ pub async fn list_root(
|
||||
pub async fn get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
path: EntryPath,
|
||||
params: ListQueryParams,
|
||||
) -> Result<impl IntoResponse> {
|
||||
verify(&path)?;
|
||||
|
||||
let public_key = pubky.public_key().clone();
|
||||
|
||||
if path.as_str().ends_with('/') {
|
||||
@@ -215,9 +209,9 @@ mod tests {
|
||||
async fn if_last_modified() {
|
||||
let mut server = HomeserverCore::test().unwrap();
|
||||
|
||||
let public_key = Keypair::random().public_key();
|
||||
let cookie = server.create_user(&public_key).unwrap();
|
||||
let cookie = cookie.to_string();
|
||||
let keypair = Keypair::random();
|
||||
let public_key = keypair.public_key();
|
||||
let cookie = server.create_root_user(&keypair).await.unwrap().to_string();
|
||||
|
||||
let data = vec![1_u8, 2, 3, 4, 5];
|
||||
|
||||
@@ -271,9 +265,10 @@ mod tests {
|
||||
async fn if_none_match() {
|
||||
let mut server = HomeserverCore::test().unwrap();
|
||||
|
||||
let public_key = Keypair::random().public_key();
|
||||
let cookie = server.create_user(&public_key).unwrap();
|
||||
let cookie = cookie.to_string();
|
||||
let keypair = Keypair::random();
|
||||
let public_key = keypair.public_key();
|
||||
|
||||
let cookie = server.create_root_user(&keypair).await.unwrap().to_string();
|
||||
|
||||
let data = vec![1_u8, 2, 3, 4, 5];
|
||||
|
||||
|
||||
@@ -3,27 +3,20 @@ use std::io::Write;
|
||||
use futures_util::stream::StreamExt;
|
||||
|
||||
use axum::{body::Body, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use tower_cookies::Cookies;
|
||||
|
||||
use crate::core::{
|
||||
error::{Error, Result},
|
||||
extractors::{EntryPath, Pubky},
|
||||
extractors::{EntryPath, PubkyHost},
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::{authorize, verify};
|
||||
|
||||
pub async fn delete(
|
||||
State(mut state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
path: EntryPath,
|
||||
cookies: Cookies,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let public_key = pubky.public_key().clone();
|
||||
|
||||
verify(&path)?;
|
||||
authorize(&mut state, cookies, &public_key, &path)?;
|
||||
|
||||
// TODO: should we wrap this with `tokio::task::spawn_blocking` in case it takes too long?
|
||||
let deleted = state.db.delete_entry(&public_key, &path)?;
|
||||
|
||||
@@ -37,16 +30,12 @@ pub async fn delete(
|
||||
|
||||
pub async fn put(
|
||||
State(mut state): State<AppState>,
|
||||
pubky: Pubky,
|
||||
pubky: PubkyHost,
|
||||
path: EntryPath,
|
||||
cookies: Cookies,
|
||||
body: Body,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let public_key = pubky.public_key().clone();
|
||||
|
||||
verify(&path)?;
|
||||
authorize(&mut state, cookies, &public_key, &path)?;
|
||||
|
||||
let mut entry_writer = state.db.write_entry(&public_key, &path)?;
|
||||
|
||||
let mut stream = body.into_data_stream();
|
||||
|
||||
@@ -7,7 +7,7 @@ const TESTNET_HTTP_RELAY = "http://localhost:15412/link";
|
||||
|
||||
// TODO: test multiple users in wasm
|
||||
|
||||
test('auth', async (t) => {
|
||||
test('Auth: basic', async (t) => {
|
||||
const client = Client.testnet();
|
||||
|
||||
const keypair = Keypair.random()
|
||||
@@ -33,7 +33,34 @@ test('auth', async (t) => {
|
||||
}
|
||||
})
|
||||
|
||||
test("3rd party signin", async (t) => {
|
||||
test("Auth: multi-user (cookies)", async (t) => {
|
||||
const client = Client.testnet();
|
||||
|
||||
const alice = Keypair.random()
|
||||
const bob = Keypair.random()
|
||||
|
||||
await client.signup(alice, HOMESERVER_PUBLICKEY )
|
||||
|
||||
let session = await client.session(alice.publicKey())
|
||||
t.ok(session, "signup")
|
||||
|
||||
{
|
||||
await client.signup(bob, HOMESERVER_PUBLICKEY )
|
||||
|
||||
const session = await client.session(bob.publicKey())
|
||||
t.ok(session, "signup")
|
||||
}
|
||||
|
||||
session = await client.session(alice.publicKey());
|
||||
t.is(session.pubky().z32(), alice.publicKey().z32(), "alice is still signed in")
|
||||
|
||||
await client.signout(bob.publicKey());
|
||||
|
||||
session = await client.session(alice.publicKey());
|
||||
t.is(session.pubky().z32(), alice.publicKey().z32(), "alice is still signed in after signout of bob")
|
||||
})
|
||||
|
||||
test("Auth: 3rd party signin", async (t) => {
|
||||
let keypair = Keypair.random();
|
||||
let pubky = keypair.publicKey().z32();
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ impl CookieStore for CookieJar {
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
|
||||
// TODO: should we return if empty or just push?
|
||||
if s.is_empty() {
|
||||
let host = url.host_str().unwrap_or("");
|
||||
|
||||
|
||||
@@ -677,9 +677,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let get = client.get(url).send().await.unwrap().bytes().await.unwrap();
|
||||
let response = client.get(url).send().await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
assert_eq!(get.as_ref(), &[0]);
|
||||
let body = response.bytes().await.unwrap();
|
||||
|
||||
assert_eq!(body.as_ref(), &[0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user