mirror of
https://github.com/aljazceru/pubky-core.git
synced 2025-12-31 12:54:35 +01:00
feat(homeserver): store and return session capabilities
This commit is contained in:
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
capabilities::Capability,
|
||||
crypto::{Keypair, PublicKey, Signature},
|
||||
timestamp::Timestamp,
|
||||
};
|
||||
@@ -30,12 +31,12 @@ pub struct AuthToken {
|
||||
subject: PublicKey,
|
||||
/// The Pubky of the party verifying the [AuthToken], for example a web server.
|
||||
audience: PublicKey,
|
||||
// Variable length scopes
|
||||
scopes: Vec<String>,
|
||||
// Variable length capabilities
|
||||
capabilities: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl AuthToken {
|
||||
pub fn sign(signer: &Keypair, audience: &PublicKey, scopes: Vec<String>) -> Self {
|
||||
pub fn sign(signer: &Keypair, audience: &PublicKey, capabilities: Vec<Capability>) -> Self {
|
||||
let timestamp = Timestamp::now();
|
||||
|
||||
let mut token = Self {
|
||||
@@ -43,7 +44,7 @@ impl AuthToken {
|
||||
subject: signer.public_key(),
|
||||
audience: audience.to_owned(),
|
||||
timestamp,
|
||||
scopes,
|
||||
capabilities,
|
||||
signature: Signature::from_bytes(&[0; 64]),
|
||||
};
|
||||
|
||||
@@ -54,6 +55,10 @@ impl AuthToken {
|
||||
token
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &[Capability] {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
/// Authenticate signer to an audience directly with [] capailities.
|
||||
///
|
||||
///
|
||||
@@ -150,7 +155,7 @@ impl AuthVerifier {
|
||||
match seen.binary_search_by(|element| element.cmp(&id)) {
|
||||
Ok(_) => Err(Error::AlreadyUsed),
|
||||
Err(index) => {
|
||||
seen.insert(index, id.into());
|
||||
seen.insert(index, id);
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
@@ -192,7 +197,9 @@ pub enum Error {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{auth::TIMESTAMP_WINDOW, crypto::Keypair, timestamp::Timestamp};
|
||||
use crate::{
|
||||
auth::TIMESTAMP_WINDOW, capabilities::Capability, crypto::Keypair, timestamp::Timestamp,
|
||||
};
|
||||
|
||||
use super::{AuthToken, AuthVerifier, Error};
|
||||
|
||||
@@ -200,9 +207,9 @@ mod tests {
|
||||
fn v0_id_signable() {
|
||||
let signer = Keypair::random();
|
||||
let audience = Keypair::random().public_key();
|
||||
let scopes = vec!["*:*".to_string()];
|
||||
let capabilities = vec![Capability::pubky_root()];
|
||||
|
||||
let token = AuthToken::sign(&signer, &audience, scopes.clone());
|
||||
let token = AuthToken::sign(&signer, &audience, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
@@ -221,24 +228,24 @@ mod tests {
|
||||
fn sign_verify() {
|
||||
let signer = Keypair::random();
|
||||
let audience = Keypair::random().public_key();
|
||||
let scopes = vec!["*:*".to_string()];
|
||||
let capabilities = vec![Capability::pubky_root()];
|
||||
|
||||
let verifier = AuthVerifier::new(audience.clone());
|
||||
|
||||
let token = AuthToken::sign(&signer, &audience, scopes.clone());
|
||||
let token = AuthToken::sign(&signer, &audience, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
verifier.verify(serialized).unwrap();
|
||||
|
||||
assert_eq!(token.scopes, scopes);
|
||||
assert_eq!(token.capabilities, capabilities);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired() {
|
||||
let signer = Keypair::random();
|
||||
let audience = Keypair::random().public_key();
|
||||
let scopes = vec!["*:*".to_string()];
|
||||
let capabilities = vec![Capability::pubky_root()];
|
||||
|
||||
let verifier = AuthVerifier::new(audience.clone());
|
||||
|
||||
@@ -247,7 +254,7 @@ mod tests {
|
||||
let mut signable = vec![];
|
||||
signable.extend_from_slice(signer.public_key().as_bytes());
|
||||
signable.extend_from_slice(audience.as_bytes());
|
||||
signable.extend_from_slice(&postcard::to_allocvec(&scopes).unwrap());
|
||||
signable.extend_from_slice(&postcard::to_allocvec(&capabilities).unwrap());
|
||||
|
||||
let signature = signer.sign(&signable);
|
||||
|
||||
@@ -257,7 +264,7 @@ mod tests {
|
||||
audience,
|
||||
timestamp,
|
||||
signature,
|
||||
scopes,
|
||||
capabilities,
|
||||
};
|
||||
|
||||
let serialized = token.serialize();
|
||||
@@ -271,17 +278,17 @@ mod tests {
|
||||
fn already_used() {
|
||||
let signer = Keypair::random();
|
||||
let audience = Keypair::random().public_key();
|
||||
let scopes = vec!["*:*".to_string()];
|
||||
let capabilities = vec![Capability::pubky_root()];
|
||||
|
||||
let verifier = AuthVerifier::new(audience.clone());
|
||||
|
||||
let token = AuthToken::sign(&signer, &audience, scopes.clone());
|
||||
let token = AuthToken::sign(&signer, &audience, capabilities.clone());
|
||||
|
||||
let serialized = &token.serialize();
|
||||
|
||||
verifier.verify(serialized).unwrap();
|
||||
|
||||
assert_eq!(token.scopes, scopes);
|
||||
assert_eq!(token.capabilities, capabilities);
|
||||
|
||||
assert_eq!(verifier.verify(serialized), Err(Error::AlreadyUsed));
|
||||
}
|
||||
|
||||
170
pubky-common/src/capabilities.rs
Normal file
170
pubky-common/src/capabilities.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PUBKY_CAP_PREFIX: &str = "pk!";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Capability {
|
||||
/// Pubky Homeserver's capabilities
|
||||
Pubky(PubkyCap),
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Create a [PubkyCap] at the root path `/` with all the available [PubkyAbility]
|
||||
pub fn pubky_root() -> Self {
|
||||
Capability::Pubky(PubkyCap {
|
||||
path: "/".to_string(),
|
||||
abilities: vec![PubkyAbility::Read, PubkyAbility::Write],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PubkyCap {
|
||||
pub path: String,
|
||||
pub abilities: Vec<PubkyAbility>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PubkyAbility {
|
||||
/// Can read the resource at the specified path (GET requests).
|
||||
Read,
|
||||
/// Can write to the resource at the specified path (PUT/POST/DELETE requests).
|
||||
Write,
|
||||
}
|
||||
|
||||
impl From<&PubkyAbility> for char {
|
||||
fn from(value: &PubkyAbility) -> Self {
|
||||
match value {
|
||||
PubkyAbility::Read => 'r',
|
||||
PubkyAbility::Write => 'w',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<char> for PubkyAbility {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: char) -> Result<Self, Error> {
|
||||
match value {
|
||||
'r' => Ok(Self::Read),
|
||||
'w' => Ok(Self::Write),
|
||||
_ => Err(Error::InvalidPubkyAbility),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Capability {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Error> {
|
||||
value.as_str().try_into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Capability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Pubky(cap) => write!(
|
||||
f,
|
||||
"{}{}:{}",
|
||||
PUBKY_CAP_PREFIX,
|
||||
cap.path,
|
||||
cap.abilities.iter().map(char::from).collect::<String>()
|
||||
),
|
||||
Self::Unknown(string) => write!(f, "{string}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Capability {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Error> {
|
||||
if value.starts_with(PUBKY_CAP_PREFIX) {
|
||||
let mut rsplit = value.rsplit(':');
|
||||
|
||||
let mut abilities = Vec::new();
|
||||
|
||||
for char in rsplit
|
||||
.next()
|
||||
.ok_or(Error::MissingField("abilities"))?
|
||||
.chars()
|
||||
{
|
||||
let ability = PubkyAbility::try_from(char)?;
|
||||
|
||||
match abilities.binary_search_by(|element| char::from(element).cmp(&char)) {
|
||||
Ok(_) => {}
|
||||
Err(index) => {
|
||||
abilities.insert(index, ability);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let path = rsplit.next().ok_or(Error::MissingField("path"))?[PUBKY_CAP_PREFIX.len()..]
|
||||
.to_string();
|
||||
|
||||
if !path.starts_with('/') {
|
||||
return Err(Error::InvalidPath);
|
||||
}
|
||||
|
||||
return Ok(Capability::Pubky(PubkyCap { path, abilities }));
|
||||
}
|
||||
|
||||
Ok(Capability::Unknown(value.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
#[error("PubkyCap: Missing field {0}")]
|
||||
MissingField(&'static str),
|
||||
#[error("PubkyCap: InvalidPath does not start with `/`")]
|
||||
InvalidPath,
|
||||
#[error("Invalid PubkyAbility")]
|
||||
InvalidPubkyAbility,
|
||||
}
|
||||
|
||||
impl Serialize for Capability {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let string = self.to_string();
|
||||
|
||||
string.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capability {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let string: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
string.try_into().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pubky_caps() {
|
||||
let cap = Capability::Pubky(PubkyCap {
|
||||
path: "/pub/pubky.app/".to_string(),
|
||||
abilities: vec![PubkyAbility::Read, PubkyAbility::Write],
|
||||
});
|
||||
|
||||
// Read and write withing directory `/pub/pubky.app/`.
|
||||
let expected_string = "pk!/pub/pubky.app/:rw";
|
||||
|
||||
assert_eq!(cap.to_string(), expected_string);
|
||||
|
||||
assert_eq!(Capability::try_from(expected_string), Ok(cap))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod capabilities;
|
||||
pub mod crypto;
|
||||
pub mod namespaces;
|
||||
pub mod session;
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
extern crate alloc;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::timestamp::Timestamp;
|
||||
use crate::{auth::AuthToken, capabilities::Capability, timestamp::Timestamp};
|
||||
|
||||
// TODO: add IP address?
|
||||
// TODO: use https://crates.io/crates/user-agent-parser to parse the session
|
||||
@@ -16,14 +16,23 @@ pub struct Session {
|
||||
/// User specified name, defaults to the user-agent.
|
||||
pub name: String,
|
||||
pub user_agent: String,
|
||||
pub capabilities: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pub fn new(token: &AuthToken, user_agent: Option<String>) -> Self {
|
||||
let mut session = Self {
|
||||
created_at: Timestamp::now().into_inner(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
session.set_capabilities(token.capabilities().to_vec());
|
||||
|
||||
if let Some(user_agent) = user_agent {
|
||||
session.set_user_agent(user_agent);
|
||||
}
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
// === Setters ===
|
||||
@@ -38,6 +47,12 @@ impl Session {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_capabilities(&mut self, capabilities: Vec<Capability>) -> &mut Self {
|
||||
self.capabilities = capabilities;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
// === Public Methods ===
|
||||
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
@@ -71,12 +86,16 @@ mod tests {
|
||||
fn serialize() {
|
||||
let session = Session {
|
||||
user_agent: "foo".to_string(),
|
||||
capabilities: vec![Capability::pubky_root()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let serialized = session.serialize();
|
||||
|
||||
assert_eq!(serialized, [0, 0, 0, 3, 102, 111, 111,]);
|
||||
assert_eq!(
|
||||
serialized,
|
||||
[0, 0, 0, 3, 102, 111, 111, 1, 7, 112, 107, 33, 47, 58, 114, 119]
|
||||
);
|
||||
|
||||
let deseiralized = Session::deserialize(&serialized).unwrap();
|
||||
|
||||
|
||||
@@ -8,10 +8,17 @@ use heed::{Env, RwTxn};
|
||||
use blobs::{BlobsTable, BLOBS_TABLE};
|
||||
use entries::{EntriesTable, ENTRIES_TABLE};
|
||||
|
||||
use self::{
|
||||
sessions::{SessionsTable, SESSIONS_TABLE},
|
||||
users::{UsersTable, USERS_TABLE},
|
||||
};
|
||||
|
||||
pub const TABLES_COUNT: u32 = 4;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tables {
|
||||
pub users: UsersTable,
|
||||
pub sessions: SessionsTable,
|
||||
pub blobs: BlobsTable,
|
||||
pub entries: EntriesTable,
|
||||
}
|
||||
@@ -19,6 +26,12 @@ pub struct Tables {
|
||||
impl Tables {
|
||||
pub fn new(env: &Env, wtxn: &mut RwTxn) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
users: env
|
||||
.open_database(wtxn, Some(USERS_TABLE))?
|
||||
.expect("Users table already created"),
|
||||
sessions: env
|
||||
.open_database(wtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Sessions table already created"),
|
||||
blobs: env
|
||||
.open_database(wtxn, Some(BLOBS_TABLE))?
|
||||
.expect("Blobs table already created"),
|
||||
|
||||
@@ -13,7 +13,7 @@ use pubky_common::{crypto::random_bytes, session::Session, timestamp::Timestamp}
|
||||
use crate::{
|
||||
database::tables::{
|
||||
sessions::{SessionsTable, SESSIONS_TABLE},
|
||||
users::{User, UsersTable, USERS_TABLE},
|
||||
users::User,
|
||||
},
|
||||
error::{Error, Result},
|
||||
extractors::Pubky,
|
||||
@@ -97,12 +97,8 @@ pub async fn signin(
|
||||
let public_key = token.subject();
|
||||
|
||||
let mut wtxn = state.db.env.write_txn()?;
|
||||
let users: UsersTable = state
|
||||
.db
|
||||
.env
|
||||
.open_database(&wtxn, Some(USERS_TABLE))?
|
||||
.expect("Users table already created");
|
||||
|
||||
let users = state.db.tables.users;
|
||||
if let Some(existing) = users.get(&wtxn, public_key)? {
|
||||
users.put(&mut wtxn, public_key, &existing)?;
|
||||
} else {
|
||||
@@ -117,21 +113,14 @@ pub async fn signin(
|
||||
|
||||
let session_secret = base32::encode(base32::Alphabet::Crockford, &random_bytes::<16>());
|
||||
|
||||
let sessions: SessionsTable = state
|
||||
.db
|
||||
.env
|
||||
.open_database(&wtxn, Some(SESSIONS_TABLE))?
|
||||
.expect("Sessions table already created");
|
||||
|
||||
let mut session = Session::new();
|
||||
|
||||
if let Some(user_agent) = user_agent {
|
||||
session.set_user_agent(user_agent.to_string());
|
||||
}
|
||||
|
||||
sessions.put(&mut wtxn, &session_secret, &session.serialize())?;
|
||||
state.db.tables.sessions.put(
|
||||
&mut wtxn,
|
||||
&session_secret,
|
||||
&Session::new(&token, user_agent.map(|ua| ua.to_string())).serialize(),
|
||||
)?;
|
||||
|
||||
let mut cookie = Cookie::new(public_key.to_string(), session_secret);
|
||||
|
||||
cookie.set_path("/");
|
||||
if *uri.scheme().unwrap_or(&Scheme::HTTP) == Scheme::HTTPS {
|
||||
cookie.set_secure(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use reqwest::{Method, StatusCode};
|
||||
|
||||
use pkarr::{Keypair, PublicKey};
|
||||
use pubky_common::{auth::AuthToken, session::Session};
|
||||
use pubky_common::{auth::AuthToken, capabilities::Capability, session::Session};
|
||||
|
||||
use crate::{error::Result, PubkyClient};
|
||||
|
||||
@@ -24,9 +24,9 @@ impl PubkyClient {
|
||||
mut url,
|
||||
} = self.resolve_endpoint(&homeserver).await?;
|
||||
|
||||
url.set_path(&format!("/signup"));
|
||||
url.set_path("/signup");
|
||||
|
||||
let body = AuthToken::sign(keypair, &audience, vec![]).serialize();
|
||||
let body = AuthToken::sign(keypair, &audience, vec![Capability::pubky_root()]).serialize();
|
||||
|
||||
let response = self
|
||||
.request(Method::POST, url.clone())
|
||||
@@ -87,9 +87,9 @@ impl PubkyClient {
|
||||
mut url,
|
||||
} = self.resolve_pubky_homeserver(&pubky).await?;
|
||||
|
||||
url.set_path(&format!("/session"));
|
||||
url.set_path("/session");
|
||||
|
||||
let body = AuthToken::sign(keypair, &audience, vec![]).serialize();
|
||||
let body = AuthToken::sign(keypair, &audience, vec![Capability::pubky_root()]).serialize();
|
||||
|
||||
let response = self.request(Method::POST, url).body(body).send().await?;
|
||||
|
||||
@@ -105,7 +105,7 @@ mod tests {
|
||||
use crate::*;
|
||||
|
||||
use pkarr::{mainline::Testnet, Keypair};
|
||||
use pubky_common::session::Session;
|
||||
use pubky_common::capabilities::Capability;
|
||||
use pubky_homeserver::Homeserver;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -125,7 +125,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session, Session { ..session.clone() });
|
||||
assert!(session.capabilities.contains(&Capability::pubky_root()));
|
||||
|
||||
client.signout(&keypair.public_key()).await.unwrap();
|
||||
|
||||
@@ -144,7 +144,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session, Session { ..session.clone() });
|
||||
assert!(session.capabilities.contains(&Capability::pubky_root()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user