Add bcrypt password hashing and user gender handling in yourchat2

Integrated bcrypt for password verification and updated user profile management to include gender and rights handling. Enhanced room access validation to consider gender restrictions and user rights. Updated database queries and structures to support new fields, ensuring compatibility with existing functionalities.
This commit is contained in:
Torsten Schulz (local)
2026-03-04 18:30:59 +01:00
parent 9478e6a91a
commit fbbb698ed9
6 changed files with 241 additions and 41 deletions

24
Cargo.lock generated
View File

@@ -53,6 +53,19 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bcrypt"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
dependencies = [
"base64",
"blowfish",
"getrandom 0.4.2",
"subtle",
"zeroize",
]
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -68,6 +81,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.20.2"
@@ -1602,6 +1625,7 @@ dependencies = [
name = "yourchat2"
version = "0.1.0"
dependencies = [
"bcrypt",
"futures-util",
"hex",
"openssl",

View File

@@ -16,3 +16,4 @@ hex = "0.4"
scrypt = "0.11"
tokio-rustls = "0.26"
rustls-pemfile = "2"
bcrypt = "0.19.0"

View File

@@ -1,4 +1,5 @@
use crate::types::{ChatState, ClientId, Command, RoomMeta, ServerConfig};
use bcrypt::verify as bcrypt_verify;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Arc;
@@ -169,7 +170,9 @@ async fn handle_init_command(
guard.room_meta.get(&resolved_room_name),
&profile.display_name,
profile.falukant_user_id,
profile.gender_id,
profile.age,
&profile.right_type_ids,
&room_password,
) {
drop(guard);
@@ -189,8 +192,10 @@ async fn handle_init_command(
client.color = profile.color.clone();
client.falukant_user_id = profile.falukant_user_id;
client.chat_user_id = profile.chat_user_id;
client.gender_id = profile.gender_id;
client.age = profile.age;
client.rights = profile.rights.clone();
client.right_type_ids = profile.right_type_ids.clone();
client.logged_in = true;
client.room = resolved_room_name.clone();
@@ -271,17 +276,25 @@ async fn handle_join_command(
send_error(client_id, Arc::clone(&state), "room_not_found_or_join_failed").await;
return;
};
let (name_for_check, falukant_user_id, age) = {
let (name_for_check, falukant_user_id, gender_id, age, right_type_ids) = {
let Some(client) = guard.clients.get(&client_id) else {
return;
};
(client.user_name.clone(), client.falukant_user_id, client.age)
(
client.user_name.clone(),
client.falukant_user_id,
client.gender_id,
client.age,
client.right_type_ids.clone(),
)
};
if !room_access_allowed(
guard.room_meta.get(&resolved_room),
&name_for_check,
falukant_user_id,
gender_id,
age,
&right_type_ids,
&password,
) {
drop(guard);
@@ -770,45 +783,82 @@ fn room_access_allowed(
room_meta: Option<&RoomMeta>,
_user_name: &str,
falukant_user_id: Option<i32>,
user_gender_id: Option<i32>,
age: Option<i32>,
user_right_type_ids: &std::collections::HashSet<i32>,
provided_password: &str,
) -> bool {
let Some(room) = room_meta else {
return false;
};
if let Some(required_gender) = room.gender_restriction_id {
if required_gender > 0 && user_gender_id != Some(required_gender) {
return false;
}
}
if let Some(room_password) = &room.password {
if !room_password.is_empty() && room_password != provided_password {
if !room_password.is_empty() && !password_matches(room_password, provided_password) {
return false;
}
}
if let Some(min_age) = room.min_age {
if min_age >= 18 {
if min_age > 0 {
let Some(fid) = falukant_user_id else {
return false;
};
if fid <= 0 {
return false;
}
if let Some(user_age) = age {
if user_age < min_age {
return false;
}
let Some(user_age) = age else {
return false;
};
if user_age < min_age {
return false;
}
}
}
if let Some(max_age) = room.max_age {
if let Some(user_age) = age {
if max_age > 0 {
let Some(user_age) = age else {
return false;
};
if user_age > max_age {
return false;
}
}
}
if let Some(required_right) = room.required_user_right_id {
if required_right > 0 && !user_right_type_ids.contains(&required_right) {
return false;
}
}
true
}
fn password_matches(stored_password_or_hash: &str, provided_password: &str) -> bool {
if stored_password_or_hash.is_empty() {
return true;
}
if stored_password_or_hash == provided_password {
return true;
}
if provided_password.is_empty() {
return false;
}
if stored_password_or_hash.starts_with("$2a$")
|| stored_password_or_hash.starts_with("$2b$")
|| stored_password_or_hash.starts_with("$2x$")
|| stored_password_or_hash.starts_with("$2y$")
{
return bcrypt_verify(provided_password, stored_password_or_hash).unwrap_or(false);
}
false
}
fn resolve_room_name(state: &ChatState, requested: &str) -> Option<String> {
if state.room_meta.contains_key(requested) {
return Some(requested.to_string());

163
src/db.rs
View File

@@ -42,8 +42,10 @@ pub async fn load_user_profile(user_name: &str, config: &ServerConfig) -> Result
color: None,
falukant_user_id: None,
chat_user_id: None,
gender_id: None,
age: None,
rights: HashSet::new(),
right_type_ids: HashSet::new(),
});
};
@@ -88,27 +90,28 @@ pub async fn load_user_profile(user_name: &str, config: &ServerConfig) -> Result
)
};
let rights_rows = client
.query(
"SELECT r.tr FROM chat.user_rights ur JOIN chat.rights r ON ur.chat_right_id = r.id WHERE ur.chat_user_id = $1",
&[&chat_user_id],
)
.await
.map_err(|_| "db_error")?;
let mut rights = HashSet::new();
for row in rights_rows {
rights.insert(row.get::<_, String>("tr"));
if let Ok(chat_rights) = load_chat_rights(client.clone(), chat_user_id).await {
rights.extend(chat_rights);
}
let mut right_type_ids = HashSet::new();
if let Ok((community_rights, community_right_ids)) = load_community_rights(client.clone(), falukant_user_id).await {
rights.extend(community_rights);
right_type_ids.extend(community_right_ids);
}
let age = load_user_age(client.clone(), falukant_user_id).await;
let gender_id = load_user_gender(client.clone(), falukant_user_id).await;
Ok(UserProfile {
display_name,
color,
falukant_user_id: Some(falukant_user_id),
chat_user_id: Some(chat_user_id),
gender_id,
age,
rights,
right_type_ids,
})
}
@@ -152,21 +155,36 @@ pub async fn load_room_configs(config: &ServerConfig) -> Result<Vec<RoomMeta>, &
}]);
};
let rows = client
.query(
"SELECT r.title, r.password_hash, r.is_public, r.owner_id, r.min_age, r.max_age
FROM chat.room r
LEFT JOIN chat.room_type rt ON r.room_type_id = rt.id",
&[],
)
let has_gender_restriction = column_exists(client.clone(), "chat", "room", "gender_restriction_id")
.await
.map_err(|_| "db_error")?;
.unwrap_or(false);
let has_required_user_right = column_exists(client.clone(), "chat", "room", "required_user_right_id")
.await
.unwrap_or(false);
let gender_column = if has_gender_restriction {
"r.gender_restriction_id"
} else {
"NULL::int"
};
let required_right_column = if has_required_user_right {
"r.required_user_right_id"
} else {
"NULL::int"
};
let query = format!(
"SELECT r.title, r.password_hash, r.is_public, r.owner_id, r.min_age, r.max_age, {gender_column} AS gender_restriction_id, {required_right_column} AS required_user_right_id
FROM chat.room r
LEFT JOIN chat.room_type rt ON r.room_type_id = rt.id"
);
let rows = client.query(&query, &[]).await.map_err(|_| "db_error")?;
let mut rooms = Vec::new();
for row in rows {
rooms.push(RoomMeta {
name: row.get::<_, String>("title"),
password: row.get::<_, Option<String>>("password_hash"),
gender_restriction_id: row.get::<_, Option<i32>>("gender_restriction_id"),
required_user_right_id: row.get::<_, Option<i32>>("required_user_right_id"),
min_age: row.get::<_, Option<i32>>("min_age"),
max_age: row.get::<_, Option<i32>>("max_age"),
is_public: row.get::<_, bool>("is_public"),
@@ -185,17 +203,25 @@ pub async fn load_room_configs(config: &ServerConfig) -> Result<Vec<RoomMeta>, &
}
async fn load_user_age(client: Arc<PgClient>, falukant_user_id: i32) -> Option<i32> {
let row = client
.query_opt(
"SELECT up.value
FROM community.user_param up
JOIN \"type\".user_param tp ON up.param_type_id = tp.id
WHERE up.user_id = $1 AND tp.description = 'birthdate'
LIMIT 1",
&[&falukant_user_id],
)
.await
.ok()??;
let mut row = None;
for description in ["birthdate", "birthday", "geburtsdatum"] {
row = client
.query_opt(
"SELECT up.value
FROM community.user_param up
JOIN \"type\".user_param tp ON up.param_type_id = tp.id
WHERE up.user_id = $1 AND tp.description = $2
LIMIT 1",
&[&falukant_user_id, &description],
)
.await
.ok()
.flatten();
if row.is_some() {
break;
}
}
let row = row?;
let encrypted_or_plain = row.get::<_, Option<String>>("value")?;
let normalized = normalize_birthdate_value(&encrypted_or_plain)?;
parse_age_from_yyyy_mm_dd(&normalized)
@@ -276,3 +302,84 @@ fn decrypt_aes256_ecb_hex(encrypted_hex: &str) -> Option<String> {
String::from_utf8(out).ok()
}
async fn load_user_gender(client: Arc<PgClient>, user_id: i32) -> Option<i32> {
for query in [
"SELECT gender_restriction_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
"SELECT gender_type_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
"SELECT gender_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
"SELECT sex_id AS gender_id FROM community.\"user\" WHERE id = $1 LIMIT 1",
] {
match client.query_opt(query, &[&user_id]).await {
Ok(Some(row)) => {
let value: Option<i32> = row.try_get("gender_id").ok();
if value.unwrap_or(0) > 0 {
return value;
}
}
Ok(None) => {}
Err(_) => {}
}
}
None
}
async fn column_exists(client: Arc<PgClient>, schema: &str, table: &str, column: &str) -> Result<bool, ()> {
let row = client
.query_one(
"SELECT EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_schema = $1
AND table_name = $2
AND column_name = $3
) AS exists",
&[&schema, &table, &column],
)
.await
.map_err(|_| ())?;
Ok(row.get::<_, bool>("exists"))
}
async fn load_chat_rights(client: Arc<PgClient>, chat_user_id: i32) -> Result<HashSet<String>, ()> {
let rows = client
.query(
"SELECT r.tr
FROM chat.user_rights ur
JOIN chat.rights r ON ur.chat_right_id = r.id
WHERE ur.chat_user_id = $1",
&[&chat_user_id],
)
.await
.map_err(|_| ())?;
let mut rights = HashSet::new();
for row in rows {
let right = row.get::<_, String>("tr");
rights.insert(right.clone());
rights.insert(right.to_lowercase());
}
Ok(rights)
}
async fn load_community_rights(client: Arc<PgClient>, falukant_user_id: i32) -> Result<(HashSet<String>, HashSet<i32>), ()> {
let rows = client
.query(
"SELECT tur.id AS right_type_id, tur.title
FROM community.user_right ur
JOIN \"type\".user_right tur ON ur.right_type_id = tur.id
WHERE ur.user_id = $1",
&[&falukant_user_id],
)
.await
.map_err(|_| ())?;
let mut rights = HashSet::new();
let mut right_ids = HashSet::new();
for row in rows {
let right_title = row.get::<_, String>("title");
let right_id = row.get::<_, i32>("right_type_id");
rights.insert(right_title.clone());
rights.insert(right_title.to_lowercase());
right_ids.insert(right_id);
}
Ok((rights, right_ids))
}

View File

@@ -232,15 +232,19 @@ async fn print_rooms_for_cli(
if config.db_client.is_some() { "database" } else { "fallback" }
);
println!(
"{:<30} {:<8} {:<8} {:<8} {:<10}",
"name", "public", "min_age", "max_age", "password"
"{:<24} {:<8} {:<8} {:<8} {:<8} {:<10} {:<8}",
"name", "public", "gender", "min_age", "max_age", "password", "right_id"
);
println!("{}", "-".repeat(72));
println!("{}", "-".repeat(92));
for room in rooms {
println!(
"{:<30} {:<8} {:<8} {:<8} {:<10}",
"{:<24} {:<8} {:<8} {:<8} {:<8} {:<10} {:<8}",
room.name,
if room.is_public { "yes" } else { "no" },
room.gender_restriction_id
.filter(|v| *v > 0)
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string()),
room.min_age
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string()),
@@ -252,6 +256,10 @@ async fn print_rooms_for_cli(
} else {
"set"
},
room.required_user_right_id
.filter(|v| *v > 0)
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string()),
);
}
Ok(())
@@ -283,8 +291,10 @@ where
token: None,
falukant_user_id: None,
chat_user_id: None,
gender_id: None,
age: None,
rights: HashSet::new(),
right_type_ids: HashSet::new(),
logged_in: false,
tx: tx.clone(),
},
@@ -353,8 +363,10 @@ where
token: None,
falukant_user_id: None,
chat_user_id: None,
gender_id: None,
age: None,
rights: HashSet::new(),
right_type_ids: HashSet::new(),
logged_in: false,
tx: tx.clone(),
},

View File

@@ -15,8 +15,10 @@ pub(crate) struct ClientConn {
pub(crate) token: Option<String>,
pub(crate) falukant_user_id: Option<i32>,
pub(crate) chat_user_id: Option<i32>,
pub(crate) gender_id: Option<i32>,
pub(crate) age: Option<i32>,
pub(crate) rights: HashSet<String>,
pub(crate) right_type_ids: HashSet<i32>,
pub(crate) logged_in: bool,
pub(crate) tx: mpsc::UnboundedSender<String>,
}
@@ -43,8 +45,10 @@ pub(crate) struct UserProfile {
pub(crate) color: Option<String>,
pub(crate) falukant_user_id: Option<i32>,
pub(crate) chat_user_id: Option<i32>,
pub(crate) gender_id: Option<i32>,
pub(crate) age: Option<i32>,
pub(crate) rights: HashSet<String>,
pub(crate) right_type_ids: HashSet<i32>,
}
#[derive(Debug, Deserialize)]
@@ -83,6 +87,8 @@ pub(crate) struct DiceGame {
pub(crate) struct RoomMeta {
pub(crate) name: String,
pub(crate) password: Option<String>,
pub(crate) gender_restriction_id: Option<i32>,
pub(crate) required_user_right_id: Option<i32>,
pub(crate) min_age: Option<i32>,
pub(crate) max_age: Option<i32>,
pub(crate) is_public: bool,