Skip to content

Commit c2b9f87

Browse files
committed
feat: progress on axum-multi-tenant example
1 parent 7d646a9 commit c2b9f87

File tree

7 files changed

+128
-56
lines changed

7 files changed

+128
-56
lines changed

Cargo.lock

Lines changed: 11 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/postgres/axum-multi-tenant/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[package]
2-
name = "multi-tenant"
2+
name = "axum-multi-tenant"
33
version.workspace = true
44
license.workspace = true
55
edition.workspace = true

examples/postgres/axum-multi-tenant/accounts/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
sqlx = { workspace = true, features = ["postgres", "time"] }
8-
argon2 = { version = "0.5.3", features = ["password-hash"] }
7+
sqlx = { workspace = true, features = ["postgres", "time", "uuid"] }
98
tokio = { version = "1", features = ["rt", "sync"] }
109

10+
argon2 = { version = "0.5.3", features = ["password-hash"] }
11+
password-hash = { version = "0.5", features = ["std"] }
12+
1113
uuid = "1"
1214
thiserror = "1"
1315
rand = "0.8"

examples/postgres/axum-multi-tenant/accounts/migrations/02_account.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
create table account
1+
create table accounts.account
22
(
33
account_id uuid primary key default gen_random_uuid(),
44
email text unique not null,

examples/postgres/axum-multi-tenant/accounts/sqlx.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ migrations-table = "accounts._sqlx_migrations"
44

55
[macros.table-overrides.'accounts.account']
66
'account_id' = "crate::AccountId"
7+
'password_hash' = "sqlx::types::Text<password_hash::PasswordHashString>"

examples/postgres/axum-multi-tenant/accounts/src/lib.rs

Lines changed: 109 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
use argon2::{password_hash, Argon2, PasswordHasher, PasswordVerifier};
12
use std::error::Error;
2-
use argon2::{password_hash, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
3+
use std::sync::Arc;
34

45
use password_hash::PasswordHashString;
56

@@ -10,21 +11,24 @@ use uuid::Uuid;
1011

1112
use tokio::sync::Semaphore;
1213

13-
#[derive(sqlx::Type)]
14+
#[derive(sqlx::Type, Debug)]
1415
#[sqlx(transparent)]
1516
pub struct AccountId(pub Uuid);
1617

17-
1818
pub struct AccountsManager {
19-
hashing_semaphore: Semaphore,
19+
hashing_semaphore: Arc<Semaphore>,
2020
}
2121

2222
#[derive(Debug, thiserror::Error)]
2323
pub enum CreateError {
24-
#[error("email in-use")]
24+
#[error("error creating account: email in-use")]
2525
EmailInUse,
26-
General(#[source]
27-
#[from] GeneralError),
26+
#[error("error creating account")]
27+
General(
28+
#[source]
29+
#[from]
30+
GeneralError,
31+
),
2832
}
2933

3034
#[derive(Debug, thiserror::Error)]
@@ -33,50 +37,95 @@ pub enum AuthenticateError {
3337
UnknownEmail,
3438
#[error("invalid password")]
3539
InvalidPassword,
36-
General(#[source]
37-
#[from] GeneralError),
40+
#[error("authentication error")]
41+
General(
42+
#[source]
43+
#[from]
44+
GeneralError,
45+
),
3846
}
3947

4048
#[derive(Debug, thiserror::Error)]
4149
pub enum GeneralError {
42-
Sqlx(#[source]
43-
#[from] sqlx::Error),
44-
PasswordHash(#[source] #[from] argon2::password_hash::Error),
45-
Task(#[source]
46-
#[from] tokio::task::JoinError),
50+
#[error("database error")]
51+
Sqlx(
52+
#[source]
53+
#[from]
54+
sqlx::Error,
55+
),
56+
#[error("error hashing password")]
57+
PasswordHash(
58+
#[source]
59+
#[from]
60+
argon2::password_hash::Error,
61+
),
62+
#[error("task panicked")]
63+
Task(
64+
#[source]
65+
#[from]
66+
tokio::task::JoinError,
67+
),
4768
}
4869

4970
impl AccountsManager {
50-
pub async fn new(conn: &mut PgConnection, max_hashing_threads: usize) -> Result<Self, GeneralError> {
51-
sqlx::migrate!().run(conn).await?;
71+
pub async fn new(
72+
conn: &mut PgConnection,
73+
max_hashing_threads: usize,
74+
) -> Result<Self, GeneralError> {
75+
sqlx::migrate!()
76+
.run(conn)
77+
.await
78+
.map_err(sqlx::Error::from)?;
5279

53-
AccountsManager {
54-
hashing_semaphore: Semaphore::new(max_hashing_threads)
55-
}
80+
Ok(AccountsManager {
81+
hashing_semaphore: Semaphore::new(max_hashing_threads).into(),
82+
})
5683
}
5784

58-
async fn hash_password(&self, password: String) -> Result<PasswordHash, GeneralError> {
59-
let guard = self.hashing_semaphore.acquire().await
85+
async fn hash_password(&self, password: String) -> Result<PasswordHashString, GeneralError> {
86+
let guard = self
87+
.hashing_semaphore
88+
.clone()
89+
.acquire_owned()
90+
.await
6091
.expect("BUG: this semaphore should not be closed");
6192

6293
// We transfer ownership to the blocking task and back to ensure Tokio doesn't spawn
6394
// excess threads.
6495
let (_guard, res) = tokio::task::spawn_blocking(move || {
6596
let salt = argon2::password_hash::SaltString::generate(rand::thread_rng());
66-
(guard, Argon2::default().hash_password(password.as_bytes(), &salt))
97+
(
98+
guard,
99+
Argon2::default()
100+
.hash_password(password.as_bytes(), &salt)
101+
.map(|hash| hash.serialize()),
102+
)
67103
})
68-
.await?;
104+
.await?;
69105

70106
Ok(res?)
71107
}
72108

73-
async fn verify_password(&self, password: String, hash: PasswordHashString) -> Result<(), AuthenticateError> {
74-
let guard = self.hashing_semaphore.acquire().await
109+
async fn verify_password(
110+
&self,
111+
password: String,
112+
hash: PasswordHashString,
113+
) -> Result<(), AuthenticateError> {
114+
let guard = self
115+
.hashing_semaphore
116+
.clone()
117+
.acquire_owned()
118+
.await
75119
.expect("BUG: this semaphore should not be closed");
76120

77121
let (_guard, res) = tokio::task::spawn_blocking(move || {
78-
(guard, Argon2::default().verify_password(password.as_bytes(), &hash.password_hash()))
79-
}).await.map_err(GeneralError::from)?;
122+
(
123+
guard,
124+
Argon2::default().verify_password(password.as_bytes(), &hash.password_hash()),
125+
)
126+
})
127+
.await
128+
.map_err(GeneralError::from)?;
80129

81130
if let Err(password_hash::Error::Password) = res {
82131
return Err(AuthenticateError::InvalidPassword);
@@ -87,46 +136,64 @@ impl AccountsManager {
87136
Ok(())
88137
}
89138

90-
pub async fn create(&self, txn: &mut PgTransaction, email: &str, password: String) -> Result<AccountId, CreateError> {
139+
pub async fn create(
140+
&self,
141+
txn: &mut PgTransaction<'_>,
142+
email: &str,
143+
password: String,
144+
) -> Result<AccountId, CreateError> {
91145
// Hash password whether the account exists or not to make it harder
92146
// to tell the difference in the timing.
93147
let hash = self.hash_password(password).await?;
94148

149+
// Thanks to `sqlx.toml`, `account_id` maps to `AccountId`
95150
// language=PostgreSQL
96-
sqlx::query!(
151+
sqlx::query_scalar!(
97152
"insert into accounts.account(email, password_hash) \
98153
values ($1, $2) \
99154
returning account_id",
100155
email,
101-
Text(hash) as Text<PasswordHash<'static>>,
156+
hash.as_str(),
102157
)
103-
.fetch_one(&mut *txn)
104-
.await
105-
.map_err(|e| if e.constraint() == Some("account_account_id_key") {
158+
.fetch_one(&mut **txn)
159+
.await
160+
.map_err(|e| {
161+
if e.as_database_error().and_then(|dbe| dbe.constraint()) == Some("account_account_id_key") {
106162
CreateError::EmailInUse
107163
} else {
108164
GeneralError::from(e).into()
109-
})
165+
}
166+
})
110167
}
111168

112-
pub async fn authenticate(&self, conn: &mut PgConnection, email: &str, password: String) -> Result<AccountId, AuthenticateError> {
169+
pub async fn authenticate(
170+
&self,
171+
conn: &mut PgConnection,
172+
email: &str,
173+
password: String,
174+
) -> Result<AccountId, AuthenticateError> {
175+
// Thanks to `sqlx.toml`:
176+
// * `account_id` maps to `AccountId`
177+
// * `password_hash` maps to `Text<PasswordHashString>`
113178
let maybe_account = sqlx::query!(
114-
"select account_id, password_hash as \"password_hash: Text<PasswordHashString>\" \
179+
"select account_id, password_hash \
115180
from accounts.account \
116-
where email_id = $1",
181+
where email = $1",
117182
email
118183
)
119-
.fetch_optional(&mut *conn)
120-
.await
121-
.map_err(GeneralError::from)?;
184+
.fetch_optional(&mut *conn)
185+
.await
186+
.map_err(GeneralError::from)?;
122187

123188
let Some(account) = maybe_account else {
124189
// Hash the password whether the account exists or not to hide the difference in timing.
125-
self.hash_password(password).await.map_err(GeneralError::from)?;
190+
self.hash_password(password)
191+
.await
192+
.map_err(GeneralError::from)?;
126193
return Err(AuthenticateError::UnknownEmail);
127194
};
128195

129-
self.verify_password(password, account.password_hash.into())?;
196+
self.verify_password(password, account.password_hash.into_inner()).await?;
130197

131198
Ok(account.account_id)
132199
}

examples/postgres/axum-multi-tenant/payments/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
sqlx = { workspace = true, features = ["postgres", "time"] }
7+
sqlx = { workspace = true, features = ["postgres", "time", "uuid"] }

0 commit comments

Comments
 (0)