bitwarden_importers/importers/onepassword/access/
two_factor.rs1use async_trait::async_trait;
7use serde::de::IgnoredAny;
8use serde_json::json;
9
10use super::{
11 device::ClientInfo, error::OnePasswordError, opdata::AesKey, rest::RestClient, wire::MfaInfo,
12};
13
14const MFA_ENDPOINT: &str = "v1/auth/mfa";
15
16pub enum TotpResult {
18 Code(String),
20 Cancel,
22}
23
24#[async_trait]
26pub trait TwoFactorUi: Send + Sync {
27 async fn provide_totp(&self, attempt: u32) -> TotpResult;
30}
31
32#[derive(Debug)]
34pub(super) enum MfaOutcome {
35 Verified,
37 BadOtp,
39}
40
41pub(super) async fn perform_second_factor_authentication(
43 mfa: &MfaInfo,
44 client_info: &ClientInfo,
45 session_key: &AesKey,
46 attempt: u32,
47 ui: &dyn TwoFactorUi,
48 rest: &RestClient,
49) -> Result<MfaOutcome, OnePasswordError> {
50 if !mfa.totp_enabled() {
51 return Err(OnePasswordError::Unsupported(format!(
52 "account requires an unsupported 2FA method (offered: {})",
53 mfa.enabled_methods().join(", ")
54 )));
55 }
56
57 let passcode = match ui.provide_totp(attempt).await {
58 TotpResult::Code(passcode) => passcode,
59 TotpResult::Cancel => return Err(OnePasswordError::TwoFactorFailed),
60 };
61
62 match submit_totp(client_info, session_key, &passcode, rest).await {
63 Ok(()) => Ok(MfaOutcome::Verified),
64 Err(OnePasswordError::BadCredentials) => Ok(MfaOutcome::BadOtp),
66 Err(error) => Err(error),
67 }
68}
69
70async fn submit_totp(
73 client_info: &ClientInfo,
74 session_key: &AesKey,
75 passcode: &str,
76 rest: &RestClient,
77) -> Result<(), OnePasswordError> {
78 let params = json!({
79 "sessionID": session_key.id,
80 "client": client_info.client_id(),
81 "totp": { "code": passcode.trim() },
82 });
83 let _: IgnoredAny = rest
84 .post_encrypted_json(MFA_ENDPOINT, params, session_key)
85 .await?;
86 Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91 use bitwarden_api_base::new_http_client;
92 use rand::Rng;
93 use serde_json::Value;
94 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
95
96 use super::{super::opdata::decode64_loose, *};
97
98 struct ScriptedUi {
99 result: TotpResult,
100 }
101
102 impl ScriptedUi {
103 fn code(passcode: &str) -> ScriptedUi {
104 ScriptedUi {
105 result: TotpResult::Code(passcode.into()),
106 }
107 }
108
109 fn cancel() -> ScriptedUi {
110 ScriptedUi {
111 result: TotpResult::Cancel,
112 }
113 }
114 }
115
116 #[async_trait]
117 impl TwoFactorUi for ScriptedUi {
118 async fn provide_totp(&self, _attempt: u32) -> TotpResult {
119 match &self.result {
120 TotpResult::Code(passcode) => TotpResult::Code(passcode.clone()),
121 TotpResult::Cancel => TotpResult::Cancel,
122 }
123 }
124 }
125
126 fn session_key() -> AesKey {
127 AesKey::new(
128 "SESSION",
129 decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"),
130 )
131 }
132
133 fn client(server: &MockServer) -> RestClient {
134 RestClient::new(
135 new_http_client(),
136 format!("http://{}/api", server.address()),
137 "client-id",
138 "user-agent",
139 "op-user-agent",
140 )
141 .expect("valid headers")
142 }
143
144 fn mfa(json: &str) -> MfaInfo {
145 serde_json::from_str(json).expect("valid mfa info")
146 }
147
148 fn encrypted_body(key: &AesKey, plaintext: &[u8]) -> Value {
150 let mut iv = [0u8; 12];
151 bitwarden_random::rng().fill_bytes(&mut iv);
152 let envelope = key.encrypt(plaintext, &iv).expect("encrypts");
153 serde_json::to_value(&envelope).expect("serializes")
154 }
155
156 #[tokio::test]
157 async fn submits_the_code_from_the_callback() {
158 let key = session_key();
159 let server = MockServer::start().await;
160 server
161 .register(
162 Mock::given(matchers::path("/api/v1/auth/mfa"))
163 .and(matchers::method("POST"))
164 .respond_with(
165 ResponseTemplate::new(200)
166 .set_body_json(encrypted_body(&key, br#"{"sessionID":"SESSION"}"#)),
167 )
168 .expect(1),
169 )
170 .await;
171
172 let outcome = perform_second_factor_authentication(
173 &mfa(r#"{"totp":{"enabled":true}}"#),
174 &ClientInfo::for_desktop("device-uuid"),
175 &key,
176 0,
177 &ScriptedUi::code("123456"),
178 &client(&server),
179 )
180 .await
181 .expect("2FA completes");
182
183 assert!(matches!(outcome, MfaOutcome::Verified));
184 server.verify().await;
185 }
186
187 #[tokio::test]
188 async fn a_rejected_code_asks_for_a_restart() {
189 let key = session_key();
190 let server = MockServer::start().await;
191 server
192 .register(
193 Mock::given(matchers::path("/api/v1/auth/mfa"))
194 .respond_with(ResponseTemplate::new(401).set_body_json(
195 serde_json::json!({"errorCode": 102, "errorMessage": "bad code"}),
196 ))
197 .expect(1),
198 )
199 .await;
200
201 let outcome = perform_second_factor_authentication(
202 &mfa(r#"{"totp":{"enabled":true}}"#),
203 &ClientInfo::for_desktop("device-uuid"),
204 &key,
205 1,
206 &ScriptedUi::code("000000"),
207 &client(&server),
208 )
209 .await
210 .expect("a bad code is not a hard failure");
211
212 assert!(matches!(outcome, MfaOutcome::BadOtp));
213 server.verify().await;
214 }
215
216 #[tokio::test]
217 async fn cancelling_the_prompt_fails_the_login() {
218 let server = MockServer::start().await;
219
220 let error = perform_second_factor_authentication(
221 &mfa(r#"{"totp":{"enabled":true}}"#),
222 &ClientInfo::for_desktop("device-uuid"),
223 &session_key(),
224 0,
225 &ScriptedUi::cancel(),
226 &client(&server),
227 )
228 .await
229 .expect_err("the user declined");
230
231 assert!(matches!(error, OnePasswordError::TwoFactorFailed));
232 }
233
234 #[tokio::test]
235 async fn rejects_accounts_without_totp() {
236 let server = MockServer::start().await;
237
238 let error = perform_second_factor_authentication(
239 &mfa(r#"{"totp":{"enabled":false},"duo":{"enabled":true}}"#),
240 &ClientInfo::for_desktop("device-uuid"),
241 &session_key(),
242 0,
243 &ScriptedUi::code("123456"),
244 &client(&server),
245 )
246 .await
247 .expect_err("Duo is not supported");
248
249 assert!(matches!(error, OnePasswordError::Unsupported(_)));
250 assert!(error.to_string().contains("Duo"));
251 }
252}