add request verification extractor
This commit is contained in:
parent
7a694623e5
commit
1e53661b0a
18 changed files with 482 additions and 32 deletions
|
@ -14,7 +14,7 @@ create table users (
|
|||
instance_id text not null references identity_instances (id) on delete cascade,
|
||||
remote_user_id text not null,
|
||||
username text not null,
|
||||
|
||||
|
||||
avatar text -- URL, not hash, as this is a remote file
|
||||
);
|
||||
|
||||
|
@ -46,6 +46,14 @@ create table messages (
|
|||
channel_id text not null references channels (id) on delete cascade,
|
||||
author_id text not null,
|
||||
updated_at timestamptz not null default now(),
|
||||
|
||||
|
||||
content text not null
|
||||
);
|
||||
|
||||
create table instance (
|
||||
id integer not null primary key default 1,
|
||||
public_key text not null,
|
||||
private_key text not null,
|
||||
|
||||
constraint singleton check (id = 1)
|
||||
);
|
||||
|
|
8
chat/src/app_state.rs
Normal file
8
chat/src/app_state.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub struct AppState {
|
||||
pub pool: Pool<Postgres>,
|
||||
pub config: Config,
|
||||
}
|
56
chat/src/config.rs
Normal file
56
chat/src/config.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use eyre::Result;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::{env, fs};
|
||||
use tracing::Level;
|
||||
|
||||
pub const CONFIG_FILE: &str = "config.chat.toml";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub port: u16,
|
||||
pub domain: String,
|
||||
pub auto_migrate: Option<bool>,
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let cwd = env::current_dir()?;
|
||||
let config_file = Path::join(cwd.as_path(), Path::new(CONFIG_FILE));
|
||||
println!("config file: {}", config_file.display());
|
||||
|
||||
let s = fs::read_to_string(config_file)?;
|
||||
let config = toml::from_str(s.as_str())?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn tracing_level(&self) -> Option<Level> {
|
||||
match self
|
||||
.log_level
|
||||
.as_deref()
|
||||
.unwrap_or("INFO")
|
||||
{
|
||||
"TRACE" => Some(Level::TRACE),
|
||||
"DEBUG" => Some(Level::DEBUG),
|
||||
"INFO" => Some(Level::INFO),
|
||||
"WARN" => Some(Level::WARN),
|
||||
"ERROR" => Some(Level::ERROR),
|
||||
_ => None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
database_url: env::var("DATABASE_URL").unwrap_or("".into()),
|
||||
domain: "".into(),
|
||||
port: 3000,
|
||||
auto_migrate: None,
|
||||
log_level: None,
|
||||
}
|
||||
}
|
||||
}
|
50
chat/src/db/mod.rs
Normal file
50
chat/src/db/mod.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use eyre::{OptionExt, Result};
|
||||
use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey, LineEnding};
|
||||
use rsa::{RsaPrivateKey, RsaPublicKey};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn init(dsn: &str) -> Result<Pool<Postgres>> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.acquire_timeout(Duration::from_secs(2)) // Fail fast and don't hang
|
||||
.max_connections(100)
|
||||
.connect(dsn)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
const PRIVATE_KEY_BITS: usize = 2048;
|
||||
|
||||
pub async fn init_instance(pool: &Pool<Postgres>) -> Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Check if we already have an instance configuration
|
||||
let row = sqlx::query!("select exists(select * from instance)")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if row.exists.ok_or_eyre("exists was null")? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Generate public/private key
|
||||
let mut rng = rand::thread_rng();
|
||||
let priv_key = RsaPrivateKey::new(&mut rng, PRIVATE_KEY_BITS)?;
|
||||
let pub_key = RsaPublicKey::from(&priv_key);
|
||||
|
||||
let priv_key_string = priv_key.to_pkcs1_pem(LineEnding::LF)?;
|
||||
let pub_key_string = pub_key.to_pkcs1_pem(LineEnding::LF)?;
|
||||
|
||||
sqlx::query!(
|
||||
"insert into instance (public_key, private_key) values ($1, $2)",
|
||||
pub_key_string,
|
||||
priv_key_string.to_string(),
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
102
chat/src/fed/mod.rs
Normal file
102
chat/src/fed/mod.rs
Normal file
|
@ -0,0 +1,102 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::FromRequestParts,
|
||||
http::{
|
||||
header::{CONTENT_LENGTH, DATE, HOST},
|
||||
request::Parts,
|
||||
},
|
||||
Extension,
|
||||
};
|
||||
use foxchat::{
|
||||
fed::{SERVER_HEADER, SIGNATURE_HEADER, USER_HEADER},
|
||||
http::ApiError,
|
||||
signature::{parse_date, verify_signature},
|
||||
FoxError,
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{app_state::AppState, model::identity_instance::IdentityInstance};
|
||||
|
||||
pub struct FoxRequestData {
|
||||
pub instance: IdentityInstance,
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for FoxRequestData
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let state: Extension<Arc<AppState>> = Extension::from_request_parts(parts, state)
|
||||
.await
|
||||
.expect("AppState was not added as an extension");
|
||||
|
||||
let domain = parts
|
||||
.headers
|
||||
.get(SERVER_HEADER)
|
||||
.ok_or(FoxError::InvalidHeader)?
|
||||
.to_str()?;
|
||||
let instance = IdentityInstance::get(state.0, domain).await?;
|
||||
let public_key = instance.parse_public_key()?;
|
||||
|
||||
let date = parse_date(
|
||||
parts
|
||||
.headers
|
||||
.get(DATE)
|
||||
.ok_or(FoxError::InvalidHeader)?
|
||||
.to_str()?,
|
||||
)?;
|
||||
let signature = parts
|
||||
.headers
|
||||
.get(SIGNATURE_HEADER)
|
||||
.ok_or(FoxError::MissingSignature)?
|
||||
.to_str()?
|
||||
.to_string();
|
||||
let host = parts
|
||||
.headers
|
||||
.get(HOST)
|
||||
.ok_or(FoxError::InvalidHeader)?
|
||||
.to_str()?;
|
||||
|
||||
let content_length = if let Some(raw_length) = parts.headers.get(CONTENT_LENGTH) {
|
||||
Some(raw_length.to_str()?.parse::<usize>()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_id = if let Some(raw_id) = parts.headers.get(USER_HEADER) {
|
||||
Some(raw_id.to_str()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Err(e) = verify_signature(
|
||||
&public_key,
|
||||
signature,
|
||||
date,
|
||||
host,
|
||||
parts.uri.path(),
|
||||
content_length,
|
||||
user_id,
|
||||
) {
|
||||
error!(
|
||||
"Verifying signature from request for {} from {}: {}",
|
||||
parts.uri.path(),
|
||||
domain,
|
||||
e
|
||||
);
|
||||
|
||||
return Err(FoxError::InvalidSignature.into());
|
||||
}
|
||||
|
||||
Ok(FoxRequestData {
|
||||
instance,
|
||||
user_id: user_id.map(|v| v.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,3 +1,65 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod config;
|
||||
mod db;
|
||||
mod fed;
|
||||
mod app_state;
|
||||
mod model;
|
||||
|
||||
use crate::config::Config;
|
||||
use clap::{Parser, Subcommand};
|
||||
use eyre::Result;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
Serve,
|
||||
Migrate,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let config = Config::load()?;
|
||||
let args = Cli::parse();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(config.tracing_level().unwrap_or(tracing::Level::INFO))
|
||||
.init();
|
||||
|
||||
match args.command.unwrap_or(Command::Serve) {
|
||||
Command::Serve => main_web(config).await,
|
||||
Command::Migrate => main_migrate(config).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn main_migrate(config: Config) -> Result<()> {
|
||||
info!("Connecting to database");
|
||||
let pool = db::init(&config.database_url).await?;
|
||||
info!("Migrating database");
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
info!("Migrated database");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn main_web(config: Config) -> Result<()> {
|
||||
info!("Connecting to database");
|
||||
let pool = db::init(&config.database_url).await?;
|
||||
|
||||
if config.auto_migrate.unwrap_or(false) {
|
||||
info!("Auto-migrate is enabled, migrating database");
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
info!("Migrated database");
|
||||
}
|
||||
|
||||
info!("Initializing instance data");
|
||||
db::init_instance(&pool).await?;
|
||||
info!("Initialized instance data!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
52
chat/src/model/identity_instance.rs
Normal file
52
chat/src/model/identity_instance.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use eyre::{Result, Context};
|
||||
use foxchat::{fed::request::is_valid_domain, FoxError};
|
||||
use rsa::{RsaPublicKey, pkcs1::DecodeRsaPublicKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IdentityInstance {
|
||||
pub id: String,
|
||||
pub domain: String,
|
||||
pub base_url: String,
|
||||
pub public_key: String,
|
||||
pub status: InstanceStatus,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, sqlx::Type)]
|
||||
#[sqlx(type_name = "instance_status", rename_all = "lowercase")]
|
||||
pub enum InstanceStatus {
|
||||
Active,
|
||||
Suspended,
|
||||
}
|
||||
|
||||
impl IdentityInstance {
|
||||
pub async fn get(state: Arc<AppState>, domain: &str) -> Result<Self> {
|
||||
if !is_valid_domain(domain) {
|
||||
return Err(FoxError::InvalidServer.into());
|
||||
}
|
||||
|
||||
if let Some(instance) = sqlx::query_as!(
|
||||
Self,
|
||||
r#"select id, domain, base_url, public_key,
|
||||
status as "status: InstanceStatus", reason
|
||||
from identity_instances where domain = $1"#,
|
||||
domain
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(instance);
|
||||
}
|
||||
|
||||
return Err(FoxError::InvalidServer.into());
|
||||
}
|
||||
|
||||
pub fn parse_public_key(&self) -> Result<RsaPublicKey> {
|
||||
RsaPublicKey::from_pkcs1_pem(&self.public_key).wrap_err("parsing identity instance public key")
|
||||
}
|
||||
}
|
27
chat/src/model/instance.rs
Normal file
27
chat/src/model/instance.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
use eyre::Result;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use rsa::{RsaPrivateKey, RsaPublicKey, pkcs1::{DecodeRsaPublicKey, DecodeRsaPrivateKey}};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Instance {
|
||||
pub public_key: RsaPublicKey,
|
||||
pub private_key: RsaPrivateKey,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Gets the instance's configuration.
|
||||
/// This is a singleton row that is always present.
|
||||
pub async fn get(pool: &Pool<Postgres>) -> Result<Self> {
|
||||
let instance = sqlx::query!("SELECT * FROM instance WHERE id = 1")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let public_key = RsaPublicKey::from_pkcs1_pem(&instance.public_key)?;
|
||||
let private_key = RsaPrivateKey::from_pkcs1_pem(&instance.private_key)?;
|
||||
|
||||
Ok(Self {
|
||||
public_key,
|
||||
private_key,
|
||||
})
|
||||
}
|
||||
}
|
2
chat/src/model/mod.rs
Normal file
2
chat/src/model/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod instance;
|
||||
pub mod identity_instance;
|
Loading…
Add table
Add a link
Reference in a new issue