Skip to main content

shared/application/auth/
user_service.rs

1use crate::error::Result;
2
3use crate::domain::model::{
4    Account, AccountStatus, CreateUserOutcome, LoginRequest, RegisterRequest, User,
5};
6use crate::intern::auth::AuthService;
7
8use super::support;
9use super::tracker::LoginAttemptTracker;
10
11impl AuthService {
12    #[tracing::instrument(
13        name = "auth.authenticate_user", skip(self, body, device_cookie),
14        fields(
15            attempts.remaining = tracing::field::Empty
16        )
17    )]
18    pub async fn authenticate_user(
19        &self,
20        body: &LoginRequest,
21        device_cookie: Option<&str>,
22    ) -> Result<(User, Account, AccountStatus, u8)> {
23        let user_repo = &self.user_repository;
24
25        // 1. ALWAYS verify password (constant-time even with fake hash)
26        let app_state = self;
27        let (target_user, target_account) =
28            support::resolve_user_with_password(&body.email, app_state).await?;
29        let password_valid = self
30            .crypto
31            .password_hasher
32            .verify(&body.password, &target_account.password)?;
33        let user_id = target_user.id()?;
34
35        // 3.
36        let tracker = LoginAttemptTracker::new(&self.crypto);
37        let identity = tracker.resolve_identity(device_cookie, &body.email);
38        let lockout_key = tracker.resolve_lockout_key(device_cookie, &body.email);
39
40        // FIXME
41        // 4. Use cache_service instead of user_service (more readable)
42        if user_repo.is_locked(&lockout_key).await {
43            tracing::warn!(
44                user.id = %user_id,
45                error.code = "ForbiddenReason::AccountSuspended",
46                "Login blocked — account is locked"
47            );
48            return Ok((
49                target_user.clone(),
50                target_account.clone(),
51                AccountStatus::Suspended,
52                0,
53            ));
54        }
55
56        // 5.
57        tracing::Span::current().record("user.id", user_id);
58        let (account_status, attempts) = match password_valid {
59            true => {
60                let _ = user_repo.clear_key(&identity).await;
61                (AccountStatus::Active, 0)
62            }
63            _ => {
64                let attempts = self
65                    .register_failed_attempt(&identity, device_cookie)
66                    .await
67                    .unwrap_or(1);
68
69                let remaining = &self
70                    .configuration
71                    .security
72                    .auth
73                    .max_failed_attempts
74                    .saturating_sub(attempts);
75
76                tracing::Span::current().record("attempts.remaining", remaining);
77                (AccountStatus::InvalidCredentials, attempts)
78            }
79        };
80
81        Ok((
82            target_user.clone(),
83            target_account.clone(),
84            account_status,
85            attempts,
86        ))
87    }
88
89    async fn register_failed_attempt(
90        &self,
91        identity: &str,
92        device_cookie: Option<&str>,
93    ) -> Result<u8> {
94        let user_repo = &self.user_repository;
95
96        let auth_security = &self.configuration.security.auth;
97        let expiry = auth_security.lockout_duration as u64;
98
99        let attempts = user_repo.increment(identity, expiry).await;
100
101        // max_failed_attempts of authentication within for this specific cookie
102        let max_failed_attempts = match device_cookie {
103            Some(_) => auth_security.max_failed_attempts * 2,
104            None => auth_security.max_failed_attempts,
105        };
106        if attempts >= max_failed_attempts {
107            user_repo.put_cookie_in_lockout(identity, expiry).await?;
108            tracing::warn!("Authentication Failed — update lockout countdown");
109        }
110
111        Ok(attempts)
112    }
113
114    #[tracing::instrument(
115        name = "auth.create_user",
116        skip(self, body),
117        fields(user.id = tracing::field::Empty)
118    )]
119    pub async fn create_user(&self, body: RegisterRequest) -> Result<CreateUserOutcome> {
120        // let mut session = self.transaction_repository.start_transactions().await?;
121
122        // 1. Find if user already exist
123        let app_state = self;
124        let (_, user_exist) = support::resolve_user(&body.email, app_state).await?;
125
126        // 2. Hash user password
127        let password = self.crypto.password_hasher.hash(&body.password)?;
128
129        if user_exist {
130            tracing::warn!(
131                email = %body.email,
132                error.code = "ConflictReason::AlreadyExists",
133                "Registeration failed — user already exists"
134            );
135            return Ok(CreateUserOutcome::AlreadyExists);
136        }
137
138        let mut user = User::new()
139            .with_username(&body.username)
140            .with_email(&body.email);
141
142        // let user_id: String = self.user_service.insert(&user, Some(&mut session)).await?;
143        let user_id: String = self.user_repository.insert(&user).await?;
144        user.with_id(&user_id);
145
146        let account = Account::user(&user_id).with_password(&password);
147        self.account_repository.insert(account).await?;
148
149        // self.transaction_repository
150        //     .commit_transaction(session)
151        //     .await?;
152
153        tracing::info!(user.id = %user_id, "User created successfully");
154        Ok(CreateUserOutcome::Created(user))
155    }
156
157    #[tracing::instrument(
158        name = "auth.find_user_by_email",
159        skip(self),
160        fields(user.email = email)
161    )]
162    pub async fn find_user_by_email(&self, email: &str) -> Result<User> {
163        self.user_repository.find_by_email(email).await
164    }
165
166    #[tracing::instrument(
167        name = "auth.find_user",
168        skip(self),
169        fields(user.id = id)
170    )]
171    pub async fn find_user(&self, id: &str) -> Result<User> {
172        self.user_repository.find(id).await
173    }
174}