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
This commit is contained in:
Ellie Huxtable 2024-06-24 14:54:54 +01:00 committed by GitHub
parent 8956142cc5
commit 67d64ec4b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 401 additions and 16 deletions

View file

@ -0,0 +1,8 @@
alter table users add verified_at timestamp with time zone default null;
create table user_verification_token(
id bigserial primary key,
user_id bigint unique references users(id),
token text,
valid_until timestamp with time zone
);

View file

@ -3,6 +3,7 @@ use std::ops::Range;
use async_trait::async_trait;
use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
use atuin_common::utils::crypto_random_string;
use atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User};
use atuin_server_database::{Database, DbError, DbResult};
use futures_util::TryStreamExt;
@ -11,7 +12,7 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::Row;
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
use tracing::instrument;
use tracing::{instrument, trace};
use uuid::Uuid;
use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
@ -100,18 +101,100 @@ impl Database for Postgres {
#[instrument(skip_all)]
async fn get_user(&self, username: &str) -> DbResult<User> {
sqlx::query_as("select id, username, email, password from users where username = $1")
.bind(username)
.fetch_one(&self.pool)
.await
.map_err(fix_error)
.map(|DbUser(user)| user)
sqlx::query_as(
"select id, username, email, password, verified_at from users where username = $1",
)
.bind(username)
.fetch_one(&self.pool)
.await
.map_err(fix_error)
.map(|DbUser(user)| user)
}
#[instrument(skip_all)]
async fn user_verified(&self, id: i64) -> DbResult<bool> {
let res: (bool,) =
sqlx::query_as("select verified_at is not null from users where id = $1")
.bind(id)
.fetch_one(&self.pool)
.await
.map_err(fix_error)?;
Ok(res.0)
}
#[instrument(skip_all)]
async fn verify_user(&self, id: i64) -> DbResult<()> {
sqlx::query(
"update users set verified_at = (current_timestamp at time zone 'utc') where id=$1",
)
.bind(id)
.execute(&self.pool)
.await
.map_err(fix_error)?;
Ok(())
}
/// Return a valid verification token for the user
/// If the user does not have any token, create one, insert it, and return
/// If the user has a token, but it's invalid, delete it, create a new one, return
/// If the user already has a valid token, return it
#[instrument(skip_all)]
async fn user_verification_token(&self, id: i64) -> DbResult<String> {
const TOKEN_VALID_MINUTES: i64 = 15;
// First we check if there is a verification token
let token: Option<(String, sqlx::types::time::OffsetDateTime)> = sqlx::query_as(
"select token, valid_until from user_verification_token where user_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(fix_error)?;
let token = if let Some((token, valid_until)) = token {
trace!("Token for user {id} valid until {valid_until}");
// We have a token, AND it's still valid
if valid_until > time::OffsetDateTime::now_utc() {
token
} else {
// token has expired. generate a new one, return it
let token = crypto_random_string::<24>();
sqlx::query("update user_verification_token set token = $2, valid_until = $3 where user_id=$1")
.bind(id)
.bind(&token)
.bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES))
.execute(&self.pool)
.await
.map_err(fix_error)?;
token
}
} else {
// No token in the database! Generate one, insert it
let token = crypto_random_string::<24>();
sqlx::query("insert into user_verification_token (user_id, token, valid_until) values ($1, $2, $3)")
.bind(id)
.bind(&token)
.bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES))
.execute(&self.pool)
.await
.map_err(fix_error)?;
token
};
Ok(token)
}
#[instrument(skip_all)]
async fn get_session_user(&self, token: &str) -> DbResult<User> {
sqlx::query_as(
"select users.id, users.username, users.email, users.password from users
"select users.id, users.username, users.email, users.password, users.verified_at from users
inner join sessions
on users.id = sessions.user_id
and sessions.token = $1",

View file

@ -16,6 +16,7 @@ impl<'a> FromRow<'a, PgRow> for DbUser {
username: row.try_get("username")?,
email: row.try_get("email")?,
password: row.try_get("password")?,
verified: row.try_get("verified_at")?,
}))
}
}