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)
123 lines
3.7 KiB
Rust
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>,
|
|
}
|