Skip to content

Commit 67d64ec

Browse files
authored
feat: add user account verification (#2190)
* add verified column to users table * add database functions to check if verified, or to verify * getting there * verification check * use base64 urlsafe no pad * add verification client * clippy * correct docs * fix integration tests
1 parent 8956142 commit 67d64ec

File tree

17 files changed

+401
-16
lines changed

17 files changed

+401
-16
lines changed

Cargo.lock

+20
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/atuin-client/src/api_client.rs

+34-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ use reqwest::{
1111
use atuin_common::{
1212
api::{
1313
AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
14-
ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse,
15-
SyncHistoryResponse,
14+
ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse,
15+
SendVerificationResponse, StatusResponse, SyncHistoryResponse, VerificationTokenRequest,
16+
VerificationTokenResponse,
1617
},
1718
record::RecordStatus,
1819
};
@@ -403,4 +404,35 @@ impl<'a> Client<'a> {
403404
bail!("Unknown error");
404405
}
405406
}
407+
408+
// Either request a verification email if token is null, or validate a token
409+
pub async fn verify(&self, token: Option<String>) -> Result<(bool, bool)> {
410+
// could dedupe this a bit, but it's simple at the moment
411+
let (email_sent, verified) = if let Some(token) = token {
412+
let url = format!("{}/api/v0/account/verify", self.sync_addr);
413+
let url = Url::parse(url.as_str())?;
414+
415+
let resp = self
416+
.client
417+
.post(url)
418+
.json(&VerificationTokenRequest { token })
419+
.send()
420+
.await?;
421+
let resp = handle_resp_error(resp).await?;
422+
let resp = resp.json::<VerificationTokenResponse>().await?;
423+
424+
(false, resp.verified)
425+
} else {
426+
let url = format!("{}/api/v0/account/send-verification", self.sync_addr);
427+
let url = Url::parse(url.as_str())?;
428+
429+
let resp = self.client.post(url).send().await?;
430+
let resp = handle_resp_error(resp).await?;
431+
let resp = resp.json::<SendVerificationResponse>().await?;
432+
433+
(resp.email_sent, resp.verified)
434+
};
435+
436+
Ok((email_sent, verified))
437+
}
406438
}

crates/atuin-common/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ semver = { workspace = true }
2424
thiserror = { workspace = true }
2525
directories = { workspace = true }
2626
sysinfo = "0.30.7"
27+
base64 = { workspace = true }
28+
getrandom = "0.2"
2729

2830
lazy_static = "1.4.0"
2931

crates/atuin-common/src/api.rs

+16
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ pub struct RegisterResponse {
3333
#[derive(Debug, Serialize, Deserialize)]
3434
pub struct DeleteUserResponse {}
3535

36+
#[derive(Debug, Serialize, Deserialize)]
37+
pub struct SendVerificationResponse {
38+
pub email_sent: bool,
39+
pub verified: bool,
40+
}
41+
42+
#[derive(Debug, Serialize, Deserialize)]
43+
pub struct VerificationTokenRequest {
44+
pub token: String,
45+
}
46+
47+
#[derive(Debug, Serialize, Deserialize)]
48+
pub struct VerificationTokenResponse {
49+
pub verified: bool,
50+
}
51+
3652
#[derive(Debug, Serialize, Deserialize)]
3753
pub struct ChangePasswordRequest {
3854
pub current_password: String,

crates/atuin-common/src/utils.rs

+30-3
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,30 @@ use std::path::PathBuf;
44

55
use eyre::{eyre, Result};
66

7-
use rand::RngCore;
7+
use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
8+
use getrandom::getrandom;
89
use uuid::Uuid;
910

10-
pub fn random_bytes<const N: usize>() -> [u8; N] {
11+
/// Generate N random bytes, using a cryptographically secure source
12+
pub fn crypto_random_bytes<const N: usize>() -> [u8; N] {
13+
// rand say they are in principle safe for crypto purposes, but that it is perhaps a better
14+
// idea to use getrandom for things such as passwords.
1115
let mut ret = [0u8; N];
1216

13-
rand::thread_rng().fill_bytes(&mut ret);
17+
getrandom(&mut ret).expect("Failed to generate random bytes!");
1418

1519
ret
1620
}
1721

22+
/// Generate N random bytes using a cryptographically secure source, return encoded as a string
23+
pub fn crypto_random_string<const N: usize>() -> String {
24+
let bytes = crypto_random_bytes::<N>();
25+
26+
// We only use this to create a random string, and won't be reversing it to find the original
27+
// data - no padding is OK there. It may be in URLs.
28+
BASE64_URL_SAFE_NO_PAD.encode(bytes)
29+
}
30+
1831
pub fn uuid_v7() -> Uuid {
1932
Uuid::now_v7()
2033
}
@@ -178,6 +191,7 @@ impl<T: AsRef<str>> Escapable for T {}
178191

179192
#[cfg(test)]
180193
mod tests {
194+
use pretty_assertions::assert_ne;
181195
use time::Month;
182196

183197
use super::*;
@@ -292,4 +306,17 @@ mod tests {
292306
Cow::Owned(_)
293307
));
294308
}
309+
310+
#[test]
311+
fn dumb_random_test() {
312+
// Obviously not a test of randomness, but make sure we haven't made some
313+
// catastrophic error
314+
315+
assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
316+
assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
317+
assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
318+
assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
319+
assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
320+
assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
321+
}
295322
}

crates/atuin-server-database/src/lib.rs

+5
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ pub trait Database: Sized + Clone + Send + Sync + 'static {
5353
async fn get_user(&self, username: &str) -> DbResult<User>;
5454
async fn get_user_session(&self, u: &User) -> DbResult<Session>;
5555
async fn add_user(&self, user: &NewUser) -> DbResult<i64>;
56+
57+
async fn user_verified(&self, id: i64) -> DbResult<bool>;
58+
async fn verify_user(&self, id: i64) -> DbResult<()>;
59+
async fn user_verification_token(&self, id: i64) -> DbResult<String>;
60+
5661
async fn update_user_password(&self, u: &User) -> DbResult<()>;
5762

5863
async fn total_history(&self) -> DbResult<i64>;

crates/atuin-server-database/src/models.rs

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub struct User {
3232
pub username: String,
3333
pub email: String,
3434
pub password: String,
35+
pub verified: Option<OffsetDateTime>,
3536
}
3637

3738
pub struct Session {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
alter table users add verified_at timestamp with time zone default null;
2+
3+
create table user_verification_token(
4+
id bigserial primary key,
5+
user_id bigint unique references users(id),
6+
token text,
7+
valid_until timestamp with time zone
8+
);

crates/atuin-server-postgres/src/lib.rs

+91-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::ops::Range;
33

44
use async_trait::async_trait;
55
use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
6+
use atuin_common::utils::crypto_random_string;
67
use atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User};
78
use atuin_server_database::{Database, DbError, DbResult};
89
use futures_util::TryStreamExt;
@@ -11,7 +12,7 @@ use sqlx::postgres::PgPoolOptions;
1112
use sqlx::Row;
1213

1314
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
14-
use tracing::instrument;
15+
use tracing::{instrument, trace};
1516
use uuid::Uuid;
1617
use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
1718

@@ -100,18 +101,100 @@ impl Database for Postgres {
100101

101102
#[instrument(skip_all)]
102103
async fn get_user(&self, username: &str) -> DbResult<User> {
103-
sqlx::query_as("select id, username, email, password from users where username = $1")
104-
.bind(username)
105-
.fetch_one(&self.pool)
106-
.await
107-
.map_err(fix_error)
108-
.map(|DbUser(user)| user)
104+
sqlx::query_as(
105+
"select id, username, email, password, verified_at from users where username = $1",
106+
)
107+
.bind(username)
108+
.fetch_one(&self.pool)
109+
.await
110+
.map_err(fix_error)
111+
.map(|DbUser(user)| user)
112+
}
113+
114+
#[instrument(skip_all)]
115+
async fn user_verified(&self, id: i64) -> DbResult<bool> {
116+
let res: (bool,) =
117+
sqlx::query_as("select verified_at is not null from users where id = $1")
118+
.bind(id)
119+
.fetch_one(&self.pool)
120+
.await
121+
.map_err(fix_error)?;
122+
123+
Ok(res.0)
124+
}
125+
126+
#[instrument(skip_all)]
127+
async fn verify_user(&self, id: i64) -> DbResult<()> {
128+
sqlx::query(
129+
"update users set verified_at = (current_timestamp at time zone 'utc') where id=$1",
130+
)
131+
.bind(id)
132+
.execute(&self.pool)
133+
.await
134+
.map_err(fix_error)?;
135+
136+
Ok(())
137+
}
138+
139+
/// Return a valid verification token for the user
140+
/// If the user does not have any token, create one, insert it, and return
141+
/// If the user has a token, but it's invalid, delete it, create a new one, return
142+
/// If the user already has a valid token, return it
143+
#[instrument(skip_all)]
144+
async fn user_verification_token(&self, id: i64) -> DbResult<String> {
145+
const TOKEN_VALID_MINUTES: i64 = 15;
146+
147+
// First we check if there is a verification token
148+
let token: Option<(String, sqlx::types::time::OffsetDateTime)> = sqlx::query_as(
149+
"select token, valid_until from user_verification_token where user_id = $1",
150+
)
151+
.bind(id)
152+
.fetch_optional(&self.pool)
153+
.await
154+
.map_err(fix_error)?;
155+
156+
let token = if let Some((token, valid_until)) = token {
157+
trace!("Token for user {id} valid until {valid_until}");
158+
159+
// We have a token, AND it's still valid
160+
if valid_until > time::OffsetDateTime::now_utc() {
161+
token
162+
} else {
163+
// token has expired. generate a new one, return it
164+
let token = crypto_random_string::<24>();
165+
166+
sqlx::query("update user_verification_token set token = $2, valid_until = $3 where user_id=$1")
167+
.bind(id)
168+
.bind(&token)
169+
.bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES))
170+
.execute(&self.pool)
171+
.await
172+
.map_err(fix_error)?;
173+
174+
token
175+
}
176+
} else {
177+
// No token in the database! Generate one, insert it
178+
let token = crypto_random_string::<24>();
179+
180+
sqlx::query("insert into user_verification_token (user_id, token, valid_until) values ($1, $2, $3)")
181+
.bind(id)
182+
.bind(&token)
183+
.bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES))
184+
.execute(&self.pool)
185+
.await
186+
.map_err(fix_error)?;
187+
188+
token
189+
};
190+
191+
Ok(token)
109192
}
110193

111194
#[instrument(skip_all)]
112195
async fn get_session_user(&self, token: &str) -> DbResult<User> {
113196
sqlx::query_as(
114-
"select users.id, users.username, users.email, users.password from users
197+
"select users.id, users.username, users.email, users.password, users.verified_at from users
115198
inner join sessions
116199
on users.id = sessions.user_id
117200
and sessions.token = $1",

crates/atuin-server-postgres/src/wrappers.rs

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ impl<'a> FromRow<'a, PgRow> for DbUser {
1616
username: row.try_get("username")?,
1717
email: row.try_get("email")?,
1818
password: row.try_get("password")?,
19+
verified: row.try_get("verified_at")?,
1920
}))
2021
}
2122
}

crates/atuin-server/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ argon2 = "0.5"
3737
semver = { workspace = true }
3838
metrics-exporter-prometheus = "0.12.1"
3939
metrics = "0.21.1"
40+
postmark = {version= "0.10.0", features=["reqwest"]}

0 commit comments

Comments
 (0)