Add create-user command

This commit is contained in:
silverpill 2023-04-10 21:20:47 +00:00
parent 4a4e3e9e4a
commit f881779b60
19 changed files with 123 additions and 56 deletions

View file

@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Support Monero Wallet RPC authentication.
- Added `create-user` command.
### Changed

View file

@ -32,6 +32,12 @@ List generated invites:
mitractl list-invite-codes
```
Create user:
```shell
mitractl create-user <username> <password> <role-name>
```
Set or change password:
```shell

View file

@ -8,6 +8,7 @@ use mitra::activitypub::{
builders::delete_person::prepare_delete_person,
fetcher::fetchers::fetch_actor,
};
use mitra::admin::roles::{role_from_str, ALLOWED_ROLES};
use mitra::ethereum::{
signatures::generate_ecdsa_key,
sync::save_current_block_number,
@ -22,7 +23,10 @@ use mitra::monero::{
helpers::check_expired_invoice,
wallet::create_monero_wallet,
};
use mitra::validators::emojis::EMOJI_LOCAL_MAX_SIZE;
use mitra::validators::{
emojis::EMOJI_LOCAL_MAX_SIZE,
users::validate_local_username,
};
use mitra_config::Config;
use mitra_models::{
attachments::queries::delete_unused_attachments,
@ -47,12 +51,13 @@ use mitra_models::{
subscriptions::queries::reset_subscriptions,
users::queries::{
create_invite_code,
create_user,
get_invite_codes,
get_user_by_id,
set_user_password,
set_user_role,
},
users::types::Role,
users::types::UserCreateData,
};
use mitra_utils::{
crypto_rsa::{
@ -77,6 +82,7 @@ pub enum SubCommand {
GenerateInviteCode(GenerateInviteCode),
ListInviteCodes(ListInviteCodes),
CreateUser(CreateUser),
SetPassword(SetPassword),
SetRole(SetRole),
RefetchActor(RefetchActor),
@ -168,6 +174,39 @@ impl ListInviteCodes {
}
}
/// Create new user
#[derive(Parser)]
pub struct CreateUser {
username: String,
password: String,
#[clap(value_parser = ALLOWED_ROLES)]
role: String,
}
impl CreateUser {
pub async fn execute(
&self,
db_client: &mut impl DatabaseClient,
) -> Result<(), Error> {
validate_local_username(&self.username)?;
let password_hash = hash_password(&self.password)?;
let private_key = generate_rsa_key()?;
let private_key_pem = serialize_private_key(&private_key)?;
let role = role_from_str(&self.role)?;
let user_data = UserCreateData {
username: self.username.clone(),
password_hash: Some(password_hash),
private_key_pem,
wallet_address: None,
invite_code: None,
role,
};
create_user(db_client, user_data).await?;
println!("user created");
Ok(())
}
}
/// Set password
#[derive(Parser)]
pub struct SetPassword {
@ -193,7 +232,7 @@ impl SetPassword {
#[derive(Parser)]
pub struct SetRole {
id: Uuid,
#[clap(value_parser = ["admin", "user", "read_only_user"])]
#[clap(value_parser = ALLOWED_ROLES)]
role: String,
}
@ -202,12 +241,7 @@ impl SetRole {
&self,
db_client: &impl DatabaseClient,
) -> Result<(), Error> {
let role = match self.role.as_str() {
"user" => Role::NormalUser,
"admin" => Role::Admin,
"read_only_user" => Role::ReadOnlyUser,
_ => return Err(anyhow!("unknown role")),
};
let role = role_from_str(&self.role)?;
set_user_role(db_client, &self.id, role).await?;
println!("role changed");
Ok(())

View file

@ -31,6 +31,7 @@ async fn main() {
match subcmd {
SubCommand::GenerateInviteCode(cmd) => cmd.execute(db_client).await.unwrap(),
SubCommand::ListInviteCodes(cmd) => cmd.execute(db_client).await.unwrap(),
SubCommand::CreateUser(cmd) => cmd.execute(db_client).await.unwrap(),
SubCommand::SetPassword(cmd) => cmd.execute(db_client).await.unwrap(),
SubCommand::SetRole(cmd) => cmd.execute(db_client).await.unwrap(),
SubCommand::RefetchActor(cmd) => cmd.execute(&config, db_client).await.unwrap(),
@ -48,7 +49,7 @@ async fn main() {
SubCommand::ResetSubscriptions(cmd) => cmd.execute(&config, db_client).await.unwrap(),
SubCommand::CreateMoneroWallet(cmd) => cmd.execute(&config).await.unwrap(),
SubCommand::CheckExpiredInvoice(cmd) => cmd.execute(&config, db_client).await.unwrap(),
_ => panic!(),
_ => unreachable!(),
};
},
};

View file

@ -142,6 +142,7 @@ mod tests {
let sender = create_profile(db_client, sender_data).await.unwrap();
let recipient_data = UserCreateData {
username: "recipient".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let recipient = create_user(db_client, recipient_data).await.unwrap();

View file

@ -223,6 +223,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
@ -248,6 +249,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();

View file

@ -157,15 +157,20 @@ mod tests {
};
use super::*;
async fn create_test_user(db_client: &mut Client, username: &str) -> User {
let user_data = UserCreateData {
username: username.to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
create_user(db_client, user_data).await.unwrap()
}
#[tokio::test]
#[serial]
async fn test_add_related_posts() {
let db_client = &mut create_test_database().await;
let author_data = UserCreateData {
username: "test".to_string(),
..Default::default()
};
let author = create_user(db_client, author_data).await.unwrap();
let author = create_test_user(db_client, "test").await;
let post_data = PostCreateData {
content: "post".to_string(),
..Default::default()
@ -222,14 +227,6 @@ mod tests {
assert_eq!(result, true);
}
async fn create_test_user(db_client: &mut Client, username: &str) -> User {
let user_data = UserCreateData {
username: username.to_string(),
..Default::default()
};
create_user(db_client, user_data).await.unwrap()
}
#[tokio::test]
#[serial]
async fn test_can_view_post_followers_only_anonymous() {

View file

@ -1375,6 +1375,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
@ -1400,6 +1401,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
@ -1419,6 +1421,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let current_user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let current_user = create_user(db_client, current_user_data).await.unwrap();
@ -1438,6 +1441,7 @@ mod tests {
// Another user's public post
let user_data_1 = UserCreateData {
username: "another-user".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user_1 = create_user(db_client, user_data_1).await.unwrap();
@ -1464,6 +1468,7 @@ mod tests {
// Followed user's public post
let user_data_2 = UserCreateData {
username: "followed".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user_2 = create_user(db_client, user_data_2).await.unwrap();
@ -1504,6 +1509,7 @@ mod tests {
// Subscribers-only post by subscription (without mention)
let user_data_3 = UserCreateData {
username: "subscription".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user_3 = create_user(db_client, user_data_3).await.unwrap();
@ -1525,6 +1531,7 @@ mod tests {
// Repost from followed user if hiding reposts
let user_data_4 = UserCreateData {
username: "hide reposts".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user_4 = create_user(db_client, user_data_4).await.unwrap();
@ -1559,6 +1566,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
@ -1622,6 +1630,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();

View file

@ -596,6 +596,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let source_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let source = create_user(db_client, source_data).await.unwrap();
@ -657,6 +658,7 @@ mod tests {
let source = create_profile(db_client, source_data).await.unwrap();
let target_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let target = create_user(db_client, target_data).await.unwrap();

View file

@ -222,6 +222,7 @@ mod tests {
let sender_address = "0xb9c5714089478a327f09197987f16f9e5d936e8a";
let recipient_data = UserCreateData {
username: "recipient".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let recipient = create_user(db_client, recipient_data).await.unwrap();

View file

@ -78,6 +78,8 @@ pub async fn create_user(
db_client: &mut impl DatabaseClient,
user_data: UserCreateData,
) -> Result<User, DatabaseError> {
assert!(user_data.password_hash.is_some() ||
user_data.wallet_address.is_some());
let mut transaction = db_client.transaction().await?;
// Prevent changes to actor_profile table
transaction.execute(
@ -351,6 +353,7 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "myname".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
@ -364,11 +367,13 @@ mod tests {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData {
username: "myname".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
create_user(db_client, user_data).await.unwrap();
let another_user_data = UserCreateData {
username: "myName".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let result = create_user(db_client, another_user_data).await;
@ -379,7 +384,11 @@ mod tests {
#[serial]
async fn test_set_user_role() {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData::default();
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
assert_eq!(user.role, Role::NormalUser);
set_user_role(db_client, &user.id, Role::ReadOnlyUser).await.unwrap();
@ -391,7 +400,11 @@ mod tests {
#[serial]
async fn test_update_client_config() {
let db_client = &mut create_test_database().await;
let user_data = UserCreateData::default();
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user = create_user(db_client, user_data).await.unwrap();
assert_eq!(user.client_config.is_empty(), true);
let client_name = "test";

1
src/admin/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod roles;

15
src/admin/roles.rs Normal file
View file

@ -0,0 +1,15 @@
use mitra_models::users::types::Role;
use crate::errors::ValidationError;
pub const ALLOWED_ROLES: [&str; 3] = ["admin", "user", "read_only_user"];
pub fn role_from_str(role_str: &str) -> Result<Role, ValidationError> {
let role = match role_str {
"user" => Role::NormalUser,
"admin" => Role::Admin,
"read_only_user" => Role::ReadOnlyUser,
_ => return Err(ValidationError("unknown role")),
};
Ok(role)
}

View file

@ -1,4 +1,5 @@
pub mod activitypub;
pub mod admin;
pub mod atom;
mod errors;
pub mod ethereum;

View file

@ -110,11 +110,13 @@ mod tests {
{
let user_data_1 = UserCreateData {
username: "user".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user_1 = create_user(db_client, user_data_1).await.unwrap();
let user_data_2 = UserCreateData {
username: "another-user".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
let user_2 = create_user(db_client, user_data_2).await.unwrap();

View file

@ -37,10 +37,6 @@ use crate::mastodon_api::{
uploads::{save_b64_file, UploadError},
};
use crate::media::get_file_url;
use crate::validators::{
profiles::validate_username,
users::validate_local_username,
};
/// https://docs.joinmastodon.org/entities/field/
#[derive(Serialize)]
@ -268,18 +264,6 @@ pub struct AccountCreateData {
pub invite_code: Option<String>,
}
impl AccountCreateData {
pub fn clean(&self) -> Result<(), ValidationError> {
validate_username(&self.username)?;
validate_local_username(&self.username)?;
if self.password.is_none() && self.message.is_none() {
return Err(ValidationError("password or EIP-4361 message is required"));
};
Ok(())
}
}
#[derive(Deserialize)]
struct AccountFieldSource {
name: String,
@ -563,19 +547,6 @@ mod tests {
const INSTANCE_URL: &str = "https://example.com";
#[test]
fn test_validate_account_create_data() {
let account_data = AccountCreateData {
username: "test".to_string(),
password: None,
message: None,
signature: Some("test".to_string()),
invite_code: None,
};
let error = account_data.clean().unwrap_err();
assert_eq!(error.to_string(), "password or EIP-4361 message is required");
}
#[test]
fn test_create_account_from_profile() {
let profile = DbActorProfile {

View file

@ -100,7 +100,10 @@ use crate::mastodon_api::{
statuses::helpers::build_status_list,
statuses::types::Status,
};
use crate::validators::profiles::clean_profile_update_data;
use crate::validators::{
profiles::clean_profile_update_data,
users::validate_local_username,
};
use super::helpers::{
get_aliases,
get_relationship,
@ -135,7 +138,6 @@ pub async fn create_account(
) -> Result<HttpResponse, MastodonError> {
let db_client = &mut **get_database_client(&db_pool).await?;
// Validate
account_data.clean()?;
if config.registration.registration_type == RegistrationType::Invite {
let invite_code = account_data.invite_code.as_ref()
.ok_or(ValidationError("invite code is required"))?;
@ -144,6 +146,10 @@ pub async fn create_account(
};
};
validate_local_username(&account_data.username)?;
if account_data.password.is_none() && account_data.message.is_none() {
return Err(ValidationError("password or EIP-4361 message is required").into());
};
let maybe_password_hash = if let Some(password) = account_data.password.as_ref() {
let password_hash = hash_password(password)
.map_err(|_| MastodonError::InternalError)?;

View file

@ -2,7 +2,10 @@ use regex::Regex;
use crate::errors::ValidationError;
use super::profiles::validate_username;
pub fn validate_local_username(username: &str) -> Result<(), ValidationError> {
validate_username(username)?;
// The username regexp should not allow domain names and IP addresses
let username_regexp = Regex::new(r"^[a-z0-9_]+$").unwrap();
if !username_regexp.is_match(username) {

View file

@ -125,6 +125,7 @@ mod tests {
let instance = Instance::for_test("https://example.com");
let user_data = UserCreateData {
username: "test".to_string(),
password_hash: Some("test".to_string()),
..Default::default()
};
create_user(db_client, user_data).await.unwrap();