lighty_auth/
azuriom.rs

1// Copyright (c) 2025 Hamadi
2// Licensed under the MIT License
3
4//! Azuriom CMS authentication
5//!
6//! Supports authentication via the Azuriom API with:
7//! - Email/password login
8//! - Two-factor authentication (2FA)
9//! - Token verification
10//! - Logout
11
12use crate::{Authenticator, AuthError, AuthResult, UserProfile, UserRole};
13use lighty_core::hosts::HTTP_CLIENT as CLIENT;
14use serde::Deserialize;
15
16#[cfg(feature = "events")]
17use lighty_event::{EventBus, Event, AuthEvent};
18
19/// Azuriom authenticator
20///
21/// Authenticates users via an Azuriom CMS instance.
22///
23/// # Example
24/// ```no_run
25/// use lighty_auth::azuriom::AzuriomAuth;
26/// use lighty_auth::Authenticator;
27///
28/// #[tokio::main]
29/// async fn main() {
30///     let mut auth = AzuriomAuth::new(
31///         "https://example.com",
32///         "user@example.com",
33///         "password123"
34///     );
35///
36///     let profile = auth.authenticate().await.unwrap();
37///     println!("Logged in as: {}", profile.username);
38/// }
39/// ```
40pub struct AzuriomAuth {
41    base_url: String,
42    email: String,
43    password: String,
44    two_factor_code: Option<String>,
45}
46
47impl AzuriomAuth {
48    /// Create a new Azuriom authenticator
49    ///
50    /// # Arguments
51    /// - `base_url`: Base URL of the Azuriom instance (e.g., "https://example.com")
52    /// - `email`: User email address
53    /// - `password`: User password
54    pub fn new(base_url: impl Into<String>, email: impl Into<String>, password: impl Into<String>) -> Self {
55        Self {
56            base_url: base_url.into().trim_end_matches('/').to_string(),
57            email: email.into(),
58            password: password.into(),
59            two_factor_code: None,
60        }
61    }
62
63    /// Set the 2FA code (call this if authentication returns `TwoFactorRequired`)
64    ///
65    /// # Arguments
66    /// - `code`: The 2FA code from the authenticator app
67    pub fn set_two_factor_code(&mut self, code: impl Into<String>) {
68        self.two_factor_code = Some(code.into());
69    }
70
71    /// Clear the 2FA code
72    pub fn clear_two_factor_code(&mut self) {
73        self.two_factor_code = None;
74    }
75}
76
77
78/// Azuriom API response for successful authentication
79#[derive(Debug, Deserialize)]
80struct AzuriomAuthResponse {
81    id: u64,
82    username: String,
83    uuid: String,
84    access_token: String,
85    email_verified: Option<bool>,
86    money: Option<f64>,
87    role: Option<AzuriomRole>,
88    banned: Option<bool>,
89}
90
91/// Azuriom role information
92#[derive(Debug, Deserialize)]
93struct AzuriomRole {
94    name: String,
95    color: Option<String>,
96}
97
98/// Azuriom API error response
99#[derive(Debug, Deserialize)]
100struct AzuriomErrorResponse {
101    status: String, // Always "error"
102    reason: String,
103    message: String,
104}
105impl Authenticator for AzuriomAuth {
106    async fn authenticate(
107        &mut self,
108        #[cfg(feature = "events")] event_bus: Option<&EventBus>,
109    ) -> AuthResult<UserProfile> {
110        let url = format!("{}/api/auth/authenticate", self.base_url);
111        lighty_core::trace_debug!(url = %url, email = %self.email, "Authenticating with Azuriom");
112
113        // Emit authentication started
114        #[cfg(feature = "events")]
115        if let Some(bus) = event_bus {
116            bus.emit(Event::Auth(AuthEvent::AuthenticationStarted {
117                provider: "Azuriom".to_string(),
118            }));
119        }
120
121        // Build request body
122        let mut body = serde_json::json!({
123            "email": self.email,
124            "password": self.password,
125        });
126
127        // Add 2FA code if provided
128        if let Some(code) = &self.two_factor_code {
129            body["code"] = serde_json::json!(code);
130        }
131
132        // Send request
133        let response = CLIENT
134            .post(&url)
135            .json(&body)
136            .send()
137            .await?;
138
139        let status = response.status();
140        let response_text = response.text().await?;
141
142        // Parse response
143        if status.is_success() {
144            let azuriom_response: AzuriomAuthResponse = serde_json::from_str(&response_text)
145                .map_err(|e| AuthError::InvalidResponse(format!("Failed to parse response: {}", e)))?;
146
147            // Check if account is banned
148            if azuriom_response.banned.unwrap_or(false) {
149                lighty_core::trace_error!(username = %azuriom_response.username, "Account is banned");
150                #[cfg(feature = "events")]
151                if let Some(bus) = event_bus {
152                    bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
153                        provider: "Azuriom".to_string(),
154                        error: "Account is banned".to_string(),
155                    }));
156                }
157                return Err(AuthError::AccountBanned(
158                    azuriom_response.username.clone()
159                ));
160            }
161
162            lighty_core::trace_info!(username = %azuriom_response.username, uuid = %azuriom_response.uuid, "Successfully authenticated");
163
164            // Emit authentication success
165            #[cfg(feature = "events")]
166            if let Some(bus) = event_bus {
167                bus.emit(Event::Auth(AuthEvent::AuthenticationSuccess {
168                    provider: "Azuriom".to_string(),
169                    username: azuriom_response.username.clone(),
170                    uuid: azuriom_response.uuid.clone(),
171                }));
172            }
173
174            Ok(UserProfile {
175                id: Some(azuriom_response.id),
176                username: azuriom_response.username,
177                uuid: azuriom_response.uuid,
178                access_token: Some(azuriom_response.access_token),
179                email: Some(self.email.clone()),
180                email_verified: azuriom_response.email_verified.unwrap_or(true),
181                money: azuriom_response.money,
182                role: azuriom_response.role.map(|r| UserRole {
183                    name: r.name,
184                    color: r.color,
185                }),
186                banned: azuriom_response.banned.unwrap_or(false),
187            })
188        } else {
189            // Parse error response
190            let error_response: AzuriomErrorResponse = serde_json::from_str(&response_text)
191                .map_err(|_| AuthError::InvalidResponse(format!("HTTP {}: {}", status, response_text)))?;
192
193            lighty_core::trace_error!(reason = %error_response.reason, message = %error_response.message, "Authentication failed");
194
195            let error = match error_response.reason.as_str() {
196                "invalid_credentials" => AuthError::InvalidCredentials,
197                "requires_2fa" => AuthError::TwoFactorRequired,
198                "invalid_2fa" => AuthError::Invalid2FACode,
199                "email_not_verified" => AuthError::EmailNotVerified,
200                "banned" => AuthError::AccountBanned(String::new()),
201                _ => AuthError::Custom(error_response.message.clone()),
202            };
203
204            // Emit authentication failed
205            #[cfg(feature = "events")]
206            if let Some(bus) = event_bus {
207                bus.emit(Event::Auth(AuthEvent::AuthenticationFailed {
208                    provider: "Azuriom".to_string(),
209                    error: error_response.message,
210                }));
211            }
212
213            Err(error)
214        }
215    }
216
217    async fn verify(&self, token: &str) -> AuthResult<UserProfile> {
218        let url = format!("{}/api/auth/verify", self.base_url);
219        lighty_core::trace_debug!(url = %url, "Verifying token");
220
221        let response = CLIENT
222            .post(&url)
223            .json(&serde_json::json!({
224                "access_token": token
225            }))
226            .send()
227            .await?;
228
229        let status = response.status();
230        let response_text = response.text().await?;
231
232        if status.is_success() {
233            let azuriom_response: AzuriomAuthResponse = serde_json::from_str(&response_text)
234                .map_err(|e| AuthError::InvalidResponse(format!("Failed to parse response: {}", e)))?;
235
236            lighty_core::trace_info!(username = %azuriom_response.username, "Token verified successfully");
237
238            Ok(UserProfile {
239                id: Some(azuriom_response.id),
240                username: azuriom_response.username,
241                uuid: azuriom_response.uuid,
242                access_token: Some(azuriom_response.access_token),
243                email: None, // Not returned by verify endpoint
244                email_verified: azuriom_response.email_verified.unwrap_or(true),
245                money: azuriom_response.money,
246                role: azuriom_response.role.map(|r| UserRole {
247                    name: r.name,
248                    color: r.color,
249                }),
250                banned: azuriom_response.banned.unwrap_or(false),
251            })
252        } else {
253            lighty_core::trace_error!(status = %status, "Token verification failed");
254            Err(AuthError::InvalidToken)
255        }
256    }
257
258    async fn logout(&self, token: &str) -> AuthResult<()> {
259        let url = format!("{}/api/auth/logout", self.base_url);
260        lighty_core::trace_debug!(url = %url, "Logging out");
261
262        let response = CLIENT
263            .post(&url)
264            .json(&serde_json::json!({
265                "access_token": token
266            }))
267            .send()
268            .await?;
269
270        if response.status().is_success() {
271            lighty_core::trace_info!("Successfully logged out");
272            Ok(())
273        } else {
274            lighty_core::trace_error!(status = %response.status(), "Logout failed");
275            Err(AuthError::InvalidToken)
276        }
277    }
278}
279
280