Compare commits

...

4 commits

Author SHA1 Message Date
be6d9bf28b
chore: use ' ' instead of " " so clippy is happy 2024-05-13 18:54:09 +02:00
bd9b9782b4
feat: added registration cli and route and cfg
defaults to disabled registrations ofc
2024-05-13 18:53:51 +02:00
401ef08af3
fix: shared inbox MUST NOT contain private stuff 2024-05-13 18:53:03 +02:00
d2f0ce0391
chore: moved http signatures code in separate file 2024-05-13 18:52:42 +02:00
12 changed files with 327 additions and 184 deletions

View file

@ -10,8 +10,8 @@ pub use faker::*;
mod relay; mod relay;
pub use relay::*; pub use relay::*;
//mod register; mod register;
//pub use register::*; pub use register::*;
mod update; mod update;
pub use update::*; pub use update::*;
@ -64,6 +64,32 @@ pub enum CliCommand {
#[arg(long, short, default_value_t = 7)] #[arg(long, short, default_value_t = 7)]
/// number of days after which users should get updated /// number of days after which users should get updated
days: i64, days: i64,
},
/// register a new local user
Register {
/// username for new user, must be unique locally and cannot be changed
username: String,
/// password for new user
// TODO get this with getpass rather than argv!!!!
password: String,
/// display name for new user
#[arg(long = "name")]
display_name: Option<String>,
/// summary text for new user
#[arg(long = "summary")]
summary: Option<String>,
/// url for avatar image of new user
#[arg(long = "avatar")]
avatar_url: Option<String>,
/// url for banner image of new user
#[arg(long = "banner")]
banner_url: Option<String>,
} }
} }
@ -87,5 +113,7 @@ pub async fn run(
Ok(fix(ctx, likes, shares, replies).await?), Ok(fix(ctx, likes, shares, replies).await?),
CliCommand::Update { days } => CliCommand::Update { days } =>
Ok(update_users(ctx, days).await?), Ok(update_users(ctx, days).await?),
CliCommand::Register { username, password, display_name, summary, avatar_url, banner_url } =>
Ok(register(ctx, username, password, display_name, summary, avatar_url, banner_url).await?),
} }
} }

View file

@ -2,37 +2,24 @@ use openssl::rsa::Rsa;
use sea_orm::{EntityTrait, IntoActiveModel}; use sea_orm::{EntityTrait, IntoActiveModel};
pub async fn register( pub async fn register(
db: sea_orm::DatabaseConnection, ctx: crate::server::Context,
domain: String, username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
) -> crate::Result<()> { ) -> crate::Result<()> {
let key = Rsa::generate(2048).unwrap(); ctx.register_user(
let test_user = crate::model::user::Model { username.clone(),
id: format!("{domain}/users/test"), password,
name: Some("μpub".into()), display_name,
domain: clean_domain(&domain), summary,
preferred_username: "test".to_string(), avatar_url,
summary: Some("hello world! i'm manually generated but served dynamically from db! check progress at https://git.alemi.dev/upub.git".to_string()), banner_url,
following: None, ).await?;
following_count: 0,
followers: None,
followers_count: 0,
statuses_count: 0,
icon: Some("https://cdn.alemi.dev/social/circle-square.png".to_string()),
image: Some("https://cdn.alemi.dev/social/someriver-xs.jpg".to_string()),
inbox: None,
shared_inbox: None,
outbox: None,
actor_type: apb::ActorType::Person,
created: chrono::Utc::now(),
updated: chrono::Utc::now(),
private_key: Some(std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string()),
// TODO generate a fresh one every time
public_key: std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string(),
};
crate::model::user::Entity::insert(test_user.clone().into_active_model()).exec(&db).await?; tracing::info!("registered new user: {username}");
Ok(())
} }
// TODO duplicated, make an util?? idk // TODO duplicated, make an util?? idk

View file

@ -9,9 +9,31 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub datasource: DatasourceConfig, pub datasource: DatasourceConfig,
#[serde(default)]
pub security: SecurityConfig,
// TODO should i move app keys here? // TODO should i move app keys here?
} }
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct InstanceConfig {
#[serde_inline_default("μpub".into())]
pub name: String,
#[serde_inline_default("micro social network, federated".into())]
pub description: String,
#[serde_inline_default("upub.social".into())]
pub domain: String,
#[serde(default)]
pub contact: Option<String>,
#[serde(default)]
pub frontend: Option<String>,
}
#[serde_inline_default::serde_inline_default] #[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)] #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct DatasourceConfig { pub struct DatasourceConfig {
@ -39,26 +61,12 @@ pub struct DatasourceConfig {
#[serde_inline_default::serde_inline_default] #[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)] #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct InstanceConfig { pub struct SecurityConfig {
#[serde_inline_default("μpub".into())]
pub name: String,
#[serde_inline_default("micro social network, federated".into())]
pub description: String,
#[serde_inline_default("upub.social".into())]
pub domain: String,
#[serde(default)] #[serde(default)]
pub contact: Option<String>, pub allow_registration: bool,
#[serde(default)]
pub frontend: Option<String>,
} }
impl Config { impl Config {
pub fn load(path: Option<std::path::PathBuf>) -> Self { pub fn load(path: Option<std::path::PathBuf>) -> Self {
let Some(cfg_path) = path else { return Config::default() }; let Some(cfg_path) = path else { return Config::default() };

View file

@ -2,7 +2,7 @@ use axum::{http::StatusCode, extract::State, Json};
use rand::Rng; use rand::Rng;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use crate::{errors::UpubError, model, server::Context}; use crate::{errors::UpubError, model, server::{admin::Administrable, Context}};
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
@ -18,7 +18,10 @@ pub struct AuthSuccess {
expires: chrono::DateTime<chrono::Utc>, expires: chrono::DateTime<chrono::Utc>,
} }
pub async fn login(State(ctx): State<Context>, Json(login): Json<LoginForm>) -> crate::Result<Json<AuthSuccess>> { pub async fn login(
State(ctx): State<Context>,
Json(login): Json<LoginForm>
) -> crate::Result<Json<AuthSuccess>> {
// TODO salt the pwd // TODO salt the pwd
match model::credential::Entity::find() match model::credential::Entity::find()
.filter(Condition::all() .filter(Condition::all()
@ -53,3 +56,33 @@ pub async fn login(State(ctx): State<Context>, Json(login): Json<LoginForm>) ->
None => Err(UpubError::unauthorized()), None => Err(UpubError::unauthorized()),
} }
} }
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RegisterForm {
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
}
pub async fn register(
State(ctx): State<Context>,
Json(registration): Json<RegisterForm>
) -> crate::Result<Json<String>> {
if !ctx.cfg().security.allow_registration {
return Err(UpubError::forbidden());
}
ctx.register_user(
registration.username.clone(),
registration.password,
registration.display_name,
registration.summary,
registration.avatar_url,
registration.banner_url
).await?;
Ok(Json(ctx.uid(registration.username)))
}

View file

@ -1,5 +1,6 @@
use apb::{server::Inbox, Activity, ActivityType}; use apb::{server::Inbox, Activity, ActivityType};
use axum::{extract::{Query, State}, http::StatusCode, Json}; use axum::{extract::{Query, State}, http::StatusCode, Json};
use sea_orm::{sea_query::IntoCondition, ColumnTrait};
use crate::{errors::UpubError, server::{auth::{AuthIdentity, Identity}, Context}, url}; use crate::{errors::UpubError, server::{auth::{AuthIdentity, Identity}, Context}, url};
@ -19,7 +20,8 @@ pub async fn page(
) -> crate::Result<JsonLD<serde_json::Value>> { ) -> crate::Result<JsonLD<serde_json::Value>> {
crate::server::builders::paginate( crate::server::builders::paginate(
url!(ctx, "/inbox/page"), url!(ctx, "/inbox/page"),
auth.filter_condition(), crate::model::addressing::Column::Actor.eq(apb::target::PUBLIC)
.into_condition(),
ctx.db(), ctx.db(),
page, page,
auth.my_id(), auth.my_id(),

View file

@ -11,7 +11,7 @@ pub mod well_known;
pub mod jsonld; pub mod jsonld;
pub use jsonld::JsonLD; pub use jsonld::JsonLD;
use axum::{http::StatusCode, response::IntoResponse, routing::{get, post}, Router}; use axum::{http::StatusCode, response::IntoResponse, routing::{get, post, put}, Router};
pub trait ActivityPubRouter { pub trait ActivityPubRouter {
fn ap_routes(self) -> Self; fn ap_routes(self) -> Self;
@ -35,6 +35,7 @@ impl ActivityPubRouter for Router<crate::server::Context> {
.route("/outbox/page", get(ap::outbox::page)) .route("/outbox/page", get(ap::outbox::page))
// AUTH routes // AUTH routes
.route("/auth", post(ap::auth::login)) .route("/auth", post(ap::auth::login))
.route("/auth", put(ap::auth::register))
// .well-known and discovery // .well-known and discovery
.route("/.well-known/webfinger", get(ap::well_known::webfinger)) .route("/.well-known/webfinger", get(ap::well_known::webfinger))
.route("/.well-known/host-meta", get(ap::well_known::host_meta)) .route("/.well-known/host-meta", get(ap::well_known::host_meta))

82
src/server/admin.rs Normal file
View file

@ -0,0 +1,82 @@
use sea_orm::{EntityTrait, IntoActiveModel};
#[axum::async_trait]
pub trait Administrable {
async fn register_user(
&self,
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
) -> crate::Result<()>;
}
#[axum::async_trait]
impl Administrable for super::Context {
async fn register_user(
&self,
username: String,
password: String,
display_name: Option<String>,
summary: Option<String>,
avatar_url: Option<String>,
banner_url: Option<String>,
) -> crate::Result<()> {
let key = openssl::rsa::Rsa::generate(2048).unwrap();
let ap_id = self.uid(username.clone());
let db = self.db();
let domain = self.domain().to_string();
let user_model = crate::model::user::Model {
id: ap_id.clone(),
name: display_name,
domain, summary,
preferred_username: username.clone(),
following: None,
following_count: 0,
followers: None,
followers_count: 0,
statuses_count: 0,
icon: avatar_url,
image: banner_url,
inbox: None,
shared_inbox: None,
outbox: None,
actor_type: apb::ActorType::Person,
created: chrono::Utc::now(),
updated: chrono::Utc::now(),
private_key: Some(std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string()),
public_key: std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string(),
};
crate::model::user::Entity::insert(user_model.into_active_model())
.exec(db)
.await?;
let config_model = crate::model::config::Model {
id: ap_id.clone(),
accept_follow_requests: true,
show_followers_count: true,
show_following_count: true,
show_followers: false,
show_following: false,
};
crate::model::config::Entity::insert(config_model.into_active_model())
.exec(db)
.await?;
let credentials_model = crate::model::credential::Model {
id: ap_id,
email: username,
password,
};
crate::model::credential::Entity::insert(credentials_model.into_active_model())
.exec(db)
.await?;
Ok(())
}
}

View file

@ -1,14 +1,10 @@
use std::collections::BTreeMap;
use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}}; use axum::{extract::{FromRef, FromRequestParts}, http::{header, request::Parts}};
use base64::Engine;
use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier};
use reqwest::StatusCode; use reqwest::StatusCode;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
use crate::{errors::UpubError, model, server::Context}; use crate::{errors::UpubError, model, server::Context};
use super::fetcher::Fetcher; use super::{fetcher::Fetcher, httpsign::HttpSignature};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Identity { pub enum Identity {
@ -147,129 +143,3 @@ where
Ok(AuthIdentity(identity)) Ok(AuthIdentity(identity))
} }
} }
#[derive(Debug, Clone, Default)]
pub struct HttpSignature {
pub key_id: String,
pub algorithm: String,
pub headers: Vec<String>,
pub signature: String,
pub control: String,
}
impl HttpSignature {
pub fn new(key_id: String, algorithm: String, headers: &[&str]) -> Self {
HttpSignature {
key_id, algorithm,
headers: headers.iter().map(|x| x.to_string()).collect(),
signature: String::new(),
control: String::new(),
}
}
pub fn parse(header: &str) -> Self {
let mut sig = HttpSignature::default();
header.split(',')
.filter_map(|x| x.split_once('='))
.map(|(k, v)| (k, v.trim_end_matches('"').trim_matches('"')))
.for_each(|(k, v)| match k {
"keyId" => sig.key_id = v.to_string(),
"algorithm" => sig.algorithm = v.to_string(),
"signature" => sig.signature = v.to_string(),
"headers" => sig.headers = v.split(' ').map(|x| x.to_string()).collect(),
_ => tracing::warn!("unexpected field in http signature: '{k}=\"{v}\"'"),
});
sig
}
pub fn header(&self) -> String {
format!(
"keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"",
self.key_id, self.algorithm, self.headers.join(" "), self.signature,
)
}
pub fn build_manually(&mut self, method: &str, target: &str, mut headers: BTreeMap<String, String>) -> &mut Self {
let mut out = Vec::new();
for header in &self.headers {
match header.as_str() {
"(request-target)" => out.push(format!("(request-target): {method} {target}")),
// TODO other pseudo-headers
_ => out.push(
format!("{header}: {}", headers.remove(header).unwrap_or_default())
),
}
}
self.control = out.join("\n");
self
}
pub fn build_from_parts(&mut self, parts: &Parts) -> &mut Self {
let mut out = Vec::new();
for header in self.headers.iter() {
match header.as_str() {
"(request-target)" => out.push(
format!(
"(request-target): {} {}",
parts.method.to_string().to_lowercase(),
parts.uri.path_and_query().map(|x| x.as_str()).unwrap_or("/")
)
),
// TODO other pseudo-headers,
_ => out.push(format!("{}: {}",
header.to_lowercase(),
parts.headers.get(header).map(|x| x.to_str().unwrap_or("")).unwrap_or("")
)),
}
}
self.control = out.join("\n");
self
}
pub fn verify(&self, key: &str) -> crate::Result<bool> {
let pubkey = PKey::public_key_from_pem(key.as_bytes())?;
let mut verifier = Verifier::new(MessageDigest::sha256(), &pubkey)?;
let signature = base64::prelude::BASE64_STANDARD.decode(&self.signature)?;
Ok(verifier.verify_oneshot(&signature, self.control.as_bytes())?)
}
pub fn sign(&mut self, key: &str) -> crate::Result<&str> {
let privkey = PKey::private_key_from_pem(key.as_bytes())?;
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &privkey)?;
signer.update(self.control.as_bytes())?;
self.signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec()?);
Ok(&self.signature)
}
}
#[cfg(test)]
mod test {
#[test]
fn http_signature_signs_and_verifies() {
let key = openssl::rsa::Rsa::generate(2048).unwrap();
let private_key = std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string();
let public_key = std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string();
let mut signer = super::HttpSignature {
key_id: "test".to_string(),
algorithm: "rsa-sha256".to_string(),
headers: vec![
"(request-target)".to_string(),
"host".to_string(),
"date".to_string(),
],
signature: String::new(),
control: String::new(),
};
signer
.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into())
.sign(&private_key)
.unwrap();
let mut verifier = super::HttpSignature::parse(&signer.header());
verifier.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into());
assert!(verifier.verify(&public_key).unwrap());
}
}

View file

@ -111,7 +111,7 @@ impl Context {
.replace("///", "/@/") // omg wordpress PLEASE AAAAAAAAAAAAAAAAAAAA .replace("///", "/@/") // omg wordpress PLEASE AAAAAAAAAAAAAAAAAAAA
.replace("//", "/@") // oops my method sucks!! TODO .replace("//", "/@") // oops my method sucks!! TODO
.replacen('+', "https://", 1) .replacen('+', "https://", 1)
.replace(" ", "%20") // omg wordpress .replace(' ', "%20") // omg wordpress
} else { // bare local id } else { // bare local id
format!("{}{}/{}/{}", self.0.protocol, self.0.domain, entity, id) format!("{}{}/{}/{}", self.0.protocol, self.0.domain, entity, id)
} }

View file

@ -7,7 +7,7 @@ use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, IntoActiveModel, QueryF
use crate::{errors::UpubError, model, VERSION}; use crate::{errors::UpubError, model, VERSION};
use super::{auth::HttpSignature, Context}; use super::{httpsign::HttpSignature, Context};
#[axum::async_trait] #[axum::async_trait]
pub trait Fetcher { pub trait Fetcher {

130
src/server/httpsign.rs Normal file
View file

@ -0,0 +1,130 @@
use std::collections::BTreeMap;
use axum::http::request::Parts;
use base64::Engine;
use openssl::{hash::MessageDigest, pkey::PKey, sign::Verifier};
#[derive(Debug, Clone, Default)]
pub struct HttpSignature {
pub key_id: String,
pub algorithm: String,
pub headers: Vec<String>,
pub signature: String,
pub control: String,
}
impl HttpSignature {
pub fn new(key_id: String, algorithm: String, headers: &[&str]) -> Self {
HttpSignature {
key_id, algorithm,
headers: headers.iter().map(|x| x.to_string()).collect(),
signature: String::new(),
control: String::new(),
}
}
pub fn parse(header: &str) -> Self {
let mut sig = HttpSignature::default();
header.split(',')
.filter_map(|x| x.split_once('='))
.map(|(k, v)| (k, v.trim_end_matches('"').trim_matches('"')))
.for_each(|(k, v)| match k {
"keyId" => sig.key_id = v.to_string(),
"algorithm" => sig.algorithm = v.to_string(),
"signature" => sig.signature = v.to_string(),
"headers" => sig.headers = v.split(' ').map(|x| x.to_string()).collect(),
_ => tracing::warn!("unexpected field in http signature: '{k}=\"{v}\"'"),
});
sig
}
pub fn header(&self) -> String {
format!(
"keyId=\"{}\",algorithm=\"{}\",headers=\"{}\",signature=\"{}\"",
self.key_id, self.algorithm, self.headers.join(" "), self.signature,
)
}
pub fn build_manually(&mut self, method: &str, target: &str, mut headers: BTreeMap<String, String>) -> &mut Self {
let mut out = Vec::new();
for header in &self.headers {
match header.as_str() {
"(request-target)" => out.push(format!("(request-target): {method} {target}")),
// TODO other pseudo-headers
_ => out.push(
format!("{header}: {}", headers.remove(header).unwrap_or_default())
),
}
}
self.control = out.join("\n");
self
}
pub fn build_from_parts(&mut self, parts: &Parts) -> &mut Self {
let mut out = Vec::new();
for header in self.headers.iter() {
match header.as_str() {
"(request-target)" => out.push(
format!(
"(request-target): {} {}",
parts.method.to_string().to_lowercase(),
parts.uri.path_and_query().map(|x| x.as_str()).unwrap_or("/")
)
),
// TODO other pseudo-headers,
_ => out.push(format!("{}: {}",
header.to_lowercase(),
parts.headers.get(header).map(|x| x.to_str().unwrap_or("")).unwrap_or("")
)),
}
}
self.control = out.join("\n");
self
}
pub fn verify(&self, key: &str) -> crate::Result<bool> {
let pubkey = PKey::public_key_from_pem(key.as_bytes())?;
let mut verifier = Verifier::new(MessageDigest::sha256(), &pubkey)?;
let signature = base64::prelude::BASE64_STANDARD.decode(&self.signature)?;
Ok(verifier.verify_oneshot(&signature, self.control.as_bytes())?)
}
pub fn sign(&mut self, key: &str) -> crate::Result<&str> {
let privkey = PKey::private_key_from_pem(key.as_bytes())?;
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &privkey)?;
signer.update(self.control.as_bytes())?;
self.signature = base64::prelude::BASE64_STANDARD.encode(signer.sign_to_vec()?);
Ok(&self.signature)
}
}
#[cfg(test)]
mod test {
#[test]
fn http_signature_signs_and_verifies() {
let key = openssl::rsa::Rsa::generate(2048).unwrap();
let private_key = std::str::from_utf8(&key.private_key_to_pem().unwrap()).unwrap().to_string();
let public_key = std::str::from_utf8(&key.public_key_to_pem().unwrap()).unwrap().to_string();
let mut signer = super::HttpSignature {
key_id: "test".to_string(),
algorithm: "rsa-sha256".to_string(),
headers: vec![
"(request-target)".to_string(),
"host".to_string(),
"date".to_string(),
],
signature: String::new(),
control: String::new(),
};
signer
.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into())
.sign(&private_key)
.unwrap();
let mut verifier = super::HttpSignature::parse(&signer.header());
verifier.build_manually("get", "/actor/inbox", [("host".into(), "example.net".into()), ("date".into(), "Sat, 13 Apr 2024 13:36:23 GMT".into())].into());
assert!(verifier.verify(&public_key).unwrap());
}
}

View file

@ -1,3 +1,4 @@
pub mod admin;
pub mod context; pub mod context;
pub mod dispatcher; pub mod dispatcher;
pub mod fetcher; pub mod fetcher;
@ -5,5 +6,6 @@ pub mod inbox;
pub mod outbox; pub mod outbox;
pub mod auth; pub mod auth;
pub mod builders; pub mod builders;
pub mod httpsign;
pub use context::Context; pub use context::Context;