View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2010  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
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 org.apache.http.HttpStatus;
33  import org.apache.http.client.utils.URIBuilder;
34  import org.apache.log4j.Logger;
35  import org.codehaus.jettison.json.JSONException;
36  import org.codehaus.jettison.json.JSONObject;
37  
38  import javax.swing.*;
39  import java.awt.*;
40  import java.io.BufferedReader;
41  import java.io.IOException;
42  import java.io.InputStreamReader;
43  import java.net.URI;
44  import java.net.URISyntaxException;
45  import java.util.regex.Matcher;
46  import java.util.regex.Pattern;
47  
48  public class O365Authenticator implements ExchangeAuthenticator {
49      protected static final Logger LOGGER = Logger.getLogger(O365Authenticator.class);
50  
51      private String tenantId;
52      // Office 365 username
53      private String username;
54      // authentication userid, can be different from username
55      private String userid;
56      private String password;
57      private O365Token token;
58  
59      public static String buildAuthorizeUrl(String tenantId, String clientId, String redirectUri, String username) throws IOException {
60          URI uri;
61          try {
62              URIBuilder uriBuilder = new URIBuilder(Settings.getO365LoginUrl())
63                      .addParameter("client_id", clientId)
64                      .addParameter("response_type", "code")
65                      .addParameter("redirect_uri", redirectUri)
66                      .addParameter("response_mode", "query")
67                      .addParameter("login_hint", username);
68  
69              // force consent
70              //uriBuilder.addParameter("prompt", "consent");
71  
72              if ("https://outlook.live.com".equals(Settings.getOutlookUrl())) {
73                  // live.com endpoint, able to obtain a token but EWS endpoint does not work
74                  String liveAuthorizeUrl = "https://login.live.com/oauth20_authorize.srf";
75                  uriBuilder = new URIBuilder(liveAuthorizeUrl)
76                          .addParameter("client_id", clientId)
77                          .addParameter("response_type", "code")
78                          .addParameter("redirect_uri", redirectUri)
79                          .addParameter("response_mode", "query")
80                          .addParameter("login_hint", username)
81                          .addParameter("scope", "openid offline_access https://outlook.live.com/EWS.AccessAsUser.All Mail.ReadWrite MailboxSettings.Read")
82                          .addParameter("resource", "https://outlook.live.com")
83                  //.addParameter("prompt", "consent")
84                  ;
85  
86              } else if (Settings.getBooleanProperty("davmail.enableGraph", false)) {
87                  if (Settings.getBooleanProperty("davmail.enableOidc", false)) {
88                      // OIDC compliant
89                      uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
90                              .addParameter("scope", "openid profile offline_access Mail.ReadWrite Calendars.ReadWrite MailboxSettings.Read Mail.ReadWrite.Shared Contacts.ReadWrite Tasks.ReadWrite Mail.Send");
91                      // return scopes with 00000003-0000-0000-c000-000000000000 scopes
92                      //.addParameter("scope", "openid profile offline_access .default");
93                      //.addParameter("scope", "openid profile offline_access "+Settings.getGraphUrl()+"/.default");
94                      //.addParameter("scope", "openid " + Settings.getOutlookUrl() + "/EWS.AccessAsUser.All AuditLog.Read.All Calendar.ReadWrite Calendars.Read.Shared Calendars.ReadWrite Contacts.ReadWrite DataLossPreventionPolicy.Evaluate Directory.AccessAsUser.All Directory.Read.All Files.Read Files.Read.All Files.ReadWrite.All Group.Read.All Group.ReadWrite.All InformationProtectionPolicy.Read Mail.ReadWrite Mail.Send Notes.Create Organization.Read.All People.Read People.Read.All Printer.Read.All PrintJob.ReadWriteBasic SensitiveInfoType.Detect SensitiveInfoType.Read.All SensitivityLabel.Evaluate Tasks.ReadWrite TeamMember.ReadWrite.All TeamsTab.ReadWriteForChat User.Read.All User.ReadBasic.All User.ReadWrite Users.Read");
95                  } else {
96                      // Outlook desktop relies on classic authorize endpoint
97                      // on graph endpoint default scopes are scope -> AuditLog.Create Calendar.ReadWrite Calendars.Read.Shared Calendars.ReadWrite Contacts.ReadWrite DataLossPreventionPolicy.Evaluate Directory.AccessAsUser.All Directory.Read.All Files.Read Files.Read.All Files.ReadWrite.All FileStorageContainer.Selected Group.Read.All Group.ReadWrite.All InformationProtectionPolicy.Read Mail.ReadWrite Mail.Send Notes.Create Organization.Read.All People.Read People.Read.All Printer.Read.All PrinterShare.ReadBasic.All PrintJob.Create PrintJob.ReadWriteBasic Reports.Read.All SensitiveInfoType.Detect SensitiveInfoType.Read.All SensitivityLabel.Evaluate Tasks.ReadWrite TeamMember.ReadWrite.All TeamsTab.ReadWriteForChat User.Read.All User.ReadBasic.All User.ReadWrite Users.Read
98                      uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
99                              .addParameter("resource", Settings.getGraphUrl());
100                 }
101                 // Probably irrelevant except for graph api, see above, switch to new v2.0 OIDC compliant endpoint https://docs.microsoft.com/en-us/azure/active-directory/develop/azure-ad-endpoint-comparison
102             } else if (Settings.getBooleanProperty("davmail.enableOidc", false)) {
103                 uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
104                         .addParameter("scope", "openid profile offline_access " + Settings.getOutlookUrl() + "/EWS.AccessAsUser.All");
105             } else {
106                 uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
107                         .addParameter("resource", Settings.getOutlookUrl());
108             }
109 
110             uri = uriBuilder.build();
111         } catch (URISyntaxException e) {
112             throw new IOException(e);
113         }
114         return uri.toString();
115     }
116 
117     public void setUsername(String username) {
118         if (username.contains("|")) {
119             this.userid = username.substring(0, username.indexOf("|"));
120             this.username = username.substring(username.indexOf("|") + 1);
121         } else {
122             this.username = username;
123             this.userid = username;
124         }
125     }
126 
127     public void setPassword(String password) {
128         this.password = password;
129     }
130 
131     public O365Token getToken() {
132         return token;
133     }
134 
135     public URI getExchangeUri() {
136         return URI.create(Settings.getO365Url());
137     }
138 
139     /**
140      * Return a pool enabled HttpClientAdapter instance to access O365
141      *
142      * @return HttpClientAdapter instance
143      */
144     @Override
145     public HttpClientAdapter getHttpClientAdapter() {
146         return new HttpClientAdapter(getExchangeUri(), username, password, true);
147     }
148 
149     public void authenticate() throws IOException {
150         // common DavMail client id
151         String clientId = Settings.getProperty("davmail.oauth.clientId", "facd6cff-a294-4415-b59f-c5b01937d7bd");
152         // standard native app redirectUri
153         String redirectUri = Settings.getProperty("davmail.oauth.redirectUri", Settings.getO365LoginUrl() + "/common/oauth2/nativeclient");
154         // company tenantId or common
155         tenantId = Settings.getProperty("davmail.oauth.tenantId", "common");
156 
157         // first try to load stored token
158         token = O365Token.load(tenantId, clientId, redirectUri, username, password);
159         if (token != null) {
160             return;
161         }
162 
163         String url = O365Authenticator.buildAuthorizeUrl(tenantId, clientId, redirectUri, username);
164 
165         try (
166                 HttpClientAdapter httpClientAdapter = new HttpClientAdapter(url, userid, password)
167         ) {
168 
169             GetRequest getRequest = new GetRequest(url);
170             String responseBodyAsString = executeFollowRedirect(httpClientAdapter, getRequest);
171             String code;
172             if (!responseBodyAsString.contains("Config=") && responseBodyAsString.contains("ServerData =")) {
173                 // live.com form
174                 JSONObject config = extractServerData(responseBodyAsString);
175 
176                 String referer = getRequest.getURI().toString();
177                 code = authenticateLive(httpClientAdapter, config, referer);
178             } else if (!responseBodyAsString.contains("Config=")) {
179                 // we are no longer on Microsoft, try ADFS
180                 code = authenticateADFS(httpClientAdapter, responseBodyAsString, url);
181             } else {
182                 JSONObject config = extractConfig(responseBodyAsString);
183 
184                 checkConfigErrors(config);
185 
186                 String context = config.getString("sCtx"); // csts request
187                 String apiCanary = config.getString("apiCanary"); // canary for API calls
188                 String clientRequestId = config.getString("correlationId");
189                 String hpgact = config.getString("hpgact");
190                 String hpgid = config.getString("hpgid");
191                 String flowToken = config.getString("sFT");
192                 String canary = config.getString("canary");
193                 String sessionId = config.getString("sessionId");
194 
195                 String referer = getRequest.getURI().toString();
196 
197                 RestRequest getCredentialMethod = new RestRequest(Settings.getO365LoginUrl() + "/" + tenantId + "/GetCredentialType");
198                 getCredentialMethod.setRequestHeader("Accept", "application/json");
199                 getCredentialMethod.setRequestHeader("canary", apiCanary);
200                 getCredentialMethod.setRequestHeader("client-request-id", clientRequestId);
201                 getCredentialMethod.setRequestHeader("hpgact", hpgact);
202                 getCredentialMethod.setRequestHeader("hpgid", hpgid);
203                 getCredentialMethod.setRequestHeader("hpgrequestid", sessionId);
204                 getCredentialMethod.setRequestHeader("Referer", referer);
205 
206                 final JSONObject jsonObject = new JSONObject();
207                 jsonObject.put("username", username);
208                 jsonObject.put("isOtherIdpSupported", true);
209                 jsonObject.put("checkPhones", false);
210                 jsonObject.put("isRemoteNGCSupported", false);
211                 jsonObject.put("isCookieBannerShown", false);
212                 jsonObject.put("isFidoSupported", false);
213                 jsonObject.put("flowToken", flowToken);
214                 jsonObject.put("originalRequest", context);
215 
216                 getCredentialMethod.setJsonBody(jsonObject);
217 
218                 JSONObject credentialType = httpClientAdapter.executeRestRequest(getCredentialMethod);
219 
220                 LOGGER.debug("CredentialType=" + credentialType);
221 
222                 JSONObject credentials = credentialType.getJSONObject("Credentials");
223                 String federationRedirectUrl = credentials.optString("FederationRedirectUrl");
224 
225                 if (federationRedirectUrl != null && !federationRedirectUrl.isEmpty()) {
226                     LOGGER.debug("Detected ADFS, redirecting to " + federationRedirectUrl);
227                     code = authenticateRedirectADFS(httpClientAdapter, federationRedirectUrl, url);
228                 } else {
229                     PostRequest logonMethod = new PostRequest(Settings.getO365LoginUrl() + "/" + tenantId + "/login");
230                     logonMethod.setRequestHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
231                     logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
232 
233                     logonMethod.setRequestHeader("Referer", referer);
234 
235                     logonMethod.setParameter("canary", canary);
236                     logonMethod.setParameter("ctx", context);
237                     logonMethod.setParameter("flowToken", flowToken);
238                     logonMethod.setParameter("hpgrequestid", sessionId);
239                     logonMethod.setParameter("login", username);
240                     logonMethod.setParameter("loginfmt", username);
241                     logonMethod.setParameter("passwd", password);
242 
243                     responseBodyAsString = httpClientAdapter.executePostRequest(logonMethod);
244                     URI location = logonMethod.getRedirectLocation();
245 
246                     if (responseBodyAsString != null && responseBodyAsString.contains("arrUserProofs")) {
247                         location = handleMfa(httpClientAdapter, logonMethod, username, clientRequestId);
248                     }
249 
250                     if (location == null || !location.toString().startsWith(redirectUri)) {
251                         // extract response
252                         config = extractConfig(logonMethod.getResponseBodyAsString());
253                         if (config.optJSONArray("arrScopes") != null || config.optJSONArray("urlPostRedirect") != null) {
254                             LOGGER.warn("Authentication successful but user consent or validation needed, please open the following url in a browser");
255                             LOGGER.warn(url);
256                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
257                         } else if ("ConvergedChangePassword".equals(config.optString("pgid"))) {
258                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_PASSWORD_EXPIRED");
259                         } else if ("50126".equals(config.optString("sErrorCode"))) {
260                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
261                         } else if ("50125".equals(config.optString("sErrorCode"))) {
262                             throw new DavMailAuthenticationException("LOG_MESSAGE", "Your organization needs more information to keep your account secure, authenticate once in a web browser and try again");
263                         } else if ("50128".equals(config.optString("sErrorCode"))) {
264                             throw new DavMailAuthenticationException("LOG_MESSAGE", "Invalid domain name - No tenant-identifying information found in either the request or implied by any provided credentials.");
265                         } else if (config.optString("strServiceExceptionMessage", null) != null) {
266                             LOGGER.debug("O365 returned error: " + config.optString("strServiceExceptionMessage"));
267                             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
268                         } else {
269                             throw new DavMailAuthenticationException("LOG_MESSAGE", "Authentication failed, unknown error: " + config);
270                         }
271                     }
272                     String query = location.toString();
273                     if (query.contains("code=")) {
274                         code = query.substring(query.indexOf("code=") + 5, query.indexOf("&session_state="));
275                     } else {
276                         throw new DavMailAuthenticationException("LOG_MESSAGE", "Authentication failed, unknown error: " + query);
277                     }
278                 }
279             }
280             LOGGER.debug("Authentication Code: " + code);
281 
282             token = O365Token.build(tenantId, clientId, redirectUri, code, password);
283 
284             LOGGER.debug("Authenticated username: " + token.getUsername());
285             if (!username.equalsIgnoreCase(token.getUsername())) {
286                 throw new IOException("Authenticated username " + token.getUsername() + " does not match " + username);
287             }
288 
289         } catch (JSONException e) {
290             throw new IOException(e + " " + e.getMessage());
291         }
292 
293     }
294 
295     private void checkConfigErrors(JSONObject config) throws DavMailAuthenticationException {
296         if (config.optString("strServiceExceptionMessage", null) != null) {
297             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", config.optString("strServiceExceptionMessage"));
298         }
299     }
300 
301     private String authenticateLive(HttpClientAdapter httpClientAdapter, JSONObject config, String referer) throws JSONException, IOException {
302         String urlPost = config.getString("urlPost");
303         PostRequest logonMethod = new PostRequest(urlPost);
304         logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
305         logonMethod.setRequestHeader("Referer", referer);
306         String sFTTag = config.optString("sFTTag");
307         String ppft = "";
308         if (sFTTag.contains("value=")) {
309             ppft = sFTTag.substring(sFTTag.indexOf("value=\"")+7, sFTTag.indexOf("\"/>"));
310         }
311 
312         logonMethod.setParameter("PPFT", ppft);
313 
314         logonMethod.setParameter("login", username);
315         logonMethod.setParameter("loginfmt", username);
316 
317         logonMethod.setParameter("passwd", password);
318 
319         String responseBodyAsString = httpClientAdapter.executePostRequest(logonMethod);
320         URI location = logonMethod.getRedirectLocation();
321         if (location == null) {
322             if (responseBodyAsString.contains("ServerData =")) {
323                 String errorMessage = extractServerData(responseBodyAsString).optString("sErrTxt");
324                 throw new IOException("Live.com authentication failure: "+errorMessage);
325             }
326         } else {
327             String query = location.getQuery();
328             if (query.contains("code=")) {
329                 String code = query.substring(query.indexOf("code=") + 5);
330                 LOGGER.debug("Authentication Code: " + code);
331                 return code;
332             }
333         }
334         throw new IOException("Unknown Live.com authentication failure");
335     }
336 
337     private String authenticateRedirectADFS(HttpClientAdapter httpClientAdapter, String federationRedirectUrl, String authorizeUrl) throws IOException, JSONException {
338         // get ADFS login form
339         GetRequest logonFormMethod = new GetRequest(federationRedirectUrl);
340         logonFormMethod = httpClientAdapter.executeFollowRedirect(logonFormMethod);
341         String responseBodyAsString = logonFormMethod.getResponseBodyAsString();
342         return authenticateADFS(httpClientAdapter, responseBodyAsString, authorizeUrl);
343     }
344 
345     private String authenticateADFS(HttpClientAdapter httpClientAdapter, String responseBodyAsString, String authorizeUrl) throws IOException, JSONException {
346         URI location;
347 
348         if (responseBodyAsString.contains(Settings.getO365LoginUrl())) {
349             LOGGER.info("Already authenticated through Basic or NTLM");
350         } else {
351             // parse form to get target url, authenticate as userid
352             PostRequest logonMethod = new PostRequest(extract("method=\"post\" action=\"([^\"]+)\"", responseBodyAsString));
353             logonMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
354 
355             logonMethod.setParameter("UserName", userid);
356             logonMethod.setParameter("Password", password);
357             logonMethod.setParameter("AuthMethod", "FormsAuthentication");
358 
359             httpClientAdapter.executePostRequest(logonMethod);
360             location = logonMethod.getRedirectLocation();
361             if (location == null) {
362                 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
363             }
364 
365             GetRequest redirectMethod = new GetRequest(location);
366             responseBodyAsString = httpClientAdapter.executeGetRequest(redirectMethod);
367         }
368 
369         if (!responseBodyAsString.contains(Settings.getO365LoginUrl())) {
370             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
371         }
372         String targetUrl = extract("action=\"([^\"]+)\"", responseBodyAsString);
373         String wa = extract("name=\"wa\" value=\"([^\"]+)\"", responseBodyAsString);
374         String wresult = extract("name=\"wresult\" value=\"([^\"]+)\"", responseBodyAsString);
375         // decode wresult
376         wresult = wresult.replaceAll(""", "\"");
377         wresult = wresult.replaceAll("&lt;", "<");
378         wresult = wresult.replaceAll("&gt;", ">");
379         String wctx = extract("name=\"wctx\" value=\"([^\"]+)\"", responseBodyAsString);
380         wctx = wctx.replaceAll("&amp;", "&");
381 
382         PostRequest targetMethod = new PostRequest(targetUrl);
383         targetMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
384         targetMethod.setParameter("wa", wa);
385         targetMethod.setParameter("wresult", wresult);
386         targetMethod.setParameter("wctx", wctx);
387 
388         responseBodyAsString = httpClientAdapter.executePostRequest(targetMethod);
389         location = targetMethod.getRedirectLocation();
390 
391         LOGGER.debug(targetMethod.getURI().toString());
392         LOGGER.debug(targetMethod.getReasonPhrase());
393         LOGGER.debug(responseBodyAsString);
394 
395         if (targetMethod.getStatusCode() == HttpStatus.SC_OK) {
396             JSONObject config = extractConfig(responseBodyAsString);
397             if (config.optJSONArray("arrScopes") != null || config.optJSONArray("urlPostRedirect") != null) {
398                 LOGGER.warn("Authentication successful but user consent or validation needed, please open the following url in a browser");
399                 LOGGER.warn(authorizeUrl);
400                 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
401             }
402         } else if (targetMethod.getStatusCode() != HttpStatus.SC_MOVED_TEMPORARILY || location == null) {
403             throw new IOException("Unknown ADFS authentication failure");
404         }
405 
406         if (location.getHost().startsWith("device")) {
407             location = processDeviceLogin(httpClientAdapter, location);
408         }
409         String query = location.getQuery();
410         if (query == null) {
411             // failover for null query with non https URI like urn:ietf:wg:oauth:2.0:oob?code=...
412             query = location.getSchemeSpecificPart();
413         }
414 
415         if (query.contains("code=") && query.contains("&session_state=")) {
416             String code = query.substring(query.indexOf("code=") + 5, query.indexOf("&session_state="));
417             LOGGER.debug("Authentication Code: " + code);
418             return code;
419         }
420         throw new IOException("Unknown ADFS authentication failure");
421     }
422 
423     private URI processDeviceLogin(HttpClientAdapter httpClient, URI location) throws IOException, JSONException {
424         URI result = location;
425         LOGGER.debug("Proceed to device authentication, must have access to a client certificate signed by MS-Organization-Access");
426         if (Settings.isWindows() &&
427                 (System.getProperty("java.version").compareTo("13") < 0
428                         || !"MSCAPI".equals(Settings.getProperty("davmail.ssl.clientKeystoreType")))
429         ) {
430             LOGGER.warn("MSCAPI and Java version 13 or higher required to access TPM protected client certificate on Windows");
431         }
432         GetRequest deviceLoginMethod = new GetRequest(location);
433 
434         String responseBodyAsString = httpClient.executeGetRequest(deviceLoginMethod);
435 
436         if (responseBodyAsString.contains(Settings.getO365LoginUrl())) {
437             String ctx = extract("name=\"ctx\" value=\"([^\"]+)\"", responseBodyAsString);
438             String flowtoken = extract("name=\"flowtoken\" value=\"([^\"]+)\"", responseBodyAsString);
439 
440             PostRequest processMethod = new PostRequest(extract("action=\"([^\"]+)\"", responseBodyAsString));
441             processMethod.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
442 
443             processMethod.setParameter("ctx", ctx);
444             processMethod.setParameter("flowtoken", flowtoken);
445 
446             responseBodyAsString = httpClient.executePostRequest(processMethod);
447             result = processMethod.getRedirectLocation();
448 
449             // MFA triggered after device authentication
450             if (result == null && responseBodyAsString != null && responseBodyAsString.contains("arrUserProofs")) {
451                 result = handleMfa(httpClient, processMethod, username, null);
452             }
453 
454             if (result == null) {
455                 throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
456             }
457 
458         }
459         return result;
460     }
461 
462     private URI handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMethod, String username, String clientRequestId) throws IOException, JSONException {
463         JSONObject config = extractConfig(logonMethod.getResponseBodyAsString());
464         LOGGER.debug("Config=" + config);
465 
466         String urlBeginAuth = config.getString("urlBeginAuth");
467         String urlEndAuth = config.getString("urlEndAuth");
468         // Get processAuth url from config
469         String urlProcessAuth = config.optString("urlPost", Settings.getO365LoginUrl() + "/" + tenantId + "/SAS/ProcessAuth");
470 
471         boolean isMFAMethodSupported = false;
472         String chosenAuthMethodId = null;
473         String chosenAuthMethodPrompt = null;
474 
475         for (int i = 0; i < config.getJSONArray("arrUserProofs").length(); i++) {
476             JSONObject authMethod = (JSONObject) config.getJSONArray("arrUserProofs").get(i);
477             String authMethodId = authMethod.getString("authMethodId");
478             LOGGER.debug("Authentication method: " + authMethodId);
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                 // prefer phone app
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         // clientRequestId is null coming from device login
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         // only support PhoneAppNotification
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         // look for number matching value
539         String entropy = config.optString("Entropy", null);
540 
541         // display number matching value to user
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                 // When in beginAuthMethod is used 'AuthMethodId': 'OneWaySMS', then in endAuthMethod is send SMS code
585                 // via attribute 'AdditionalAuthData'
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 (config.getBoolean("Success")) {
600                     success = true;
601                 }
602             }
603         } finally {
604             // close number matching frame if exists
605             if (numberMatchingFrame != null && numberMatchingFrame.isVisible()) {
606                 final JFrame finalNumberMatchingFrame = numberMatchingFrame;
607                 SwingUtilities.invokeLater(() -> {
608                     finalNumberMatchingFrame.setVisible(false);
609                     finalNumberMatchingFrame.dispose();
610                 });
611             }
612 
613         }
614         if (!success) {
615             throw new IOException("Authentication failed: " + config);
616         }
617 
618         String authMethod = "PhoneAppOTP";
619         String type = "22";
620 
621         context = config.getString("Ctx");
622         flowToken = config.getString("FlowToken");
623 
624         // process auth
625         PostRequest processAuthMethod = new PostRequest(urlProcessAuth);
626         processAuthMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
627         processAuthMethod.setParameter("type", type);
628         processAuthMethod.setParameter("request", context);
629         processAuthMethod.setParameter("mfaAuthMethod", authMethod);
630         processAuthMethod.setParameter("canary", canary);
631         processAuthMethod.setParameter("login", username);
632         processAuthMethod.setParameter("flowToken", flowToken);
633         processAuthMethod.setParameter("hpgrequestid", hpgrequestid);
634 
635         httpClientAdapter.executePostRequest(processAuthMethod);
636         return processAuthMethod.getRedirectLocation();
637 
638     }
639 
640     private String retrieveSmsCode(String chosenAuthMethodId, String chosenAuthMethodPrompt) throws IOException {
641         String smsCode = null;
642         if ("OneWaySMS".equals(chosenAuthMethodId)) {
643             LOGGER.info("Need to retrieve SMS verification code for " + username);
644             if (Settings.getBooleanProperty("davmail.server") || GraphicsEnvironment.isHeadless()) {
645                 // headless or server mode
646                 System.out.print(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
647                 BufferedReader inReader = new BufferedReader(new InputStreamReader(System.in));
648                 smsCode = inReader.readLine();
649             } else {
650                 PasswordPromptDialog passwordPromptDialog = new PasswordPromptDialog(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
651                 smsCode = String.valueOf(passwordPromptDialog.getPassword());
652             }
653         }
654         return smsCode;
655     }
656 
657     private String executeFollowRedirect(HttpClientAdapter httpClientAdapter, GetRequest getRequest) throws IOException {
658         LOGGER.debug(getRequest.getURI());
659         ResponseWrapper responseWrapper = httpClientAdapter.executeFollowRedirect(getRequest);
660         String responseHost = responseWrapper.getURI().getHost();
661         if (responseHost.endsWith("okta.com")) {
662             throw new DavMailAuthenticationException("LOG_MESSAGE", "Okta authentication not supported, please try O365Interactive");
663         }
664         return responseWrapper.getResponseBodyAsString();
665     }
666 
667     public JSONObject extractConfig(String content) throws IOException {
668         try {
669             return new JSONObject(extract("Config=([^\n]+);", content));
670         } catch (JSONException e1) {
671             LOGGER.debug(content);
672             throw new IOException("Unable to extract config from response body");
673         }
674     }
675 
676     /**
677      * Live.com logon form information
678      * @param content response form
679      * @return parsed configuration json
680      * @throws IOException on error
681      */
682     public JSONObject extractServerData(String content) throws IOException {
683         try {
684             return new JSONObject(extract("ServerData =([^\n]+);", content));
685         } catch (JSONException e1) {
686             LOGGER.debug(content);
687             throw new IOException("Unable to extract config from response body");
688         }
689     }
690 
691     public String extract(String pattern, String content) throws IOException {
692         String value;
693         Matcher matcher = Pattern.compile(pattern).matcher(content);
694         if (matcher.find()) {
695             value = matcher.group(1);
696         } else {
697             throw new IOException("pattern not found");
698         }
699         return value;
700     }
701 
702 }