application: auth bounded context (register, login)
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -11,6 +11,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"domain",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/domain"]
|
members = ["crates/domain", "crates/application"]
|
||||||
exclude = ["k-tv-backend", "k-tv-frontend"]
|
exclude = ["k-tv-backend", "k-tv-frontend"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
13
crates/application/Cargo.toml
Normal file
13
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "application"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
|
tokio = { workspace = true }
|
||||||
11
crates/application/src/auth/commands.rs
Normal file
11
crates/application/src/auth/commands.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// Register a new local user.
|
||||||
|
pub struct RegisterCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log in with email + password.
|
||||||
|
pub struct LoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
14
crates/application/src/auth/deps.rs
Normal file
14
crates/application/src/auth/deps.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{AuthService, EventPublisher, UserCommand, UserQuery};
|
||||||
|
|
||||||
|
/// Dependencies for auth use cases.
|
||||||
|
///
|
||||||
|
/// Aggregates the ports required by register/login operations.
|
||||||
|
/// Built once at startup and shared via `Arc<AuthDeps>` or passed by reference.
|
||||||
|
pub struct AuthDeps {
|
||||||
|
pub user_command: Arc<dyn UserCommand>,
|
||||||
|
pub user_query: Arc<dyn UserQuery>,
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
38
crates/application/src/auth/login.rs
Normal file
38
crates/application/src/auth/login.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use domain::models::User;
|
||||||
|
use domain::{DomainError, DomainResult, Email};
|
||||||
|
|
||||||
|
use super::commands::LoginCommand;
|
||||||
|
use super::deps::AuthDeps;
|
||||||
|
|
||||||
|
/// Log in with email + password.
|
||||||
|
///
|
||||||
|
/// Flow: validate email -> find user -> verify password -> return User.
|
||||||
|
/// JWT generation belongs in the presentation layer, not here.
|
||||||
|
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<User> {
|
||||||
|
// Validate email format
|
||||||
|
let email = Email::new(&cmd.email)?;
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
let user = deps
|
||||||
|
.user_query
|
||||||
|
.find_by_email(email.as_ref())
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::unauthenticated("Invalid credentials"))?;
|
||||||
|
|
||||||
|
// Must have a password hash (not an OIDC-only user)
|
||||||
|
let hash = user
|
||||||
|
.password_hash()
|
||||||
|
.ok_or_else(|| DomainError::unauthenticated("Invalid credentials"))?;
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
let valid = deps.auth_service.verify_password(&cmd.password, hash)?;
|
||||||
|
if !valid {
|
||||||
|
return Err(DomainError::unauthenticated("Invalid credentials"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/login.rs"]
|
||||||
|
mod tests;
|
||||||
8
crates/application/src/auth/mod.rs
Normal file
8
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod login;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod register;
|
||||||
|
|
||||||
|
pub use commands::{LoginCommand, RegisterCommand};
|
||||||
|
pub use deps::AuthDeps;
|
||||||
1
crates/application/src/auth/queries.rs
Normal file
1
crates/application/src/auth/queries.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Auth queries (reserved for future use, e.g. GetCurrentUserQuery).
|
||||||
46
crates/application/src/auth/register.rs
Normal file
46
crates/application/src/auth/register.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::models::User;
|
||||||
|
use domain::{DomainResult, Email, Password};
|
||||||
|
|
||||||
|
use super::commands::RegisterCommand;
|
||||||
|
use super::deps::AuthDeps;
|
||||||
|
|
||||||
|
/// Register a new local user.
|
||||||
|
///
|
||||||
|
/// Flow: validate email/password -> check duplicate -> hash password ->
|
||||||
|
/// create User (first user gets admin) -> save -> publish event -> return User.
|
||||||
|
pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User> {
|
||||||
|
// Validate inputs via domain value objects
|
||||||
|
let email = Email::new(&cmd.email)?;
|
||||||
|
let password = Password::new(&cmd.password)?;
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
if deps.user_query.find_by_email(email.as_ref()).await?.is_some() {
|
||||||
|
return Err(domain::DomainError::UserAlreadyExists(cmd.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
let hash = deps.auth_service.hash_password(password.as_ref())?;
|
||||||
|
|
||||||
|
// Create user; first user gets admin
|
||||||
|
let mut user = User::new_local(email, hash);
|
||||||
|
if deps.user_query.count_users().await? == 0 {
|
||||||
|
user.promote_to_admin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
deps.user_command.save(&user).await?;
|
||||||
|
|
||||||
|
// Publish event
|
||||||
|
deps.event_publisher
|
||||||
|
.publish(DomainEvent::UserRegistered {
|
||||||
|
user_id: user.id(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/register.rs"]
|
||||||
|
mod tests;
|
||||||
164
crates/application/src/auth/tests/login.rs
Normal file
164
crates/application/src/auth/tests/login.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::errors::DomainResult;
|
||||||
|
use domain::ports::AuthService;
|
||||||
|
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
|
||||||
|
use domain::{DomainError, Email};
|
||||||
|
|
||||||
|
use crate::auth::commands::LoginCommand;
|
||||||
|
use crate::auth::deps::AuthDeps;
|
||||||
|
use crate::auth::login;
|
||||||
|
|
||||||
|
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
|
||||||
|
struct FakeAuthService;
|
||||||
|
|
||||||
|
impl AuthService for FakeAuthService {
|
||||||
|
fn hash_password(&self, password: &str) -> DomainResult<String> {
|
||||||
|
Ok(format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
|
||||||
|
Ok(hash == format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_deps_with_user(
|
||||||
|
email: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> (AuthDeps, Arc<InMemoryUserRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
|
||||||
|
// Seed a user
|
||||||
|
let e = Email::new(email).unwrap();
|
||||||
|
let user = domain::models::User::new_local(e, password_hash);
|
||||||
|
repo.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(user.id().value(), user);
|
||||||
|
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_succeeds_with_correct_credentials() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
|
||||||
|
|
||||||
|
let user = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user.email().as_ref(), "alice@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_with_wrong_password() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:correct");
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "wrong".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::Unauthenticated(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_for_unknown_email() {
|
||||||
|
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:pw");
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "nobody@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::Unauthenticated(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_for_oidc_only_user() {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
|
||||||
|
// Create an OIDC user (no password hash)
|
||||||
|
let email = Email::new("oidc@example.com").unwrap();
|
||||||
|
let user = domain::models::User::new("oidc|subject", email);
|
||||||
|
repo.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(user.id().value(), user);
|
||||||
|
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "oidc@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::Unauthenticated(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_rejects_invalid_email() {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&deps,
|
||||||
|
LoginCommand {
|
||||||
|
email: "not-an-email".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ValidationError(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
164
crates/application/src/auth/tests/register.rs
Normal file
164
crates/application/src/auth/tests/register.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::errors::DomainResult;
|
||||||
|
use domain::ports::AuthService;
|
||||||
|
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
|
||||||
|
use domain::{DomainError, Email};
|
||||||
|
|
||||||
|
use crate::auth::commands::RegisterCommand;
|
||||||
|
use crate::auth::deps::AuthDeps;
|
||||||
|
use crate::auth::register;
|
||||||
|
|
||||||
|
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
|
||||||
|
struct FakeAuthService;
|
||||||
|
|
||||||
|
impl AuthService for FakeAuthService {
|
||||||
|
fn hash_password(&self, password: &str) -> DomainResult<String> {
|
||||||
|
Ok(format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
|
||||||
|
Ok(hash == format!("hashed:{}", password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_deps() -> (AuthDeps, Arc<InMemoryUserRepository>) {
|
||||||
|
let repo = Arc::new(InMemoryUserRepository::new());
|
||||||
|
let deps = AuthDeps {
|
||||||
|
user_command: repo.clone(),
|
||||||
|
user_query: repo.clone(),
|
||||||
|
auth_service: Arc::new(FakeAuthService),
|
||||||
|
event_publisher: Arc::new(NoopEventPublisher::new()),
|
||||||
|
};
|
||||||
|
(deps, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registers_new_user() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
|
||||||
|
let user = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "alice@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user.email().as_ref(), "alice@example.com");
|
||||||
|
assert!(user.password_hash().unwrap().starts_with("hashed:"));
|
||||||
|
// First user gets admin
|
||||||
|
assert!(user.is_admin());
|
||||||
|
|
||||||
|
// Verify persisted
|
||||||
|
let stored = repo
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.cloned()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.id(), user.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn second_user_is_not_admin() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
// First user
|
||||||
|
register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "first@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second user
|
||||||
|
let user = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "second@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!user.is_admin());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_fails_for_duplicate_email() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
|
||||||
|
// Pre-populate with existing user
|
||||||
|
let email = Email::new("taken@example.com").unwrap();
|
||||||
|
let existing = domain::models::User::new_local(email, "existing_hash");
|
||||||
|
repo.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(existing.id().value(), existing);
|
||||||
|
|
||||||
|
let result = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "taken@example.com".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
DomainError::UserAlreadyExists(email) => {
|
||||||
|
assert_eq!(email, "taken@example.com");
|
||||||
|
}
|
||||||
|
other => panic!("expected UserAlreadyExists, got: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_invalid_email() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "not-an-email".into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ValidationError(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_short_password() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: "valid@example.com".into(),
|
||||||
|
password: "short".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
DomainError::ValidationError(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
1
crates/application/src/lib.rs
Normal file
1
crates/application/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
@@ -32,6 +32,8 @@ pub enum DomainEvent {
|
|||||||
ChannelUpdated { channel_id: ChannelId },
|
ChannelUpdated { channel_id: ChannelId },
|
||||||
/// A channel was deleted.
|
/// A channel was deleted.
|
||||||
ChannelDeleted { channel_id: ChannelId },
|
ChannelDeleted { channel_id: ChannelId },
|
||||||
|
/// A new user was registered.
|
||||||
|
UserRegistered { user_id: crate::value_objects::UserId },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ impl User {
|
|||||||
pub fn created_at(&self) -> DateTime<Utc> {
|
pub fn created_at(&self) -> DateTime<Utc> {
|
||||||
self.created_at
|
self.created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Mutations --
|
||||||
|
|
||||||
|
/// Promote this user to admin.
|
||||||
|
pub fn promote_to_admin(&mut self) {
|
||||||
|
self.is_admin = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user