passwordless login + proper migrations
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline was successful
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
2026-08-03 00:23:10 -04:00
parent 1291c5a33a
commit 209363774c
14 changed files with 753 additions and 188 deletions
+59 -92
View File
@@ -16,6 +16,9 @@ use crate::ports::{
UserRepository,
};
/// The embedded SQL migrations, applied automatically on startup.
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
#[derive(Clone)]
pub struct SqliteDatabase {
pool: SqlitePool,
@@ -30,7 +33,7 @@ impl SqliteDatabase {
.busy_timeout(std::time::Duration::from_secs(5))
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?;
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
seed_default_categories(&pool).await?;
Ok(Self { pool })
}
@@ -56,7 +59,7 @@ impl SqliteDatabase {
.busy_timeout(std::time::Duration::from_secs(5))
.create_if_missing(true);
let pool = SqlitePool::connect_with(options).await.map_err(db_error)?;
migrate(&pool).await?;
MIGRATOR.run(&pool).await.map_err(migrate_error)?;
seed_default_categories(&pool).await?;
Ok(Self { pool })
}
@@ -92,85 +95,6 @@ impl SqliteDatabase {
}
}
async fn migrate(pool: &SqlitePool) -> DomainResult<()> {
sqlx::raw_sql(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS passkeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
credential TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
checked INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 1,
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS meal_ingredients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS meal_ingredients_meal_idx ON meal_ingredients(meal_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
)
.execute(pool)
.await
.map_err(db_error)?;
Ok(())
}
/// Inserts the default global categories once, if the categories table is empty.
async fn seed_default_categories(pool: &SqlitePool) -> DomainResult<()> {
let count: i64 = sqlx::query("SELECT COUNT(*) FROM categories")
@@ -205,13 +129,17 @@ impl UserRepository for SqliteUserRepository {
display_name: String,
password_hash: String,
) -> DomainResult<User> {
// Generate a random, high-entropy user handle per the WebAuthn spec so
// the value embedded in authenticators is opaque and unguessable.
let user_handle = new_user_handle();
let result = sqlx::query(
"INSERT INTO users (email, display_name, password_hash, created_at)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO users (email, display_name, password_hash, user_handle, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
)
.bind(&email)
.bind(&display_name)
.bind(&password_hash)
.bind(&user_handle)
.bind(now())
.execute(&mut *txn)
.await;
@@ -226,6 +154,7 @@ impl UserRepository for SqliteUserRepository {
id,
email,
display_name,
user_handle,
})
}
Err(error) if is_unique_violation(&error) => Err(DomainError::Conflict),
@@ -239,7 +168,7 @@ impl UserRepository for SqliteUserRepository {
email: String,
) -> DomainResult<Option<(User, String)>> {
let row = sqlx::query(
"SELECT id, email, display_name, password_hash
"SELECT id, email, display_name, user_handle, password_hash
FROM users WHERE email = ?1 COLLATE NOCASE",
)
.bind(&email)
@@ -252,12 +181,34 @@ impl UserRepository for SqliteUserRepository {
id: row.get(0),
email: row.get(1),
display_name: row.get(2),
user_handle: row.get(3),
},
row.get(3),
row.get(4),
)
}))
}
async fn find_user_by_handle(
&self,
txn: &mut SqliteConnection,
user_handle: Vec<u8>,
) -> DomainResult<Option<User>> {
let row = sqlx::query(
"SELECT id, email, display_name, user_handle
FROM users WHERE user_handle = ?1",
)
.bind(&user_handle)
.fetch_optional(&mut *txn)
.await
.map_err(db_error)?;
Ok(row.map(|row| User {
id: row.get(0),
email: row.get(1),
display_name: row.get(2),
user_handle: row.get(3),
}))
}
async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult<bool> {
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users)")
.fetch_one(&mut *txn)
@@ -388,15 +339,15 @@ impl SessionRepository for SqliteSessionRepository {
txn: &mut SqliteConnection,
user_id: i64,
) -> DomainResult<(String, String)> {
let session_token = crate::security::new_secret();
let csrf_token = crate::security::new_secret();
let session_token = hex::encode(crate::security::new_secret());
let csrf_token = hex::encode(crate::security::new_secret());
sqlx::query(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(hash_secret(&session_token))
.bind(user_id)
.bind(&csrf_token)
.bind(hex::decode(&csrf_token).expect("csrf_token is valid hex"))
.bind(now() + 60 * 60 * 24 * 30)
.execute(&mut *txn)
.await
@@ -410,7 +361,7 @@ impl SessionRepository for SqliteSessionRepository {
session_token: String,
) -> DomainResult<Option<SessionUser>> {
let row = sqlx::query(
"SELECT u.id, u.email, u.display_name, s.csrf_token
"SELECT u.id, u.email, u.display_name, u.user_handle, s.csrf_token
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?1 AND s.expires_at > ?2",
@@ -425,8 +376,9 @@ impl SessionRepository for SqliteSessionRepository {
id: row.get(0),
email: row.get(1),
display_name: row.get(2),
user_handle: row.get(3),
},
csrf_token: row.get(3),
csrf_token: hex::encode(row.get::<Vec<u8>, _>(4)),
}))
}
@@ -1109,10 +1061,10 @@ const DEFAULT_CATEGORIES: &[&str] = &[
"Household",
];
fn hash_secret(secret: &str) -> String {
fn hash_secret(secret: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
hex::encode(hasher.finalize())
hasher.finalize().to_vec()
}
fn now() -> i64 {
@@ -1122,6 +1074,17 @@ fn now() -> i64 {
.as_secs() as i64
}
/// A random, high-entropy user handle used as the WebAuthn userHandle.
/// 32 random bytes, which is exactly the 64-byte maximum the WebAuthn spec
/// allows for a userHandle while still providing 256 bits of entropy. Opaque
/// and unguessable per the spec. Stored as raw bytes.
fn new_user_handle() -> Vec<u8> {
use rand::RngCore;
let mut bytes = [0_u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
bytes.to_vec()
}
fn is_unique_violation(error: &sqlx::Error) -> bool {
error
.as_database_error()
@@ -1133,6 +1096,10 @@ fn db_error(error: sqlx::Error) -> DomainError {
DomainError::Database(error.to_string())
}
fn migrate_error(error: sqlx::migrate::MigrateError) -> DomainError {
DomainError::Database(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;