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 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      // Office 365 username
59      private String username;
60      // authentication userid, can be different from username
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              // force consent
76              //uriBuilder.addParameter("prompt", "consent");
77  
78              if (Settings.getBooleanProperty("davmail.enableGraph")) {
79                  // Graph backend
80                  // default to v2.0 OIDC endpoint
81                  if (Settings.getBooleanProperty("davmail.enableOidc", true)) {
82                      // OIDC compliant, use this with default DavMail clientId, requires admin consent
83                      uriBuilder.setPath("/" + tenantId + "/oauth2/v2.0/authorize")
84                              .addParameter("scope", Settings.getOauthScope());
85                  } else {
86                      // Outlook desktop can work with classic resource based endpoint for authorization
87                      // on graph endpoint default scopes are 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
88                      uriBuilder.setPath("/" + tenantId + "/oauth2/authorize")
89                              .addParameter("resource", Settings.getGraphUrl());
90                  }
91              } else if (Settings.getBooleanProperty("davmail.enableOidc")) {
92                  // switch to OIDC endpoint in EWS mode
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                  // Default endpoint for EWS, scopes defined on application
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      * Return a pool enabled HttpClientAdapter instance to access O365
132      *
133      * @return HttpClientAdapter instance
134      */
135     @Override
136     public HttpClientAdapter getHttpClientAdapter() {
137         return new HttpClientAdapter(getExchangeUri(), username, password, true);
138     }
139 
140     public void authenticate() throws IOException {
141         // common DavMail client id
142         String clientId = Settings.getProperty("davmail.oauth.clientId", "facd6cff-a294-4415-b59f-c5b01937d7bd");
143         // standard native app redirectUri
144         final String redirectUri = Settings.getProperty("davmail.oauth.redirectUri", Settings.getO365LoginUrl() + "/common/oauth2/nativeclient");
145         // company tenantId or common
146         tenantId = Settings.getProperty("davmail.oauth.tenantId", "common");
147 
148         // first try to load stored token
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                 // live.com form
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                 // we are no longer on Microsoft, try ADFS
171                 code = authenticateADFS(httpClientAdapter, responseBodyAsString, url);
172             } else {
173                 JSONObject config = extractConfig(responseBodyAsString);
174 
175                 checkConfigErrors(config);
176 
177                 String context = config.getString("sCtx"); // csts request
178                 String apiCanary = config.getString("apiCanary"); // canary for API calls
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                         // extract response
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         // get ADFS login form
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             // parse form to get target url, authenticate as userid
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         // decode wresult
368         wresult = wresult.replaceAll(""", "\"");
369         wresult = wresult.replaceAll("&lt;", "<");
370         wresult = wresult.replaceAll("&gt;", ">");
371         String wctx = extract("name=\"wctx\" value=\"([^\"]+)\"", responseBodyAsString);
372         wctx = wctx.replaceAll("&amp;", "&");
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             // failover for null query with non https URI like urn:ietf:wg:oauth:2.0:oob?code=...
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             // MFA triggered after device authentication
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         // Get processAuth url from config
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                 // 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 ("PhoneAppOtpAuthFailedDuplicateCodeEntered".equals(resultValue)) {
600                     // TOTP code already used, prompt for a new one
601                     smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
602                 }
603                 if (config.getBoolean("Success")) {
604                     success = true;
605                 }
606             }
607         } finally {
608             // close number matching frame if exists
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         // process auth
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             // try to retrieve user TOTP secret from settings
693             String totpSecretProperty = "davmail.oauth.totpSecret." + username;
694             String totpSecret = Settings.getProperty(totpSecretProperty);
695             if (totpSecret == null || totpSecret.isEmpty()) {
696                 // fallback to global totpSecret
697                 totpSecretProperty = "davmail.oauth.totpSecret";
698                 totpSecret = Settings.getProperty(totpSecretProperty);
699             }
700             if (totpSecret != null && !totpSecret.isEmpty()) {
701                 // decrypt if encrypted, encrypt and save if plaintext
702                 if (totpSecret.startsWith("{AES}")) {
703                     totpSecret = new StringEncryptor(password).decryptString(totpSecret);
704                 } else {
705                     // encrypt on first use
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                 // headless or server mode
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      * Live.com logon form information
757      * @param content response form
758      * @return parsed configuration json
759      * @throws IOException on error
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 }