feat(homeserver): add PubkyHost and Authz layers

This commit is contained in:
nazeh
2024-12-20 11:30:58 +03:00
parent 3cfe6567f0
commit 738bff1ae1
14 changed files with 393 additions and 220 deletions

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View 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())
}

View File

@@ -0,0 +1,2 @@
pub mod authz;
pub mod pubky_host;

View 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!())
})
}
}

View File

@@ -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> {

View File

@@ -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(())

View File

@@ -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)
}

View File

@@ -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()))
}

View File

@@ -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];

View File

@@ -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();

View File

@@ -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();

View File

@@ -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("");

View File

@@ -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]