1use 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
23pub const RATE_LIMIT_ROUTE: &str = "idp_password_change";
25
26#[derive(Debug, Error)]
28pub enum PasswordChangeError {
29 #[error("rate limited, retry after {retry_after_secs}s")]
31 RateLimited { retry_after_secs: u64 },
32
33 #[error("invalid current password")]
35 InvalidCurrentPassword,
36
37 #[error("new password must be at least {min_length} characters")]
39 PasswordTooShort { min_length: usize },
40
41 #[error("invalid request: {0}")]
43 InvalidRequest(String),
44
45 #[error("user store: {0}")]
47 UserStore(String),
48
49 #[error("password hash: {0}")]
51 Hash(String),
52}
53
54#[derive(Debug, Deserialize)]
56pub struct PasswordChangeRequest {
57 pub current_password: String,
58 pub new_password: String,
59}
60
61#[derive(Debug, Clone, Serialize)]
63pub struct PasswordChangeResponse {
64 pub message: String,
65}
66
67#[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 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 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 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 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 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 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 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}