1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 package davmail.exchange.auth;
21
22 import davmail.BundleMessage;
23 import davmail.Settings;
24 import davmail.exception.DavMailAuthenticationException;
25 import davmail.http.HttpClientAdapter;
26 import davmail.http.request.GetRequest;
27 import davmail.http.request.PostRequest;
28 import davmail.http.request.ResponseWrapper;
29 import davmail.http.request.RestRequest;
30 import davmail.ui.NumberMatchingFrame;
31 import davmail.ui.PasswordPromptDialog;
32 import davmail.util.StringEncryptor;
33 import org.apache.http.HttpStatus;
34 import org.apache.http.client.utils.URIBuilder;
35 import org.apache.log4j.Logger;
36 import org.codehaus.jettison.json.JSONException;
37 import org.codehaus.jettison.json.JSONObject;
38
39 import javax.crypto.Mac;
40 import javax.crypto.spec.SecretKeySpec;
41 import javax.swing.*;
42 import java.awt.*;
43 import java.io.BufferedReader;
44 import java.io.IOException;
45 import java.io.InputStreamReader;
46 import java.net.URI;
47 import java.net.URISyntaxException;
48 import java.nio.ByteBuffer;
49 import java.security.InvalidKeyException;
50 import java.security.NoSuchAlgorithmException;
51 import java.util.regex.Matcher;
52 import java.util.regex.Pattern;
53
54 public class O365Authenticator implements ExchangeAuthenticator {
55 protected static final Logger LOGGER = Logger.getLogger(O365Authenticator.class);
56
57 private String tenantId;
58
59 private String username;
60
61 private String userid;
62 private String password;
63 private O365Token token;
64
65 public static String buildAuthorizeUrl(String tenantId, String clientId, String redirectUri, String username) throws IOException {
66 URI uri;
67 try {
68 URIBuilder uriBuilder = new URIBuilder(Settings.getO365LoginUrl())
69 .addParameter("client_id", clientId)
70 .addParameter("response_type", "code")
71 .addParameter("redirect_uri", redirectUri)
72 .addParameter("response_mode", "query")
73 .addParameter("login_hint", username);
74
75
76
77
78 if (Settings.getBooleanProperty("davmail.enableGraph")) {
79
80
81 if (Settings.getBooleanProperty("davmail.enableOidc", true)) {
82
83 uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
84 .addParameter("scope", Settings.getOauthScope());
85 } else {
86
87
88 uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
89 .addParameter("resource", Settings.getGraphUrl());
90 }
91 } else if (Settings.getBooleanProperty("davmail.enableOidc")) {
92
93 uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
94 .addParameter("scope", Settings.getProperty("davmail.oauth.scope", "openid profile offline_access " + Settings.getOutlookUrl() + "/EWS.AccessAsUser.All"));
95 } else {
96
97 uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
98 .addParameter("resource", Settings.getOutlookUrl());
99 }
100
101 uri = uriBuilder.build();
102 } catch (URISyntaxException e) {
103 throw new IOException(e);
104 }
105 return uri.toString();
106 }
107
108 public void setUsername(String username) {
109 if (username.contains("|")) {
110 this.userid = username.substring(0, username.indexOf("|"));
111 this.username = username.substring(username.indexOf("|") + 1);
112 } else {
113 this.username = username;
114 this.userid = username;
115 }
116 }
117
118 public void setPassword(String password) {
119 this.password = password;
120 }
121
122 public O365Token getToken() {
123 return token;
124 }
125
126 public URI getExchangeUri() {
127 return URI.create(Settings.getO365Url());
128 }
129
130
131
132
133
134
135 @Override
136 public HttpClientAdapter getHttpClientAdapter() {
137 return new HttpClientAdapter(getExchangeUri(), username, password, true);
138 }
139
140 public void authenticate() throws IOException {
141
142 String clientId = Settings.getProperty("davmail.oauth.clientId", "facd6cff-a294-4415-b59f-c5b01937d7bd");
143
144 final String redirectUri = Settings.getProperty("davmail.oauth.redirectUri", Settings.getO365LoginUrl() + "/common/oauth2/nativeclient");
145
146 tenantId = Settings.getProperty("davmail.oauth.tenantId", "common");
147
148
149 token = O365Token.load(tenantId, clientId, redirectUri, username, password);
150 if (token != null) {
151 return;
152 }
153
154 String url = O365Authenticator.buildAuthorizeUrl(tenantId, clientId, redirectUri, username);
155
156 try (
157 HttpClientAdapter httpClientAdapter = new HttpClientAdapter(url, userid, password)
158 ) {
159
160 GetRequest getRequest = new GetRequest(url);
161 String responseBodyAsString = executeFollowRedirect(httpClientAdapter, getRequest);
162 String code;
163 if (!responseBodyAsString.contains("Config=") && responseBodyAsString.contains("ServerData =")) {
164
165 JSONObject config = extractServerData(responseBodyAsString);
166
167 String referer = getRequest.getURI().toString();
168 code = authenticateLive(httpClientAdapter, config, referer);
169 } else if (!responseBodyAsString.contains("Config=")) {
170
171 code = authenticateADFS(httpClientAdapter, responseBodyAsString, url);
172 } else {
173 JSONObject config = extractConfig(responseBodyAsString);
174
175 checkConfigErrors(config);
176
177 String context = config.getString("sCtx");
178 String apiCanary = config.getString("apiCanary");
179 String clientRequestId = config.getString("correlationId");
180 String hpgact = config.getString("hpgact");
181 String hpgid = config.getString("hpgid");
182 String flowToken = config.getString("sFT");
183 String canary = config.getString("canary");
184 String sessionId = config.getString("sessionId");
185
186 String referer = getRequest.getURI().toString();
187
188 RestRequest getCredentialMethod = new RestRequest(Settings.getO365LoginUrl() + "/" + tenantId + "/GetCredentialType");
189 getCredentialMethod.setRequestHeader("Accept", "application/json");
190 getCredentialMethod.setRequestHeader("canary", apiCanary);
191 getCredentialMethod.setRequestHeader("client-request-id", clientRequestId);
192 getCredentialMethod.setRequestHeader("hpgact", hpgact);
193 getCredentialMethod.setRequestHeader("hpgid", hpgid);
194 getCredentialMethod.setRequestHeader("hpgrequestid", sessionId);
195 getCredentialMethod.setRequestHeader("Referer", referer);
196
197 final JSONObject jsonObject = new JSONObject();
198 jsonObject.put("username", username);
199 jsonObject.put("isOtherIdpSupported", true);
200 jsonObject.put("checkPhones", false);
201 jsonObject.put("isRemoteNGCSupported", false);
202 jsonObject.put("isCookieBannerShown", false);
203 jsonObject.put("isFidoSupported", false);
204 jsonObject.put("flowToken", flowToken);
205 jsonObject.put("originalRequest", context);
206
207 getCredentialMethod.setJsonBody(jsonObject);
208
209 JSONObject credentialType = httpClientAdapter.executeRestRequest(getCredentialMethod);
210
211 LOGGER.debug("CredentialType=" + credentialType);
212
213 JSONObject credentials = credentialType.getJSONObject("Credentials");
214 String federationRedirectUrl = credentials.optString("FederationRedirectUrl");
215
216 if (federationRedirectUrl != null && !federationRedirectUrl.isEmpty()) {
217 LOGGER.debug("Detected ADFS, redirecting to " + federationRedirectUrl);
218 code = authenticateRedirectADFS(httpClientAdapter, federationRedirectUrl, url);
219 } else {
220 PostRequest logonMethod = new PostRequest(Settings.getO365LoginUrl() + "/" + tenantId + "/login");
221 logonMethod.setRequestHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
222 logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
223
224 logonMethod.setRequestHeader("Referer", referer);
225
226 logonMethod.setParameter("canary", canary);
227 logonMethod.setParameter("ctx", context);
228 logonMethod.setParameter("flowToken", flowToken);
229 logonMethod.setParameter("hpgrequestid", sessionId);
230 logonMethod.setParameter("login", username);
231 logonMethod.setParameter("loginfmt", username);
232 logonMethod.setParameter("passwd", password);
233
234 responseBodyAsString = httpClientAdapter.executePostRequest(logonMethod);
235 URI location = logonMethod.getRedirectLocation();
236
237 if (responseBodyAsString != null && responseBodyAsString.contains("arrUserProofs")) {
238 logonMethod = handleMfa(httpClientAdapter, logonMethod, username, clientRequestId);
239 location = logonMethod.getRedirectLocation();
240 }
241
242 if (location == null || !location.toString().startsWith(redirectUri)) {
243
244 config = extractConfig(logonMethod.getResponseBodyAsString());
245 if (config.optJSONArray("arrScopes") != null || config.optJSONArray("urlPostRedirect") != null) {
246 LOGGER.warn("Authentication successful but user consent or validation needed, please open the following url in a browser");
247 LOGGER.warn(url);
248 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
249 } else if ("ConvergedChangePassword".equals(config.optString("pgid"))) {
250 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_PASSWORD_EXPIRED");
251 } else if ("50126".equals(config.optString("sErrorCode"))) {
252 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
253 } else if ("50125".equals(config.optString("sErrorCode"))) {
254 throw new DavMailAuthenticationException("LOG_MESSAGE", "Your organization needs more information to keep your account secure, authenticate once in a web browser and try again");
255 } else if ("50128".equals(config.optString("sErrorCode"))) {
256 throw new DavMailAuthenticationException("LOG_MESSAGE", "Invalid domain name - No tenant-identifying information found in either the request or implied by any provided credentials.");
257 } else if (config.optString("strServiceExceptionMessage", null) != null) {
258 LOGGER.debug("O365 returned error: " + config.optString("strServiceExceptionMessage"));
259 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
260 } else {
261 throw new DavMailAuthenticationException("LOG_MESSAGE", "Authentication failed, unknown error: " + config);
262 }
263 }
264 String query = location.toString();
265 if (query.contains("code=")) {
266 code = query.substring(query.indexOf("code=") + 5, query.indexOf("&session_state="));
267 } else {
268 throw new DavMailAuthenticationException("LOG_MESSAGE", "Authentication failed, unknown error: " + query);
269 }
270 }
271 }
272 LOGGER.debug("Authentication Code: " + code);
273
274 token = O365Token.build(tenantId, clientId, redirectUri, code, password);
275
276 LOGGER.debug("Authenticated username: " + token.getUsername());
277 if (!username.equalsIgnoreCase(token.getUsername())) {
278 throw new IOException("Authenticated username " + token.getUsername() + " does not match " + username);
279 }
280
281 } catch (JSONException e) {
282 throw new IOException(e + " " + e.getMessage());
283 }
284
285 }
286
287 private void checkConfigErrors(JSONObject config) throws DavMailAuthenticationException {
288 if (config.optString("strServiceExceptionMessage", null) != null) {
289 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", config.optString("strServiceExceptionMessage"));
290 }
291 }
292
293 private String authenticateLive(HttpClientAdapter httpClientAdapter, JSONObject config, String referer) throws JSONException, IOException {
294 String urlPost = config.getString("urlPost");
295 PostRequest logonMethod = new PostRequest(urlPost);
296 logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
297 logonMethod.setRequestHeader("Referer", referer);
298 String sFTTag = config.optString("sFTTag");
299 String ppft = "";
300 if (sFTTag.contains("value=")) {
301 ppft = sFTTag.substring(sFTTag.indexOf("value=\"")+7, sFTTag.indexOf("\"/>"));
302 }
303
304 logonMethod.setParameter("PPFT", ppft);
305
306 logonMethod.setParameter("login", username);
307 logonMethod.setParameter("loginfmt", username);
308
309 logonMethod.setParameter("passwd", password);
310
311 String responseBodyAsString = httpClientAdapter.executePostRequest(logonMethod);
312 URI location = logonMethod.getRedirectLocation();
313 if (location == null) {
314 if (responseBodyAsString.contains("ServerData =")) {
315 String errorMessage = extractServerData(responseBodyAsString).optString("sErrTxt");
316 throw new IOException("Live.com authentication failure: "+errorMessage);
317 }
318 } else {
319 String query = location.getQuery();
320 if (query.contains("code=")) {
321 String code = query.substring(query.indexOf("code=") + 5);
322 LOGGER.debug("Authentication Code: " + code);
323 return code;
324 }
325 }
326 throw new IOException("Unknown Live.com authentication failure");
327 }
328
329 private String authenticateRedirectADFS(HttpClientAdapter httpClientAdapter, String federationRedirectUrl, String authorizeUrl) throws IOException, JSONException {
330
331 GetRequest logonFormMethod = new GetRequest(federationRedirectUrl);
332 logonFormMethod = httpClientAdapter.executeFollowRedirect(logonFormMethod);
333 String responseBodyAsString = logonFormMethod.getResponseBodyAsString();
334 return authenticateADFS(httpClientAdapter, responseBodyAsString, authorizeUrl);
335 }
336
337 private String authenticateADFS(HttpClientAdapter httpClientAdapter, String responseBodyAsString, String authorizeUrl) throws IOException, JSONException {
338 URI location;
339
340 if (responseBodyAsString.contains(Settings.getO365LoginUrl())) {
341 LOGGER.info("Already authenticated through Basic or NTLM");
342 } else {
343
344 PostRequest logonMethod = new PostRequest(extract("method=\"post\" action=\"([^\"]+)\"", responseBodyAsString));
345 logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
346
347 logonMethod.setParameter("UserName", userid);
348 logonMethod.setParameter("Password", password);
349 logonMethod.setParameter("AuthMethod", "FormsAuthentication");
350
351 httpClientAdapter.executePostRequest(logonMethod);
352 location = logonMethod.getRedirectLocation();
353 if (location == null) {
354 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
355 }
356
357 GetRequest redirectMethod = new GetRequest(location);
358 responseBodyAsString = httpClientAdapter.executeGetRequest(redirectMethod);
359 }
360
361 if (!responseBodyAsString.contains(Settings.getO365LoginUrl())) {
362 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
363 }
364 String targetUrl = extract("action=\"([^\"]+)\"", responseBodyAsString);
365 String wa = extract("name=\"wa\" value=\"([^\"]+)\"", responseBodyAsString);
366 String wresult = extract("name=\"wresult\" value=\"([^\"]+)\"", responseBodyAsString);
367
368 wresult = wresult.replaceAll(""", "\"");
369 wresult = wresult.replaceAll("<", "<");
370 wresult = wresult.replaceAll(">", ">");
371 String wctx = extract("name=\"wctx\" value=\"([^\"]+)\"", responseBodyAsString);
372 wctx = wctx.replaceAll("&", "&");
373
374 PostRequest targetMethod = new PostRequest(targetUrl);
375 targetMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
376 targetMethod.setParameter("wa", wa);
377 targetMethod.setParameter("wresult", wresult);
378 targetMethod.setParameter("wctx", wctx);
379
380 responseBodyAsString = httpClientAdapter.executePostRequest(targetMethod);
381 location = targetMethod.getRedirectLocation();
382
383 LOGGER.debug(targetMethod.getURI().toString());
384 LOGGER.debug(targetMethod.getReasonPhrase());
385 LOGGER.debug(responseBodyAsString);
386
387 if (targetMethod.getStatusCode() == HttpStatus.SC_OK) {
388 JSONObject config = extractConfig(responseBodyAsString);
389 if (config.optJSONArray("arrScopes") != null || config.optJSONArray("urlPostRedirect") != null) {
390 LOGGER.warn("Authentication successful but user consent or validation needed, please open the following url in a browser");
391 LOGGER.warn(authorizeUrl);
392 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
393 }
394 } else if (targetMethod.getStatusCode() != HttpStatus.SC_MOVED_TEMPORARILY || location == null) {
395 throw new IOException("Unknown ADFS authentication failure");
396 }
397
398 if (location.getHost().startsWith("device")) {
399 location = processDeviceLogin(httpClientAdapter, location);
400 }
401 String query = location.getQuery();
402 if (query == null) {
403
404 query = location.getSchemeSpecificPart();
405 }
406
407 if (query.contains("code=") && query.contains("&session_state=")) {
408 String code = query.substring(query.indexOf("code=") + 5, query.indexOf("&session_state="));
409 LOGGER.debug("Authentication Code: " + code);
410 return code;
411 }
412 throw new IOException("Unknown ADFS authentication failure");
413 }
414
415 private URI processDeviceLogin(HttpClientAdapter httpClient, URI location) throws IOException, JSONException {
416 URI result = location;
417 LOGGER.debug("Proceed to device authentication, must have access to a client certificate signed by MS-Organization-Access");
418 if (Settings.isWindows() &&
419 (System.getProperty("java.version").compareTo("13") < 0
420 || !"MSCAPI".equals(Settings.getProperty("davmail.ssl.clientKeystoreType")))
421 ) {
422 LOGGER.warn("MSCAPI and Java version 13 or higher required to access TPM protected client certificate on Windows");
423 }
424 GetRequest deviceLoginMethod = new GetRequest(location);
425
426 String responseBodyAsString = httpClient.executeGetRequest(deviceLoginMethod);
427
428 if (responseBodyAsString.contains(Settings.getO365LoginUrl())) {
429 String ctx = extract("name=\"ctx\" value=\"([^\"]+)\"", responseBodyAsString);
430 String flowtoken = extract("name=\"flowtoken\" value=\"([^\"]+)\"", responseBodyAsString);
431
432 PostRequest processMethod = new PostRequest(extract("action=\"([^\"]+)\"", responseBodyAsString));
433 processMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
434
435 processMethod.setParameter("ctx", ctx);
436 processMethod.setParameter("flowtoken", flowtoken);
437
438 responseBodyAsString = httpClient.executePostRequest(processMethod);
439 result = processMethod.getRedirectLocation();
440
441
442 if (result == null && responseBodyAsString != null && responseBodyAsString.contains("arrUserProofs")) {
443 processMethod = handleMfa(httpClient, processMethod, username, null);
444 result = processMethod.getRedirectLocation();
445 }
446
447 if (result == null) {
448 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
449 }
450
451 }
452 return result;
453 }
454
455 private PostRequest handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMethod, String username, String clientRequestId) throws IOException, JSONException {
456 JSONObject config = extractConfig(logonMethod.getResponseBodyAsString());
457 LOGGER.debug("Config=" + config);
458
459 String urlBeginAuth = config.getString("urlBeginAuth");
460 String urlEndAuth = config.getString("urlEndAuth");
461
462 String urlProcessAuth = config.optString("urlPost", Settings.getO365LoginUrl() + "/" + tenantId + "/SAS/ProcessAuth");
463
464 boolean isMFAMethodSupported = false;
465 String chosenAuthMethodId = null;
466 String chosenAuthMethodPrompt = null;
467
468 for (int i = 0; i < config.getJSONArray("arrUserProofs").length(); i++) {
469 JSONObject authMethod = (JSONObject) config.getJSONArray("arrUserProofs").get(i);
470 String authMethodId = authMethod.getString("authMethodId");
471 LOGGER.debug("Authentication method: " + authMethodId);
472 if ("PhoneAppOTP".equals(authMethodId)) {
473 LOGGER.debug("Found PhoneAppOTP (TOTP) auth method " + authMethod.getString("display"));
474 isMFAMethodSupported = true;
475 chosenAuthMethodId = authMethodId;
476 chosenAuthMethodPrompt = authMethod.getString("display");
477 break;
478 }
479 if ("PhoneAppNotification".equals(authMethodId)) {
480 LOGGER.debug("Found phone app auth method " + authMethod.getString("display"));
481 isMFAMethodSupported = true;
482 chosenAuthMethodId = authMethodId;
483 chosenAuthMethodPrompt = authMethod.getString("display");
484
485 break;
486 }
487 if ("OneWaySMS".equals(authMethodId)) {
488 LOGGER.debug("Found OneWaySMS auth method " + authMethod.getString("display"));
489 chosenAuthMethodId = authMethodId;
490 chosenAuthMethodPrompt = authMethod.getString("display");
491 isMFAMethodSupported = true;
492 }
493 }
494
495 if (!isMFAMethodSupported) {
496 throw new IOException("MFA authentication methods not supported");
497 }
498
499 String context = config.getString("sCtx");
500 String flowToken = config.getString("sFT");
501
502 String canary = config.getString("canary");
503 String apiCanary = config.getString("apiCanary");
504
505 String hpgrequestid = logonMethod.getResponseHeader("x-ms-request-id").getValue();
506 String hpgact = config.getString("hpgact");
507 String hpgid = config.getString("hpgid");
508
509
510 String correlationId = clientRequestId;
511 if (correlationId == null) {
512 correlationId = config.getString("correlationId");
513 }
514
515 RestRequest beginAuthMethod = new RestRequest(urlBeginAuth);
516 beginAuthMethod.setRequestHeader("Accept", "application/json");
517 beginAuthMethod.setRequestHeader("canary", apiCanary);
518 beginAuthMethod.setRequestHeader("client-request-id", correlationId);
519 beginAuthMethod.setRequestHeader("hpgact", hpgact);
520 beginAuthMethod.setRequestHeader("hpgid", hpgid);
521 beginAuthMethod.setRequestHeader("hpgrequestid", hpgrequestid);
522
523
524 JSONObject beginAuthJson = new JSONObject();
525 beginAuthJson.put("AuthMethodId", chosenAuthMethodId);
526 beginAuthJson.put("Ctx", context);
527 beginAuthJson.put("FlowToken", flowToken);
528 beginAuthJson.put("Method", "BeginAuth");
529 beginAuthMethod.setJsonBody(beginAuthJson);
530
531 config = httpClientAdapter.executeRestRequest(beginAuthMethod);
532 LOGGER.debug(config);
533
534 if (!config.getBoolean("Success")) {
535 throw new IOException("Authentication failed: " + config);
536 }
537
538
539 String entropy = config.optString("Entropy", null);
540
541
542 NumberMatchingFrame numberMatchingFrame = null;
543 if (entropy != null && !"0".equals(entropy)) {
544 LOGGER.info("Number matching value for " + username + ": " + entropy);
545 if (!Settings.getBooleanProperty("davmail.server") && !GraphicsEnvironment.isHeadless()) {
546 numberMatchingFrame = new NumberMatchingFrame(entropy);
547 }
548 }
549
550 String smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
551
552 context = config.getString("Ctx");
553 flowToken = config.getString("FlowToken");
554 String sessionId = config.getString("SessionId");
555
556 int i = 0;
557 boolean success = false;
558 try {
559 while (!success && i++ < 12) {
560
561 try {
562 Thread.sleep(5000);
563 } catch (InterruptedException e) {
564 LOGGER.debug("Interrupted");
565 Thread.currentThread().interrupt();
566 }
567
568 RestRequest endAuthMethod = new RestRequest(urlEndAuth);
569 endAuthMethod.setRequestHeader("Accept", "application/json");
570 endAuthMethod.setRequestHeader("canary", apiCanary);
571 endAuthMethod.setRequestHeader("client-request-id", clientRequestId);
572 endAuthMethod.setRequestHeader("hpgact", hpgact);
573 endAuthMethod.setRequestHeader("hpgid", hpgid);
574 endAuthMethod.setRequestHeader("hpgrequestid", hpgrequestid);
575
576 JSONObject endAuthJson = new JSONObject();
577 endAuthJson.put("AuthMethodId", chosenAuthMethodId);
578 endAuthJson.put("Ctx", context);
579 endAuthJson.put("FlowToken", flowToken);
580 endAuthJson.put("Method", "EndAuth");
581 endAuthJson.put("PollCount", "1");
582 endAuthJson.put("SessionId", sessionId);
583
584
585
586 endAuthJson.put("AdditionalAuthData", smsCode);
587
588 endAuthMethod.setJsonBody(endAuthJson);
589
590 config = httpClientAdapter.executeRestRequest(endAuthMethod);
591 LOGGER.debug(config);
592 String resultValue = config.getString("ResultValue");
593 if ("PhoneAppDenied".equals(resultValue) || "PhoneAppNoResponse".equals(resultValue)) {
594 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", resultValue);
595 }
596 if ("SMSAuthFailedWrongCodeEntered".equals(resultValue)) {
597 smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
598 }
599 if ("PhoneAppOtpAuthFailedDuplicateCodeEntered".equals(resultValue)) {
600
601 smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
602 }
603 if (config.getBoolean("Success")) {
604 success = true;
605 }
606 }
607 } finally {
608
609 if (numberMatchingFrame != null && numberMatchingFrame.isVisible()) {
610 final JFrame finalNumberMatchingFrame = numberMatchingFrame;
611 SwingUtilities.invokeLater(() -> {
612 finalNumberMatchingFrame.setVisible(false);
613 finalNumberMatchingFrame.dispose();
614 });
615 }
616
617 }
618 if (!success) {
619 throw new IOException("Authentication failed: " + config);
620 }
621
622 String authMethod = chosenAuthMethodId;
623 String type = "22";
624
625 context = config.getString("Ctx");
626 flowToken = config.getString("FlowToken");
627
628
629 PostRequest processAuthMethod = new PostRequest(urlProcessAuth);
630 processAuthMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
631 processAuthMethod.setParameter("type", type);
632 processAuthMethod.setParameter("request", context);
633 processAuthMethod.setParameter("mfaAuthMethod", authMethod);
634 processAuthMethod.setParameter("canary", canary);
635 processAuthMethod.setParameter("login", username);
636 processAuthMethod.setParameter("flowToken", flowToken);
637 processAuthMethod.setParameter("hpgrequestid", hpgrequestid);
638
639 httpClientAdapter.executePostRequest(processAuthMethod);
640 return processAuthMethod;
641
642 }
643
644 private static byte[] base32Decode(String base32) {
645 String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
646 base32 = base32.toUpperCase().replaceAll("[\\s=-]", "");
647 int bits = 0;
648 int value = 0;
649 int index = 0;
650 byte[] output = new byte[base32.length() * 5 / 8];
651 for (int i = 0; i < base32.length(); i++) {
652 int digit = alphabet.indexOf(base32.charAt(i));
653 if (digit < 0) continue;
654 value = (value << 5) | digit;
655 bits += 5;
656 if (bits >= 8) {
657 output[index++] = (byte) ((value >> (bits - 8)) & 0xFF);
658 bits -= 8;
659 }
660 }
661 byte[] result = new byte[index];
662 System.arraycopy(output, 0, result, 0, index);
663 return result;
664 }
665
666 private static String generateTotpCode(String base32Secret) throws IOException {
667 try {
668 byte[] key = base32Decode(base32Secret);
669 long timeStep = System.currentTimeMillis() / 1000L / 30L;
670 byte[] timeBytes = ByteBuffer.allocate(8).putLong(timeStep).array();
671
672 Mac mac = Mac.getInstance("HmacSHA1");
673 mac.init(new SecretKeySpec(key, "HmacSHA1"));
674 byte[] hash = mac.doFinal(timeBytes);
675
676 int offset = hash[hash.length - 1] & 0x0F;
677 int code = ((hash[offset] & 0x7F) << 24)
678 | ((hash[offset + 1] & 0xFF) << 16)
679 | ((hash[offset + 2] & 0xFF) << 8)
680 | (hash[offset + 3] & 0xFF);
681 code = code % 1000000;
682
683 return String.format("%06d", code);
684 } catch (NoSuchAlgorithmException | InvalidKeyException e) {
685 throw new IOException("Failed to generate TOTP code: " + e.getMessage(), e);
686 }
687 }
688
689 private String retrieveSmsCode(String chosenAuthMethodId, String chosenAuthMethodPrompt) throws IOException {
690 String smsCode = null;
691 if ("PhoneAppOTP".equals(chosenAuthMethodId)) {
692
693 String totpSecretProperty = "davmail.oauth.totpSecret." + username;
694 String totpSecret = Settings.getProperty(totpSecretProperty);
695 if (totpSecret == null || totpSecret.isEmpty()) {
696
697 totpSecretProperty = "davmail.oauth.totpSecret";
698 totpSecret = Settings.getProperty(totpSecretProperty);
699 }
700 if (totpSecret != null && !totpSecret.isEmpty()) {
701
702 if (totpSecret.startsWith("{AES}")) {
703 totpSecret = new StringEncryptor(password).decryptString(totpSecret);
704 } else {
705
706 String encrypted = new StringEncryptor(password).encryptString(totpSecret);
707 Settings.saveProperty(totpSecretProperty, encrypted);
708 LOGGER.info("Encrypted TOTP secret for " + username);
709 }
710 smsCode = generateTotpCode(totpSecret);
711 LOGGER.info("Auto-generated TOTP code for " + username);
712 } else if (Settings.getBooleanProperty("davmail.server") || GraphicsEnvironment.isHeadless()) {
713 LOGGER.info("Need to retrieve TOTP verification code for " + username);
714 System.out.print("Enter TOTP code: ");
715 BufferedReader inReader = new BufferedReader(new InputStreamReader(System.in));
716 smsCode = inReader.readLine();
717 } else {
718 PasswordPromptDialog passwordPromptDialog = new PasswordPromptDialog("Enter TOTP code");
719 smsCode = String.valueOf(passwordPromptDialog.getPassword());
720 }
721 } else if ("OneWaySMS".equals(chosenAuthMethodId)) {
722 LOGGER.info("Need to retrieve SMS verification code for " + username);
723 if (Settings.getBooleanProperty("davmail.server") || GraphicsEnvironment.isHeadless()) {
724
725 System.out.print(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
726 BufferedReader inReader = new BufferedReader(new InputStreamReader(System.in));
727 smsCode = inReader.readLine();
728 } else {
729 PasswordPromptDialog passwordPromptDialog = new PasswordPromptDialog(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
730 smsCode = String.valueOf(passwordPromptDialog.getPassword());
731 }
732 }
733 return smsCode;
734 }
735
736 private String executeFollowRedirect(HttpClientAdapter httpClientAdapter, GetRequest getRequest) throws IOException {
737 LOGGER.debug(getRequest.getURI());
738 ResponseWrapper responseWrapper = httpClientAdapter.executeFollowRedirect(getRequest);
739 String responseHost = responseWrapper.getURI().getHost();
740 if (responseHost.endsWith("okta.com")) {
741 throw new DavMailAuthenticationException("LOG_MESSAGE", "Okta authentication not supported, please try O365Interactive");
742 }
743 return responseWrapper.getResponseBodyAsString();
744 }
745
746 public JSONObject extractConfig(String content) throws IOException {
747 try {
748 return new JSONObject(extract("Config=([^\n]+);", content));
749 } catch (JSONException e1) {
750 LOGGER.debug(content);
751 throw new IOException("Unable to extract config from response body");
752 }
753 }
754
755
756
757
758
759
760
761 public JSONObject extractServerData(String content) throws IOException {
762 try {
763 return new JSONObject(extract("ServerData =([^\n]+);", content));
764 } catch (JSONException e1) {
765 LOGGER.debug(content);
766 throw new IOException("Unable to extract config from response body");
767 }
768 }
769
770 public String extract(String pattern, String content) throws IOException {
771 String value;
772 Matcher matcher = Pattern.compile(pattern).matcher(content);
773 if (matcher.find()) {
774 value = matcher.group(1);
775 } else {
776 throw new IOException("pattern not found");
777 }
778 return value;
779 }
780
781 }