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.isGraphEnabled()) {
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.replace(""", "\"");
369         wresult = wresult.replace("&lt;", "<");
370         wresult = wresult.replace("&gt;", ">");
371         String wctx = extract("name=\"wctx\" value=\"([^\"]+)\"", responseBodyAsString);
372         wctx = wctx.replace("&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             } else {
548                 // headless mode, display entropy value
549                 System.out.println("Number matching value for " + username + ": " + entropy);
550             }
551         }
552 
553         String smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
554 
555         context = config.getString("Ctx");
556         flowToken = config.getString("FlowToken");
557         String sessionId = config.getString("SessionId");
558 
559         int i = 0;
560         boolean success = false;
561         try {
562             while (!success && i++ < 12) {
563 
564                 try {
565                     Thread.sleep(5000);
566                 } catch (InterruptedException e) {
567                     LOGGER.debug("Interrupted");
568                     Thread.currentThread().interrupt();
569                 }
570 
571                 RestRequest endAuthMethod = new RestRequest(urlEndAuth);
572                 endAuthMethod.setRequestHeader("Accept", "application/json");
573                 endAuthMethod.setRequestHeader("canary", apiCanary);
574                 endAuthMethod.setRequestHeader("client-request-id", clientRequestId);
575                 endAuthMethod.setRequestHeader("hpgact", hpgact);
576                 endAuthMethod.setRequestHeader("hpgid", hpgid);
577                 endAuthMethod.setRequestHeader("hpgrequestid", hpgrequestid);
578 
579                 JSONObject endAuthJson = new JSONObject();
580                 endAuthJson.put("AuthMethodId", chosenAuthMethodId);
581                 endAuthJson.put("Ctx", context);
582                 endAuthJson.put("FlowToken", flowToken);
583                 endAuthJson.put("Method", "EndAuth");
584                 endAuthJson.put("PollCount", "1");
585                 endAuthJson.put("SessionId", sessionId);
586 
587                 // When in beginAuthMethod is used 'AuthMethodId': 'OneWaySMS', then in endAuthMethod is send SMS code
588                 // via attribute 'AdditionalAuthData'
589                 endAuthJson.put("AdditionalAuthData", smsCode);
590 
591                 endAuthMethod.setJsonBody(endAuthJson);
592 
593                 config = httpClientAdapter.executeRestRequest(endAuthMethod);
594                 LOGGER.debug(config);
595                 String resultValue = config.getString("ResultValue");
596                 if ("PhoneAppDenied".equals(resultValue) || "PhoneAppNoResponse".equals(resultValue)) {
597                     throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", resultValue);
598                 }
599                 if ("SMSAuthFailedWrongCodeEntered".equals(resultValue)) {
600                     smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
601                 }
602                 if ("PhoneAppOtpAuthFailedDuplicateCodeEntered".equals(resultValue)) {
603                     // TOTP code already used, prompt for a new one
604                     smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt);
605                 }
606                 if (config.getBoolean("Success")) {
607                     success = true;
608                 }
609             }
610         } finally {
611             // close number matching frame if exists
612             if (numberMatchingFrame != null && numberMatchingFrame.isVisible()) {
613                 final JFrame finalNumberMatchingFrame = numberMatchingFrame;
614                 SwingUtilities.invokeLater(() -> {
615                     finalNumberMatchingFrame.setVisible(false);
616                     finalNumberMatchingFrame.dispose();
617                 });
618             }
619 
620         }
621         if (!success) {
622             throw new IOException("Authentication failed: " + config);
623         }
624 
625         String authMethod = chosenAuthMethodId;
626         String type = "22";
627 
628         context = config.getString("Ctx");
629         flowToken = config.getString("FlowToken");
630 
631         // process auth
632         PostRequest processAuthMethod = new PostRequest(urlProcessAuth);
633         processAuthMethod.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
634         processAuthMethod.setParameter("type", type);
635         processAuthMethod.setParameter("request", context);
636         processAuthMethod.setParameter("mfaAuthMethod", authMethod);
637         processAuthMethod.setParameter("canary", canary);
638         processAuthMethod.setParameter("login", username);
639         processAuthMethod.setParameter("flowToken", flowToken);
640         processAuthMethod.setParameter("hpgrequestid", hpgrequestid);
641 
642         httpClientAdapter.executePostRequest(processAuthMethod);
643         return processAuthMethod;
644 
645     }
646 
647     private static byte[] base32Decode(String base32) {
648         String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
649         base32 = base32.toUpperCase().replaceAll("[\\s=-]", "");
650         int bits = 0;
651         int value = 0;
652         int index = 0;
653         byte[] output = new byte[base32.length() * 5 / 8];
654         for (int i = 0; i < base32.length(); i++) {
655             int digit = alphabet.indexOf(base32.charAt(i));
656             if (digit < 0) continue;
657             value = (value << 5) | digit;
658             bits += 5;
659             if (bits >= 8) {
660                 output[index++] = (byte) ((value >> (bits - 8)) & 0xFF);
661                 bits -= 8;
662             }
663         }
664         byte[] result = new byte[index];
665         System.arraycopy(output, 0, result, 0, index);
666         return result;
667     }
668 
669     private static String generateTotpCode(String base32Secret) throws IOException {
670         try {
671             byte[] key = base32Decode(base32Secret);
672             long timeStep = System.currentTimeMillis() / 1000L / 30L;
673             byte[] timeBytes = ByteBuffer.allocate(8).putLong(timeStep).array();
674 
675             Mac mac = Mac.getInstance("HmacSHA1");
676             mac.init(new SecretKeySpec(key, "HmacSHA1"));
677             byte[] hash = mac.doFinal(timeBytes);
678 
679             int offset = hash[hash.length - 1] & 0x0F;
680             int code = ((hash[offset] & 0x7F) << 24)
681                     | ((hash[offset + 1] & 0xFF) << 16)
682                     | ((hash[offset + 2] & 0xFF) << 8)
683                     | (hash[offset + 3] & 0xFF);
684             code = code % 1000000;
685 
686             return String.format("%06d", code);
687         } catch (NoSuchAlgorithmException | InvalidKeyException e) {
688             throw new IOException("Failed to generate TOTP code: " + e.getMessage(), e);
689         }
690     }
691 
692     private String retrieveSmsCode(String chosenAuthMethodId, String chosenAuthMethodPrompt) throws IOException {
693         String smsCode = null;
694         if ("PhoneAppOTP".equals(chosenAuthMethodId)) {
695             // try to retrieve user TOTP secret from settings
696             String totpSecretProperty = "davmail.oauth.totpSecret." + username;
697             String totpSecret = Settings.getProperty(totpSecretProperty);
698             if (totpSecret == null || totpSecret.isEmpty()) {
699                 // fallback to global totpSecret
700                 totpSecretProperty = "davmail.oauth.totpSecret";
701                 totpSecret = Settings.getProperty(totpSecretProperty);
702             }
703             if (totpSecret != null && !totpSecret.isEmpty()) {
704                 // decrypt if encrypted, encrypt and save if plaintext
705                 if (totpSecret.startsWith("{AES}")) {
706                     totpSecret = new StringEncryptor(password).decryptString(totpSecret);
707                 } else {
708                     // encrypt on first use
709                     String encrypted = new StringEncryptor(password).encryptString(totpSecret);
710                     Settings.saveProperty(totpSecretProperty, encrypted);
711                     LOGGER.info("Encrypted TOTP secret for " + username);
712                 }
713                 smsCode = generateTotpCode(totpSecret);
714                 LOGGER.info("Auto-generated TOTP code for " + username);
715             } else if (Settings.getBooleanProperty("davmail.server") || GraphicsEnvironment.isHeadless()) {
716                 LOGGER.info("Need to retrieve TOTP verification code for " + username);
717                 System.out.print("Enter TOTP code: ");
718                 BufferedReader inReader = new BufferedReader(new InputStreamReader(System.in));
719                 smsCode = inReader.readLine();
720             } else {
721                 PasswordPromptDialog passwordPromptDialog = new PasswordPromptDialog("Enter TOTP code");
722                 smsCode = String.valueOf(passwordPromptDialog.getPassword());
723             }
724         } else if ("OneWaySMS".equals(chosenAuthMethodId)) {
725             LOGGER.info("Need to retrieve SMS verification code for " + username);
726             if (Settings.getBooleanProperty("davmail.server") || GraphicsEnvironment.isHeadless()) {
727                 // headless or server mode
728                 System.out.print(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
729                 BufferedReader inReader = new BufferedReader(new InputStreamReader(System.in));
730                 smsCode = inReader.readLine();
731             } else {
732                 PasswordPromptDialog passwordPromptDialog = new PasswordPromptDialog(BundleMessage.format("UI_SMS_PHONE_CODE", chosenAuthMethodPrompt));
733                 smsCode = String.valueOf(passwordPromptDialog.getPassword());
734             }
735         }
736         return smsCode;
737     }
738 
739     private String executeFollowRedirect(HttpClientAdapter httpClientAdapter, GetRequest getRequest) throws IOException {
740         LOGGER.debug(getRequest.getURI());
741         ResponseWrapper responseWrapper = httpClientAdapter.executeFollowRedirect(getRequest);
742         String responseHost = responseWrapper.getURI().getHost();
743         if (responseHost.endsWith("okta.com")) {
744             throw new DavMailAuthenticationException("LOG_MESSAGE", "Okta authentication not supported, please try O365Interactive");
745         }
746         return responseWrapper.getResponseBodyAsString();
747     }
748 
749     public JSONObject extractConfig(String content) throws IOException {
750         try {
751             return new JSONObject(extract("Config=([^\n]+);", content));
752         } catch (JSONException e1) {
753             LOGGER.debug(content);
754             throw new IOException("Unable to extract config from response body");
755         }
756     }
757 
758     /**
759      * Live.com logon form information
760      * @param content response form
761      * @return parsed configuration json
762      * @throws IOException on error
763      */
764     public JSONObject extractServerData(String content) throws IOException {
765         try {
766             return new JSONObject(extract("ServerData =([^\n]+);", content));
767         } catch (JSONException e1) {
768             LOGGER.debug(content);
769             throw new IOException("Unable to extract config from response body");
770         }
771     }
772 
773     public String extract(String pattern, String content) throws IOException {
774         String value;
775         Matcher matcher = Pattern.compile(pattern).matcher(content);
776         if (matcher.find()) {
777             value = matcher.group(1);
778         } else {
779             throw new IOException("pattern not found");
780         }
781         return value;
782     }
783 
784 }