Compare commits

..

7 Commits

Author SHA1 Message Date
projectmoon 76214bc790 Add an account deletion command.
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2021-05-22 23:12:17 +00:00
projectmoon 921c4cd644 Update sqlx offline json for user query.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2021-05-22 22:53:01 +00:00
projectmoon 8c2a90e86b Tests for secure commands and user DB API.
continuous-integration/drone/pr Build was killed Details
continuous-integration/drone/push Build is failing Details
2021-05-22 22:48:47 +00:00
projectmoon 926dae57fb Add check password command.
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2021-05-22 22:25:00 +00:00
projectmoon 4557498ac6 Improved command logging, sensitive to secure commands.
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2021-05-22 22:17:33 +00:00
projectmoon ca34841d86 Functional user account registration.
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2021-05-22 14:52:32 +00:00
projectmoon c1ec7366e4 Add user accounts, registration command, secure command valiation. 2021-05-22 14:01:16 +00:00
14 changed files with 618 additions and 19 deletions

46
Cargo.lock generated
View File

@ -101,6 +101,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "arrayref"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.5.2" version = "0.5.2"
@ -198,6 +204,17 @@ dependencies = [
"wyz", "wyz",
] ]
[[package]]
name = "blake2b_simd"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587"
dependencies = [
"arrayref",
"arrayvec",
"constant_time_eq",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.9.0" version = "0.9.0"
@ -313,6 +330,12 @@ version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7" checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7"
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.1" version = "0.9.1"
@ -2025,6 +2048,18 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rust-argon2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb"
dependencies = [
"base64",
"blake2b_simd",
"constant_time_eq",
"crossbeam-utils",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.2.3" version = "0.2.3"
@ -2432,6 +2467,15 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "substring"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.4.0" version = "2.4.0"
@ -2510,8 +2554,10 @@ dependencies = [
"phf", "phf",
"rand 0.8.3", "rand 0.8.3",
"refinery", "refinery",
"rust-argon2",
"serde", "serde",
"sqlx", "sqlx",
"substring",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tokio", "tokio",

View File

@ -16,6 +16,7 @@ tracing-subscriber = "0.2"
toml = "0.5" toml = "0.5"
nom = "5" nom = "5"
rand = "0.8" rand = "0.8"
rust-argon2 = "0.8"
thiserror = "1.0" thiserror = "1.0"
itertools = "0.10" itertools = "0.10"
async-trait = "0.1" async-trait = "0.1"
@ -30,6 +31,7 @@ matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "
refinery = { version = "0.5", features = ["rusqlite"]} refinery = { version = "0.5", features = ["rusqlite"]}
barrel = { version = "0.6", features = ["sqlite3"] } barrel = { version = "0.6", features = ["sqlite3"] }
tempfile = "3" tempfile = "3"
substring = "1.4"
[dependencies.sqlx] [dependencies.sqlx]
version = "0.5" version = "0.5"

View File

@ -60,6 +60,30 @@
] ]
} }
}, },
"64e137107139c56a43f7041db933671c210df4fa5110fe481d191fd63b2d3aeb": {
"query": "SELECT user_id, password FROM accounts\n WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "user_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
}
},
"711d222911c1258365a6a0de1fe00eeec4686fd3589e976e225ad599e7cfc75d": { "711d222911c1258365a6a0de1fe00eeec4686fd3589e976e225ad599e7cfc75d": {
"query": "SELECT count(*) as \"count: i32\" FROM user_variables\n WHERE room_id = ? and user_id = ?", "query": "SELECT count(*) as \"count: i32\" FROM user_variables\n WHERE room_id = ? and user_id = ?",
"describe": { "describe": {

View File

@ -4,7 +4,6 @@ use crate::db::sqlite::Database;
use crate::error::BotError; use crate::error::BotError;
use crate::matrix; use crate::matrix;
use futures::stream::{self, StreamExt}; use futures::stream::{self, StreamExt};
use log::{error, info};
use matrix_sdk::{self, identifiers::EventId, room::Joined, Client}; use matrix_sdk::{self, identifiers::EventId, room::Joined, Client};
use std::clone::Clone; use std::clone::Clone;
@ -17,13 +16,6 @@ pub(super) async fn handle_single_result(
room: &Joined, room: &Joined,
event_id: EventId, event_id: EventId,
) { ) {
if cmd_result.is_err() {
error!(
"Command execution error: {}",
cmd_result.as_ref().err().unwrap()
);
}
let html = cmd_result.message_html(respond_to); let html = cmd_result.message_html(respond_to);
matrix::send_message(client, room.room_id(), &html, Some(event_id)).await; matrix::send_message(client, room.room_id(), &html, Some(event_id)).await;
} }
@ -49,10 +41,6 @@ pub(super) async fn handle_multiple_results(
}) })
.collect(); .collect();
for result in errors.iter() {
error!("Command execution error: '{}' - {}", result.0, result.1);
}
let message = if errors.len() == 0 { let message = if errors.len() == 0 {
format!("{}: Executed {} commands", respond_to, results.len()) format!("{}: Executed {} commands", respond_to, results.len())
} else { } else {
@ -109,10 +97,6 @@ pub(super) async fn execute(
Err(e) => (command.to_owned(), Err(ExecutionError(e))), Err(e) => (command.to_owned(), Err(ExecutionError(e))),
Ok(ctx) => { Ok(ctx) => {
let cmd_result = execute_command(&ctx).await; let cmd_result = execute_command(&ctx).await;
info!(
"[{}] {} executed: {}",
ctx.room.display_name, sender, command
);
(command.to_owned(), cmd_result) (command.to_owned(), cmd_result)
} }
} }

View File

@ -1,6 +1,9 @@
use super::{Command, Execution, ExecutionResult}; use super::{Command, Execution, ExecutionResult};
use crate::context::Context; use crate::context::Context;
use crate::logic::record_room_information; use crate::db::Users;
use crate::error::BotError::{AccountDoesNotExist, AuthenticationError, PasswordCreationError};
use crate::logic::{hash_password, record_room_information};
use crate::models::User;
use async_trait::async_trait; use async_trait::async_trait;
use matrix_sdk::identifiers::UserId; use matrix_sdk::identifiers::UserId;
@ -33,3 +36,74 @@ impl Command for ResyncCommand {
Execution::success(message) Execution::success(message)
} }
} }
pub struct RegisterCommand(pub String);
#[async_trait]
impl Command for RegisterCommand {
fn name(&self) -> &'static str {
"register user account"
}
fn is_secure(&self) -> bool {
true
}
async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult {
let pw_hash = hash_password(&self.0).map_err(|e| PasswordCreationError(e))?;
let user = User {
username: ctx.username.to_owned(),
password: pw_hash,
};
ctx.db.upsert_user(&user).await?;
Execution::success("User account registered/updated".to_string())
}
}
pub struct CheckCommand(pub String);
#[async_trait]
impl Command for CheckCommand {
fn name(&self) -> &'static str {
"check user password"
}
fn is_secure(&self) -> bool {
true
}
async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult {
let user = ctx.db.authenticate_user(&ctx.username, &self.0).await?;
match user {
Some(_) => Execution::success("Password is correct!".to_string()),
None => Err(AuthenticationError.into()),
}
}
}
pub struct UnregisterCommand;
#[async_trait]
impl Command for UnregisterCommand {
fn name(&self) -> &'static str {
"unregister user account"
}
fn is_secure(&self) -> bool {
true
}
async fn execute(&self, ctx: &Context<'_>) -> ExecutionResult {
let user = ctx.db.get_user(&ctx.username).await?;
match user {
Some(_) => {
ctx.db.delete_user(&ctx.username).await?;
Execution::success("Your user account has been removed.".to_string())
}
None => Err(AccountDoesNotExist.into()),
}
}
}

View File

@ -1,6 +1,7 @@
use crate::context::Context; use crate::context::Context;
use crate::error::BotError; use crate::error::BotError;
use async_trait::async_trait; use async_trait::async_trait;
use log::{error, info};
use thiserror::Error; use thiserror::Error;
use BotError::DataError; use BotError::DataError;
@ -19,6 +20,9 @@ pub enum CommandError {
#[error("invalid command: {0}")] #[error("invalid command: {0}")]
InvalidCommand(String), InvalidCommand(String),
#[error("command can only be executed from encrypted direct message")]
InsecureExecution,
#[error("ignored command")] #[error("ignored command")]
IgnoredCommand, IgnoredCommand,
} }
@ -99,18 +103,72 @@ pub trait Command: Send + Sync {
fn is_secure(&self) -> bool; fn is_secure(&self) -> bool;
} }
/// Determine if we are allowed to execute this command. Currently the
/// rules are that secure commands must be executed in secure rooms
/// (encrypted + direct), and anything else can be executed where
/// ever. Later, we can add stuff like admin/regular user power
/// separation, etc.
fn execution_allowed(cmd: &(impl Command + ?Sized), ctx: &Context<'_>) -> Result<(), CommandError> {
if cmd.is_secure() {
if ctx.is_secure() {
Ok(())
} else {
Err(CommandError::InsecureExecution)
}
} else {
Ok(())
}
}
/// Attempt to execute a command, and return the content that should /// Attempt to execute a command, and return the content that should
/// go back to Matrix, if the command was executed (successfully or /// go back to Matrix, if the command was executed (successfully or
/// not). If a command is determined to be ignored, this function will /// not). If a command is determined to be ignored, this function will
/// return None, signifying that we should not send a response. /// return None, signifying that we should not send a response.
pub async fn execute_command(ctx: &Context<'_>) -> ExecutionResult { pub async fn execute_command(ctx: &Context<'_>) -> ExecutionResult {
let cmd = parser::parse_command(&ctx.message_body)?; let cmd = parser::parse_command(&ctx.message_body)?;
cmd.execute(ctx).await
let result = match execution_allowed(cmd.as_ref(), ctx) {
Ok(_) => cmd.execute(ctx).await,
Err(e) => Err(ExecutionError(e.into())),
};
log_command(cmd.as_ref(), ctx, &result);
result
}
/// Log result of an executed command.
fn log_command(cmd: &(impl Command + ?Sized), ctx: &Context, result: &ExecutionResult) {
use substring::Substring;
let command = match cmd.is_secure() {
true => cmd.name(),
false => ctx.message_body.substring(0, 30),
};
let dots = match ctx.message_body.len() {
_len if _len > 30 => "[...]",
_ => "",
};
match result {
Ok(_) => {
info!(
"[{}] {} <{}{}> - success",
ctx.room.display_name, ctx.username, command, dots
);
}
Err(e) => {
error!(
"[{}] {} <{}{}> - {}",
ctx.room.display_name, ctx.username, command, dots, e
);
}
};
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use management::RegisterCommand;
use url::Url; use url::Url;
macro_rules! dummy_room { macro_rules! dummy_room {
@ -123,6 +181,100 @@ mod tests {
}; };
} }
macro_rules! secure_room {
() => {
crate::context::RoomContext {
id: &matrix_sdk::identifiers::room_id!("!fakeroomid:example.com"),
display_name: "displayname".to_owned(),
secure: true,
}
};
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn secure_context_secure_command_allows_execution() {
let db_path = tempfile::NamedTempFile::new_in(".").unwrap();
let db = crate::db::sqlite::Database::new(db_path.path().to_str().unwrap())
.await
.unwrap();
let homeserver = Url::parse("http://example.com").unwrap();
let ctx = Context {
db: db,
matrix_client: &matrix_sdk::Client::new(homeserver).unwrap(),
room: secure_room!(),
username: "myusername",
message_body: "!notacommand",
};
let cmd = RegisterCommand("".to_owned());
assert_eq!(execution_allowed(&cmd, &ctx).is_ok(), true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn secure_context_insecure_command_allows_execution() {
let db_path = tempfile::NamedTempFile::new_in(".").unwrap();
let db = crate::db::sqlite::Database::new(db_path.path().to_str().unwrap())
.await
.unwrap();
let homeserver = Url::parse("http://example.com").unwrap();
let ctx = Context {
db: db,
matrix_client: &matrix_sdk::Client::new(homeserver).unwrap(),
room: secure_room!(),
username: "myusername",
message_body: "!notacommand",
};
let cmd = variables::GetVariableCommand("".to_owned());
assert_eq!(execution_allowed(&cmd, &ctx).is_ok(), true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn insecure_context_insecure_command_allows_execution() {
let db_path = tempfile::NamedTempFile::new_in(".").unwrap();
let db = crate::db::sqlite::Database::new(db_path.path().to_str().unwrap())
.await
.unwrap();
let homeserver = Url::parse("http://example.com").unwrap();
let ctx = Context {
db: db,
matrix_client: &matrix_sdk::Client::new(homeserver).unwrap(),
room: dummy_room!(),
username: "myusername",
message_body: "!notacommand",
};
let cmd = variables::GetVariableCommand("".to_owned());
assert_eq!(execution_allowed(&cmd, &ctx).is_ok(), true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn insecure_context_secure_command_denies_execution() {
let db_path = tempfile::NamedTempFile::new_in(".").unwrap();
let db = crate::db::sqlite::Database::new(db_path.path().to_str().unwrap())
.await
.unwrap();
let homeserver = Url::parse("http://example.com").unwrap();
let ctx = Context {
db: db,
matrix_client: &matrix_sdk::Client::new(homeserver).unwrap(),
room: dummy_room!(),
username: "myusername",
message_body: "!notacommand",
};
let cmd = RegisterCommand("".to_owned());
assert_eq!(execution_allowed(&cmd, &ctx).is_err(), true);
}
#[test] #[test]
fn command_result_extractor_creates_bubble() { fn command_result_extractor_creates_bubble() {
let result = Execution::success("test".to_string()); let result = Execution::success("test".to_string());
@ -148,6 +300,7 @@ mod tests {
username: "myusername", username: "myusername",
message_body: "!notacommand", message_body: "!notacommand",
}; };
let result = execute_command(&ctx).await; let result = execute_command(&ctx).await;
assert!(result.is_err()); assert!(result.is_err());
} }

View File

@ -9,7 +9,7 @@ use crate::commands::{
basic_rolling::RollCommand, basic_rolling::RollCommand,
cofd::PoolRollCommand, cofd::PoolRollCommand,
cthulhu::{CthAdvanceRoll, CthRoll}, cthulhu::{CthAdvanceRoll, CthRoll},
management::ResyncCommand, management::{CheckCommand, RegisterCommand, ResyncCommand, UnregisterCommand},
misc::HelpCommand, misc::HelpCommand,
variables::{ variables::{
DeleteVariableCommand, GetAllVariablesCommand, GetVariableCommand, SetVariableCommand, DeleteVariableCommand, GetAllVariablesCommand, GetVariableCommand, SetVariableCommand,
@ -47,6 +47,18 @@ fn parse_roll(input: &str) -> Result<Box<dyn Command>, BotError> {
} }
} }
fn parse_register_command(input: &str) -> Result<Box<dyn Command>, BotError> {
Ok(Box::new(RegisterCommand(input.to_owned())))
}
fn parse_check_command(input: &str) -> Result<Box<dyn Command>, BotError> {
Ok(Box::new(CheckCommand(input.to_owned())))
}
fn parse_unregister_command() -> Result<Box<dyn Command>, BotError> {
Ok(Box::new(UnregisterCommand))
}
fn parse_get_variable_command(input: &str) -> Result<Box<dyn Command>, BotError> { fn parse_get_variable_command(input: &str) -> Result<Box<dyn Command>, BotError> {
Ok(Box::new(GetVariableCommand(input.to_owned()))) Ok(Box::new(GetVariableCommand(input.to_owned())))
} }
@ -141,6 +153,9 @@ pub fn parse_command(input: &str) -> Result<Box<dyn Command>, BotError> {
"cthadv" | "ctharoll" => parse_cth_advancement_roll(&cmd_input), "cthadv" | "ctharoll" => parse_cth_advancement_roll(&cmd_input),
"chance" => chance_die(), "chance" => chance_die(),
"help" => help(&cmd_input), "help" => help(&cmd_input),
"register" => parse_register_command(&cmd_input),
"check" => parse_check_command(&cmd_input),
"unregister" => parse_unregister_command(),
_ => Err(CommandParsingError::UnrecognizedCommand(cmd).into()), _ => Err(CommandParsingError::UnrecognizedCommand(cmd).into()),
}, },
//All other errors passed up. //All other errors passed up.

View File

@ -1,3 +1,5 @@
use crate::error::BotError;
use crate::models::User;
use async_trait::async_trait; use async_trait::async_trait;
use errors::DataError; use errors::DataError;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -14,6 +16,21 @@ pub(crate) trait DbState {
async fn set_device_id(&self, device_id: &str) -> Result<(), DataError>; async fn set_device_id(&self, device_id: &str) -> Result<(), DataError>;
} }
#[async_trait]
pub(crate) trait Users {
async fn upsert_user(&self, user: &User) -> Result<(), DataError>;
async fn get_user(&self, username: &str) -> Result<Option<User>, DataError>;
async fn delete_user(&self, username: &str) -> Result<(), DataError>;
async fn authenticate_user(
&self,
username: &str,
raw_password: &str,
) -> Result<Option<User>, BotError>;
}
#[async_trait] #[async_trait]
pub(crate) trait Rooms { pub(crate) trait Rooms {
async fn should_process(&self, room_id: &str, event_id: &str) -> Result<bool, DataError>; async fn should_process(&self, room_id: &str, event_id: &str) -> Result<bool, DataError>;

View File

@ -0,0 +1,18 @@
use barrel::backend::Sqlite;
use barrel::{types, types::Type, Migration};
fn primary_uuid() -> Type {
types::text().unique(true).primary(true).nullable(false)
}
pub fn migration() -> String {
let mut m = Migration::new();
//Table of room ID, event ID, event timestamp
m.create_table("accounts", move |t| {
t.add_column("user_id", primary_uuid());
t.add_column("password", types::text().nullable(false));
});
m.make::<Sqlite>()
}

View File

@ -7,6 +7,7 @@ use std::str::FromStr;
pub mod migrator; pub mod migrator;
pub mod rooms; pub mod rooms;
pub mod state; pub mod state;
pub mod users;
pub mod variables; pub mod variables;
pub struct Database { pub struct Database {

210
src/db/sqlite/users.rs Normal file
View File

@ -0,0 +1,210 @@
use super::Database;
use crate::db::{errors::DataError, Users};
use crate::error::BotError;
use crate::models::User;
use async_trait::async_trait;
#[async_trait]
impl Users for Database {
async fn upsert_user(&self, user: &User) -> Result<(), DataError> {
sqlx::query(
r#"INSERT INTO accounts (user_id, password) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET password = ?"#,
)
.bind(&user.username)
.bind(&user.password)
.bind(&user.password)
.execute(&self.conn)
.await?;
Ok(())
}
async fn delete_user(&self, username: &str) -> Result<(), DataError> {
sqlx::query(r#"DELETE FROM accounts WHERE user_id = ?"#)
.bind(&username)
.execute(&self.conn)
.await?;
Ok(())
}
async fn get_user(&self, username: &str) -> Result<Option<User>, DataError> {
let user_row = sqlx::query!(
r#"SELECT user_id, password FROM accounts
WHERE user_id = ?"#,
username
)
.fetch_optional(&self.conn)
.await?;
Ok(user_row.map(|u| User {
username: u.user_id,
password: u.password,
}))
}
async fn authenticate_user(
&self,
username: &str,
raw_password: &str,
) -> Result<Option<User>, BotError> {
let user = self.get_user(username).await?;
Ok(user.filter(|u| u.verify_password(raw_password)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::sqlite::Database;
use crate::db::Users;
async fn create_db() -> Database {
let db_path = tempfile::NamedTempFile::new_in(".").unwrap();
crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap())
.await
.unwrap();
Database::new(db_path.path().to_str().unwrap())
.await
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn create_and_get_user_test() {
let db = create_db().await;
let insert_result = db
.upsert_user(&User {
username: "myuser".to_string(),
password: "abc".to_string(),
})
.await;
assert!(insert_result.is_ok());
let user = db
.get_user("myuser")
.await
.expect("User retrieval query failed");
assert!(user.is_some());
let user = user.unwrap();
assert_eq!(user.username, "myuser");
assert_eq!(user.password, "abc");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn can_update_user() {
let db = create_db().await;
let insert_result1 = db
.upsert_user(&User {
username: "myuser".to_string(),
password: "abc".to_string(),
})
.await;
assert!(insert_result1.is_ok());
let insert_result2 = db
.upsert_user(&User {
username: "myuser".to_string(),
password: "123".to_string(),
})
.await;
assert!(insert_result2.is_ok());
let user = db
.get_user("myuser")
.await
.expect("User retrieval query failed");
assert!(user.is_some());
let user = user.unwrap();
assert_eq!(user.username, "myuser");
assert_eq!(user.password, "123"); //From second upsert
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn can_delete_user() {
let db = create_db().await;
let insert_result = db
.upsert_user(&User {
username: "myuser".to_string(),
password: "abc".to_string(),
})
.await;
assert!(insert_result.is_ok());
db.delete_user("myuser")
.await
.expect("User deletion query failed");
let user = db
.get_user("myuser")
.await
.expect("User retrieval query failed");
assert!(user.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn username_not_in_db_returns_none() {
let db = create_db().await;
let user = db
.get_user("does not exist")
.await
.expect("Get user query failure");
assert!(user.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn authenticate_user_is_some_with_valid_password() {
let db = create_db().await;
let insert_result = db
.upsert_user(&User {
username: "myuser".to_string(),
password: crate::logic::hash_password("abc").expect("password hash error!"),
})
.await;
assert!(insert_result.is_ok());
let user = db
.authenticate_user("myuser", "abc")
.await
.expect("User retrieval query failed");
assert!(user.is_some());
let user = user.unwrap();
assert_eq!(user.username, "myuser");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn authenticate_user_is_none_with_wrong_password() {
let db = create_db().await;
let insert_result = db
.upsert_user(&User {
username: "myuser".to_string(),
password: crate::logic::hash_password("abc").expect("password hash error!"),
})
.await;
assert!(insert_result.is_ok());
let user = db
.authenticate_user("myuser", "wrong-password")
.await
.expect("User retrieval query failed");
assert!(user.is_none());
}
}

View File

@ -78,6 +78,15 @@ pub enum BotError {
#[error("identifier error: {0}")] #[error("identifier error: {0}")]
IdentifierError(#[from] matrix_sdk::identifiers::Error), IdentifierError(#[from] matrix_sdk::identifiers::Error),
#[error("password creation error: {0}")]
PasswordCreationError(argon2::Error),
#[error("account does not exist, or password incorrect")]
AuthenticationError,
#[error("user account does not exist, try registering")]
AccountDoesNotExist,
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]

View File

@ -4,8 +4,10 @@ use crate::error::{BotError, DiceRollingError};
use crate::matrix; use crate::matrix;
use crate::models::RoomInfo; use crate::models::RoomInfo;
use crate::parser::dice::{Amount, Element}; use crate::parser::dice::{Amount, Element};
use argon2::{self, Config, Error as ArgonError};
use futures::stream::{self, StreamExt, TryStreamExt}; use futures::stream::{self, StreamExt, TryStreamExt};
use matrix_sdk::{self, identifiers::RoomId, Client}; use matrix_sdk::{self, identifiers::RoomId, Client};
use rand::Rng;
use std::slice; use std::slice;
/// Record the information about a room, including users in it. /// Record the information about a room, including users in it.
@ -86,3 +88,10 @@ pub async fn calculate_dice_amount(amounts: &[Amount], ctx: &Context<'_>) -> Res
Ok(dice_amount) Ok(dice_amount)
} }
/// Hash a password using the argon2 algorithm with a 16 byte salt.
pub(crate) fn hash_password(raw_password: &str) -> Result<String, ArgonError> {
let salt = rand::thread_rng().gen::<[u8; 16]>();
let config = Config::default();
argon2::hash_encoded(raw_password.as_bytes(), &salt, &config)
}

View File

@ -6,3 +6,40 @@ pub struct RoomInfo {
pub room_id: String, pub room_id: String,
pub room_name: String, pub room_name: String,
} }
#[derive(Eq, PartialEq, Debug)]
pub struct User {
pub username: String,
pub password: String,
}
impl User {
pub fn verify_password(&self, raw_password: &str) -> bool {
argon2::verify_encoded(&self.password, raw_password.as_bytes()).unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_password_passes_with_correct_password() {
let user = User {
username: "myuser".to_string(),
password: crate::logic::hash_password("mypassword").expect("Password hashing error!"),
};
assert_eq!(user.verify_password("mypassword"), true);
}
#[test]
fn verify_password_fails_with_wrong_password() {
let user = User {
username: "myuser".to_string(),
password: crate::logic::hash_password("mypassword").expect("Password hashing error!"),
};
assert_eq!(user.verify_password("wrong-password"), false);
}
}