diff --git a/migrations/0002_auth.sql b/migrations/0002_auth.sql new file mode 100644 index 0000000..df765ad --- /dev/null +++ b/migrations/0002_auth.sql @@ -0,0 +1,20 @@ +-- migrate:up +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_used_at TEXT, + revoked_at TEXT +); + +CREATE INDEX idx_tokens_user_id ON tokens(user_id); +CREATE INDEX idx_tokens_token_hash ON tokens(token_hash); diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 46b73e4..34318ea 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -5,6 +5,8 @@ pub mod repositories; pub mod roasters; pub mod roasts; pub mod timeline; +pub mod tokens; +pub mod users; use std::fmt::Display; use thiserror::Error; diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs index 3aa7006..4aedbef 100644 --- a/src/domain/repositories.rs +++ b/src/domain/repositories.rs @@ -6,6 +6,8 @@ use crate::domain::roasters::{Roaster, UpdateRoaster}; use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; +use crate::domain::tokens::{Token, TokenId}; +use crate::domain::users::{User, UserId}; use async_trait::async_trait; #[async_trait] @@ -76,3 +78,21 @@ pub trait TimelineEventRepository: Send + Sync { Ok(page.items) } } + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn insert(&self, user: User) -> Result; + async fn get(&self, id: UserId) -> Result; + async fn get_by_username(&self, username: &str) -> Result; + async fn exists(&self) -> Result; +} + +#[async_trait] +pub trait TokenRepository: Send + Sync { + async fn insert(&self, token: Token) -> Result; + async fn get(&self, id: TokenId) -> Result; + async fn get_by_token_hash(&self, token_hash: &str) -> Result; + async fn list_by_user(&self, user_id: UserId) -> Result, RepositoryError>; + async fn revoke(&self, id: TokenId) -> Result; + async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError>; +} diff --git a/src/domain/tokens.rs b/src/domain/tokens.rs new file mode 100644 index 0000000..ffa2738 --- /dev/null +++ b/src/domain/tokens.rs @@ -0,0 +1,52 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::users::UserId; + +pub type TokenId = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Token { + pub id: TokenId, + pub user_id: UserId, + #[serde(skip_serializing)] + pub token_hash: String, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NewToken { + pub user_id: UserId, + pub name: String, +} + +impl Token { + pub fn new( + id: TokenId, + user_id: UserId, + token_hash: String, + name: String, + created_at: DateTime, + ) -> Self { + Self { + id, + user_id, + token_hash, + name, + created_at, + last_used_at: None, + revoked_at: None, + } + } + + pub fn is_revoked(&self) -> bool { + self.revoked_at.is_some() + } + + pub fn is_active(&self) -> bool { + !self.is_revoked() + } +} diff --git a/src/domain/users.rs b/src/domain/users.rs new file mode 100644 index 0000000..5b86cf5 --- /dev/null +++ b/src/domain/users.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +pub type UserId = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: UserId, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NewUser { + pub username: String, + pub password: String, +} + +impl User { + pub fn new(id: UserId, username: String, password_hash: String, created_at: DateTime) -> Self { + Self { + id, + username, + password_hash, + created_at, + } + } +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index db0bc1f..f23b2af 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,3 +1,5 @@ pub mod roasters; pub mod roasts; pub mod timeline_events; +pub mod tokens; +pub mod users; diff --git a/src/infrastructure/repositories/tokens.rs b/src/infrastructure/repositories/tokens.rs new file mode 100644 index 0000000..f50d43f --- /dev/null +++ b/src/infrastructure/repositories/tokens.rs @@ -0,0 +1,147 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::query_as; + +use crate::domain::RepositoryError; +use crate::domain::repositories::TokenRepository; +use crate::domain::tokens::{Token, TokenId}; +use crate::domain::users::UserId; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlTokenRepository { + pool: DatabasePool, +} + +impl SqlTokenRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn to_domain(record: TokenRecord) -> Result { + let TokenRecord { + id, + user_id, + token_hash, + name, + created_at, + last_used_at, + revoked_at, + } = record; + + Ok(Token { + id, + user_id, + token_hash, + name, + created_at, + last_used_at, + revoked_at, + }) + } +} + +#[async_trait] +impl TokenRepository for SqlTokenRepository { + async fn insert(&self, token: Token) -> Result { + let query = "INSERT INTO tokens (id, user_id, token_hash, name, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; + + sqlx::query(query) + .bind(&token.id) + .bind(&token.user_id) + .bind(&token.token_hash) + .bind(&token.name) + .bind(&token.created_at) + .bind(&token.last_used_at) + .bind(&token.revoked_at) + .execute(&self.pool) + .await + .map_err(|err| { + if let sqlx::Error::Database(db_err) = &err { + if db_err.is_unique_violation() { + return RepositoryError::conflict("token already exists"); + } + } + RepositoryError::unexpected(err.to_string()) + })?; + + Ok(token) + } + + async fn get(&self, id: TokenId) -> Result { + let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE id = ?"; + + let record = query_as::<_, TokenRecord>(query) + .bind(&id) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Self::to_domain(record) + } + + async fn get_by_token_hash(&self, token_hash: &str) -> Result { + let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE token_hash = ?"; + + let record = query_as::<_, TokenRecord>(query) + .bind(token_hash) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Self::to_domain(record) + } + + async fn list_by_user(&self, user_id: UserId) -> Result, RepositoryError> { + let query = "SELECT id, user_id, token_hash, name, created_at, last_used_at, revoked_at FROM tokens WHERE user_id = ? ORDER BY created_at DESC"; + + let records = query_as::<_, TokenRecord>(query) + .bind(&user_id) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + records.into_iter().map(Self::to_domain).collect() + } + + async fn revoke(&self, id: TokenId) -> Result { + let query = "UPDATE tokens SET revoked_at = ? WHERE id = ?"; + let now = Utc::now(); + + sqlx::query(query) + .bind(&now) + .bind(&id) + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + self.get(id).await + } + + async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError> { + let query = "UPDATE tokens SET last_used_at = ? WHERE id = ?"; + let now = Utc::now(); + + sqlx::query(query) + .bind(&now) + .bind(&id) + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct TokenRecord { + id: TokenId, + user_id: UserId, + token_hash: String, + name: String, + created_at: DateTime, + last_used_at: Option>, + revoked_at: Option>, +} diff --git a/src/infrastructure/repositories/users.rs b/src/infrastructure/repositories/users.rs new file mode 100644 index 0000000..ccd0f36 --- /dev/null +++ b/src/infrastructure/repositories/users.rs @@ -0,0 +1,100 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::query_as; + +use crate::domain::RepositoryError; +use crate::domain::repositories::UserRepository; +use crate::domain::users::{User, UserId}; +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) -> Result { + let UserRecord { + id, + username, + password_hash, + created_at, + } = record; + + Ok(User::new(id, username, password_hash, created_at)) + } +} + +#[async_trait] +impl UserRepository for SqlUserRepository { + async fn insert(&self, user: User) -> Result { + let query = "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)"; + + sqlx::query(query) + .bind(&user.id) + .bind(&user.username) + .bind(&user.password_hash) + .bind(&user.created_at) + .execute(&self.pool) + .await + .map_err(|err| { + if let sqlx::Error::Database(db_err) = &err { + if db_err.is_unique_violation() { + return RepositoryError::conflict("user already exists"); + } + } + RepositoryError::unexpected(err.to_string()) + })?; + + Ok(user) + } + + async fn get(&self, id: UserId) -> Result { + let query = "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"; + + let record = query_as::<_, UserRecord>(query) + .bind(&id) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Self::to_domain(record) + } + + async fn get_by_username(&self, username: &str) -> Result { + let query = "SELECT id, username, password_hash, 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)?; + + Self::to_domain(record) + } + + async fn exists(&self) -> Result { + 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) + } +} + +#[derive(sqlx::FromRow)] +struct UserRecord { + id: UserId, + username: String, + password_hash: String, + created_at: DateTime, +}