From 533cabe0051978b89cac9e332296a5e66dc1b7dd Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 14 Dec 2022 15:28:36 +0100 Subject: [PATCH] Use the new password manager --- crates/cli/src/commands/manage.rs | 50 +-- crates/cli/src/commands/server.rs | 22 +- crates/cli/src/main.rs | 1 + crates/cli/src/util.rs | 37 ++ crates/config/src/sections/passwords.rs | 156 ++++++++ crates/data-model/src/lib.rs | 2 +- crates/data-model/src/users.rs | 9 + crates/handlers/src/lib.rs | 4 +- crates/handlers/src/passwords.rs | 17 +- crates/handlers/src/upstream_oauth2/link.rs | 6 +- crates/handlers/src/views/account/password.rs | 45 ++- crates/handlers/src/views/login.rs | 98 ++++- crates/handlers/src/views/reauth.rs | 47 ++- crates/handlers/src/views/register.rs | 26 +- .../20221213145242_password_schemes.sql | 24 ++ crates/storage/sqlx-data.json | 76 +++- crates/storage/src/user/authentication.rs | 105 ++++++ crates/storage/src/{user.rs => user/mod.rs} | 335 +----------------- crates/storage/src/user/password.rs | 135 +++++++ 19 files changed, 768 insertions(+), 427 deletions(-) create mode 100644 crates/cli/src/util.rs create mode 100644 crates/config/src/sections/passwords.rs create mode 100644 crates/storage/migrations/20221213145242_password_schemes.sql create mode 100644 crates/storage/src/user/authentication.rs rename crates/storage/src/{user.rs => user/mod.rs} (73%) create mode 100644 crates/storage/src/user/password.rs diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 72356acd..8ad6b3a8 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -13,16 +13,14 @@ // limitations under the License. use anyhow::Context; -use argon2::Argon2; use clap::{Parser, ValueEnum}; -use mas_config::{DatabaseConfig, RootConfig}; +use mas_config::{DatabaseConfig, PasswordsConfig, RootConfig}; use mas_iana::{jose::JsonWebSignatureAlg, oauth::OAuthClientAuthenticationMethod}; use mas_router::UrlBuilder; use mas_storage::{ oauth2::client::{insert_client_from_config, lookup_client, truncate_clients}, user::{ - lookup_user_by_username, lookup_user_email, mark_user_email_as_verified, register_user, - set_password, + add_user_password, lookup_user_by_username, lookup_user_email, mark_user_email_as_verified, }, Clock, }; @@ -30,6 +28,8 @@ use oauth2_types::scope::Scope; use rand::SeedableRng; use tracing::{info, warn}; +use crate::util::password_manager_from_config; + #[derive(Parser, Debug)] pub(super) struct Options { #[command(subcommand)] @@ -142,9 +142,6 @@ impl From<&SigningAlgorithm> for JsonWebSignatureAlg { #[derive(Parser, Debug)] enum Subcommand { - /// Register a new user - Register { username: String, password: String }, - /// Mark email address as verified VerifyEmail { username: String, email: String }, @@ -196,30 +193,33 @@ impl Options { let mut rng = rand_chacha::ChaChaRng::from_entropy(); match &self.subcommand { - SC::Register { username, password } => { - let config: DatabaseConfig = root.load_config()?; - let pool = config.connect().await?; - let mut txn = pool.begin().await?; - let hasher = Argon2::default(); - - let user = - register_user(&mut txn, &mut rng, &clock, hasher, username, password).await?; - txn.commit().await?; - info!(%user.id, %user.username, "User registered"); - - Ok(()) - } - SC::SetPassword { username, password } => { - let config: DatabaseConfig = root.load_config()?; - let pool = config.connect().await?; + let database_config: DatabaseConfig = root.load_config()?; + let passwords_config: PasswordsConfig = root.load_config()?; + + let pool = database_config.connect().await?; + let password_manager = password_manager_from_config(&passwords_config).await?; + let mut txn = pool.begin().await?; - let hasher = Argon2::default(); let user = lookup_user_by_username(&mut txn, username) .await? .context("User not found")?; - set_password(&mut txn, &mut rng, &clock, hasher, &user, password).await?; + let password = password.as_bytes().to_vec().into(); + + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + + add_user_password( + &mut txn, + &mut rng, + &clock, + &user, + version, + hashed_password, + None, + ) + .await?; + info!(%user.id, %user.username, "Password changed"); txn.commit().await?; diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index fff9756d..506ed4b6 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -20,7 +20,7 @@ use futures_util::stream::{StreamExt, TryStreamExt}; use itertools::Itertools; use mas_config::RootConfig; use mas_email::Mailer; -use mas_handlers::{passwords::PasswordManager, AppState, HttpClientFactory, MatrixHomeserver}; +use mas_handlers::{AppState, HttpClientFactory, MatrixHomeserver}; use mas_listener::{server::Server, shutdown::ShutdownStream}; use mas_policy::PolicyFactory; use mas_router::UrlBuilder; @@ -30,6 +30,8 @@ use mas_templates::Templates; use tokio::signal::unix::SignalKind; use tracing::{error, info, log::warn}; +use crate::util::password_manager_from_config; + #[derive(Parser, Debug, Default)] pub(super) struct Options { /// Automatically apply pending migrations @@ -168,23 +170,7 @@ impl Options { let listeners_config = config.http.listeners.clone(); - let password_manager = config - .passwords - .load() - .await - .context("failed to load the password schemes")? - .into_iter() - .map(|(version, algorithm, secret)| { - use mas_handlers::passwords::Hasher; - let hasher = match algorithm { - mas_config::PasswordAlgorithm::Pbkdf2 => Hasher::pbkdf2(secret), - mas_config::PasswordAlgorithm::Bcrypt { cost } => Hasher::bcrypt(cost, secret), - mas_config::PasswordAlgorithm::Argon2id => Hasher::argon2id(secret), - }; - - (version, hasher) - }); - let password_manager = PasswordManager::new(password_manager)?; + let password_manager = password_manager_from_config(&config.passwords).await?; // Explicitely the config to properly zeroize secret keys drop(config); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 25077016..b0f7af05 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -28,6 +28,7 @@ use tracing_subscriber::{ mod commands; mod server; mod telemetry; +mod util; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs new file mode 100644 index 00000000..9ffb44d8 --- /dev/null +++ b/crates/cli/src/util.rs @@ -0,0 +1,37 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use mas_config::PasswordsConfig; +use mas_handlers::passwords::PasswordManager; + +pub async fn password_manager_from_config( + config: &PasswordsConfig, +) -> Result { + let schemes = config + .load() + .await? + .into_iter() + .map(|(version, algorithm, secret)| { + use mas_handlers::passwords::Hasher; + let hasher = match algorithm { + mas_config::PasswordAlgorithm::Pbkdf2 => Hasher::pbkdf2(secret), + mas_config::PasswordAlgorithm::Bcrypt { cost } => Hasher::bcrypt(cost, secret), + mas_config::PasswordAlgorithm::Argon2id => Hasher::argon2id(secret), + }; + + (version, hasher) + }); + + PasswordManager::new(schemes) +} diff --git a/crates/config/src/sections/passwords.rs b/crates/config/src/sections/passwords.rs new file mode 100644 index 00000000..19365534 --- /dev/null +++ b/crates/config/src/sections/passwords.rs @@ -0,0 +1,156 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::borrow::Cow; + +use anyhow::bail; +use async_trait::async_trait; +use camino::Utf8PathBuf; +use rand::Rng; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::ConfigurationSection; + +fn default_schemes() -> Vec { + vec![HashingScheme { + version: 1, + algorithm: Algorithm::Argon2id, + secret: None, + }] +} + +/// User password hashing config +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PasswordsConfig { + #[serde(default = "default_schemes")] + schemes: Vec, +} + +impl Default for PasswordsConfig { + fn default() -> Self { + Self { + schemes: default_schemes(), + } + } +} + +#[async_trait] +impl ConfigurationSection<'_> for PasswordsConfig { + fn path() -> &'static str { + "passwords" + } + + async fn generate(_rng: R) -> anyhow::Result + where + R: Rng + Send, + { + Ok(Self::default()) + } + + fn test() -> Self { + Self::default() + } +} + +impl PasswordsConfig { + /// Load the password hashing schemes defined by the config + /// + /// # Errors + /// + /// Returns an error if the config is invalid, or if the secret file could + /// not be read. + pub async fn load(&self) -> Result>)>, anyhow::Error> { + let mut schemes: Vec<&HashingScheme> = self.schemes.iter().collect(); + schemes.sort_unstable_by_key(|a| a.version); + schemes.dedup_by_key(|a| a.version); + schemes.reverse(); + + if schemes.len() != self.schemes.len() { + // Some schemes had duplicated versions + bail!("Multiple password schemes have the same versions"); + } + + if schemes.is_empty() { + bail!("Requires at least one password scheme in the config"); + } + + let mut mapped_result = Vec::with_capacity(schemes.len()); + + for scheme in schemes { + let secret = if let Some(secret_or_file) = &scheme.secret { + Some(secret_or_file.load().await?.into_owned()) + } else { + None + }; + + mapped_result.push((scheme.version, scheme.algorithm, secret)); + } + + Ok(mapped_result) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SecretOrFile { + Secret(String), + #[schemars(with = "String")] + SecretFile(Utf8PathBuf), +} + +impl SecretOrFile { + async fn load(&self) -> Result, std::io::Error> { + match self { + Self::Secret(secret) => Ok(Cow::Borrowed(secret.as_bytes())), + Self::SecretFile(path) => { + let secret = tokio::fs::read(path).await?; + Ok(Cow::Owned(secret)) + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct HashingScheme { + version: u16, + + #[serde(flatten)] + algorithm: Algorithm, + + #[serde(flatten)] + secret: Option, +} + +fn default_bcrypt_cost() -> u32 { + 12 +} + +/// A hashing algorithm +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase", tag = "algorithm")] +pub enum Algorithm { + /// bcrypt + Bcrypt { + /// Hashing cost + #[serde(default = "default_bcrypt_cost")] + cost: u32, + }, + + /// argon2id + Argon2id, + + /// PBKDF2 + Pbkdf2, +} diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 261d20a5..d104642e 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -43,7 +43,7 @@ pub use self::{ UpstreamOAuthAuthorizationSession, UpstreamOAuthLink, UpstreamOAuthProvider, }, users::{ - Authentication, BrowserSession, User, UserEmail, UserEmailVerification, + Authentication, BrowserSession, Password, User, UserEmail, UserEmailVerification, UserEmailVerificationState, }, }; diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index fa8eaf70..4d9c884a 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -37,6 +37,15 @@ impl User { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Password { + pub id: Ulid, + pub hashed_password: String, + pub version: u16, + pub upgraded_from_id: Option, + pub created_at: DateTime, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Authentication { pub id: Ulid, diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 5b5da712..ed308c29 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -41,6 +41,7 @@ use mas_keystore::{Encrypter, Keystore}; use mas_policy::PolicyFactory; use mas_router::{Route, UrlBuilder}; use mas_templates::{ErrorContext, Templates}; +use passwords::PasswordManager; use rand::SeedableRng; use sqlx::PgPool; use tower::util::AndThenLayer; @@ -253,6 +254,7 @@ where Mailer: FromRef, Keystore: FromRef, HttpClientFactory: FromRef, + PasswordManager: FromRef, { Router::new() .route( @@ -351,7 +353,7 @@ where async fn test_state(pool: PgPool) -> Result { use mas_email::MailTransport; - use crate::passwords::{Hasher, PasswordManager}; + use crate::passwords::Hasher; let workspace_root = camino::Utf8Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") diff --git a/crates/handlers/src/passwords.rs b/crates/handlers/src/passwords.rs index eb9e7b6d..89326c9a 100644 --- a/crates/handlers/src/passwords.rs +++ b/crates/handlers/src/passwords.rs @@ -71,6 +71,7 @@ impl PasswordManager { /// # Errors /// /// Returns an error if the hashing failed + #[tracing::instrument(skip_all)] pub async fn hash( &self, rng: R, @@ -78,7 +79,7 @@ impl PasswordManager { ) -> Result<(SchemeVersion, String), anyhow::Error> { // Seed a future-local RNG so the RNG passed in parameters doesn't have to be // 'static - let mut rng = rand_chacha::ChaChaRng::from_rng(rng)?; + let rng = rand_chacha::ChaChaRng::from_rng(rng)?; let hashers = self.hashers.clone(); let default_hasher_version = self.default_hasher; @@ -87,7 +88,7 @@ impl PasswordManager { .get(&default_hasher_version) .context("Default hasher not found")?; - default_hasher.hash_blocking(&mut rng, &password) + default_hasher.hash_blocking(rng, &password) }) .await??; @@ -95,7 +96,12 @@ impl PasswordManager { } /// Verify a password hash for the given hashing scheme. - async fn verify( + /// + /// # Errors + /// + /// Returns an error if the password hash verification failed + #[tracing::instrument(skip_all, fields(%scheme))] + pub async fn verify( &self, scheme: SchemeVersion, password: Zeroizing>, @@ -118,6 +124,7 @@ impl PasswordManager { /// # Errors /// /// Returns an error if the password hash verification failed + #[tracing::instrument(skip_all, fields(%scheme))] pub async fn verify_and_upgrade( &self, rng: R, @@ -172,7 +179,7 @@ impl Hasher { fn hash_blocking( &self, - rng: &mut R, + rng: R, password: &[u8], ) -> Result { self.algorithm @@ -195,7 +202,7 @@ enum Algorithm { impl Algorithm { fn hash_blocking( self, - rng: &mut R, + mut rng: R, password: &[u8], pepper: Option<&[u8]>, ) -> Result { diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index a5e077f5..15c5ac93 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -28,9 +28,7 @@ use mas_storage::{ upstream_oauth2::{ associate_link_to_user, consume_session, lookup_link, lookup_session_on_link, }, - user::{ - authenticate_session_with_upstream, lookup_user, register_passwordless_user, start_session, - }, + user::{add_user, authenticate_session_with_upstream, lookup_user, start_session}, }; use mas_templates::{ EmptyContext, TemplateContext, Templates, UpstreamExistingLinkContext, UpstreamRegister, @@ -236,7 +234,7 @@ pub(crate) async fn post( } (None, None, FormData::Register { username }) => { - let user = register_passwordless_user(&mut txn, &mut rng, &clock, &username).await?; + let user = add_user(&mut txn, &mut rng, &clock, &username).await?; associate_link_to_user(&mut txn, &link, &user).await?; start_session(&mut txn, &mut rng, &clock, user).await? diff --git a/crates/handlers/src/views/account/password.rs b/crates/handlers/src/views/account/password.rs index 95dbba9f..2ba4b3f8 100644 --- a/crates/handlers/src/views/account/password.rs +++ b/crates/handlers/src/views/account/password.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use argon2::Argon2; +use anyhow::Context; use axum::{ extract::{Form, State}, response::{Html, IntoResponse, Response}, @@ -26,13 +26,16 @@ use mas_data_model::BrowserSession; use mas_keystore::Encrypter; use mas_router::Route; use mas_storage::{ - user::{authenticate_session, set_password}, + user::{add_user_password, authenticate_session_with_password, lookup_user_password}, Clock, }; use mas_templates::{EmptyContext, TemplateContext, Templates}; use rand::Rng; use serde::Deserialize; use sqlx::PgPool; +use zeroize::Zeroizing; + +use crate::passwords::PasswordManager; #[derive(Deserialize)] pub struct ChangeForm { @@ -80,6 +83,7 @@ async fn render( } pub(crate) async fn post( + State(password_manager): State, State(templates): State, State(pool): State, cookie_jar: PrivateCookieJar, @@ -101,31 +105,42 @@ pub(crate) async fn post( return Ok((cookie_jar, login.go()).into_response()); }; - authenticate_session( - &mut txn, - &mut rng, - &clock, - &mut session, - &form.current_password, - ) - .await?; + let user_password = lookup_user_password(&mut txn, &session.user) + .await? + .context("user has no password")?; + + let password = Zeroizing::new(form.current_password.into_bytes()); + let new_password = Zeroizing::new(form.new_password.into_bytes()); + let new_password_confirm = Zeroizing::new(form.new_password_confirm.into_bytes()); + + password_manager + .verify( + user_password.version, + password, + user_password.hashed_password, + ) + .await?; // TODO: display nice form errors - if form.new_password != form.new_password_confirm { + if new_password != new_password_confirm { return Err(anyhow::anyhow!("password mismatch").into()); } - let phf = Argon2::default(); - set_password( + let (version, hashed_password) = password_manager.hash(&mut rng, new_password).await?; + let user_password = add_user_password( &mut txn, &mut rng, &clock, - phf, &session.user, - &form.new_password, + version, + hashed_password, + None, ) .await?; + authenticate_session_with_password(&mut txn, &mut rng, &clock, &mut session, &user_password) + .await?; + let reply = render(&mut rng, &clock, templates.clone(), session, cookie_jar).await?; txn.commit().await?; diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 91c4c614..24fc17b7 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -21,15 +21,25 @@ use mas_axum_utils::{ csrf::{CsrfExt, CsrfToken, ProtectedForm}, FancyError, SessionInfoExt, }; +use mas_data_model::BrowserSession; use mas_keystore::Encrypter; -use mas_storage::user::{login, LoginError}; +use mas_storage::{ + user::{ + add_user_password, authenticate_session_with_password, lookup_user_by_username, + lookup_user_password, start_session, + }, + Clock, +}; use mas_templates::{ FieldError, FormError, LoginContext, LoginFormField, TemplateContext, Templates, ToFormState, }; +use rand::{CryptoRng, Rng}; use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, PgPool}; +use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; +use crate::passwords::PasswordManager; #[derive(Debug, Deserialize, Serialize)] pub(crate) struct LoginForm { @@ -74,6 +84,7 @@ pub(crate) async fn get( } pub(crate) async fn post( + State(password_manager): State, State(templates): State, State(pool): State, Query(query): Query, @@ -118,19 +129,25 @@ pub(crate) async fn post( return Ok((cookie_jar, Html(content)).into_response()); } - match login(&mut conn, &mut rng, &clock, &form.username, &form.password).await { + lookup_user_by_username(&mut conn, &form.username).await?; + + match login( + password_manager, + &mut conn, + rng, + &clock, + &form.username, + &form.password, + ) + .await + { Ok(session_info) => { let cookie_jar = cookie_jar.set_session(&session_info); let reply = query.go_next(); Ok((cookie_jar, reply).into_response()) } Err(e) => { - let state = match e { - LoginError::NotFound { .. } | LoginError::Authentication { .. } => { - state.with_error_on_form(FormError::InvalidCredentials) - } - LoginError::Other(_) => state.with_error_on_form(FormError::Internal), - }; + let state = state.with_error_on_form(e); let content = render( LoginContext::default().with_form_state(state), @@ -146,6 +163,71 @@ pub(crate) async fn post( } } +// TODO: move that logic elsewhere? +async fn login( + password_manager: PasswordManager, + conn: &mut PgConnection, + mut rng: impl Rng + CryptoRng + Send, + clock: &Clock, + username: &str, + password: &str, +) -> Result { + // XXX: we're loosing the error context here + // First, lookup the user + let user = lookup_user_by_username(&mut *conn, username) + .await + .map_err(|_e| FormError::Internal)? + .ok_or(FormError::InvalidCredentials)?; + + // And its password + let user_password = lookup_user_password(&mut *conn, &user) + .await + .map_err(|_e| FormError::Internal)? + .ok_or(FormError::InvalidCredentials)?; + + let password = Zeroizing::new(password.as_bytes().to_vec()); + + // Verify the password, and upgrade it on-the-fly if needed + let new_password_hash = password_manager + .verify_and_upgrade( + &mut rng, + user_password.version, + password, + user_password.hashed_password.clone(), + ) + .await + .map_err(|_| FormError::InvalidCredentials)?; + + let user_password = if let Some((version, new_password_hash)) = new_password_hash { + // Save the upgraded password + add_user_password( + &mut *conn, + &mut rng, + clock, + &user, + version, + new_password_hash, + Some(user_password), + ) + .await + .map_err(|_| FormError::Internal)? + } else { + user_password + }; + + // Start a new session + let mut user_session = start_session(&mut *conn, &mut rng, clock, user) + .await + .map_err(|_| FormError::Internal)?; + + // And mark it as authenticated by the password + authenticate_session_with_password(&mut *conn, rng, clock, &mut user_session, &user_password) + .await + .map_err(|_| FormError::Internal)?; + + Ok(user_session) +} + async fn render( ctx: LoginContext, action: OptionalPostAuthAction, diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index 1b546ee1..875189a7 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use anyhow::Context; use axum::{ extract::{Form, Query, State}, response::{Html, IntoResponse, Response}, @@ -23,12 +24,16 @@ use mas_axum_utils::{ }; use mas_keystore::Encrypter; use mas_router::Route; -use mas_storage::user::authenticate_session; +use mas_storage::user::{ + add_user_password, authenticate_session_with_password, lookup_user_password, +}; use mas_templates::{ReauthContext, TemplateContext, Templates}; use serde::Deserialize; use sqlx::PgPool; +use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; +use crate::passwords::PasswordManager; #[derive(Deserialize, Debug)] pub(crate) struct ReauthForm { @@ -73,6 +78,7 @@ pub(crate) async fn get( } pub(crate) async fn post( + State(password_manager): State, State(pool): State, Query(query): Query, cookie_jar: PrivateCookieJar, @@ -96,8 +102,43 @@ pub(crate) async fn post( return Ok((cookie_jar, login.go()).into_response()); }; - // TODO: recover from errors here - authenticate_session(&mut txn, &mut rng, &clock, &mut session, &form.password).await?; + // Load the user password + let user_password = lookup_user_password(&mut txn, &session.user) + .await? + .context("User has no password")?; + + let password = Zeroizing::new(form.password.as_bytes().to_vec()); + + // TODO: recover from errors + // Verify the password, and upgrade it on-the-fly if needed + let new_password_hash = password_manager + .verify_and_upgrade( + &mut rng, + user_password.version, + password, + user_password.hashed_password.clone(), + ) + .await?; + + let user_password = if let Some((version, new_password_hash)) = new_password_hash { + // Save the upgraded password + add_user_password( + &mut *txn, + &mut rng, + &clock, + &session.user, + version, + new_password_hash, + Some(user_password), + ) + .await? + } else { + user_password + }; + + // Mark the session as authenticated by the password + authenticate_session_with_password(&mut txn, rng, &clock, &mut session, &user_password).await?; + let cookie_jar = cookie_jar.set_session(&session); txn.commit().await?; diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 7ae2d1cb..9a12efac 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -16,7 +16,6 @@ use std::{str::FromStr, sync::Arc}; -use argon2::Argon2; use axum::{ extract::{Form, Query, State}, response::{Html, IntoResponse, Response}, @@ -33,7 +32,8 @@ use mas_keystore::Encrypter; use mas_policy::PolicyFactory; use mas_router::Route; use mas_storage::user::{ - add_user_email, add_user_email_verification_code, register_user, start_session, username_exists, + add_user, add_user_email, add_user_email_verification_code, add_user_password, + authenticate_session_with_password, start_session, username_exists, }; use mas_templates::{ EmailVerificationContext, FieldError, FormError, RegisterContext, RegisterFormField, @@ -42,8 +42,10 @@ use mas_templates::{ use rand::{distributions::Uniform, Rng}; use serde::{Deserialize, Serialize}; use sqlx::{PgConnection, PgPool}; +use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; +use crate::passwords::PasswordManager; #[derive(Debug, Deserialize, Serialize)] pub(crate) struct RegisterForm { @@ -88,8 +90,9 @@ pub(crate) async fn get( } } -#[allow(clippy::too_many_lines)] +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] pub(crate) async fn post( + State(password_manager): State, State(mailer): State, State(policy_factory): State>, State(templates): State, @@ -182,14 +185,17 @@ pub(crate) async fn post( return Ok((cookie_jar, Html(content)).into_response()); } - let pfh = Argon2::default(); - let user = register_user( + let user = add_user(&mut txn, &mut rng, &clock, &form.username).await?; + let password = Zeroizing::new(form.password.into_bytes()); + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + let user_password = add_user_password( &mut txn, &mut rng, &clock, - pfh, - &form.username, - &form.password, + &user, + version, + hashed_password, + None, ) .await?; @@ -222,7 +228,9 @@ pub(crate) async fn post( let next = mas_router::AccountVerifyEmail::new(verification.email.id) .and_maybe(query.post_auth_action); - let session = start_session(&mut txn, &mut rng, &clock, user).await?; + let mut session = start_session(&mut txn, &mut rng, &clock, user).await?; + authenticate_session_with_password(&mut txn, &mut rng, &clock, &mut session, &user_password) + .await?; txn.commit().await?; diff --git a/crates/storage/migrations/20221213145242_password_schemes.sql b/crates/storage/migrations/20221213145242_password_schemes.sql new file mode 100644 index 00000000..1ccdba51 --- /dev/null +++ b/crates/storage/migrations/20221213145242_password_schemes.sql @@ -0,0 +1,24 @@ +-- Copyright 2022 The Matrix.org Foundation C.I.C. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +ALTER TABLE "user_passwords" + ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1, + ADD COLUMN "upgraded_from_id" UUID + CONSTRAINT "user_passwords_upgraded_from_id_fkey" + REFERENCES "user_passwords" ("user_password_id") + ON DELETE SET NULL; + +-- Remove the default after creating the column +ALTER TABLE "user_passwords" + ALTER COLUMN "version" DROP DEFAULT; diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index cc8925f2..92699bf9 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -988,21 +988,6 @@ }, "query": "\n SELECT\n upstream_oauth_link_id,\n upstream_oauth_provider_id,\n user_id,\n subject,\n created_at\n FROM upstream_oauth_links\n WHERE upstream_oauth_link_id = $1\n " }, - "47fff42fd9871f73baf3e3ebb9e296fa65f7bc99f94639891f29d56d204b659a": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Timestamptz" - ] - } - }, - "query": "\n INSERT INTO user_passwords (user_password_id, user_id, hashed_password, created_at)\n VALUES ($1, $2, $3, $4)\n " - }, "4f8ec19f3f1bfe0268fe102a24e5a9fa542e77eccbebdce65e6deb1c197adf36": { "describe": { "columns": [ @@ -2170,6 +2155,50 @@ }, "query": "\n SELECT\n ct.compat_access_token_id,\n ct.access_token AS \"compat_access_token\",\n ct.created_at AS \"compat_access_token_created_at\",\n ct.expires_at AS \"compat_access_token_expires_at\",\n cs.compat_session_id,\n cs.created_at AS \"compat_session_created_at\",\n cs.finished_at AS \"compat_session_finished_at\",\n cs.device_id AS \"compat_session_device_id\",\n u.user_id AS \"user_id!\",\n u.username AS \"user_username!\",\n ue.user_email_id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n\n FROM compat_access_tokens ct\n INNER JOIN compat_sessions cs\n USING (compat_session_id)\n INNER JOIN users u\n USING (user_id)\n LEFT JOIN user_emails ue\n ON ue.user_email_id = u.primary_user_email_id\n\n WHERE ct.access_token = $1\n AND ct.expires_at < $2\n AND cs.finished_at IS NULL \n " }, + "a1c19d9d7f1522d126787c7f9946ed51cbbd8f27a4947bc371acab3e7bf23267": { + "describe": { + "columns": [ + { + "name": "user_password_id", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "hashed_password", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "upgraded_from_id", + "ordinal": 3, + "type_info": "Uuid" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + true, + false + ], + "parameters": { + "Left": [ + "Uuid" + ] + } + }, + "query": "\n SELECT up.user_password_id\n , up.hashed_password\n , up.version\n , up.upgraded_from_id\n , up.created_at\n FROM user_passwords up\n WHERE up.user_id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n " + }, "a5a7dad633396e087239d5629092e4a305908ffce9c2610db07372f719070546": { "describe": { "columns": [], @@ -2366,6 +2395,23 @@ }, "query": "\n INSERT INTO oauth2_sessions\n (oauth2_session_id, user_session_id, oauth2_client_id, scope, created_at)\n SELECT\n $1,\n $2,\n og.oauth2_client_id,\n og.scope,\n $3\n FROM\n oauth2_authorization_grants og\n WHERE\n og.oauth2_authorization_grant_id = $4\n " }, + "bd7a4a008851f3f6d7591e3463e4369cee08820af57dcd3faf95f8e9be82857d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Int4", + "Uuid", + "Timestamptz" + ] + } + }, + "query": "\n INSERT INTO user_passwords\n (user_password_id, user_id, hashed_password, version, upgraded_from_id, created_at)\n VALUES ($1, $2, $3, $4, $5, $6)\n " + }, "c52c911bf39ada298bfdc4526028f1b29fdcb6f557b288bb7ea2472b160c8698": { "describe": { "columns": [ diff --git a/crates/storage/src/user/authentication.rs b/crates/storage/src/user/authentication.rs new file mode 100644 index 00000000..546b54a2 --- /dev/null +++ b/crates/storage/src/user/authentication.rs @@ -0,0 +1,105 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use mas_data_model::{Authentication, BrowserSession, Password, UpstreamOAuthLink}; +use rand::Rng; +use sqlx::PgExecutor; +use ulid::Ulid; +use uuid::Uuid; + +use crate::Clock; + +#[tracing::instrument( + skip_all, + fields( + user.id = %user_session.user.id, + %user_password.id, + %user_session.id, + user_session_authentication.id, + ), + err, +)] +pub async fn authenticate_session_with_password( + executor: impl PgExecutor<'_>, + mut rng: impl Rng + Send, + clock: &Clock, + user_session: &mut BrowserSession, + user_password: &Password, +) -> Result<(), sqlx::Error> { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); + tracing::Span::current().record( + "user_session_authentication.id", + tracing::field::display(id), + ); + + sqlx::query!( + r#" + INSERT INTO user_session_authentications + (user_session_authentication_id, user_session_id, created_at) + VALUES ($1, $2, $3) + "#, + Uuid::from(id), + Uuid::from(user_session.id), + created_at, + ) + .execute(executor) + .await?; + + user_session.last_authentication = Some(Authentication { id, created_at }); + + Ok(()) +} + +#[tracing::instrument( + skip_all, + fields( + user.id = %user_session.user.id, + %upstream_oauth_link.id, + %user_session.id, + user_session_authentication.id, + ), + err, +)] +pub async fn authenticate_session_with_upstream( + executor: impl PgExecutor<'_>, + mut rng: impl Rng + Send, + clock: &Clock, + user_session: &mut BrowserSession, + upstream_oauth_link: &UpstreamOAuthLink, +) -> Result<(), sqlx::Error> { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); + tracing::Span::current().record( + "user_session_authentication.id", + tracing::field::display(id), + ); + + sqlx::query!( + r#" + INSERT INTO user_session_authentications + (user_session_authentication_id, user_session_id, created_at) + VALUES ($1, $2, $3) + "#, + Uuid::from(id), + Uuid::from(user_session.id), + created_at, + ) + .execute(executor) + .await?; + + user_session.last_authentication = Some(Authentication { id, created_at }); + + Ok(()) +} diff --git a/crates/storage/src/user.rs b/crates/storage/src/user/mod.rs similarity index 73% rename from crates/storage/src/user.rs rename to crates/storage/src/user/mod.rs index a49499c4..c687a5c8 100644 --- a/crates/storage/src/user.rs +++ b/crates/storage/src/user/mod.rs @@ -12,20 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::borrow::BorrowMut; - -use anyhow::Context; -use argon2::Argon2; use chrono::{DateTime, Utc}; use mas_data_model::{ - Authentication, BrowserSession, UpstreamOAuthLink, User, UserEmail, UserEmailVerification, + Authentication, BrowserSession, User, UserEmail, UserEmailVerification, UserEmailVerificationState, }; -use password_hash::{PasswordHash, PasswordHasher, SaltString}; -use rand::{CryptoRng, Rng}; -use sqlx::{Acquire, PgExecutor, Postgres, QueryBuilder, Transaction}; -use thiserror::Error; -use tokio::task; +use rand::Rng; +use sqlx::{PgExecutor, QueryBuilder}; use tracing::{info_span, Instrument}; use ulid::Ulid; use uuid::Uuid; @@ -35,6 +28,14 @@ use crate::{ Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, }; +mod authentication; +mod password; + +pub use self::{ + authentication::{authenticate_session_with_password, authenticate_session_with_upstream}, + password::{add_user_password, lookup_user_password}, +}; + #[derive(Debug, Clone)] struct UserLookup { user_id: Uuid, @@ -45,64 +46,6 @@ struct UserLookup { user_email_confirmed_at: Option>, } -#[derive(Debug, Error)] -pub enum LoginError { - #[error("could not find user {username:?}")] - NotFound { username: String }, - - #[error("authentication failed for {username:?}")] - Authentication { - username: String, - #[source] - source: AuthenticationError, - }, - - #[error("failed to login")] - Other(#[from] anyhow::Error), -} - -#[tracing::instrument( - skip_all, - fields(user.username = username), - err, -)] -pub async fn login( - conn: impl Acquire<'_, Database = Postgres> + Send, - mut rng: impl Rng + Send, - clock: &Clock, - username: &str, - password: &str, -) -> Result { - let mut txn = conn.begin().await.context("could not start transaction")?; - let user = lookup_user_by_username(&mut txn, username) - .await - .context("Could not find user by username")?; - - let Some(user) = user else { - return Err(LoginError::NotFound { username: username.to_owned() }); - }; - - let mut session = start_session(&mut txn, &mut rng, clock, user) - .await - .context("Could not start session")?; - - authenticate_session(&mut txn, &mut rng, clock, &mut session, password) - .await - .map_err(|source| { - if matches!(source, AuthenticationError::Password { .. }) { - LoginError::Authentication { - username: username.to_owned(), - source, - } - } else { - LoginError::Other(source.into()) - } - })?; - - txn.commit().await.context("could not commit transaction")?; - Ok(session) -} - #[derive(sqlx::FromRow)] struct SessionLookup { user_session_id: Uuid, @@ -336,183 +279,6 @@ pub async fn count_active_sessions( Ok(res) } -#[derive(Debug, Error)] -pub enum AuthenticationError { - #[error("could not verify password")] - Password(#[from] password_hash::Error), - - #[error("could not fetch user password hash")] - Fetch(sqlx::Error), - - #[error("could not save session auth")] - Save(sqlx::Error), - - #[error("runtime error")] - Internal(#[from] tokio::task::JoinError), -} - -#[tracing::instrument( - skip_all, - fields( - user.id = %user_session.user.id, - %user_session.id, - user_session_authentication.id, - ), - err, -)] -pub async fn authenticate_session( - txn: &mut Transaction<'_, Postgres>, - mut rng: impl Rng + Send, - clock: &Clock, - user_session: &mut BrowserSession, - password: &str, -) -> Result<(), AuthenticationError> { - // First, fetch the hashed password from the user associated with that session - let hashed_password: String = sqlx::query_scalar!( - r#" - SELECT up.hashed_password - FROM user_passwords up - WHERE up.user_id = $1 - ORDER BY up.created_at DESC - LIMIT 1 - "#, - Uuid::from(user_session.user.id), - ) - .fetch_one(txn.borrow_mut()) - .instrument(tracing::info_span!("Lookup hashed password")) - .await - .map_err(AuthenticationError::Fetch)?; - - // TODO: pass verifiers list as parameter - // Verify the password in a blocking thread to avoid blocking the async executor - let password = password.to_owned(); - task::spawn_blocking(move || { - let context = Argon2::default(); - let hasher = PasswordHash::new(&hashed_password).map_err(AuthenticationError::Password)?; - hasher - .verify_password(&[&context], &password) - .map_err(AuthenticationError::Password) - }) - .instrument(tracing::info_span!("Verify hashed password")) - .await??; - - // That went well, let's insert the auth info - let created_at = clock.now(); - let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); - tracing::Span::current().record( - "user_session_authentication.id", - tracing::field::display(id), - ); - - sqlx::query!( - r#" - INSERT INTO user_session_authentications - (user_session_authentication_id, user_session_id, created_at) - VALUES ($1, $2, $3) - "#, - Uuid::from(id), - Uuid::from(user_session.id), - created_at, - ) - .execute(txn.borrow_mut()) - .instrument(tracing::info_span!("Save authentication")) - .await - .map_err(AuthenticationError::Save)?; - - user_session.last_authentication = Some(Authentication { id, created_at }); - - Ok(()) -} - -#[tracing::instrument( - skip_all, - fields( - user.id = %user_session.user.id, - %upstream_oauth_link.id, - %user_session.id, - user_session_authentication.id, - ), - err, -)] -pub async fn authenticate_session_with_upstream( - executor: impl PgExecutor<'_>, - mut rng: impl Rng + Send, - clock: &Clock, - user_session: &mut BrowserSession, - upstream_oauth_link: &UpstreamOAuthLink, -) -> Result<(), sqlx::Error> { - let created_at = clock.now(); - let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); - tracing::Span::current().record( - "user_session_authentication.id", - tracing::field::display(id), - ); - - sqlx::query!( - r#" - INSERT INTO user_session_authentications - (user_session_authentication_id, user_session_id, created_at) - VALUES ($1, $2, $3) - "#, - Uuid::from(id), - Uuid::from(user_session.id), - created_at, - ) - .execute(executor) - .instrument(tracing::info_span!("Save authentication")) - .await?; - - user_session.last_authentication = Some(Authentication { id, created_at }); - - Ok(()) -} - -#[tracing::instrument( - skip_all, - fields( - user.username = username, - user.id, - ), - err(Debug), -)] -pub async fn register_user( - txn: &mut Transaction<'_, Postgres>, - mut rng: impl CryptoRng + Rng + Send, - clock: &Clock, - phf: impl PasswordHasher + Send, - username: &str, - password: &str, -) -> Result { - let created_at = clock.now(); - let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); - tracing::Span::current().record("user.id", tracing::field::display(id)); - - sqlx::query!( - r#" - INSERT INTO users (user_id, username, created_at) - VALUES ($1, $2, $3) - "#, - Uuid::from(id), - username, - created_at, - ) - .execute(txn.borrow_mut()) - .instrument(info_span!("Register user")) - .await - .context("could not insert user")?; - - let user = User { - id, - username: username.to_owned(), - sub: id.to_string(), - primary_email: None, - }; - - set_password(txn.borrow_mut(), &mut rng, clock, phf, &user, password).await?; - - Ok(user) -} - #[tracing::instrument( skip_all, fields( @@ -521,7 +287,7 @@ pub async fn register_user( ), err, )] -pub async fn register_passwordless_user( +pub async fn add_user( executor: impl PgExecutor<'_>, mut rng: impl Rng + Send, clock: &Clock, @@ -551,47 +317,6 @@ pub async fn register_passwordless_user( }) } -#[tracing::instrument( - skip_all, - fields( - %user.id, - user_password.id, - ), - err(Debug), -)] -pub async fn set_password( - executor: impl PgExecutor<'_>, - mut rng: impl CryptoRng + Rng + Send, - clock: &Clock, - phf: impl PasswordHasher + Send, - user: &User, - password: &str, -) -> Result<(), anyhow::Error> { - let created_at = clock.now(); - let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); - tracing::Span::current().record("user_password.id", tracing::field::display(id)); - - let salt = SaltString::generate(&mut rng); - let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?; - - sqlx::query_scalar!( - r#" - INSERT INTO user_passwords (user_password_id, user_id, hashed_password, created_at) - VALUES ($1, $2, $3, $4) - "#, - Uuid::from(id), - Uuid::from(user.id), - hashed_password.to_string(), - created_at, - ) - .execute(executor) - .instrument(info_span!("Save user credentials")) - .await - .context("could not insert user password")?; - - Ok(()) -} - #[tracing::instrument( skip_all, fields(%user_session.id), @@ -1277,39 +1002,3 @@ pub async fn add_user_email_verification_code( Ok(verification) } - -#[cfg(test)] -mod tests { - use rand::SeedableRng; - - use super::*; - - #[sqlx::test(migrator = "crate::MIGRATOR")] - async fn test_user_registration_and_login(pool: sqlx::PgPool) -> anyhow::Result<()> { - let clock = Clock::default(); - let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42); - let mut txn = pool.begin().await?; - - let exists = username_exists(&mut txn, "john").await?; - assert!(!exists); - - let hasher = Argon2::default(); - let user = register_user(&mut txn, &mut rng, &clock, hasher, "john", "hunter2").await?; - assert_eq!(user.username, "john"); - - let exists = username_exists(&mut txn, "john").await?; - assert!(exists); - - let session = login(&mut txn, &mut rng, &clock, "john", "hunter2").await?; - assert_eq!(session.user.id, user.id); - - let user2 = lookup_user_by_username(&mut txn, "john") - .await? - .context("Could not find user")?; - assert_eq!(user.id, user2.id); - - txn.commit().await?; - - Ok(()) - } -} diff --git a/crates/storage/src/user/password.rs b/crates/storage/src/user/password.rs new file mode 100644 index 00000000..14ac5222 --- /dev/null +++ b/crates/storage/src/user/password.rs @@ -0,0 +1,135 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use chrono::{DateTime, Utc}; +use mas_data_model::{Password, User}; +use rand::Rng; +use sqlx::PgExecutor; +use ulid::Ulid; +use uuid::Uuid; + +use crate::{Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt}; + +#[tracing::instrument( + skip_all, + fields( + %user.id, + %user.username, + user_password.id, + user_password.version = version, + ), + err, +)] +pub async fn add_user_password( + executor: impl PgExecutor<'_>, + mut rng: impl Rng + Send, + clock: &Clock, + user: &User, + version: u16, + hashed_password: String, + upgraded_from: Option, +) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), &mut rng); + tracing::Span::current().record("user_password.id", tracing::field::display(id)); + + let upgraded_from_id = upgraded_from.map(|p| p.id); + + sqlx::query!( + r#" + INSERT INTO user_passwords + (user_password_id, user_id, hashed_password, version, upgraded_from_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + Uuid::from(id), + Uuid::from(user.id), + hashed_password, + i32::from(version), + upgraded_from_id.map(Uuid::from), + created_at, + ) + .execute(executor) + .await?; + + Ok(Password { + id, + hashed_password, + version, + upgraded_from_id, + created_at, + }) +} + +struct UserPasswordLookup { + user_password_id: Uuid, + hashed_password: String, + version: i32, + upgraded_from_id: Option, + created_at: DateTime, +} + +#[tracing::instrument( + skip_all, + fields( + %user.id, + %user.username, + ), + err, +)] +pub async fn lookup_user_password( + executor: impl PgExecutor<'_>, + user: &User, +) -> Result, DatabaseError> { + let res = sqlx::query_as!( + UserPasswordLookup, + r#" + SELECT up.user_password_id + , up.hashed_password + , up.version + , up.upgraded_from_id + , up.created_at + FROM user_passwords up + WHERE up.user_id = $1 + ORDER BY up.created_at DESC + LIMIT 1 + "#, + Uuid::from(user.id), + ) + .fetch_one(executor) + .await + .to_option()?; + + let Some(res) = res else { return Ok(None) }; + + let id = Ulid::from(res.user_password_id); + + let version = res.version.try_into().map_err(|e| { + DatabaseInconsistencyError::on("user_passwords") + .column("version") + .row(id) + .source(e) + })?; + + let upgraded_from_id = res.upgraded_from_id.map(Ulid::from); + let created_at = res.created_at; + let hashed_password = res.hashed_password; + + Ok(Some(Password { + id, + hashed_password, + version, + upgraded_from_id, + created_at, + })) +}