From 9d050bf8bb6358c2d27c2215d992225b656933a1 Mon Sep 17 00:00:00 2001 From: "m.frison" Date: Wed, 21 May 2025 04:01:40 +0200 Subject: [PATCH] added support for TOTP automation --- .../exchange/auth/O365Authenticator.java | 123 +++++++++++++++--- 1 file changed, 102 insertions(+), 21 deletions(-) diff --git a/src/java/davmail/exchange/auth/O365Authenticator.java b/src/java/davmail/exchange/auth/O365Authenticator.java index a2d2c670..62eed6d7 100644 --- a/src/java/davmail/exchange/auth/O365Authenticator.java +++ b/src/java/davmail/exchange/auth/O365Authenticator.java @@ -45,6 +45,13 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.HashMap; +import java.util.Map; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.apache.commons.codec.binary.Base32; + public class O365Authenticator implements ExchangeAuthenticator { protected static final Logger LOGGER = Logger.getLogger(O365Authenticator.class); @@ -458,30 +465,49 @@ private URI handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMeth String chosenAuthMethodId = null; String chosenAuthMethodPrompt = null; + Map authMethods = new HashMap<>(); + for (int i = 0; i < config.getJSONArray("arrUserProofs").length(); i++) { JSONObject authMethod = (JSONObject) config.getJSONArray("arrUserProofs").get(i); String authMethodId = authMethod.getString("authMethodId"); LOGGER.debug("Authentication method: " + authMethodId); - if ("PhoneAppNotification".equals(authMethodId)) { + if ("PhoneAppOTP".equals(authMethodId)) { + LOGGER.debug("Found phone app OTP auth method " + authMethod.getString("display")); + authMethods.put(authMethodId, authMethod.getString("display")); + } else if ("PhoneAppNotification".equals(authMethodId)) { LOGGER.debug("Found phone app auth method " + authMethod.getString("display")); - isMFAMethodSupported = true; - chosenAuthMethodId = authMethodId; - chosenAuthMethodPrompt = authMethod.getString("display"); - // prefer phone app - break; - } - if ("OneWaySMS".equals(authMethodId)) { + authMethods.put(authMethodId, authMethod.getString("display")); + } else if ("OneWaySMS".equals(authMethodId)) { LOGGER.debug("Found OneWaySMS auth method " + authMethod.getString("display")); - chosenAuthMethodId = authMethodId; - chosenAuthMethodPrompt = authMethod.getString("display"); - isMFAMethodSupported = true; + authMethods.put(authMethodId, authMethod.getString("display")); } } + + String authPreferredMethod = Settings.getProperty("davmail.mfa.preferredMethod", "PhoneAppNotification"); + + if (authMethods.containsKey(authPreferredMethod)) { + chosenAuthMethodId = authPreferredMethod; + chosenAuthMethodPrompt = authMethods.get(authPreferredMethod); + isMFAMethodSupported = true; + } else if (authMethods.containsKey("PhoneAppNotification")) { + chosenAuthMethodId = "PhoneAppNotification"; + chosenAuthMethodPrompt = authMethods.get("PhoneAppNotification"); + isMFAMethodSupported = true; + } else if (authMethods.containsKey("PhoneAppOTP")) { + chosenAuthMethodId = "PhoneAppOTP"; + chosenAuthMethodPrompt = authMethods.get("PhoneAppOTP"); + isMFAMethodSupported = true; + } else if (authMethods.containsKey("OneWaySMS")) { + chosenAuthMethodId = "OneWaySMS"; + chosenAuthMethodPrompt = authMethods.get("OneWaySMS"); + isMFAMethodSupported = true; + } if (!isMFAMethodSupported) { throw new IOException("MFA authentication methods not supported"); } + String context = config.getString("sCtx"); String flowToken = config.getString("sFT"); @@ -506,7 +532,7 @@ private URI handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMeth beginAuthMethod.setRequestHeader("hpgid", hpgid); beginAuthMethod.setRequestHeader("hpgrequestid", hpgrequestid); - // only support PhoneAppNotification + // added support for only support PhoneAppNotification JSONObject beginAuthJson = new JSONObject(); beginAuthJson.put("AuthMethodId", chosenAuthMethodId); beginAuthJson.put("Ctx", context); @@ -521,20 +547,37 @@ private URI handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMeth throw new IOException("Authentication failed: " + config); } + String code = null; // look for number matching value String entropy = config.optString("Entropy", null); // display number matching value to user NumberMatchingFrame numberMatchingFrame = null; - if (entropy != null && !"0".equals(entropy)) { - LOGGER.info("Number matching value for " + username + ": " + entropy); - if (!Settings.getBooleanProperty("davmail.server") && !GraphicsEnvironment.isHeadless()) { - numberMatchingFrame = new NumberMatchingFrame(entropy); + + if (chosenAuthMethodId.equals("PhoneAppOTP")) { + + String secretKey = Settings.getProperty("davmail.mfa.otpSecretKey"); + + if (secretKey == null) + throw new IOException("Missing secret key for TOTP auth"); + + code = retrieveOTPCode(secretKey, 6, 30); + + LOGGER.info("PhoneAppOTP code: " + code); + + } else if (chosenAuthMethodId.equals("PhoneAppNotification")) { + if (entropy != null && !"0".equals(entropy)) { + LOGGER.info("Number matching value for " + username + ": " + entropy); + if (!Settings.getBooleanProperty("davmail.server") && !GraphicsEnvironment.isHeadless()) { + numberMatchingFrame = new NumberMatchingFrame(entropy); + } } + } else if (chosenAuthMethodId.equals("OneWaySMS")) { + code = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt); + + LOGGER.info("OneWaySMS code: " + code); } - - String smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt); - + context = config.getString("Ctx"); flowToken = config.getString("FlowToken"); String sessionId = config.getString("SessionId"); @@ -569,7 +612,7 @@ private URI handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMeth // When in beginAuthMethod is used 'AuthMethodId': 'OneWaySMS', then in endAuthMethod is send SMS code // via attribute 'AdditionalAuthData' - endAuthJson.put("AdditionalAuthData", smsCode); + endAuthJson.put("AdditionalAuthData", code); endAuthMethod.setJsonBody(endAuthJson); @@ -580,7 +623,7 @@ private URI handleMfa(HttpClientAdapter httpClientAdapter, PostRequest logonMeth throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_REASON", resultValue); } if ("SMSAuthFailedWrongCodeEntered".equals(resultValue)) { - smsCode = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt); + code = retrieveSmsCode(chosenAuthMethodId, chosenAuthMethodPrompt); } if (config.getBoolean("Success")) { success = true; @@ -639,6 +682,44 @@ private String retrieveSmsCode(String chosenAuthMethodId, String chosenAuthMetho } return smsCode; } + + + private String retrieveOTPCode(String base32Secret, int digits, int timeStepSeconds) throws IOException { + try { + + long currentTimeSeconds = System.currentTimeMillis() / 1000; + long counter = currentTimeSeconds / timeStepSeconds; + + // decode Base32 to bytes + byte[] key = new Base32().decode(base32Secret); + + byte[] data = new byte[8]; + for (int i = 7; i >= 0; i--) { + data[i] = (byte) (counter & 0xFF); + counter >>= 8; + } + + Mac mac = Mac.getInstance("HmacSHA1"); // Can be HmacSHA256 or HmacSHA512 too + mac.init(new SecretKeySpec(key, "HmacSHA1")); + byte[] hmac = mac.doFinal(data); + + int offset = hmac[hmac.length - 1] & 0xF; + int binary = + ((hmac[offset] & 0x7F) << 24) | + ((hmac[offset + 1] & 0xFF) << 16) | + ((hmac[offset + 2] & 0xFF) << 8) | + (hmac[offset + 3] & 0xFF); + + int otp = binary % (int) Math.pow(10, digits); + + return String.format("%0" + digits + "d", otp); + + } catch (Exception e1) { + LOGGER.debug(e1); + throw new IOException("Unable to retrieveOTPCode"); + } + + } private String executeFollowRedirect(HttpClientAdapter httpClientAdapter, GetRequest getRequest) throws IOException { LOGGER.debug(getRequest.getURI());