client.rs (9195B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2025 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use std::borrow::Cow; 18 19 use base64::{Engine as _, prelude::BASE64_STANDARD}; 20 use p256::{ 21 PublicKey, 22 ecdsa::{DerSignature, SigningKey, signature::Signer as _}, 23 pkcs8::EncodePublicKey, 24 }; 25 use reqwest::Method; 26 use serde::{Deserialize, Serialize}; 27 use serde_json::{Value, json}; 28 29 use crate::magnet_api::{ 30 api::{ApiResult, MagnetRequest}, 31 oauth::{Token, TokenAuth}, 32 types::{ 33 Account, BalanceMini, Direction, Next, Order, PartnerList, SmsCodeSubmission, TokenInfo, 34 TransactionPage, Tx, 35 }, 36 }; 37 38 #[derive(Debug, Deserialize)] 39 struct TxWrapper { 40 #[serde(rename = "tranzakcio")] 41 tx: Tx, 42 } 43 44 #[derive(Debug, Deserialize)] 45 pub struct AccountWrapper { 46 #[serde(rename = "bankszamla")] 47 account: Account, 48 } 49 50 pub struct AuthClient<'a> { 51 client: &'a reqwest::Client, 52 pub api_url: &'a reqwest::Url, 53 consumer: &'a Token, 54 } 55 56 impl<'a> AuthClient<'a> { 57 pub fn new( 58 client: &'a reqwest::Client, 59 api_url: &'a reqwest::Url, 60 consumer: &'a Token, 61 ) -> Self { 62 Self { 63 client, 64 api_url, 65 consumer, 66 } 67 } 68 69 fn request( 70 &self, 71 method: Method, 72 path: impl Into<Cow<'static, str>>, 73 access: Option<&'a Token>, 74 verifier: Option<&'a str>, 75 ) -> MagnetRequest<'_> { 76 MagnetRequest::new( 77 self.client, 78 method, 79 self.api_url, 80 path, 81 self.consumer, 82 access, 83 verifier, 84 ) 85 } 86 87 pub async fn token_request(&self) -> ApiResult<Token> { 88 self.request(Method::GET, "/NetBankOAuth/token/request", None, None) 89 .query(&[("oauth_callback", "oob")]) 90 .parse_url() 91 .await 92 } 93 94 pub async fn token_access( 95 &self, 96 token_request: &Token, 97 token_auth: &TokenAuth, 98 ) -> ApiResult<Token> { 99 self.request( 100 Method::GET, 101 "/NetBankOAuth/token/access", 102 Some(token_request), 103 Some(&token_auth.oauth_verifier), 104 ) 105 .parse_url() 106 .await 107 } 108 109 pub fn upgrade(self, access: &'a Token) -> ApiClient<'a> { 110 ApiClient { 111 client: self.client, 112 api_url: self.api_url, 113 consumer: self.consumer, 114 access, 115 } 116 } 117 } 118 119 pub struct ApiClient<'a> { 120 pub client: &'a reqwest::Client, 121 api_url: &'a reqwest::Url, 122 consumer: &'a Token, 123 access: &'a Token, 124 } 125 126 impl ApiClient<'_> { 127 fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> MagnetRequest<'_> { 128 MagnetRequest::new( 129 self.client, 130 method, 131 self.api_url, 132 path, 133 self.consumer, 134 Some(self.access), 135 None, 136 ) 137 } 138 139 pub async fn token_info(&self) -> ApiResult<TokenInfo> { 140 self.request(Method::GET, "/RESTApi/resources/v2/token") 141 .parse_json() 142 .await 143 } 144 145 pub async fn request_sms_code(&self) -> ApiResult<SmsCodeSubmission> { 146 self.request(Method::GET, "/RESTApi/resources/v2/kodszo/sms/token") 147 .parse_json() 148 .await 149 } 150 151 pub async fn perform_sca(&self, code: &str) -> ApiResult<()> { 152 self.request(Method::PUT, "/RESTApi/resources/v2/token/SCA") 153 .json(&json!({ 154 "kodszo": code 155 })) 156 .parse_empty() 157 .await 158 } 159 160 pub async fn upload_public_key(&self, key: &SigningKey) -> ApiResult<Value> { 161 let public_key = PublicKey::from_secret_scalar(key.as_nonzero_scalar()); 162 let der = public_key.to_public_key_der().unwrap().to_vec(); 163 self.request(Method::POST, "/RESTApi/resources/v2/token/public-key") 164 .json(&json!({ 165 "keyData": BASE64_STANDARD.encode(der) 166 })) 167 .parse_json() 168 .await 169 } 170 171 pub async fn list_accounts(&self) -> ApiResult<PartnerList> { 172 self.request(Method::GET, "/RESTApi/resources/v2/partnerszamla/0") 173 .parse_json() 174 .await 175 } 176 177 pub async fn account(&self, bban: &str) -> ApiResult<Account> { 178 Ok(self 179 .request( 180 Method::GET, 181 format!("/RESTApi/resources/v2/bankszamla/{bban}"), 182 ) 183 .parse_json::<AccountWrapper>() 184 .await? 185 .account) 186 } 187 188 pub async fn balance_mini(&self, bban: &str) -> ApiResult<BalanceMini> { 189 self.request( 190 Method::GET, 191 format!("/RESTApi/resources/v2/egyenleg/{bban}/szukitett"), 192 ) 193 .parse_json() 194 .await 195 } 196 197 pub async fn get_tx(&self, code: u64) -> ApiResult<Tx> { 198 Ok(self 199 .request( 200 Method::GET, 201 format!("/RESTApi/resources/v2/tranzakcio/{code}"), 202 ) 203 .parse_json::<TxWrapper>() 204 .await? 205 .tx) 206 } 207 208 pub async fn page_tx( 209 &self, 210 direction: Direction, 211 order: Order, 212 limit: u16, 213 bban: &str, 214 next: &Option<Next>, 215 sync: bool, 216 ) -> ApiResult<TransactionPage> { 217 let mut req = self.request( 218 Method::GET, 219 format!("/RESTApi/resources/v2/tranzakcio/paginator/{bban}/{limit}"), 220 ); 221 if let Some(next) = next { 222 req = req 223 .query(&[("nextId", next.next_id)]) 224 .query(&[("nextTipus", &next.next_type)]); 225 } 226 req.query(&[("terheles", direction)]) 227 .query(&[ 228 ("tranzakciofrissites", sync), 229 ("ascending", order == Order::Ascending), 230 ]) 231 .parse_json() 232 .await 233 } 234 235 pub async fn init_tx( 236 &self, 237 account_code: u64, 238 amount: f64, 239 subject: &str, 240 date: &jiff::civil::Date, 241 creditor_name: &str, 242 creditor_bban: &str, 243 ) -> ApiResult<Tx> { 244 #[derive(Serialize)] 245 struct Req<'a> { 246 #[serde(rename = "bankszamlaKod")] 247 account_code: u64, 248 #[serde(rename = "osszeg")] 249 amount: f64, 250 #[serde(rename = "kozlemeny")] 251 subject: &'a str, 252 #[serde(rename = "ertekNap")] 253 date: &'a jiff::civil::Date, 254 #[serde(rename = "ellenpartner")] 255 creditor_name: &'a str, 256 #[serde(rename = "ellenszamla")] 257 creditor_account: &'a str, 258 } 259 260 Ok(self 261 .request(Method::POST, "/RESTApi/resources/v2/esetiatutalas") 262 .json(&Req { 263 account_code, 264 amount, 265 subject, 266 date, 267 creditor_name, 268 creditor_account: creditor_bban, 269 }) 270 .parse_json::<TxWrapper>() 271 .await? 272 .tx) 273 } 274 275 pub async fn submit_tx( 276 &self, 277 signing_key: &SigningKey, 278 bban: &str, 279 tx_code: u64, 280 amount: f64, 281 date: &jiff::civil::Date, 282 creditor_bban: &str, 283 ) -> ApiResult<Tx> { 284 #[derive(Serialize)] 285 struct Req<'a> { 286 #[serde(rename = "tranzakcioKod")] 287 tx_code: u64, 288 #[serde(rename = "forrasszamla")] 289 debtor: &'a str, 290 #[serde(rename = "ellenszamla")] 291 creditor: &'a str, 292 #[serde(rename = "osszeg")] 293 amount: f64, 294 #[serde(rename = "ertekNap")] 295 date: &'a jiff::civil::Date, 296 signature: &'a str, 297 } 298 299 let content: String = format!("{tx_code};{bban};{creditor_bban};{amount};{date};"); 300 let signature: DerSignature = signing_key.sign(content.as_bytes()); 301 let encoded = BASE64_STANDARD.encode(signature.as_bytes()); 302 Ok(self 303 .request(Method::PUT, "/RESTApi/resources/v2/tranzakcio/alairas") 304 .json(&Req { 305 tx_code, 306 debtor: bban, 307 creditor: creditor_bban, 308 amount, 309 date, 310 signature: &encoded, 311 }) 312 .parse_json::<TxWrapper>() 313 .await? 314 .tx) 315 } 316 317 pub async fn delete_tx(&self, tx_code: u64) -> ApiResult<()> { 318 self.request( 319 Method::DELETE, 320 format!("/RESTApi/resources/v2/tranzakcio/{tx_code}"), 321 ) 322 .parse_empty() 323 .await 324 } 325 }