Skip to main content

auth_framework/api/
users.rs

1//! User Management API Endpoints
2//!
3//! Handles user profile, password changes, and related user operations
4
5use crate::api::{ApiResponse, ApiState, extract_bearer_token, validate_api_token};
6use axum::{
7    Json,
8    extract::{Path, State},
9    http::HeaderMap,
10};
11use serde::{Deserialize, Serialize};
12
13/// Full user profile returned by the `/users/profile` and `/users/:id` endpoints.
14#[derive(Debug, Serialize)]
15pub struct UserProfile {
16    /// Unique user identifier.
17    pub id: String,
18    pub username: String,
19    pub email: String,
20    pub first_name: Option<String>,
21    pub last_name: Option<String>,
22    /// Role names assigned to this user.
23    pub roles: crate::types::Roles,
24    /// Effective permissions derived from roles and direct grants.
25    pub permissions: crate::types::Permissions,
26    /// Whether multi-factor authentication is enrolled.
27    pub mfa_enabled: bool,
28    /// Whether the user's email address has been verified.
29    pub email_verified: bool,
30    /// ISO-8601 creation timestamp.
31    pub created_at: String,
32    /// ISO-8601 last-modification timestamp.
33    pub updated_at: String,
34}
35
36/// Profile update request. Only the supplied fields are modified.
37#[derive(Debug, Deserialize)]
38pub struct UpdateProfileRequest {
39    #[serde(default)]
40    pub first_name: Option<String>,
41    #[serde(default)]
42    pub last_name: Option<String>,
43    /// New email address (must not already be registered to another user).
44    #[serde(default)]
45    pub email: Option<String>,
46}
47
48/// Password change request.
49///
50/// Both fields are required.  The server will verify `current_password` before
51/// applying the change, and will validate `new_password` against the same
52/// complexity rules used during registration.
53#[derive(Debug, Deserialize)]
54pub struct ChangePasswordRequest {
55    pub current_password: String,
56    pub new_password: String,
57}
58
59/// `GET /users/profile` — fetch the authenticated user's own profile.
60pub async fn get_profile(
61    State(state): State<ApiState>,
62    headers: HeaderMap,
63) -> ApiResponse<UserProfile> {
64    match extract_bearer_token(&headers) {
65        Some(token) => {
66            match validate_api_token(&state.auth_framework, &token).await {
67                Ok(auth_token) => {
68                    // Fetch actual user profile from storage
69                    match state
70                        .auth_framework
71                        .get_user_profile(&auth_token.user_id)
72                        .await
73                    {
74                        Ok(user_profile) => {
75                            // Check MFA status from AuthFramework
76                            let mfa_enabled =
77                                check_user_mfa_status(&state.auth_framework, &auth_token.user_id)
78                                    .await;
79
80                            // Extract first_name and last_name from the name field if available
81                            let (first_name, last_name) = if let Some(name) = &user_profile.name {
82                                let parts: Vec<&str> = name.split_whitespace().collect();
83                                if parts.len() >= 2 {
84                                    (Some(parts[0].to_string()), Some(parts[1..].join(" ")))
85                                } else if parts.len() == 1 {
86                                    (Some(parts[0].to_string()), None)
87                                } else {
88                                    (None, None)
89                                }
90                            } else {
91                                (None, None)
92                            };
93
94                            let profile = UserProfile {
95                                id: auth_token.user_id.clone(),
96                                username: user_profile
97                                    .username
98                                    .unwrap_or_else(|| format!("user_{}", auth_token.user_id)),
99                                email: user_profile.email.unwrap_or_default(),
100                                first_name,
101                                last_name,
102                                roles: auth_token.roles,
103                                permissions: auth_token.permissions,
104                                mfa_enabled,
105                                email_verified: user_profile.email_verified.unwrap_or(false),
106                                created_at: user_profile
107                                    .additional_data
108                                    .get("created_at")
109                                    .and_then(|v| v.as_str())
110                                    .unwrap_or("")
111                                    .to_string(),
112                                updated_at: user_profile
113                                    .additional_data
114                                    .get("updated_at")
115                                    .and_then(|v| v.as_str())
116                                    .unwrap_or("")
117                                    .to_string(),
118                            };
119
120                            ApiResponse::success(profile)
121                        }
122                        Err(e) => {
123                            tracing::warn!(
124                                "Failed to fetch user profile for user {}: {}",
125                                auth_token.user_id,
126                                e
127                            );
128                            // M-7: Return an error instead of a fabricated profile so callers
129                            // cannot mistake placeholder data for real user information.
130                            ApiResponse::error_typed(
131                                "PROFILE_UNAVAILABLE",
132                                "User profile could not be retrieved; please try again",
133                            )
134                        }
135                    }
136                }
137                Err(_e) => ApiResponse::error_typed("USER_ERROR", "User operation failed"),
138            }
139        }
140        None => ApiResponse::<UserProfile>::unauthorized_typed(),
141    }
142}
143
144/// `PUT /users/profile` — update the authenticated user's profile fields.
145pub async fn update_profile(
146    State(state): State<ApiState>,
147    headers: HeaderMap,
148    Json(req): Json<UpdateProfileRequest>,
149) -> ApiResponse<UserProfile> {
150    match extract_bearer_token(&headers) {
151        Some(token) => {
152            match validate_api_token(&state.auth_framework, &token).await {
153                Ok(auth_token) => {
154                    // Validate email format before storing to ensure consistency with
155                    // the public registration endpoint.
156                    if let Some(ref email) = req.email
157                        && crate::utils::validation::validate_email(email).is_err()
158                    {
159                        return ApiResponse::validation_error_typed("Invalid email format");
160                    }
161
162                    // Enforce length limits on name fields to prevent storage abuse.
163                    if req.first_name.as_deref().is_some_and(|n| n.len() > 100) {
164                        return ApiResponse::validation_error_typed(
165                            "First name must be 100 characters or fewer",
166                        );
167                    }
168                    if req.last_name.as_deref().is_some_and(|n| n.len() > 100) {
169                        return ApiResponse::validation_error_typed(
170                            "Last name must be 100 characters or fewer",
171                        );
172                    }
173
174                    // Persist updated profile to storage
175                    let storage = state.auth_framework.storage();
176                    let user_key = format!("user:{}", auth_token.user_id);
177                    let current_data = storage.get_kv(&user_key).await.ok().flatten();
178                    let mut user_json: serde_json::Value = current_data
179                        .and_then(|b| serde_json::from_slice(&b).ok())
180                        .unwrap_or_else(|| serde_json::json!({}));
181
182                    // Maintain the email reverse-lookup index so duplicate-email
183                    // detection at registration keeps working after an email change.
184                    if let Some(ref new_email) = req.email {
185                        let old_email = user_json["email"].as_str().unwrap_or("").to_string();
186                        if old_email != *new_email {
187                            // SECURITY: Verify the new email is not already claimed
188                            // by another user before updating the index.
189                            let new_email_key = format!("user:email:{}", new_email);
190                            if let Ok(Some(existing)) = storage.get_kv(&new_email_key).await {
191                                let owner = String::from_utf8_lossy(&existing).to_string();
192                                if owner != auth_token.user_id {
193                                    return ApiResponse::error_typed(
194                                        "CONFLICT",
195                                        "Email address is already in use",
196                                    );
197                                }
198                            }
199                            // Delete the old mapping.
200                            if !old_email.is_empty() {
201                                let _ = storage
202                                    .delete_kv(&format!("user:email:{}", old_email))
203                                    .await;
204                            }
205                            // Write the new email → user_id mapping.
206                            let _ = storage
207                                .store_kv(&new_email_key, auth_token.user_id.as_bytes(), None)
208                                .await;
209                        }
210                        user_json["email"] = serde_json::json!(new_email);
211                    }
212                    let name = match (&req.first_name, &req.last_name) {
213                        (Some(f), Some(l)) => Some(format!("{} {}", f, l)),
214                        (Some(f), None) => Some(f.clone()),
215                        (None, Some(l)) => Some(l.clone()),
216                        (None, None) => None,
217                    };
218                    if let Some(ref n) = name {
219                        user_json["name"] = serde_json::json!(n);
220                    }
221                    user_json["updated_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
222
223                    if let Ok(serialized) = serde_json::to_vec(&user_json) {
224                        let _ = storage.store_kv(&user_key, &serialized, None).await;
225                    }
226
227                    tracing::info!("Profile updated for user: {}", auth_token.user_id);
228
229                    // Read back the stored values to build an accurate response.
230                    let (stored_username, stored_email, stored_created_at) = {
231                        let fresh = storage.get_kv(&user_key).await.ok().flatten();
232                        let j: serde_json::Value = fresh
233                            .and_then(|b| serde_json::from_slice(&b).ok())
234                            .unwrap_or_default();
235                        (
236                            j["username"].as_str().map(|s| s.to_string()),
237                            j["email"].as_str().unwrap_or("").to_string(),
238                            j["created_at"].as_str().unwrap_or("").to_string(),
239                        )
240                    };
241
242                    // Return updated profile response
243                    let updated_profile = UserProfile {
244                        id: auth_token.user_id.clone(),
245                        username: stored_username
246                            .unwrap_or_else(|| format!("user_{}", auth_token.user_id)),
247                        email: stored_email,
248                        first_name: req.first_name,
249                        last_name: req.last_name,
250                        roles: auth_token.roles,
251                        permissions: auth_token.permissions,
252                        mfa_enabled: check_user_mfa_status(
253                            &state.auth_framework,
254                            &auth_token.user_id,
255                        )
256                        .await,
257                        email_verified: user_json["email_verified"].as_bool().unwrap_or(false),
258                        created_at: stored_created_at,
259                        updated_at: user_json["updated_at"].as_str().unwrap_or("").to_string(),
260                    };
261
262                    ApiResponse::success(updated_profile)
263                }
264                Err(_e) => ApiResponse::error_typed("USER_ERROR", "User operation failed"),
265            }
266        }
267        None => ApiResponse::<UserProfile>::unauthorized_typed(),
268    }
269}
270
271/// `POST /users/change-password` — change the authenticated user's password.
272///
273/// The current password must be verified before the new one is accepted.
274pub async fn change_password(
275    State(state): State<ApiState>,
276    headers: HeaderMap,
277    Json(req): Json<ChangePasswordRequest>,
278) -> ApiResponse<()> {
279    if req.current_password.is_empty() || req.new_password.is_empty() {
280        return ApiResponse::validation_error("Current password and new password are required");
281    }
282
283    // Enforce the same password complexity requirements as registration.
284    if let Err(e) = crate::utils::validation::validate_password(&req.new_password) {
285        return ApiResponse::validation_error_typed(format!("{e}"));
286    }
287
288    // Check new password against known data breaches (HIBP k-anonymity API)
289    match crate::utils::breach_check::is_password_breached(&req.new_password).await {
290        Ok(true) => {
291            return ApiResponse::validation_error(
292                "This password has appeared in a known data breach. Please choose a different password.",
293            );
294        }
295        Ok(false) => {}
296        Err(_) => {} // HIBP API unreachable; fail open
297    }
298
299    match extract_bearer_token(&headers) {
300        Some(token) => {
301            match validate_api_token(&state.auth_framework, &token).await {
302                Ok(auth_token) => {
303                    // Verify current password against stored hash
304                    match state
305                        .auth_framework
306                        .verify_user_password(&auth_token.user_id, &req.current_password)
307                        .await
308                    {
309                        Ok(true) => {}
310                        Ok(false) => {
311                            return ApiResponse::validation_error("Current password is incorrect");
312                        }
313                        Err(_) => {
314                            // Return the same generic message regardless of error type
315                            // to avoid distinguishing wrong-password from storage errors.
316                            return ApiResponse::validation_error("Current password is incorrect");
317                        }
318                    }
319
320                    // Get the username (update_user_password takes username)
321                    let username = match state
322                        .auth_framework
323                        .get_username_by_id(&auth_token.user_id)
324                        .await
325                    {
326                        Ok(u) => u,
327                        Err(e) => return ApiResponse::<()>::from(e),
328                    };
329
330                    match state
331                        .auth_framework
332                        .update_user_password(&username, &req.new_password)
333                        .await
334                    {
335                        Ok(()) => {
336                            tracing::info!("Password changed for user: {}", auth_token.user_id);
337                            ApiResponse::<()>::ok_with_message("Password changed successfully")
338                        }
339                        Err(e) => ApiResponse::<()>::from(e),
340                    }
341                }
342                Err(e) => ApiResponse::<()>::from(e),
343            }
344        }
345        None => ApiResponse::<()>::unauthorized(),
346    }
347}
348
349/// `GET /users/{user_id}/profile` — fetch another user's profile (admin only).
350pub async fn get_user_profile(
351    State(state): State<ApiState>,
352    headers: HeaderMap,
353    Path(user_id): Path<String>,
354) -> ApiResponse<UserProfile> {
355    match extract_bearer_token(&headers) {
356        Some(token) => {
357            match validate_api_token(&state.auth_framework, &token).await {
358                Ok(auth_token) => {
359                    if !auth_token.roles.contains(&"admin".to_string()) {
360                        return ApiResponse::<UserProfile>::forbidden_typed();
361                    }
362
363                    match state.auth_framework.get_user_profile(&user_id).await {
364                        Ok(user_profile) => {
365                            // Load roles from the canonical user record (same
366                            // pattern as validate_api_token) rather than hard-coding [].
367                            // Load both roles and permissions from the canonical user
368                            // record so the admin profile view stays consistent with
369                            // what validate_api_token returns for the user's own token.
370                            let user_kv_bytes = {
371                                let uk = format!("user:{}", user_id);
372                                state
373                                    .auth_framework
374                                    .storage()
375                                    .get_kv(&uk)
376                                    .await
377                                    .ok()
378                                    .flatten()
379                            };
380                            let user_kv_json: serde_json::Value = user_kv_bytes
381                                .as_deref()
382                                .and_then(|b| serde_json::from_slice(b).ok())
383                                .unwrap_or_default();
384
385                            let profile_roles: Vec<String> = user_kv_json["roles"]
386                                .as_array()
387                                .map(|a| {
388                                    a.iter()
389                                        .filter_map(|v| v.as_str().map(String::from))
390                                        .collect()
391                                })
392                                .unwrap_or_default();
393
394                            let profile_permissions: Vec<String> = user_kv_json["permissions"]
395                                .as_array()
396                                .map(|a| {
397                                    a.iter()
398                                        .filter_map(|v| v.as_str().map(String::from))
399                                        .collect()
400                                })
401                                .unwrap_or_default();
402
403                            let (first_name, last_name) = if let Some(name) = &user_profile.name {
404                                let parts: Vec<&str> = name.split_whitespace().collect();
405                                if parts.len() >= 2 {
406                                    (Some(parts[0].to_string()), Some(parts[1..].join(" ")))
407                                } else if parts.len() == 1 {
408                                    (Some(parts[0].to_string()), None)
409                                } else {
410                                    (None, None)
411                                }
412                            } else {
413                                (None, None)
414                            };
415
416                            let profile = UserProfile {
417                                id: user_id.clone(),
418                                username: user_profile
419                                    .username
420                                    .unwrap_or_else(|| format!("user_{}", user_id)),
421                                email: user_profile.email.unwrap_or_default(),
422                                first_name,
423                                last_name,
424                                roles: profile_roles.into(),
425                                permissions: profile_permissions.into(),
426                                mfa_enabled: user_profile
427                                    .additional_data
428                                    .get("mfa_enabled")
429                                    .and_then(|v| v.as_bool())
430                                    .unwrap_or(false),
431                                email_verified: user_kv_json["email_verified"]
432                                    .as_bool()
433                                    .unwrap_or(false),
434                                created_at: user_profile
435                                    .additional_data
436                                    .get("created_at")
437                                    .and_then(|v| v.as_str())
438                                    .unwrap_or("")
439                                    .to_string(),
440                                updated_at: user_profile
441                                    .additional_data
442                                    .get("updated_at")
443                                    .and_then(|v| v.as_str())
444                                    .unwrap_or("")
445                                    .to_string(),
446                            };
447                            ApiResponse::success(profile)
448                        }
449                        Err(e) => {
450                            let error_response = ApiResponse::<()>::from(e);
451                            ApiResponse::<UserProfile> {
452                                success: error_response.success,
453                                data: None,
454                                error: error_response.error,
455                                message: error_response.message,
456                            }
457                        }
458                    }
459                }
460                Err(_e) => ApiResponse::error_typed("USER_ERROR", "User operation failed"),
461            }
462        }
463        None => ApiResponse::<UserProfile>::unauthorized_typed(),
464    }
465}
466
467/// `GET /users/sessions` — list the authenticated user's active sessions.
468pub async fn get_sessions(
469    State(state): State<ApiState>,
470    headers: HeaderMap,
471) -> ApiResponse<Vec<SessionInfo>> {
472    match extract_bearer_token(&headers) {
473        Some(token) => match validate_api_token(&state.auth_framework, &token).await {
474            Ok(auth_token) => {
475                let storage = state.auth_framework.storage();
476                match storage.list_user_sessions(&auth_token.user_id).await {
477                    Ok(sessions) => {
478                        let session_list: Vec<SessionInfo> = sessions
479                            .into_iter()
480                            .filter(|s| !s.is_expired())
481                            .map(|s| SessionInfo {
482                                id: s.session_id.clone(),
483                                device: s.user_agent.unwrap_or_default(),
484                                location: String::new(),
485                                ip_address: s.ip_address.unwrap_or_default(),
486                                created_at: s.created_at.to_rfc3339(),
487                                last_active: s.last_activity.to_rfc3339(),
488                                is_current: false,
489                            })
490                            .collect();
491                        ApiResponse::success(session_list)
492                    }
493                    Err(_e) => {
494                        ApiResponse::error_typed("SESSION_ERROR", "Failed to retrieve sessions")
495                    }
496                }
497            }
498            Err(_e) => ApiResponse::error_typed("USER_ERROR", "Session operation failed"),
499        },
500        None => ApiResponse::<Vec<SessionInfo>>::unauthorized_typed(),
501    }
502}
503
504/// Summary of an active browser/device session.
505#[derive(Debug, Serialize)]
506pub struct SessionInfo {
507    /// Unique session identifier.
508    pub id: String,
509    /// User-Agent string from the session’s originating request.
510    pub device: String,
511    /// Approximate geo-location derived from the client IP (may be empty).
512    pub location: String,
513    /// Client IP address recorded at session creation.
514    pub ip_address: String,
515    /// ISO-8601 timestamp of session creation.
516    pub created_at: String,
517    /// ISO-8601 timestamp of the most recent request in this session.
518    pub last_active: String,
519    /// `true` if this session belongs to the current request.
520    pub is_current: bool,
521}
522
523/// `DELETE /users/sessions/{session_id}` — revoke a specific session.
524///
525/// Only the session owner can revoke it; attempting to revoke another user's
526/// session returns `FORBIDDEN`.
527pub async fn revoke_session(
528    State(state): State<ApiState>,
529    headers: HeaderMap,
530    Path(session_id): Path<String>,
531) -> ApiResponse<()> {
532    match extract_bearer_token(&headers) {
533        Some(token) => {
534            match validate_api_token(&state.auth_framework, &token).await {
535                Ok(auth_token) => {
536                    let storage = state.auth_framework.storage();
537
538                    // SECURITY: Verify that the session belongs to the authenticated user
539                    // before deleting it; without this check any authenticated user can
540                    // terminate any other user's session by guessing the session_id.
541                    match storage.get_session(&session_id).await {
542                        Ok(Some(ref session)) if session.user_id == auth_token.user_id => {}
543                        Ok(Some(_)) => {
544                            return ApiResponse::<()>::error_typed(
545                                "FORBIDDEN",
546                                "You do not have permission to revoke this session",
547                            );
548                        }
549                        Ok(None) => {
550                            return ApiResponse::<()>::error_typed(
551                                "NOT_FOUND",
552                                "Session not found",
553                            );
554                        }
555                        Err(e) => return ApiResponse::<()>::from(e),
556                    }
557
558                    match storage.delete_session(&session_id).await {
559                        Ok(()) => {
560                            tracing::info!("Revoked session: {}", session_id);
561                            ApiResponse::<()>::ok_with_message("Session revoked successfully")
562                        }
563                        Err(e) => ApiResponse::<()>::from(e),
564                    }
565                }
566                Err(e) => ApiResponse::<()>::from(e),
567            }
568        }
569        None => ApiResponse::<()>::unauthorized(),
570    }
571}
572
573/// Helper function for MFA status integration.
574///
575/// Delegates to the canonical implementation in [`crate::api::mfa`] which
576/// checks the `mfa_enabled:{user_id}` KV key written by the MFA verification
577/// flow.
578async fn check_user_mfa_status(
579    auth_framework: &std::sync::Arc<crate::AuthFramework>,
580    user_id: &str,
581) -> bool {
582    crate::api::mfa::check_user_mfa_status(auth_framework, user_id).await
583}