lesspass_client/
client.rs

1//
2// lesspass-client client.rs
3// Copyright (C) 2021-2025 Óscar García Amor <ogarcia@connectical.com>
4// Distributed under terms of the GNU GPLv3 license.
5//
6
7use log::{debug, trace};
8use reqwest::{Method, Response, Url};
9use serde::Serialize;
10
11use super::error::Result;
12use super::model::{
13    Token,
14    Auth,
15    Refresh,
16    User,
17    UserPassword,
18    UserChangePassword,
19    NewPassword,
20    Passwords
21};
22
23const USER_AGENT: &str = concat!(
24    env!("CARGO_PKG_NAME"),
25    "/",
26    env!("CARGO_PKG_VERSION")
27);
28
29/// Client for connecting to LessPass server
30#[derive(Clone, Debug)]
31pub struct Client {
32    pub url: String,
33    pub client: reqwest::Client
34}
35
36/// Builder interface to Client
37///
38/// Usage:
39/// ```
40/// use lesspass_client::Client;
41///
42/// let url = "https://api.lesspass.com";
43/// let lpc = Client::new(url);
44/// ```
45impl Client {
46
47    /// Configure the client itself
48    pub fn new(url: impl Into<String>) -> Client {
49        Client {
50            url: url.into(),
51            client: reqwest::Client::builder()
52                .connection_verbose(true)
53                .user_agent(USER_AGENT)
54                .build()
55                .expect("Client::new()")
56        }
57    }
58
59    /// Internal helper function to join host url with endpoint path
60    fn build_url(&self, path: &str) -> Result<Url> {
61        Ok(Url::parse(&self.url)?.join(path)?)
62    }
63
64    /// Internal function to perform authenticated empty requests
65    async fn empty_request(&self, method: Method, url: &Url, token: &str) -> Result<Response> {
66        let authorization = format!("Bearer {}", token);
67        Ok(self.client.request(method, url.as_str())
68            .header("Authorization", authorization)
69            .send().await?
70            .error_for_status()?)
71    }
72
73    /// Internal function to perform (un)authenticated requests with body
74    async fn request<J: Serialize + ?Sized>(&self, method: Method, url: &Url, token: Option<&str>, json: &J) -> Result<Response> {
75        match token {
76            Some(token) => {
77                let authorization = format!("Bearer {}", token);
78                Ok(self.client.request(method, url.as_str())
79                    .header("Authorization", authorization)
80                    .json(&json).send().await?
81                    .error_for_status()?)
82            },
83            None => Ok(self.client.request(method, url.as_str()).json(&json).send().await?.error_for_status()?)
84        }
85    }
86
87    /// Internal function to perform authenticated get requests
88    async fn get(&self, path: &str, token: &str) -> Result<Response> {
89        let url = self.build_url(path)?;
90        trace!("GET: {url}");
91        self.empty_request(Method::GET, &url, token).await
92    }
93
94    /// Internal function to perform (un)authenticated post requests
95    async fn post<J: Serialize + ?Sized>(&self, path: &str, token: Option<&str>, json: &J) -> Result<Response> {
96        let url = self.build_url(path)?;
97        trace!("POST: {url}");
98        self.request(Method::POST, &url, token, json).await
99    }
100
101    /// Internal function to perform authenticated put requests
102    async fn put<J: Serialize + ?Sized>(&self, path: &str, token: &str, json: &J) -> Result<Response> {
103        let url = self.build_url(path)?;
104        trace!("PUT: {url}");
105        self.request(Method::PUT, &url, Some(token), json).await
106    }
107
108    /// Internal function to perform authenticated delete requests without body
109    async fn empty_delete(&self, path: &str, token: &str) -> Result<Response> {
110        let url = self.build_url(path)?;
111        trace!("DELETE: {url}");
112        self.empty_request(Method::DELETE, &url, token).await
113    }
114
115    /// Internal function to perform authenticated delete requests
116    async fn delete<J: Serialize + ?Sized>(&self, path: &str, token: &str, json: &J) -> Result<Response> {
117        let url = self.build_url(path)?;
118        trace!("DELETE: {url}");
119        self.request(Method::DELETE, &url, Some(token), json).await
120    }
121
122    /// Parse self configured url as Url
123    pub fn parse_url(&self) -> Result<Url> {
124        Ok(Url::parse(&self.url)?)
125    }
126
127    /// Create a new token (perform initial auth with email and password)
128    pub async fn create_token(&self, email: &str, password: &str) -> Result<Token> {
129        debug!("Requesting new token");
130        let body = Auth { email: email.into(), password: password.into() };
131        Ok(self.post("auth/jwt/create/", None, &body).await?.json::<Token>().await?)
132    }
133
134    /// Refresh a token
135    ///
136    /// Need refresh token string
137    pub async fn refresh_token(&self, token: &str) -> Result<Token> {
138        debug!("Requesting refreshed token");
139        let body = Refresh { refresh: token.into() };
140        Ok(self.post("auth/jwt/refresh/", None, &body).await?.json::<Token>().await?)
141    }
142
143    /// Creates a new user
144    pub async fn create_user(&self, email: &str, password: &str) -> Result<()> {
145        debug!("Requesting new user");
146        let body = Auth { email: email.into(), password: password.into() };
147        self.post("auth/users/", None, &body).await?;
148        Ok(())
149    }
150
151    /// Gets current user info
152    ///
153    /// Need access token string
154    pub async fn get_user(&self, token: &str) -> Result<User> {
155        debug!("Requesting user info");
156        Ok(self.get("auth/users/me/", token).await?.json::<User>().await?)
157    }
158
159    /// Changes current user password
160    ///
161    /// Need access token string
162    pub async fn change_user_password(&self, token: &str, current_password: &str, new_password: &str) -> Result<()> {
163        debug!("Requesting a password change");
164        let body = UserChangePassword { current_password: current_password.into(), new_password: new_password.into() };
165        self.post("auth/users/set_password/", Some(token), &body).await?;
166        Ok(())
167    }
168
169    /// Deletes current user
170    ///
171    /// Need access token string
172    pub async fn delete_user(&self, token: &str, current_password: &str) -> Result<()> {
173        debug!("Requesting user deletion");
174        let body = UserPassword { current_password: current_password.into() };
175        self.delete("auth/users/me/", token, &body).await?;
176        Ok(())
177    }
178
179    /// Gets the password list
180    ///
181    /// Need access token string
182    pub async fn get_passwords(&self, token: &str) -> Result<Passwords> {
183        Ok(self.get("passwords/", token).await?.json::<Passwords>().await?)
184    }
185
186    /// Creates a new password
187    ///
188    /// Need access token string
189    pub async fn post_password(&self, token: &str, password: &NewPassword) -> Result<()> {
190        self.post("passwords/", Some(token), password).await?;
191        Ok(())
192    }
193
194    /// Updates existing password
195    ///
196    /// Need access token string
197    pub async fn put_password(&self, token: &str, id: &str, password: &NewPassword) -> Result<()> {
198        self.put(&format!("passwords/{id}/"), token, password).await?;
199        Ok(())
200    }
201
202    /// Deletes existing password
203    ///
204    /// Need access token string
205    pub async fn delete_password(&self, token: &str, id: &str) -> Result<()> {
206        self.empty_delete(&format!("passwords/{id}/"), token).await?;
207        Ok(())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    use chrono::{NaiveDate, Utc};
216    use mockito::{Matcher, Server};
217
218    const JH: (&str, &str) = ("content-type", "application/json");
219
220    #[test]
221    fn parse_url() {
222        let url = Client::new("http://localhost").parse_url().unwrap();
223        assert_eq!("http", url.scheme());
224        assert_eq!(Some("localhost"), url.host_str());
225        assert_eq!(Some(80), url.port_or_known_default());
226        let url = Client::new("https://localhost").parse_url().unwrap();
227        assert_eq!("https", url.scheme());
228        assert_eq!(Some(443), url.port_or_known_default());
229        let url = Client::new("https://example.com:6666").parse_url().unwrap();
230        assert_eq!(Some("example.com"), url.host_str());
231        assert_eq!(Some(6666), url.port_or_known_default());
232        // Bad configured client
233        let url_error = Client::new("bad").parse_url().unwrap_err();
234        assert_eq!("URL parse error, relative URL without a base", url_error.to_string());
235    }
236
237    #[tokio::test]
238    async fn create_token() {
239        let mut server = Server::new_async().await;
240        let client = Client::new(&server.url());
241        let request_body = r#"{"email": "user@example.com", "password": "password"}"#;
242        let response_body = r#"{"access": "access-token", "refresh": "refresh-token"}"#;
243        let _m = server.mock("POST", "/auth/jwt/create/")
244            .with_status(201)
245            .with_header(JH.0, JH.1)
246            .match_body(Matcher::JsonString(request_body.to_string()))
247            .with_body(response_body)
248            .create_async()
249            .await;
250        // Ok response
251        let token = client.create_token("user@example.com", "password").await.unwrap();
252        assert_eq!("access-token", &token.access);
253        assert_eq!("refresh-token", &token.refresh);
254        // Bad response caused by auth error
255        let error_token = client.create_token("bad", "bad").await.unwrap_err();
256        assert_eq!(Some(501), error_token.status());
257        let _m = server.mock("POST", "/auth/jwt/create/")
258            .with_status(201)
259            .with_header(JH.0, JH.1)
260            .with_body("unexpected")
261            .create_async()
262            .await;
263        // Bad response caused by unexpected json body
264        let error_body = client.create_token("bad", "bad").await.unwrap_err();
265        assert_eq!("reqwest error, error decoding response body", error_body.to_string());
266    }
267
268    #[tokio::test]
269    async fn refresh_token() {
270        let mut server = Server::new_async().await;
271        let client = Client::new(&server.url());
272        let request_body = r#"{"refresh": "refresh-token"}"#;
273        let response_body = r#"{"access": "new-access-token", "refresh": "new-refresh-token"}"#;
274        let _m = server.mock("POST", "/auth/jwt/refresh/")
275            .with_status(201)
276            .with_header(JH.0, JH.1)
277            .match_body(Matcher::JsonString(request_body.to_string()))
278            .with_body(response_body)
279            .create_async()
280            .await;
281        // Ok response
282        let token = client.refresh_token("refresh-token").await.unwrap();
283        assert_eq!("new-access-token", &token.access);
284        assert_eq!("new-refresh-token", &token.refresh);
285        // Bad response caused by auth error
286        let error_token = client.refresh_token("bad-token").await.unwrap_err();
287        assert_eq!(Some(501), error_token.status());
288        let _m = server.mock("POST", "/auth/jwt/refresh/")
289            .with_status(201)
290            .with_header(JH.0, JH.1)
291            .with_body("unexpected")
292            .create_async()
293            .await;
294        // Bad response caused by unexpected json body
295        let error_body = client.refresh_token("bad-token").await.unwrap_err();
296        assert_eq!("reqwest error, error decoding response body", error_body.to_string());
297    }
298
299    #[tokio::test]
300    async fn create_user() {
301        let mut server = Server::new_async().await;
302        let client = Client::new(&server.url());
303        let request_body = r#"{"email": "newuser@example.com", "password": "newpassword"}"#;
304        let _m = server.mock("POST", "/auth/users/")
305            .with_status(201)
306            .with_header(JH.0, JH.1)
307            .match_body(Matcher::JsonString(request_body.to_string()))
308            .create_async()
309            .await;
310        // Ok response
311        let user = client.create_user("newuser@example.com", "newpassword").await.unwrap();
312        assert_eq!((), user);
313    }
314
315    #[tokio::test]
316    async fn get_user() {
317        let mut server = Server::new_async().await;
318        let client = Client::new(&server.url());
319        let response_body = r#"{"id":1, "email": "newuser@example.com"}"#;
320        let _m = server.mock("GET", "/auth/users/me/")
321            .with_status(200)
322            .with_header(JH.0, JH.1)
323            .match_header("authorization", "Bearer access-token")
324            .with_body(response_body)
325            .create_async()
326            .await;
327        // Ok response
328        let user = client.get_user("access-token").await.unwrap();
329        assert_eq!("1", user.id);
330        assert_eq!("newuser@example.com", user.email);
331        // Bad response caused by token error
332        let error_in_token = client.get_user("bad-token").await.unwrap_err();
333        assert_eq!(Some(501), error_in_token.status());
334    }
335
336    #[tokio::test]
337    async fn change_user_password() {
338        let mut server = Server::new_async().await;
339        let client = Client::new(&server.url());
340        let request_body = r#"{"current_password": "current", "new_password": "new"}"#;
341        let _m = server.mock("POST", "/auth/users/set_password/")
342            .with_status(201)
343            .with_header(JH.0, JH.1)
344            .match_header("authorization", "Bearer access-token")
345            .match_body(Matcher::JsonString(request_body.to_string()))
346            .create_async()
347            .await;
348        // Ok response
349        let change_password = client.change_user_password("access-token", "current", "new").await.unwrap();
350        assert_eq!((), change_password);
351        // Bad response caused by token error
352        let error_in_token = client.change_user_password("bad-token", "current", "new").await.unwrap_err();
353        assert_eq!(Some(501), error_in_token.status());
354    }
355
356    #[tokio::test]
357    async fn delete_user() {
358        let mut server = Server::new_async().await;
359        let client = Client::new(&server.url());
360        let request_body = r#"{"current_password": "current"}"#;
361        let _m = server.mock("DELETE", "/auth/users/me/")
362            .with_status(200)
363            .with_header(JH.0, JH.1)
364            .match_header("authorization", "Bearer access-token")
365            .match_body(Matcher::JsonString(request_body.to_string()))
366            .create_async()
367            .await;
368        // Ok response
369        let delete_user = client.delete_user("access-token", "current").await.unwrap();
370        assert_eq!((), delete_user);
371        // Bad response caused by token error
372        let error_in_token = client.delete_user("bad-token", "current").await.unwrap_err();
373        assert_eq!(Some(501), error_in_token.status());
374    }
375
376    #[tokio::test]
377    async fn get_passwords() {
378        let mut server = Server::new_async().await;
379        let client = Client::new(&server.url());
380        let response_body = r#"
381        {
382          "count": 3,
383          "next": null,
384          "previous": null,
385          "results": [
386            {
387              "id": "e1a7e83c-9014-4585-95f5-4595160afe99",
388              "login": "user@example.com",
389              "site": "alice.example.com",
390              "lowercase": true,
391              "uppercase": true,
392              "symbols": true,
393              "digits": true,
394              "counter": 10,
395              "length": 16,
396              "version": 2,
397              "created": "2021-12-06T11:39:47.874027Z",
398              "modified": "2021-12-06T11:39:47.874143Z"
399            },
400            {
401              "id": "5f01f483-2b63-4faa-9c0c-b2dae03440f1",
402              "login": "user@example.com",
403              "site": "bob.example.com",
404              "lowercase": false,
405              "uppercase": true,
406              "symbols": true,
407              "numbers": false,
408              "counter": 1,
409              "length": 35,
410              "version": 2,
411              "created": "2021-11-21T11:34:18.361454Z",
412              "modified": "2021-12-07T04:12:05.131415Z"
413            },
414            {
415              "id": "10",
416              "login": "user@example.com",
417              "site": "charlie.example.com",
418              "lowercase": false,
419              "uppercase": true,
420              "symbols": true,
421              "digits": false,
422              "numbers": true,
423              "counter": 1,
424              "length": 8,
425              "version": 2,
426              "created": "2023-05-10T12:05:36",
427              "modified": "2023-06-02T17:33:54"
428            }
429          ]
430        }
431        "#;
432        let _m = server.mock("GET", "/passwords/")
433            .with_status(200)
434            .with_header(JH.0, JH.1)
435            .match_header("authorization", "Bearer access-token")
436            .with_body(response_body)
437            .create_async()
438            .await;
439        // Ok response
440        let passwords = client.get_passwords("access-token").await.unwrap();
441        assert_eq!(3, passwords.count);
442        assert_eq!("e1a7e83c-9014-4585-95f5-4595160afe99", &passwords.results[0].id);
443        assert_eq!("10", &passwords.results[2].id);
444        assert_eq!(true, passwords.results[0].lowercase);
445        assert_eq!("bob.example.com", &passwords.results[1].site);
446        assert_eq!(false, passwords.results[1].digits);
447        assert_eq!(NaiveDate::from_ymd_opt(2021, 11, 21).unwrap().and_hms_micro_opt(11, 34, 18, 361454).unwrap().and_local_timezone(Utc).unwrap(), passwords.results[1].created);
448        assert_eq!(NaiveDate::from_ymd_opt(2021, 12, 7).unwrap().and_hms_micro_opt(4, 12, 5, 131415).unwrap().and_local_timezone(Utc).unwrap(), passwords.results[1].modified);
449        assert_eq!(NaiveDate::from_ymd_opt(2023, 06, 2).unwrap().and_hms_micro_opt(17, 33, 54, 0).unwrap().and_local_timezone(Utc).unwrap(), passwords.results[2].modified);
450        // Bad response caused by token error
451        let error_in_token = client.get_passwords("bad-token").await.unwrap_err();
452        assert_eq!(Some(501), error_in_token.status());
453    }
454
455    #[tokio::test]
456    async fn post_password() {
457        let mut server = Server::new_async().await;
458        let client = Client::new(&server.url());
459        let request_body = r#"
460        {
461          "login": "newuser@example.com",
462          "site": "new.example.com",
463          "uppercase": true,
464          "lowercase": true,
465          "digits": false,
466          "symbols": true,
467          "length": 18,
468          "counter": 5,
469          "version": 2
470        }
471        "#;
472        let password = NewPassword {
473            site: "new.example.com".to_string(),
474            login: "newuser@example.com".to_string(),
475            lowercase: true,
476            uppercase: true,
477            symbols: true,
478            digits: false,
479            length: 18,
480            counter: 5,
481            version: 2
482        };
483        let _m = server.mock("POST", "/passwords/")
484            .with_status(201)
485            .with_header(JH.0, JH.1)
486            .match_header("authorization", "Bearer access-token")
487            .match_body(Matcher::JsonString(request_body.to_string()))
488            .create_async()
489            .await;
490        // Ok Response
491        let post_password = client.post_password("access-token", &password).await.unwrap();
492        assert_eq!((), post_password);
493        // Bad response caused by token error
494        let error_in_token = client.post_password("bad-token", &password).await.unwrap_err();
495        assert_eq!(Some(501), error_in_token.status());
496    }
497
498    #[tokio::test]
499    async fn put_password() {
500        let mut server = Server::new_async().await;
501        let client = Client::new(&server.url());
502        let request_body = r#"
503        {
504          "login": "updateuser@example.com",
505          "site": "update.example.com",
506          "uppercase": true,
507          "lowercase": true,
508          "digits": false,
509          "symbols": false,
510          "length": 22,
511          "counter": 1,
512          "version": 2
513        }
514        "#;
515        let password = NewPassword {
516            site: "update.example.com".to_string(),
517            login: "updateuser@example.com".to_string(),
518            lowercase: true,
519            uppercase: true,
520            symbols: false,
521            digits: false,
522            length: 22,
523            counter: 1,
524            version: 2
525        };
526        let _m = server.mock("PUT", "/passwords/ce2835da-9047-43eb-a107-bad4f01d22a0/")
527            .with_status(200)
528            .with_header(JH.0, JH.1)
529            .match_header("authorization", "Bearer access-token")
530            .match_body(Matcher::JsonString(request_body.to_string()))
531            .create_async()
532            .await;
533        // Ok Response
534        let put_password = client.put_password("access-token", "ce2835da-9047-43eb-a107-bad4f01d22a0", &password).await.unwrap();
535        assert_eq!((), put_password);
536        // Bad response caused by token error
537        let error_in_token = client.put_password("bad-token", "ce2835da-9047-43eb-a107-bad4f01d22a0", &password).await.unwrap_err();
538        assert_eq!(Some(501), error_in_token.status());
539        // Bad response caused by id error
540        let error_in_id = client.put_password("access-token", "bad-id", &password).await.unwrap_err();
541        assert_eq!(Some(501), error_in_id.status());
542    }
543
544    #[tokio::test]
545    async fn delete_password() {
546        let mut server = Server::new_async().await;
547        let client = Client::new(&server.url());
548        let _m = server.mock("DELETE", "/passwords/1c461df9-11eb-4bf1-976b-1c49d5598b8f/")
549            .with_status(204)
550            .with_header(JH.0, JH.1)
551            .match_header("authorization", "Bearer access-token")
552            .create_async()
553            .await;
554        // Ok Response
555        let delete_password = client.delete_password("access-token", "1c461df9-11eb-4bf1-976b-1c49d5598b8f").await.unwrap();
556        assert_eq!((), delete_password);
557        // Bad response caused by token error
558        let error_in_token = client.delete_password("bad-token", "1c461df9-11eb-4bf1-976b-1c49d5598b8f").await.unwrap_err();
559        assert_eq!(Some(501), error_in_token.status());
560        // Bad response caused by id error
561        let error_in_id = client.delete_password("access-token", "bad-id").await.unwrap_err();
562        assert_eq!(Some(501), error_in_id.status());
563    }
564}