feat(api): implement user authentication and registration endpoints
- Add main application logic in `api/src/main.rs` to initialize server, database, and services. - Create authentication routes in `api/src/routes/auth.rs` for login, register, logout, and user info retrieval. - Implement configuration route in `api/src/routes/config.rs` to expose application settings. - Define application state management in `api/src/state.rs` to share user service and configuration. - Set up Docker Compose configuration in `compose.yml` for backend, worker, and database services. - Establish domain logic in `domain` crate with user entities, repositories, and services. - Implement SQLite user repository in `infra/src/user_repository.rs` for user data persistence. - Create database migration handling in `infra/src/db.rs` and session store in `infra/src/session_store.rs`. - Add necessary dependencies and features in `Cargo.toml` files for both `domain` and `infra` crates.
This commit is contained in:
18
domain/Cargo.toml
Normal file
18
domain/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
async-trait = "0.1.89"
|
||||
chrono = { version = "0.4.42", features = ["serde"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.146"
|
||||
thiserror = "2.0.17"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||
futures-core = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros"] }
|
||||
64
domain/src/entities.rs
Normal file
64
domain/src/entities.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Domain entities
|
||||
//!
|
||||
//! This module contains pure domain types with no I/O dependencies.
|
||||
//! These represent the core business concepts of the application.
|
||||
|
||||
pub use crate::value_objects::{Email, UserId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A user in the system.
|
||||
///
|
||||
/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub subject: String,
|
||||
pub email: Email,
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn new(subject: impl Into<String>, email: Email) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
subject: subject.into(),
|
||||
email,
|
||||
password_hash: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_id(
|
||||
id: Uuid,
|
||||
subject: impl Into<String>,
|
||||
email: Email,
|
||||
password_hash: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
subject: subject.into(),
|
||||
email,
|
||||
password_hash,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
subject: format!("local|{}", Uuid::new_v4()),
|
||||
email,
|
||||
password_hash: Some(password_hash.into()),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to get email as string
|
||||
pub fn email_str(&self) -> &str {
|
||||
self.email.as_ref()
|
||||
}
|
||||
}
|
||||
67
domain/src/errors.rs
Normal file
67
domain/src/errors.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! Domain errors for K-Notes
|
||||
//!
|
||||
//! Uses `thiserror` for ergonomic error definitions.
|
||||
//! These errors represent domain-level failures and will be mapped
|
||||
//! to HTTP status codes in the API layer.
|
||||
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Domain-level errors for K-Notes operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DomainError {
|
||||
/// The requested user was not found
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(Uuid),
|
||||
|
||||
/// User with this email/subject already exists
|
||||
#[error("User already exists: {0}")]
|
||||
UserAlreadyExists(String),
|
||||
|
||||
/// A validation error occurred
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
/// User is not authorized to perform this action
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
/// A repository/infrastructure error occurred
|
||||
#[error("Repository error: {0}")]
|
||||
RepositoryError(String),
|
||||
|
||||
/// An infrastructure adapter error occurred
|
||||
#[error("Infrastructure error: {0}")]
|
||||
InfrastructureError(String),
|
||||
}
|
||||
|
||||
impl DomainError {
|
||||
/// Create a validation error
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self::ValidationError(message.into())
|
||||
}
|
||||
|
||||
/// Create an unauthorized error
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::Unauthorized(message.into())
|
||||
}
|
||||
|
||||
/// Check if this error indicates a "not found" condition
|
||||
pub fn is_not_found(&self) -> bool {
|
||||
matches!(self, DomainError::UserNotFound(_))
|
||||
}
|
||||
|
||||
/// Check if this error indicates a conflict (already exists)
|
||||
pub fn is_conflict(&self) -> bool {
|
||||
matches!(self, DomainError::UserAlreadyExists(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::value_objects::ValidationError> for DomainError {
|
||||
fn from(error: crate::value_objects::ValidationError) -> Self {
|
||||
DomainError::ValidationError(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for domain operations
|
||||
pub type DomainResult<T> = Result<T, DomainError>;
|
||||
17
domain/src/lib.rs
Normal file
17
domain/src/lib.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! Domain Logic
|
||||
//!
|
||||
//! This crate contains the core business logic, entities, and repository interfaces.
|
||||
//! It is completely independent of the infrastructure layer (databases, HTTP, etc.).
|
||||
|
||||
pub mod entities;
|
||||
pub mod errors;
|
||||
pub mod repositories;
|
||||
pub mod services;
|
||||
pub mod value_objects;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use entities::*;
|
||||
pub use errors::{DomainError, DomainResult};
|
||||
pub use repositories::*;
|
||||
pub use services::UserService;
|
||||
pub use value_objects::*;
|
||||
28
domain/src/repositories.rs
Normal file
28
domain/src/repositories.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Reference Repository ports (traits)
|
||||
//!
|
||||
//! These traits define the interface for data persistence.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::User;
|
||||
use crate::errors::DomainResult;
|
||||
|
||||
/// Repository port for User persistence
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
/// Find a user by their internal ID
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Find a user by their OIDC subject (used for authentication)
|
||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Find a user by their email
|
||||
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Save a new user or update an existing one
|
||||
async fn save(&self, user: &User) -> DomainResult<()>;
|
||||
|
||||
/// Delete a user by their ID
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()>;
|
||||
}
|
||||
57
domain/src/services.rs
Normal file
57
domain/src/services.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! Domain Services
|
||||
//!
|
||||
//! Services contain the business logic of the application.
|
||||
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::User;
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::repositories::UserRepository;
|
||||
use crate::value_objects::Email;
|
||||
|
||||
/// Service for managing users
|
||||
pub struct UserService {
|
||||
user_repository: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(user_repository: Arc<dyn UserRepository>) -> Self {
|
||||
Self { user_repository }
|
||||
}
|
||||
|
||||
pub async fn find_or_create(&self, subject: &str, email: &str) -> DomainResult<User> {
|
||||
// 1. Try to find by subject (OIDC id)
|
||||
if let Some(user) = self.user_repository.find_by_subject(subject).await? {
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
// 2. Try to find by email
|
||||
if let Some(mut user) = self.user_repository.find_by_email(email).await? {
|
||||
// Link subject if missing (account linking logic)
|
||||
if user.subject != subject {
|
||||
user.subject = subject.to_string();
|
||||
self.user_repository.save(&user).await?;
|
||||
}
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
// 3. Create new user
|
||||
let email = Email::try_from(email)?;
|
||||
let user = User::new(subject, email);
|
||||
self.user_repository.save(&user).await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: Uuid) -> DomainResult<User> {
|
||||
self.user_repository
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::UserNotFound(id))
|
||||
}
|
||||
|
||||
pub async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
|
||||
self.user_repository.find_by_email(email).await
|
||||
}
|
||||
}
|
||||
242
domain/src/value_objects.rs
Normal file
242
domain/src/value_objects.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
//! Value Objects for K-Notes Domain
|
||||
//!
|
||||
//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern.
|
||||
//! These types can only be constructed if the input is valid, providing compile-time guarantees.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type UserId = Uuid;
|
||||
|
||||
// ============================================================================
|
||||
// Validation Error
|
||||
// ============================================================================
|
||||
|
||||
/// Errors that occur when parsing/validating value objects
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
pub enum ValidationError {
|
||||
#[error("Invalid email format: {0}")]
|
||||
InvalidEmail(String),
|
||||
|
||||
#[error("Password must be at least {min} characters, got {actual}")]
|
||||
PasswordTooShort { min: usize, actual: usize },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email
|
||||
// ============================================================================
|
||||
|
||||
/// A validated email address.
|
||||
///
|
||||
/// Simple validation: must contain exactly one `@` with non-empty parts on both sides.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Email(String);
|
||||
|
||||
impl Email {
|
||||
/// Minimum validation: contains @ with non-empty local and domain parts
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||
let value = value.into();
|
||||
let trimmed = value.trim().to_lowercase();
|
||||
|
||||
// Basic email validation
|
||||
let parts: Vec<&str> = trimmed.split('@').collect();
|
||||
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
|
||||
return Err(ValidationError::InvalidEmail(value));
|
||||
}
|
||||
|
||||
// Domain must contain at least one dot
|
||||
if !parts[1].contains('.') {
|
||||
return Err(ValidationError::InvalidEmail(value));
|
||||
}
|
||||
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
/// Get the inner value
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Email {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Email {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Email {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Email {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Email {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Email {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password
|
||||
// ============================================================================
|
||||
|
||||
/// A validated password input (NOT the hash).
|
||||
///
|
||||
/// Enforces minimum length of 6 characters.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
/// Minimum password length
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 6;
|
||||
|
||||
impl Password {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
||||
let value = value.into();
|
||||
|
||||
if value.len() < MIN_PASSWORD_LENGTH {
|
||||
return Err(ValidationError::PasswordTooShort {
|
||||
min: MIN_PASSWORD_LENGTH,
|
||||
actual: value.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Password {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally hide password content in Debug
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Password(***)")
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Password {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Password {
|
||||
type Error = ValidationError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Password {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::new(s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod email_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(Email::new("user@example.com").is_ok());
|
||||
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
|
||||
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_normalizes() {
|
||||
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||
assert_eq!(email.as_ref(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_at() {
|
||||
assert!(Email::new("userexample.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_domain() {
|
||||
assert!(Email::new("user@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_local() {
|
||||
assert!(Email::new("@example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_dot_in_domain() {
|
||||
assert!(Email::new("user@localhost").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod password_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_password() {
|
||||
assert!(Password::new("secret123").is_ok());
|
||||
assert!(Password::new("123456").is_ok()); // Exactly 6 chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_too_short() {
|
||||
assert!(Password::new("12345").is_err()); // 5 chars
|
||||
assert!(Password::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_debug_hides_content() {
|
||||
let password = Password::new("supersecret").unwrap();
|
||||
let debug = format!("{:?}", password);
|
||||
assert!(!debug.contains("supersecret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user