From 446556ff1ad8244b608b8a799961333d3de879cd Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Tue, 12 Dec 2023 16:32:57 +0100 Subject: [PATCH] basic functionality --- Cargo.lock | 186 +++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/api.rs | 71 +++++++++---- src/database/user.rs | 68 ++++++++++-- src/federation/objects/user.rs | 4 +- tests/common.rs | 7 +- tests/test.rs | 33 ++++-- 7 files changed, 331 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 103ee61..aca52ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,19 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "bcrypt" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d1c9c15093eb224f0baa400f38fcd713fc1391a6f1c389d886beef146d60a3" +dependencies = [ + "base64 0.21.5", + "blowfish", + "getrandom", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -253,6 +266,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -338,6 +361,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.3.0" @@ -449,6 +482,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "deranged" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.12.0" @@ -672,6 +714,7 @@ dependencies = [ "async-trait", "axum", "axum-macros", + "bcrypt", "chrono", "diesel", "diesel-derive-newtype", @@ -681,6 +724,7 @@ dependencies = [ "env_logger", "futures", "hex", + "jsonwebtoken", "once_cell", "pretty_assertions", "rand", @@ -844,8 +888,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1088,6 +1134,15 @@ dependencies = [ "hashbrown 0.14.2", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" @@ -1138,6 +1193,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ea04a7c5c055c175f189b6dc6ba036fd62306b58c66c9f6389036c503a3f4" +dependencies = [ + "base64 0.21.5", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -1318,6 +1388,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -1431,6 +1522,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "pem" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" +dependencies = [ + "base64 0.21.5", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -1491,6 +1592,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1694,6 +1801,20 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -1907,6 +2028,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "skeptic" version = "0.13.7" @@ -1957,12 +2090,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -2060,6 +2205,35 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "time" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +dependencies = [ + "deranged", + "itoa", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2271,6 +2445,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.4.1" @@ -2555,3 +2735,9 @@ name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index 4e3e5b3..3e3ada3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ anyhow = "1.0.75" async-trait = "0.1.74" axum = "0.6.20" axum-macros = "0.3.8" +bcrypt = "0.15.0" chrono = { version = "0.4.31", features = ["serde"] } diesel = {version = "2.1.4", features = ["postgres", "chrono", "uuid"] } diesel-derive-newtype = "2.1.0" @@ -18,6 +19,7 @@ enum_delegate = "0.2.0" env_logger = { version = "0.10.1", default-features = false } futures = "0.3.29" hex = "0.4.3" +jsonwebtoken = "9.2.0" rand = "0.8.5" serde = "1.0.192" sha2 = "0.10.8" diff --git a/src/api.rs b/src/api.rs index 7f6d59d..d576b60 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,6 +2,7 @@ use crate::database::article::{ArticleView, DbArticle, DbArticleForm}; use crate::database::conflict::{ApiConflict, DbConflict, DbConflictForm}; use crate::database::edit::{DbEdit, DbEditForm}; use crate::database::instance::{DbInstance, InstanceView}; +use crate::database::user::{DbLocalUser, DbPerson}; use crate::database::version::EditVersion; use crate::database::MyDataHandle; use crate::error::MyResult; @@ -11,15 +12,18 @@ use crate::federation::activities::submit_article_update; use crate::utils::generate_article_version; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; +use anyhow::anyhow; use axum::extract::Query; use axum::routing::{get, post}; use axum::{Form, Json, Router}; use axum_macros::debug_handler; +use bcrypt::verify; +use chrono::Utc; use diffy::create_patch; use futures::future::try_join_all; +use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; use url::Url; -use crate::database::user::{DbLocalUserForm, DbPerson, DbPersonForm}; pub fn api_routes() -> Router { Router::new() @@ -295,29 +299,51 @@ async fn fork_article( Ok(Json(DbArticle::read_view(article.id, &data.db_connection)?)) } -#[derive(Deserialize, Serialize)] -pub struct RegisterUserData { - name: String, - password: String, +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + /// local_user.id + pub sub: String, + /// hostname + pub iss: String, + /// Creation time as unix timestamp + pub iat: i64, +} + +pub fn generate_login_token( + local_user: DbLocalUser, + data: &Data, +) -> MyResult { + let hostname = data.domain().to_string(); + let claims = Claims { + sub: local_user.id.to_string(), + iss: hostname, + iat: Utc::now().timestamp(), + }; + + // TODO: move to config + let key = EncodingKey::from_secret("secret".as_bytes()); + let jwt = encode(&Header::default(), &claims, &key)?; + Ok(LoginResponse { jwt }) } #[derive(Deserialize, Serialize)] -#[serde(transparent)] -pub struct Jwt(String); +pub struct RegisterUserData { + pub name: String, + pub password: String, +} + +#[derive(Deserialize, Serialize)] +pub struct LoginResponse { + pub jwt: String, +} #[debug_handler] async fn register_user( data: Data, Form(form): Form, -) -> MyResult> { - let local_user_form = DbLocalUserForm { - - }; - let person_form = DbPersonForm { - - }; - DbPerson::create(&person_form, Some(&local_user_form), &data.db_connection)?; - +) -> MyResult> { + let user = DbPerson::create_local(form.name, form.password, &data)?; + Ok(Json(generate_login_token(user.local_user, &data)?)) } #[derive(Deserialize, Serialize)] @@ -329,7 +355,12 @@ pub struct LoginUserData { #[debug_handler] async fn login_user( data: Data, - Form(form): Form, -) -> MyResult> { - todo!() -} \ No newline at end of file + Form(form): Form, +) -> MyResult> { + let user = DbPerson::read_local_from_name(&form.name, &data)?; + let valid = verify(&form.password, &user.local_user.password_encrypted)?; + if !valid { + return Err(anyhow!("Invalid login").into()); + } + Ok(Json(generate_login_token(user.local_user, &data)?)) +} diff --git a/src/database/user.rs b/src/database/user.rs index e5cf875..1fee90e 100644 --- a/src/database/user.rs +++ b/src/database/user.rs @@ -3,7 +3,10 @@ use crate::database::MyDataHandle; use crate::error::MyResult; use activitypub_federation::config::Data; use activitypub_federation::fetch::object_id::ObjectId; -use chrono::{DateTime, Utc}; +use activitypub_federation::http_signatures::generate_actor_keypair; +use bcrypt::hash; +use bcrypt::DEFAULT_COST; +use chrono::{DateTime, Local, Utc}; use diesel::ExpressionMethods; use diesel::QueryDsl; use diesel::{ @@ -14,6 +17,13 @@ use serde::{Deserialize, Serialize}; use std::ops::DerefMut; use std::sync::Mutex; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct LocalUserView { + pub person: DbPerson, + pub local_user: DbLocalUser, +} + /// A user with account registered on local instance. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Selectable, Identifiable)] #[diesel(table_name = local_user, check_for_backend(diesel::pg::Pg))] @@ -60,22 +70,50 @@ pub struct DbPersonForm { } impl DbPerson { - pub fn create(person_form: &DbPersonForm, local_user_form: Option<&DbLocalUserForm>, conn: &Mutex) -> MyResult { + pub fn create(person_form: &DbPersonForm, conn: &Mutex) -> MyResult { let mut conn = conn.lock().unwrap(); - let person = insert_into(person::table) + Ok(insert_into(person::table) .values(person_form) .on_conflict(person::dsl::ap_id) .do_update() .set(person_form) + .get_result::(conn.deref_mut())?) + } + + pub fn create_local( + username: String, + password: String, + data: &Data, + ) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + let hostname = data.domain(); + let ap_id = ObjectId::parse(&format!("http://{hostname}/user/{username}"))?; + let inbox_url = format!("http://{hostname}/inbox"); + let keypair = generate_actor_keypair()?; + let person_form = DbPersonForm { + username, + ap_id, + inbox_url, + public_key: keypair.public_key, + private_key: Some(keypair.private_key), + last_refreshed_at: Local::now().into(), + local: true, + }; + + let person = insert_into(person::table) + .values(person_form) .get_result::(conn.deref_mut())?; - if let Some(local_user_form) = local_user_form { - insert_into(local_user::table) - .values(local_user_form) - .get_result::(conn.deref_mut())?; - } + let local_user_form = DbLocalUserForm { + password_encrypted: hash(password, DEFAULT_COST)?, + person_id: person.id, + }; - Ok(person) + let local_user = insert_into(local_user::table) + .values(local_user_form) + .get_result::(conn.deref_mut())?; + + Ok(LocalUserView { local_user, person }) } pub fn read_from_ap_id( @@ -87,4 +125,16 @@ impl DbPerson { .filter(person::dsl::ap_id.eq(ap_id)) .get_result(conn.deref_mut())?) } + + pub fn read_local_from_name( + username: &str, + data: &Data, + ) -> MyResult { + let mut conn = data.db_connection.lock().unwrap(); + Ok(person::table + .inner_join(local_user::table) + .filter(person::dsl::local) + .filter(person::dsl::username.eq(username)) + .get_result(conn.deref_mut())?) + } } diff --git a/src/federation/objects/user.rs b/src/federation/objects/user.rs index 129a842..8cf4812 100644 --- a/src/federation/objects/user.rs +++ b/src/federation/objects/user.rs @@ -1,4 +1,4 @@ -use crate::database::user::{DbLocalUser, DbLocalUserForm, DbPerson, DbPersonForm}; +use crate::database::user::{DbPerson, DbPersonForm}; use crate::database::MyDataHandle; use crate::error::Error; use activitypub_federation::kinds::actor::PersonType; @@ -70,7 +70,7 @@ impl Object for DbPerson { last_refreshed_at: Local::now().into(), local: false, }; - DbPerson::create(&form, None, &data.db_connection) + DbPerson::create(&form, &data.db_connection) } } diff --git a/tests/common.rs b/tests/common.rs index 68aa86f..cd58575 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -12,6 +12,7 @@ use reqwest::{Client, RequestBuilder, StatusCode}; use serde::de::Deserialize; use serde::ser::Serialize; use std::env::current_dir; +use std::fs::create_dir_all; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::Once; @@ -82,10 +83,12 @@ impl TestData { /// Generate a unique db path for each postgres so that tests can run in parallel. fn generate_db_path(name: &'static str, port: i32) -> String { - format!( + let path = format!( "{}/target/test_db/{name}-{port}", current_dir().unwrap().display() - ) + ); + create_dir_all(&path).unwrap(); + path } pub struct FediwikiInstance { diff --git a/tests/test.rs b/tests/test.rs index 8305413..f22efc8 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -7,12 +7,14 @@ use crate::common::{ get_query, post, TestData, TEST_ARTICLE_DEFAULT_TEXT, }; use common::get; -use fediwiki::api::{EditArticleData, ForkArticleData, RegisterUserData, ResolveObject, SearchArticleData}; +use fediwiki::api::{ + EditArticleData, ForkArticleData, LoginResponse, RegisterUserData, ResolveObject, + SearchArticleData, +}; use fediwiki::database::article::{ArticleView, DbArticle}; -use fediwiki::error::MyResult; - use fediwiki::database::conflict::ApiConflict; use fediwiki::database::instance::{DbInstance, InstanceView}; +use fediwiki::error::MyResult; use pretty_assertions::{assert_eq, assert_ne}; use url::Url; @@ -90,6 +92,7 @@ async fn test_follow_instance() -> MyResult<()> { // check that follow was federated let alpha_instance: InstanceView = get(&data.alpha.hostname, "instance").await?; + dbg!(&alpha_instance); assert_eq!(1, alpha_instance.following.len()); assert_eq!(0, alpha_instance.followers.len()); assert_eq!( @@ -468,9 +471,25 @@ async fn test_fork_article() -> MyResult<()> { #[tokio::test] async fn test_user_registration_login() -> MyResult<()> { let data = TestData::start(); - let data = RegisterUserData { + let register_form = RegisterUserData { + name: "my_user".to_string(), + password: "hunter2".to_string(), + }; + let register: LoginResponse = + post(&data.alpha.hostname, "user/register", ®ister_form).await?; + assert!(!register.jwt.is_empty()); + + let mut login_form = RegisterUserData { + name: register_form.name.clone(), + password: "asd123".to_string(), + }; + let invalid_login = + post::<_, LoginResponse>(&data.alpha.hostname, "user/login", &login_form).await; + assert!(invalid_login.is_err()); + + login_form.password = register_form.password; + let valid_login: LoginResponse = post(&data.alpha.hostname, "user/login", &login_form).await?; + assert!(!valid_login.jwt.is_empty()); - } - post(data.alpha.hostname, "user/register") data.stop() -} \ No newline at end of file +}