e94063d96a
Password login ┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L1 │ Login by username │ auth_login.hurl Case 1 │ 200 + access_token, user.email match │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L2 │ Login by email (dispatch on @) │ auth_login.hurl Case 2 │ 200, same session shape as L1 │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L3 │ Bad password on username path │ auth_login.hurl Case 3 │ 403 anti-enum │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L4 │ Bad password on email path │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L5 │ Unknown username │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L6 │ Unknown email │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L7 │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │ └─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘ Password registration ┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R1 │ Classic username + email + password → uniform 200 │ registration.hurl Step 2 │ anti-enum message contains "request received" │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R2 │ Login after register works │ registration.hurl Step 2b │ 200 + session for the new user │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R3 │ Email collision → uniform 200 (no rewrite) │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R4 │ Username collision → uniform 200 │ registration.hurl Step 11 │ same anti-enum shape │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R5 │ Off-domain rejection │ registration.hurl Step 12 │ 403 RegistrationDomainNotAllowed │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R6 │ Case-insensitive domain match │ registration.hurl Step 12b │ uniform 200 on charlie@EXAMPLE.COM │ └─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘ Magic-link registration (email-only signup) ┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR1 │ Email-only signup → welcome mail queued │ registration.hurl Step 3 │ uniform 200 + browser-binding cookie set │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR2 │ Welcome mail contains magic-link URL │ registration.hurl Step 4 │ captured from mock SMTP │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR3 │ PR 22 cross-browser confirmation page │ registration.hurl Step 5a │ 200 HTML "different browser" │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR4 │ Cookie-bound redemption lands on SPA │ registration.hurl Step 5b │ 302 → /files (SvelteKit route, post-migration) │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR5 │ email_verified_at stamped after redemption │ registration.hurl Step 6 │ field present on /api/auth/me │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR6 │ Second magic-link post-signup │ registration.hurl Step 7 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle │ └─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ Magic-link login (existing account) ┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML1 │ Baseline password login still works │ auth_magic_link_login.hurl Steps 1-2 │ 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML2 │ magic-link/send with email identifier │ auth_magic_link_login.hurl Step 3 │ uniform 200 + cookie │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML4 │ Password-user policy: mail actually sent │ auth_magic_link_login.hurl Step 5 │ SMTP capture proves permit_magic_link_for_password_users in effect │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML5 │ Redemption creates a session │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML6 │ Anti-enum on unknown identifier │ auth_magic_link_login.hurl Step 8 │ same uniform 200 shape as ML3 │ └─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘ OIDC ┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O1 │ Setup local admin (bootstrap) │ oidc.hurl Step 1 │ 201 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2 │ Providers endpoint — OIDC visible │ oidc.hurl Step 2 │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2b │ Magic-link/send refused (endpoint layer) │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O3 │ Authorize redirect includes PKCE + state │ oidc.hurl Step 3 │ 307 to fake IdP │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O4 │ IdP round-trip + JIT provisioning │ oidc.hurl Step 4 │ Callback lands on /login?oidc_code=… │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O5 │ Code exchange → session cookies │ oidc.hurl Step 5 │ 200 + all three cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O6 │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6 │ every claim reflected on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O7 │ Refresh rotation on OIDC session │ oidc.hurl Step 7 │ new access/refresh/CSRF cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O8 │ Refreshed cookies authenticate │ oidc.hurl Step 8 │ 200 on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O9 │ Repeat login = same local user (no dup) │ oidc.hurl Step 9 │ user_id stable │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O10 │ Anti-takeover: unverified email → refused │ oidc.hurl Step 10 │ 401/403 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O11 │ One-time code replay refused │ oidc.hurl Step 11 │ second /exchange → 401 │ └─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ test
112 lines
4.6 KiB
Rust
112 lines
4.6 KiB
Rust
use sqlx::PgPool;
|
|
use std::sync::Arc;
|
|
|
|
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
|
|
|
use crate::application::services::auth_application_service::AuthApplicationService;
|
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
|
use crate::common::config::AppConfig;
|
|
use crate::common::di::AuthServices;
|
|
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
|
use crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository;
|
|
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
|
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
|
use crate::infrastructure::services::oidc_service::OidcService;
|
|
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
|
|
|
pub async fn create_auth_services(
|
|
config: &AppConfig,
|
|
pool: Arc<PgPool>,
|
|
user_lifecycle: Arc<UserLifecycleService>,
|
|
) -> Result<AuthServices> {
|
|
// Create JWT token service (TokenServicePort implementation)
|
|
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
|
|
config.auth.jwt_secret.clone(),
|
|
config.auth.access_token_expiry_secs,
|
|
config.auth.refresh_token_expiry_secs,
|
|
));
|
|
|
|
// Create password hashing service with configured Argon2id parameters
|
|
let password_hasher = Arc::new(Argon2PasswordHasher::new(
|
|
config.auth.hash_memory_cost,
|
|
config.auth.hash_time_cost,
|
|
config.auth.hash_parallelism,
|
|
));
|
|
|
|
// Create PostgreSQL repositories
|
|
let user_repository = Arc::new(UserPgRepository::new(pool.clone()));
|
|
let session_repository = Arc::new(SessionPgRepository::new(pool.clone()));
|
|
|
|
// Create authentication application service
|
|
let mut auth_app_service = AuthApplicationService::new(
|
|
user_repository,
|
|
session_repository,
|
|
password_hasher,
|
|
token_service.clone(),
|
|
config.storage_path.clone(),
|
|
);
|
|
|
|
// Wire the user-lifecycle dispatcher. Home-folder provisioning is
|
|
// now handled by PersonalDriveLifecycleHook (registered on the
|
|
// dispatcher in DI) — AuthApplicationService no longer needs a
|
|
// direct FolderService dependency for that path.
|
|
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
|
|
|
// Wire the auth-method allowlist + email-verification requirement so
|
|
// login / magic-link / register handlers consult a single snapshot
|
|
// rather than reaching into the app config on every call.
|
|
auth_app_service = auth_app_service.with_auth_policy(
|
|
config.auth.allowed_auth_methods.clone(),
|
|
config.auth.require_verified_email,
|
|
);
|
|
|
|
// Wire the magic-link token repo. Enables `GET /magic/v1/{token}`
|
|
// and the future `POST /api/auth/magic-link/send` endpoint to mint
|
|
// and consume tokens. The repo is unconditional (it's just SQL on
|
|
// an empty table when the feature is dormant); the feature kill
|
|
// switch lives in `config.magic_link.allow_external_users`, checked
|
|
// by the issuance side, not by the redemption side.
|
|
let magic_link_repo: Arc<dyn MagicLinkTokenRepository> =
|
|
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
|
|
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
|
|
|
|
// Configure OIDC service if enabled
|
|
if config.oidc.enabled {
|
|
tracing::info!(
|
|
"Initializing OIDC service (provider: {}, issuer: {})",
|
|
config.oidc.provider_name,
|
|
config.oidc.issuer_url
|
|
);
|
|
|
|
let oidc_service = Arc::new(OidcService::new(config.oidc.clone()));
|
|
auth_app_service = auth_app_service.with_oidc(oidc_service, config.oidc.clone());
|
|
|
|
if config.oidc.disable_password_login {
|
|
tracing::warn!("Password login is DISABLED — only OIDC authentication is allowed");
|
|
}
|
|
}
|
|
|
|
// Package service in Arc
|
|
let auth_application_service = Arc::new(auth_app_service);
|
|
|
|
// Account lockout service — in-memory brute-force protection
|
|
let login_lockout = Arc::new(
|
|
crate::infrastructure::services::login_lockout_service::LoginLockoutService::new(
|
|
config.auth.rate_limit.lockout_max_failures,
|
|
config.auth.rate_limit.lockout_duration_secs,
|
|
100_000, // Track up to 100k accounts concurrently
|
|
),
|
|
);
|
|
tracing::info!(
|
|
"Login lockout service initialized: max {} failures, {}s lockout",
|
|
config.auth.rate_limit.lockout_max_failures,
|
|
config.auth.rate_limit.lockout_duration_secs,
|
|
);
|
|
|
|
Ok(AuthServices {
|
|
token_service,
|
|
auth_application_service,
|
|
login_lockout,
|
|
})
|
|
}
|