feat(domain): add auth database schema and domain models
Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
parent
5aaeaa55c2
commit
ca5d25ea10
8 changed files with 373 additions and 0 deletions
20
migrations/0002_auth.sql
Normal file
20
migrations/0002_auth.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -5,6 +5,8 @@ pub mod repositories;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
pub mod tokens;
|
||||||
|
pub mod users;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ use crate::domain::roasters::{Roaster, UpdateRoaster};
|
||||||
use crate::domain::roasts::RoastSortKey;
|
use crate::domain::roasts::RoastSortKey;
|
||||||
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
|
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
|
||||||
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
|
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
|
||||||
|
use crate::domain::tokens::{Token, TokenId};
|
||||||
|
use crate::domain::users::{User, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -76,3 +78,21 @@ pub trait TimelineEventRepository: Send + Sync {
|
||||||
Ok(page.items)
|
Ok(page.items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserRepository: Send + Sync {
|
||||||
|
async fn insert(&self, user: User) -> Result<User, RepositoryError>;
|
||||||
|
async fn get(&self, id: UserId) -> Result<User, RepositoryError>;
|
||||||
|
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError>;
|
||||||
|
async fn exists(&self) -> Result<bool, RepositoryError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TokenRepository: Send + Sync {
|
||||||
|
async fn insert(&self, token: Token) -> Result<Token, RepositoryError>;
|
||||||
|
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError>;
|
||||||
|
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError>;
|
||||||
|
async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError>;
|
||||||
|
async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError>;
|
||||||
|
async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError>;
|
||||||
|
}
|
||||||
|
|
|
||||||
52
src/domain/tokens.rs
Normal file
52
src/domain/tokens.rs
Normal file
|
|
@ -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<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/domain/users.rs
Normal file
30
src/domain/users.rs
Normal file
|
|
@ -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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Utc>) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
pub mod timeline_events;
|
pub mod timeline_events;
|
||||||
|
pub mod tokens;
|
||||||
|
pub mod users;
|
||||||
|
|
|
||||||
147
src/infrastructure/repositories/tokens.rs
Normal file
147
src/infrastructure/repositories/tokens.rs
Normal file
|
|
@ -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<Token, RepositoryError> {
|
||||||
|
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<Token, RepositoryError> {
|
||||||
|
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<Token, RepositoryError> {
|
||||||
|
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<Token, RepositoryError> {
|
||||||
|
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<Vec<Token>, 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<Token, RepositoryError> {
|
||||||
|
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<Utc>,
|
||||||
|
last_used_at: Option<DateTime<Utc>>,
|
||||||
|
revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
100
src/infrastructure/repositories/users.rs
Normal file
100
src/infrastructure/repositories/users.rs
Normal file
|
|
@ -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<User, RepositoryError> {
|
||||||
|
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<User, RepositoryError> {
|
||||||
|
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<User, RepositoryError> {
|
||||||
|
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<User, RepositoryError> {
|
||||||
|
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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct UserRecord {
|
||||||
|
id: UserId,
|
||||||
|
username: String,
|
||||||
|
password_hash: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue