brewlog/src/infrastructure/repositories/users.rs
Jon Seager 03e03d87d9
feat(auth): replace password auth with WebAuthn passkeys
Replace username/password authentication with FIDO2/WebAuthn passkey-based
auth using webauthn-rs. Sessions and bearer tokens are unchanged — only the
way they are created changes.

- Add webauthn-rs, uuid, open, url deps; remove argon2, rpassword
- Add passkey_credentials and registration_tokens tables (migrations 17-18)
- Add domain entities, typed IDs, and repository traits for passkeys/tokens
- Add SQL repository implementations for passkeys and registration tokens
- Add ChallengeStore for in-memory WebAuthn ceremony state
- Add WebAuthn route handlers (register/auth start+finish ceremonies)
- Add CLI browser handoff for token creation (opens browser, local callback)
- Replace login form with "Sign in with Passkey" button
- Add registration page for first-user bootstrap via one-time token
- Replace BREWLOG_ADMIN_USERNAME/PASSWORD with BREWLOG_RP_ID/RP_ORIGIN
- Change default BREWLOG_URL from 127.0.0.1 to localhost (WebAuthn requires it)
2026-02-05 11:00:07 +00:00

123 lines
3.7 KiB
Rust

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::query_as;
use crate::domain::RepositoryError;
use crate::domain::ids::UserId;
use crate::domain::repositories::UserRepository;
use crate::domain::users::{NewUser, User};
use crate::infrastructure::database::DatabasePool;
#[derive(Clone)]
pub struct SqlUserRepository {
pool: DatabasePool,
}
impl SqlUserRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn to_domain(record: UserRecord) -> User {
let UserRecord {
id,
username,
uuid,
created_at,
} = record;
User::new(UserId::from(id), username, uuid, created_at)
}
}
#[async_trait]
impl UserRepository for SqlUserRepository {
async fn insert(&self, user: NewUser) -> Result<User, RepositoryError> {
let query = "INSERT INTO users (username, uuid) VALUES (?, ?) RETURNING id, username, uuid, created_at";
let record = sqlx::query_as::<_, UserRecord>(query)
.bind(&user.username)
.bind(&user.uuid)
.fetch_one(&self.pool)
.await
.map_err(|err| {
if let sqlx::Error::Database(db_err) = &err
&& db_err.is_unique_violation()
{
return RepositoryError::conflict("user already exists");
}
RepositoryError::unexpected(err.to_string())
})?;
Ok(Self::to_domain(record))
}
async fn get(&self, id: UserId) -> Result<User, RepositoryError> {
let query = "SELECT id, username, uuid, created_at FROM users WHERE id = ?";
let record = query_as::<_, UserRecord>(query)
.bind(i64::from(id))
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record))
}
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> {
let query = "SELECT id, username, uuid, created_at FROM users WHERE username = ?";
let record = query_as::<_, UserRecord>(query)
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record))
}
async fn get_by_uuid(&self, uuid: &str) -> Result<User, RepositoryError> {
let query = "SELECT id, username, uuid, created_at FROM users WHERE uuid = ?";
let record = query_as::<_, UserRecord>(query)
.bind(uuid)
.fetch_optional(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record))
}
async fn exists(&self) -> Result<bool, RepositoryError> {
let query = "SELECT COUNT(*) FROM users";
let count: i64 = sqlx::query_scalar(query)
.fetch_one(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(count > 0)
}
async fn list_all(&self) -> Result<Vec<User>, RepositoryError> {
let query = "SELECT id, username, uuid, created_at FROM users ORDER BY created_at ASC";
let records = query_as::<_, UserRecord>(query)
.fetch_all(&self.pool)
.await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(records.into_iter().map(Self::to_domain).collect())
}
}
#[derive(sqlx::FromRow)]
struct UserRecord {
id: i64,
username: String,
uuid: String,
created_at: DateTime<Utc>,
}