passwordless login + proper migrations
This commit is contained in:
+59
-92
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user