Use strongly-validated Username type for creating users

This commit is contained in:
Olivier 'reivilibre 2025-07-19 15:03:38 +01:00
parent e322da0185
commit 9c966ea7f1
5 changed files with 74 additions and 15 deletions

1
Cargo.lock generated
View File

@ -1892,6 +1892,7 @@ dependencies = [
"sha2", "sha2",
"sqlx", "sqlx",
"subtle", "subtle",
"thiserror 2.0.12",
"time", "time",
"tokio", "tokio",
"tower-cookies", "tower-cookies",

View File

@ -13,7 +13,7 @@ repository = "https://git.emunest.net/reivilibre/idcoop"
argon2 = "0.5.2" argon2 = "0.5.2"
async-trait = "0.1.74" async-trait = "0.1.74"
axum = { version = "0.8.4", features = ["tracing", "macros"] } axum = { version = "0.8.4", features = ["tracing", "macros"] }
axum-extra = { version = "0.10.1", features = ["typed-header"] } axum-extra = { version = "0.10.1", features = ["typed-header"] }
axum-client-ip = "0.7.0" axum-client-ip = "0.7.0"
base64 = "0.21.5" base64 = "0.21.5"
blake2 = "0.10.6" blake2 = "0.10.6"
@ -24,7 +24,9 @@ confique = { version = "0.2.4", features = ["toml"], default-features = false }
eyre = "0.6.8" eyre = "0.6.8"
futures = "0.3.29" futures = "0.3.29"
governor = "0.6.0" governor = "0.6.0"
hornbeam = { version = "0.0.4", path = "../hornbeam/hornbeam", features = ["formbeam"] } hornbeam = { version = "0.0.4", path = "../hornbeam/hornbeam", features = [
"formbeam",
] }
formbeam = { version = "0.0.4", path = "../hornbeam/formbeam" } formbeam = { version = "0.0.4", path = "../hornbeam/formbeam" }
formbeam_derive = { version = "0.0.4", path = "../hornbeam/formbeam_derive" } formbeam_derive = { version = "0.0.4", path = "../hornbeam/formbeam_derive" }
bevy_reflect = { version = "0.14.0" } bevy_reflect = { version = "0.14.0" }
@ -38,12 +40,25 @@ serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.108" serde_json = "1.0.108"
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
sha2 = "0.10.8" sha2 = "0.10.8"
sqlx = { version = "0.7.2", features = ["postgres", "runtime-tokio-native-tls", "macros", "migrate", "uuid", "chrono"] } sqlx = { version = "0.7.2", features = [
"postgres",
"runtime-tokio-native-tls",
"macros",
"migrate",
"uuid",
"chrono",
] }
subtle = "2.5.0" subtle = "2.5.0"
time = "0.3.30" time = "0.3.30"
thiserror = "2.0.12"
tokio = { version = "1.33.0", features = ["rt", "macros"] } tokio = { version = "1.33.0", features = ["rt", "macros"] }
tower-cookies = "0.11.0" tower-cookies = "0.11.0"
tower-http = { version = "0.6.4", features = ["trace", "cors", "set-header", "fs"] } tower-http = { version = "0.6.4", features = [
"trace",
"cors",
"set-header",
"fs",
] }
tracing = "0.1.37" tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
uuid = { version = "1.16.0", features = ["serde"] } uuid = { version = "1.16.0", features = ["serde"] }
@ -59,9 +74,6 @@ rstest = "0.21.0"
regex = "1.11.1" regex = "1.11.1"
# Enable optimisations for some perf-sensitive libraries # Enable optimisations for some perf-sensitive libraries
# even in dev mode # even in dev mode
[profile.dev.package.argon2] [profile.dev.package.argon2]

View File

@ -5,7 +5,7 @@ use std::io::stdin;
use crate::config::Configuration; use crate::config::Configuration;
use crate::passwords::create_password_hash; use crate::passwords::create_password_hash;
use crate::store::{CreateRole, CreateUser, IdCoopStore, IdCoopStoreTxn}; use crate::store::{CreateRole, CreateUser, IdCoopStore, IdCoopStoreTxn, Username};
use clap::Parser; use clap::Parser;
use comfy_table::presets::UTF8_FULL; use comfy_table::presets::UTF8_FULL;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Row, Table}; use comfy_table::{Attribute, Cell, Color, ContentArrangement, Row, Table};
@ -19,8 +19,7 @@ pub enum UserCommand {
#[clap(alias = "new", alias = "create")] #[clap(alias = "new", alias = "create")]
Add { Add {
/// The login name of the user. /// The login name of the user.
// TODO this should be a richer newtype with validation username: Username,
username: String,
/// Set this flag if the user should be locked. /// Set this flag if the user should be locked.
#[clap(long = "locked")] #[clap(long = "locked")]

View File

@ -3,6 +3,8 @@
//! This file contains PostgreSQL queries. //! This file contains PostgreSQL queries.
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::ops::Deref;
use std::str::FromStr;
use bevy_reflect::Reflect; use bevy_reflect::Reflect;
use chrono::DateTime; use chrono::DateTime;
@ -13,6 +15,7 @@ use eyre::eyre;
use eyre::Context; use eyre::Context;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use sqlx::{types::Uuid, Connection, PgPool, Postgres, Transaction}; use sqlx::{types::Uuid, Connection, PgPool, Postgres, Transaction};
use thiserror::Error;
use crate::web::login::{ use crate::web::login::{
LoginSession, LOGIN_SESSION_TOKEN_HASH_BYTES, LOGIN_SESSION_XSRF_SECRET_BYTES, LoginSession, LOGIN_SESSION_TOKEN_HASH_BYTES, LOGIN_SESSION_XSRF_SECRET_BYTES,
@ -22,6 +25,50 @@ use crate::web::oauth_openid::application_session_access_token::ApplicationSessi
/// Prefix for roles that are reserved by idCoop usage. /// Prefix for roles that are reserved by idCoop usage.
pub const IDCOOP_RESERVED_ROLE_PREFIX: &str = "idcoop/"; pub const IDCOOP_RESERVED_ROLE_PREFIX: &str = "idcoop/";
/// Username grammar, chosen to be fairly interoperable hopefully:
/// 1. at least 3 chars
/// 2. no more than 36 chars
/// 3. all ASCII alphanumeric and lowercase
/// 4. must start with an ASCII letter
pub fn is_valid_username(user_id: &str) -> bool {
user_id.len() >= 3
&& user_id.len() <= 36
&& user_id
.chars()
.all(|c| c.is_ascii_alphanumeric() && !c.is_ascii_uppercase())
&& user_id.chars().next().unwrap().is_ascii_alphabetic()
}
#[derive(Clone, Debug, Error)]
#[error("invalid username: {0}")]
/// An invalid username error.
pub struct InvalidUsername(String);
/// A validated username
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Username(String);
impl FromStr for Username {
type Err = InvalidUsername;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.to_owned();
if is_valid_username(&s) {
Ok(Username(s))
} else {
Err(InvalidUsername(s))
}
}
}
impl Deref for Username {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// Checks if the given role ID is valid. /// Checks if the given role ID is valid.
/// ///
/// Does NOT check whether the role ID is reserved for use by idCoop or not! /// Does NOT check whether the role ID is reserved for use by idCoop or not!
@ -110,7 +157,7 @@ pub struct User {
/// Representation of the action of creating a user. /// Representation of the action of creating a user.
pub struct CreateUser { pub struct CreateUser {
/// The system name for the user. /// The system name for the user.
pub user_login_name: String, pub user_login_name: Username,
/// The password hash of the user. See [`crate::passwords`]. /// The password hash of the user. See [`crate::passwords`].
pub password_hash: Option<String>, pub password_hash: Option<String>,
/// Whether the user is locked and is therefore not allowed to log in. /// Whether the user is locked and is therefore not allowed to log in.
@ -276,7 +323,7 @@ impl IdCoopStoreTxn<'_, '_> {
pub async fn create_user(&mut self, cu: CreateUser) -> eyre::Result<Uuid> { pub async fn create_user(&mut self, cu: CreateUser) -> eyre::Result<Uuid> {
let r = sqlx::query!( let r = sqlx::query!(
"INSERT INTO users (user_name, user_id, created_at_utc, password_hash, locked) VALUES ($1, gen_random_uuid(), NOW(), $2, $3) RETURNING user_id", "INSERT INTO users (user_name, user_id, created_at_utc, password_hash, locked) VALUES ($1, gen_random_uuid(), NOW(), $2, $3) RETURNING user_id",
&cu.user_login_name, cu.password_hash.as_ref(), cu.locked &*cu.user_login_name, cu.password_hash.as_ref(), cu.locked
) )
.fetch_one(&mut **self.txn) .fetch_one(&mut **self.txn)
.await.context("failed to create user in DB")?; .await.context("failed to create user in DB")?;

View File

@ -11,7 +11,7 @@ async fn test_cli_add_user() {
handle_user_command( handle_user_command(
UserCommand::Add { UserCommand::Add {
username: "jonathan".to_owned(), username: "jonathan".parse().unwrap(),
locked: true, locked: true,
}, },
&sys.config, &sys.config,
@ -41,7 +41,7 @@ async fn test_cli_lock_and_unlock_user() {
handle_user_command( handle_user_command(
UserCommand::Add { UserCommand::Add {
username: "jonathan".to_owned(), username: "jonathan".parse().unwrap(),
locked: false, locked: false,
}, },
&sys.config, &sys.config,
@ -117,7 +117,7 @@ async fn test_cli_del_user() {
handle_user_command( handle_user_command(
UserCommand::Add { UserCommand::Add {
username: "jonathan".to_owned(), username: "jonathan".parse().unwrap(),
locked: true, locked: true,
}, },
&sys.config, &sys.config,