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
+4
View File
@@ -19,6 +19,10 @@ pub struct User {
pub id: i64,
pub email: String,
pub display_name: String,
/// Opaque, random user handle used as the WebAuthn userHandle. Kept
/// high-entropy and unpredictable per the WebAuthn spec to avoid user
/// enumeration and cross-site correlation. Stored as raw bytes.
pub user_handle: Vec<u8>,
}
#[derive(Clone, Debug)]
+24 -13
View File
@@ -259,11 +259,13 @@ struct PasskeyRegisterFinishForm {
#[derive(Debug, Deserialize)]
struct PasskeyLoginStartForm {
#[serde(default)]
email: String,
}
#[derive(Debug, Deserialize)]
struct PasskeyLoginFinishForm {
token: String,
response: webauthn_rs::proto::PublicKeyCredential,
}
@@ -480,15 +482,28 @@ async fn passkey_login_start(
Json(form): Json<PasskeyLoginStartForm>,
) -> Result<Response, AppError> {
let email = form.email.trim().to_lowercase();
let Some((user, _)) = state.auth.find_user_by_email(email).await? else {
return Err(AppError::NotFound);
let (challenge, token) = if email.is_empty() {
// Userless sign-in: no email needed, the authenticator selects a
// discoverable credential and returns a user handle.
state
.webauthn
.start_userless_authentication()
.await
.map_err(AppError::Database)?
} else {
let Some((user, _)) = state.auth.find_user_by_email(email).await? else {
return Err(AppError::NotFound);
};
state
.webauthn
.start_authentication(user.id)
.await
.map_err(AppError::Database)?
};
let challenge = state
.webauthn
.start_authentication(user.id)
.await
.map_err(AppError::Database)?;
Ok(Json(challenge).into_response())
Ok(
Json(serde_json::json!({ "token": token, "publicKey": challenge.public_key }))
.into_response(),
)
}
async fn passkey_login_finish(
@@ -497,11 +512,7 @@ async fn passkey_login_finish(
) -> Result<Response, AppError> {
let user_id = state
.webauthn
.resolve_user_id_for_assertion(&form.response)
.await?;
state
.webauthn
.finish_authentication(user_id, form.response)
.finish_authentication(form.token, form.response)
.await?;
let (session_token, _) = state.auth.create_session_for_user(user_id).await?;
let mut response = Redirect::to("/lists").into_response();
+1 -1
View File
@@ -35,7 +35,7 @@ impl RealtimeNotifier for InMemoryHub {
}
});
let connection_id = crate::security::new_secret();
let connection_id = hex::encode(crate::security::new_secret());
let already_present = room
.connections
.values()
+1
View File
@@ -132,6 +132,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let webauthn_service = Arc::new(WebAuthnService::new(
db.clone(),
AppWebauthnConfig::new(rp_id, rp_name, origin),
Arc::clone(&users),
Arc::clone(&passkeys),
));
+5
View File
@@ -24,6 +24,11 @@ pub trait UserRepository: Send + Sync {
txn: &mut SqliteConnection,
email: String,
) -> DomainResult<Option<(User, String)>>;
async fn find_user_by_handle(
&self,
txn: &mut SqliteConnection,
user_handle: Vec<u8>,
) -> DomainResult<Option<User>>;
async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult<bool>;
}
+5 -3
View File
@@ -37,12 +37,14 @@ pub struct RandomTokenGenerator;
#[async_trait]
impl TokenGenerator for RandomTokenGenerator {
fn generate(&self) -> String {
new_secret()
hex::encode(new_secret())
}
}
pub fn new_secret() -> String {
/// Generates 32 cryptographically random bytes. Callers that need a
/// client-facing string should hex-encode the result.
pub fn new_secret() -> Vec<u8> {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
bytes.to_vec()
}
+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::*;
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
input type="hidden" name="invite" value=(invite);
}
label for="email" { "Email" }
input id="email" name="email" type="email" autocomplete="email" required autofocus;
input id="email" name="email" type="email" autocomplete="email" autofocus;
label for="password" { "Password" }
input id="password" name="password" type="password" autocomplete="current-password" required;
button class="button button-primary" type="submit" { "Sign in" }
+125 -56
View File
@@ -7,12 +7,13 @@ use webauthn_rs::{
error::WebauthnError as WanError,
proto::{
CreationChallengeResponse, Credential, PublicKeyCredential, RegisterPublicKeyCredential,
RequestChallengeResponse,
RequestChallengeResponse, UserVerificationPolicy,
},
};
use crate::domain::{DomainError, DomainResult, Passkey as DbPasskey, User};
use crate::ports::PasskeyRepository;
use crate::ports::{PasskeyRepository, UserRepository};
use crate::security::new_secret;
use crate::sqlite::SqliteDatabase;
/// Site-specific WebAuthn configuration, derived from env vars.
@@ -20,6 +21,7 @@ pub struct AppWebauthnConfig {
rp_id: String,
rp_name: String,
origin: url::Url,
require_resident_key: bool,
}
impl AppWebauthnConfig {
@@ -28,6 +30,10 @@ impl AppWebauthnConfig {
rp_id,
rp_name,
origin,
// Resident (discoverable) keys let users sign in without typing an
// email, because the authenticator can select the credential on its
// own and return the user handle.
require_resident_key: true,
}
}
}
@@ -42,18 +48,24 @@ impl WebauthnConfig for AppWebauthnConfig {
fn get_relying_party_id(&self) -> &str {
&self.rp_id
}
fn get_require_resident_key(&self) -> bool {
self.require_resident_key
}
}
/// A single-use, in-memory challenge store keyed by user id.
/// A single-use, in-memory challenge store. Registrations are keyed by user id;
/// authentications are keyed by a random token so that userless (discoverable)
/// ceremonies can be correlated back to the finish request.
#[derive(Default)]
struct ChallengeStore {
registrations: HashMap<i64, RegistrationState>,
authentications: HashMap<i64, AuthenticationState>,
authentications: HashMap<String, AuthenticationState>,
}
pub struct WebAuthnService {
db: SqliteDatabase,
webauthn: Webauthn<AppWebauthnConfig>,
users: Arc<dyn UserRepository>,
passkeys: Arc<dyn PasskeyRepository>,
challenges: Mutex<ChallengeStore>,
}
@@ -62,12 +74,14 @@ impl WebAuthnService {
pub fn new(
db: SqliteDatabase,
config: AppWebauthnConfig,
users: Arc<dyn UserRepository>,
passkeys: Arc<dyn PasskeyRepository>,
) -> Self {
let webauthn = Webauthn::new(config);
Self {
db,
webauthn,
users,
passkeys,
challenges: Mutex::new(ChallengeStore::default()),
}
@@ -75,9 +89,19 @@ impl WebAuthnService {
/// Start a passkey registration ceremony for an authenticated user.
pub fn start_registration(&self, user: &User) -> DomainResult<CreationChallengeResponse> {
// Use the user's opaque, random user handle as the WebAuthn userHandle
// so that userless (discoverable) sign-in can resolve the owning user
// from the assertion's userHandle without exposing the numeric id.
let (challenge, state) = self
.webauthn
.generate_challenge_register(&user.display_name, true)
.generate_challenge_register_options(
user.user_handle.clone(),
user.email.clone(),
user.display_name.clone(),
None,
Some(UserVerificationPolicy::Required),
None,
)
.map_err(webauthn_error)?;
self.challenges
.lock()
@@ -127,11 +151,12 @@ impl WebAuthnService {
.await
}
/// Start a passkey authentication ceremony for a user.
/// Start a passkey authentication ceremony for a user identified by email.
/// Returns the challenge and a token used to correlate the finish request.
pub async fn start_authentication(
&self,
user_id: i64,
) -> DomainResult<RequestChallengeResponse> {
) -> DomainResult<(RequestChallengeResponse, String)> {
let passkeys = Arc::clone(&self.passkeys);
let db = self.db.clone();
let credentials: Vec<Credential> = db
@@ -156,28 +181,91 @@ impl WebAuthnService {
.webauthn
.generate_challenge_authenticate(credentials)
.map_err(webauthn_error)?;
let token = hex::encode(new_secret());
self.challenges
.lock()
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
.authentications
.insert(user_id, state);
Ok(challenge)
.insert(token.clone(), state);
Ok((challenge, token))
}
/// Finish a passkey authentication ceremony.
/// Start a userless passkey authentication ceremony. No email is required:
/// the authenticator selects a discoverable credential and returns a user
/// handle that we resolve to the owning user on finish.
pub async fn start_userless_authentication(
&self,
) -> DomainResult<(RequestChallengeResponse, String)> {
let (challenge, mut state) = self
.webauthn
.generate_challenge_authenticate_options(vec![], None)
.map_err(webauthn_error)?;
// With no allowCredentials the browser will offer any discoverable
// credential for this RP; the credential set is populated from the
// user handle once the assertion is received.
state.set_allowed_credentials(vec![]);
let token = hex::encode(new_secret());
self.challenges
.lock()
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
.authentications
.insert(token.clone(), state);
Ok((challenge, token))
}
/// Finish a passkey authentication ceremony, resolving the owning user from
/// the credential id (and, for userless ceremonies, the user handle).
pub async fn finish_authentication(
&self,
user_id: i64,
token: String,
response: PublicKeyCredential,
) -> DomainResult<()> {
let state = self
) -> DomainResult<i64> {
let mut state = self
.challenges
.lock()
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
.authentications
.remove(&user_id)
.remove(&token)
.ok_or(DomainError::NotFound)?;
// For userless ceremonies the assertion carries a user handle that
// identifies the user; load that user's credentials so the signature
// can be verified against the correct key.
if let Some(user_handle) = response.get_user_handle() {
let handle = user_handle.to_vec();
let users = Arc::clone(&self.users);
let db = self.db.clone();
let user_id = db
.run(move |txn| {
let users = users.clone();
Box::pin(async move {
let user = users
.find_user_by_handle(txn, handle)
.await?
.ok_or(DomainError::NotFound)?;
Ok(user.id)
})
})
.await?;
let passkeys = Arc::clone(&self.passkeys);
let credentials: Vec<Credential> = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
let rows = passkeys.list_for_user(txn, user_id).await?;
let mut creds = Vec::new();
for row in rows {
let cred: Credential = serde_json::from_str(&row.credential)
.map_err(|e| DomainError::Database(e.to_string()))?;
creds.push(cred);
}
Ok(creds)
})
})
.await?;
state.set_allowed_credentials(credentials);
}
let (cred_id, auth_data) = self
.webauthn
.authenticate_credential(&response, &state)
@@ -189,28 +277,30 @@ impl WebAuthnService {
let passkeys = Arc::clone(&self.passkeys);
let db = self.db.clone();
let credential_id_b64 = base64_url(cred_id);
db.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
let stored = passkeys
.find_by_credential_id(txn, credential_id_b64)
.await?
.ok_or(DomainError::NotFound)?;
let mut cred: Credential = serde_json::from_str(&stored.credential)
.map_err(|e| DomainError::Database(e.to_string()))?;
cred.counter = auth_data.counter;
let serialized = serde_json::to_string(&cred)
.map_err(|e| DomainError::Database(e.to_string()))?;
sqlx::query("UPDATE passkeys SET credential = ?1 WHERE id = ?2")
.bind(&serialized)
.bind(stored.id)
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(())
let user_id = db
.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
let stored = passkeys
.find_by_credential_id(txn, credential_id_b64)
.await?
.ok_or(DomainError::NotFound)?;
let mut cred: Credential = serde_json::from_str(&stored.credential)
.map_err(|e| DomainError::Database(e.to_string()))?;
cred.counter = auth_data.counter;
let serialized = serde_json::to_string(&cred)
.map_err(|e| DomainError::Database(e.to_string()))?;
sqlx::query("UPDATE passkeys SET credential = ?1 WHERE id = ?2")
.bind(&serialized)
.bind(stored.id)
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(stored.user_id)
})
})
})
.await
.await?;
Ok(user_id)
}
/// List the passkeys registered to a user.
@@ -234,27 +324,6 @@ impl WebAuthnService {
})
.await
}
/// Resolve the user id that owns the credential in an assertion response.
pub async fn resolve_user_id_for_assertion(
&self,
response: &PublicKeyCredential,
) -> DomainResult<i64> {
let passkeys = Arc::clone(&self.passkeys);
let db = self.db.clone();
let credential_id_b64 = base64_url(&response.raw_id.0);
db.run(move |txn| {
let passkeys = passkeys.clone();
Box::pin(async move {
let stored = passkeys
.find_by_credential_id(txn, credential_id_b64)
.await?
.ok_or(DomainError::NotFound)?;
Ok(stored.user_id)
})
})
.await
}
}
fn base64_url(bytes: &[u8]) -> String {