Skip to main content

solid_pod_rs_idp/
password_change.rs

1//! `PUT /idp/credentials` — self-service password change.
2//!
3//! Authenticated users change their own password by supplying the current
4//! password and a new password. Rate-limited via the core crate's
5//! `RateLimiter` trait to mitigate brute-force guessing of the current
6//! password.
7
8use std::net::IpAddr;
9
10use argon2::password_hash::SaltString;
11use argon2::{Argon2, PasswordHasher};
12use rand::rngs::OsRng;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use solid_pod_rs::security::rate_limit::{
17    RateLimitDecision, RateLimitKey, RateLimitSubject, RateLimiter,
18};
19
20use crate::credentials::{validate_password_length, MIN_PASSWORD_LENGTH};
21use crate::user_store::{UserStore, UserStoreError};
22
23/// Rate-limit route name for password-change requests.
24pub const RATE_LIMIT_ROUTE: &str = "idp_password_change";
25
26/// Errors surfaced by [`change_password`].
27#[derive(Debug, Error)]
28pub enum PasswordChangeError {
29    /// Request is rate-limited.
30    #[error("rate limited, retry after {retry_after_secs}s")]
31    RateLimited { retry_after_secs: u64 },
32
33    /// Current password verification failed.
34    #[error("invalid current password")]
35    InvalidCurrentPassword,
36
37    /// New password does not meet the minimum length requirement.
38    #[error("new password must be at least {min_length} characters")]
39    PasswordTooShort { min_length: usize },
40
41    /// Input validation failed.
42    #[error("invalid request: {0}")]
43    InvalidRequest(String),
44
45    /// Backend failure.
46    #[error("user store: {0}")]
47    UserStore(String),
48
49    /// Hashing failure.
50    #[error("password hash: {0}")]
51    Hash(String),
52}
53
54/// Request body for `PUT /idp/credentials`.
55#[derive(Debug, Deserialize)]
56pub struct PasswordChangeRequest {
57    pub current_password: String,
58    pub new_password: String,
59}
60
61/// Success response for `PUT /idp/credentials`.
62#[derive(Debug, Clone, Serialize)]
63pub struct PasswordChangeResponse {
64    pub message: String,
65}
66
67/// Change the authenticated user's password.
68///
69/// - `user_id` — the authenticated user's internal ID (extracted from
70///   the session or access token by the transport layer).
71/// - `req` — the deserialized request body.
72/// - `user_store` — backing store.
73/// - `limiter` + `ip` — rate-limit enforcement.
74#[allow(clippy::too_many_arguments)]
75pub async fn change_password(
76    user_id: &str,
77    req: &PasswordChangeRequest,
78    user_store: &dyn UserStore,
79    limiter: &dyn RateLimiter,
80    ip: IpAddr,
81) -> Result<PasswordChangeResponse, PasswordChangeError> {
82    // --- rate-limit gate ---
83    let key = RateLimitKey {
84        route: RATE_LIMIT_ROUTE,
85        subject: RateLimitSubject::Ip(ip),
86    };
87    match limiter.check(&key).await {
88        RateLimitDecision::Allow => {}
89        RateLimitDecision::Deny {
90            retry_after_secs, ..
91        } => return Err(PasswordChangeError::RateLimited { retry_after_secs }),
92    }
93
94    // --- input validation ---
95    if req.current_password.is_empty() || req.new_password.is_empty() {
96        return Err(PasswordChangeError::InvalidRequest(
97            "current_password and new_password are required".into(),
98        ));
99    }
100
101    validate_password_length(&req.new_password).map_err(|_| {
102        PasswordChangeError::PasswordTooShort {
103            min_length: MIN_PASSWORD_LENGTH,
104        }
105    })?;
106
107    // --- look up user ---
108    let user = user_store
109        .find_by_id(user_id)
110        .await
111        .map_err(|e| PasswordChangeError::UserStore(e.to_string()))?
112        .ok_or(PasswordChangeError::InvalidRequest("user not found".into()))?;
113
114    // --- verify current password ---
115    let ok = user_store
116        .verify_password(&user, &req.current_password)
117        .await
118        .map_err(|e| PasswordChangeError::UserStore(e.to_string()))?;
119    if !ok {
120        return Err(PasswordChangeError::InvalidCurrentPassword);
121    }
122
123    // --- hash new password ---
124    let salt = SaltString::generate(&mut OsRng);
125    let new_hash = Argon2::default()
126        .hash_password(req.new_password.as_bytes(), &salt)
127        .map_err(|e| PasswordChangeError::Hash(e.to_string()))?
128        .to_string();
129
130    // --- update store ---
131    let updated = user_store
132        .update_password(user_id, new_hash)
133        .await
134        .map_err(|e| match e {
135            UserStoreError::NotImplemented => {
136                PasswordChangeError::UserStore("password change not supported by this store".into())
137            }
138            other => PasswordChangeError::UserStore(other.to_string()),
139        })?;
140
141    if !updated {
142        return Err(PasswordChangeError::InvalidRequest("user not found".into()));
143    }
144
145    Ok(PasswordChangeResponse {
146        message: "password changed".into(),
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use std::net::Ipv4Addr;
154    use std::time::Duration;
155
156    use solid_pod_rs::security::rate_limit::LruRateLimiter;
157
158    use crate::user_store::InMemoryUserStore;
159
160    fn ip() -> IpAddr {
161        IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))
162    }
163
164    fn seed() -> (InMemoryUserStore, LruRateLimiter) {
165        let store = InMemoryUserStore::new();
166        store
167            .insert_user(
168                "acct-1",
169                "alice@example.com",
170                "https://alice.example/profile#me",
171                Some("Alice".into()),
172                "hunter2!",
173            )
174            .unwrap();
175        let limiter = LruRateLimiter::with_policy(vec![(
176            RATE_LIMIT_ROUTE.to_string(),
177            5,
178            Duration::from_secs(60),
179        )]);
180        (store, limiter)
181    }
182
183    #[tokio::test]
184    async fn change_password_succeeds() {
185        let (store, limiter) = seed();
186        let req = PasswordChangeRequest {
187            current_password: "hunter2!".into(),
188            new_password: "newpass123".into(),
189        };
190        let resp = change_password("acct-1", &req, &store, &limiter, ip())
191            .await
192            .unwrap();
193        assert_eq!(resp.message, "password changed");
194
195        // Verify new password works.
196        let user = store.find_by_id("acct-1").await.unwrap().unwrap();
197        assert!(store.verify_password(&user, "newpass123").await.unwrap());
198        assert!(!store.verify_password(&user, "hunter2!").await.unwrap());
199    }
200
201    #[tokio::test]
202    async fn change_password_rejects_wrong_current() {
203        let (store, limiter) = seed();
204        let req = PasswordChangeRequest {
205            current_password: "wrong".into(),
206            new_password: "newpass123".into(),
207        };
208        let err = change_password("acct-1", &req, &store, &limiter, ip())
209            .await
210            .unwrap_err();
211        assert!(matches!(err, PasswordChangeError::InvalidCurrentPassword));
212    }
213
214    #[tokio::test]
215    async fn change_password_rejects_short_new_password() {
216        let (store, limiter) = seed();
217        let req = PasswordChangeRequest {
218            current_password: "hunter2!".into(),
219            new_password: "short".into(),
220        };
221        let err = change_password("acct-1", &req, &store, &limiter, ip())
222            .await
223            .unwrap_err();
224        assert!(matches!(
225            err,
226            PasswordChangeError::PasswordTooShort { min_length: 8 }
227        ));
228    }
229
230    #[tokio::test]
231    async fn change_password_rejects_empty_fields() {
232        let (store, limiter) = seed();
233        let req = PasswordChangeRequest {
234            current_password: "".into(),
235            new_password: "newpass123".into(),
236        };
237        let err = change_password("acct-1", &req, &store, &limiter, ip())
238            .await
239            .unwrap_err();
240        assert!(matches!(err, PasswordChangeError::InvalidRequest(_)));
241    }
242
243    #[tokio::test]
244    async fn change_password_rate_limited() {
245        let (store, limiter) = seed();
246        for _ in 0..5 {
247            let req = PasswordChangeRequest {
248                current_password: "wrong".into(),
249                new_password: "newpass123".into(),
250            };
251            let _ = change_password("acct-1", &req, &store, &limiter, ip()).await;
252        }
253        let req = PasswordChangeRequest {
254            current_password: "hunter2!".into(),
255            new_password: "newpass123".into(),
256        };
257        let err = change_password("acct-1", &req, &store, &limiter, ip())
258            .await
259            .unwrap_err();
260        assert!(matches!(err, PasswordChangeError::RateLimited { .. }));
261    }
262
263    #[tokio::test]
264    async fn change_password_unknown_user() {
265        let (store, limiter) = seed();
266        let req = PasswordChangeRequest {
267            current_password: "hunter2!".into(),
268            new_password: "newpass123".into(),
269        };
270        let err = change_password("nonexistent", &req, &store, &limiter, ip())
271            .await
272            .unwrap_err();
273        assert!(matches!(err, PasswordChangeError::InvalidRequest(_)));
274    }
275}