Skip to content

Commit 219ba74

Browse files
authored
Merge pull request #6931 from thc202/authhelper/ms-login-totp
authhelper: support TOTP in MS login
2 parents f5fb4f8 + 596d12e commit 219ba74

File tree

7 files changed

+128
-4
lines changed

7 files changed

+128
-4
lines changed

addOns/authhelper/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
## Unreleased
77
### Added
8-
- Handle account selection in Microsoft login.
8+
- Handle account selection and TOTP step in Microsoft login.
99

1010
### Changed
1111
- Fail the Microsoft login if not able to perform all the expected steps.
1212
- Track GWT headers.
1313
- Handle additional exceptions when processing JSON authentication components.
1414

15+
### Fixed
16+
- Do not include known authentication providers in context.
17+
1518
## [0.32.0] - 2025-11-07
1619
### Changed
1720
- Track authentication headers with key in the name.

addOns/authhelper/src/main/java/org/zaproxy/addon/authhelper/AuthUtils.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,15 @@ public static <T> T ignoreSeleniumExceptions(Supplier<T> supplier) {
428428
return null;
429429
}
430430

431+
public static boolean isAuthProvider(HttpMessage msg) {
432+
for (Authenticator authenticator : AUTHENTICATORS) {
433+
if (authenticator.isOwnSite(msg)) {
434+
return true;
435+
}
436+
}
437+
return false;
438+
}
439+
431440
/**
432441
* Authenticate as the given user, by filling in and submitting the login form
433442
*

addOns/authhelper/src/main/java/org/zaproxy/addon/authhelper/internal/ClientSideHandler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ public void handleMessage(HttpMessageHandlerContext ctx, HttpMessage msg) {
101101
&& containsMaybeEncodedString(reqBody, authCreds.getUsername())
102102
&& containsMaybeEncodedString(reqBody, authCreds.getPassword())
103103
&& AuthUtils.getSessionManagementDetailsForContext(user.getContext().getId())
104-
!= null) {
104+
!= null
105+
&& !AuthUtils.isAuthProvider(msg)) {
105106
// The app is sending user creds to another site. Assume this is part of the valid
106107
// auth flow and add to the context
107108
try {

addOns/authhelper/src/main/java/org/zaproxy/addon/authhelper/internal/auth/Authenticator.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import java.util.List;
2323
import org.openqa.selenium.WebDriver;
24+
import org.parosproxy.paros.network.HttpMessage;
2425
import org.zaproxy.addon.authhelper.AuthenticationDiagnostics;
2526
import org.zaproxy.addon.authhelper.internal.AuthenticationStep;
2627
import org.zaproxy.zap.authentication.UsernamePasswordAuthenticationCredentials;
@@ -33,6 +34,8 @@ public interface Authenticator {
3334
public static record Result(
3435
boolean isAttempted, boolean isSuccessful, boolean hasUserField, boolean hasPwdField) {}
3536

37+
boolean isOwnSite(HttpMessage msg);
38+
3639
Result authenticate(
3740
AuthenticationDiagnostics diags,
3841
WebDriver wd,

addOns/authhelper/src/main/java/org/zaproxy/addon/authhelper/internal/auth/DefaultAuthenticator.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.apache.logging.log4j.Logger;
2727
import org.openqa.selenium.WebDriver;
2828
import org.openqa.selenium.WebElement;
29+
import org.parosproxy.paros.network.HttpMessage;
2930
import org.zaproxy.addon.authhelper.AuthUtils;
3031
import org.zaproxy.addon.authhelper.AuthenticationDiagnostics;
3132
import org.zaproxy.addon.authhelper.internal.AuthenticationStep;
@@ -36,6 +37,12 @@ public class DefaultAuthenticator implements Authenticator {
3637

3738
private static final Logger LOGGER = LogManager.getLogger(DefaultAuthenticator.class);
3839

40+
@Override
41+
public boolean isOwnSite(HttpMessage msg) {
42+
// Default does not own any site.
43+
return false;
44+
}
45+
3946
@Override
4047
public Result authenticate(
4148
AuthenticationDiagnostics diags,

addOns/authhelper/src/main/java/org/zaproxy/addon/authhelper/internal/auth/MsLoginAuthenticator.java

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import java.util.LinkedList;
2424
import java.util.List;
2525
import java.util.Queue;
26+
import org.apache.commons.lang3.StringUtils;
2627
import org.apache.logging.log4j.LogManager;
2728
import org.apache.logging.log4j.Logger;
2829
import org.openqa.selenium.By;
@@ -33,16 +34,20 @@
3334
import org.openqa.selenium.support.ui.ExpectedConditions;
3435
import org.openqa.selenium.support.ui.WebDriverWait;
3536
import org.parosproxy.paros.Constant;
37+
import org.parosproxy.paros.network.HttpMessage;
3638
import org.zaproxy.addon.authhelper.AuthUtils;
3739
import org.zaproxy.addon.authhelper.AuthenticationDiagnostics;
3840
import org.zaproxy.addon.authhelper.internal.AuthenticationStep;
41+
import org.zaproxy.addon.commonlib.internal.TotpSupport;
3942
import org.zaproxy.zap.authentication.UsernamePasswordAuthenticationCredentials;
4043
import org.zaproxy.zap.model.Context;
4144

4245
public final class MsLoginAuthenticator implements Authenticator {
4346

4447
private static final String PARTIAL_LOGIN_URL = "login.microsoftonline";
4548

49+
private static final String LOGIN_URL = "https://" + PARTIAL_LOGIN_URL;
50+
4651
private static final Logger LOGGER = LogManager.getLogger(MsLoginAuthenticator.class);
4752

4853
private static final Duration PAGE_LOAD_WAIT_UNTIL = Duration.ofSeconds(5);
@@ -53,6 +58,8 @@ public final class MsLoginAuthenticator implements Authenticator {
5358
private static final By SUBMIT_BUTTON = By.id("idSIButton9");
5459
private static final By KMSI_FIELD = By.id("KmsiCheckboxField");
5560
private static final By PROOF_REDIRECT_FIELD = By.id("idSubmit_ProofUp_Redirect");
61+
private static final By PROOF_TOTP_FIELD = By.id("idTxtBx_SAOTCC_OTC");
62+
private static final By PROOF_TOTP_VERIFY_FIELD = By.id("idSubmit_SAOTCC_Continue");
5663
private static final By PROOF_DONE_FIELD = By.id("id__5");
5764

5865
private enum State {
@@ -68,9 +75,15 @@ private enum State {
6875
STAY_SIGNED_IN,
6976

7077
PROOF_REDIRECT,
78+
PROOF_TOTP,
7179
PROOF,
7280
}
7381

82+
@Override
83+
public boolean isOwnSite(HttpMessage msg) {
84+
return msg.getRequestHeader().getURI().toString().startsWith(LOGIN_URL);
85+
}
86+
7487
@Override
7588
public Result authenticate(
7689
AuthenticationDiagnostics diags,
@@ -101,6 +114,7 @@ private Result authenticateImpl(
101114
boolean successful = false;
102115
boolean userField = false;
103116
boolean pwdField = false;
117+
int totpRetries = 0;
104118

105119
do {
106120
switch (states.remove()) {
@@ -117,13 +131,16 @@ private Result authenticateImpl(
117131

118132
if (findElement(wd, By.tagName("div"), "Pick an account") != null) {
119133
states.add(State.ACCOUNT_SELECTION);
134+
} else if (findElement(wd, PROOF_TOTP_FIELD) != null) {
135+
userField = true;
136+
pwdField = true;
137+
states.add(State.PROOF_TOTP);
120138
} else {
121139
diags.recordStep(
122140
wd,
123141
Constant.messages.getString(
124142
"authhelper.auth.method.diags.steps.ms.missingusername"));
125-
LOGGER.debug(
126-
"Expected username field not found nor pick an account, failing login.");
143+
LOGGER.debug("Unexpected initial state, failing login.");
127144
}
128145

129146
break;
@@ -246,6 +263,21 @@ private Result authenticateImpl(
246263
// Ignore, there's still the next step to check.
247264
}
248265

266+
try {
267+
WebElement proofTotpElement =
268+
waitForElement(
269+
wd,
270+
new ElemenContainsText(
271+
By.id("idDiv_SAOTCC_Description"),
272+
"authenticator"));
273+
if (proofTotpElement != null) {
274+
states.add(State.PROOF_TOTP);
275+
break;
276+
}
277+
} catch (TimeoutException e) {
278+
// Ignore, there's still the next step to check.
279+
}
280+
249281
try {
250282
waitForElement(wd, KMSI_FIELD);
251283
states.add(State.STAY_SIGNED_IN);
@@ -286,6 +318,38 @@ private Result authenticateImpl(
286318
states.add(State.PROOF);
287319
break;
288320

321+
case PROOF_TOTP:
322+
totpRetries++;
323+
324+
if (totpRetries > 2) {
325+
diags.recordStep(
326+
wd,
327+
Constant.messages.getString(
328+
"authhelper.auth.method.diags.steps.ms.clickprooftotperror"));
329+
LOGGER.debug("TOTP proof failed, assuming unsuccessful login.");
330+
break;
331+
}
332+
333+
WebElement proofTotpElement = wd.findElement(PROOF_TOTP_FIELD);
334+
WebElement proofTotpVerifyElement = wd.findElement(PROOF_TOTP_VERIFY_FIELD);
335+
proofTotpElement.clear();
336+
proofTotpElement.sendKeys(TotpSupport.getCode(credentials));
337+
proofTotpVerifyElement.click();
338+
diags.recordStep(
339+
wd,
340+
Constant.messages.getString(
341+
"authhelper.auth.method.diags.steps.ms.clickprooftotpverify"),
342+
proofTotpVerifyElement);
343+
344+
if (findElementContains(wd, By.id("idDiv_SAOTCC_ErrorMsg_OTC"), "try again")
345+
!= null) {
346+
states.add(State.PROOF_TOTP);
347+
} else {
348+
states.add(State.POST_PASSWORD);
349+
}
350+
351+
break;
352+
289353
case PROOF:
290354
try {
291355
waitForElement(wd, new ElementWithText(By.tagName("button"), "Skip setup"));
@@ -320,6 +384,17 @@ private static boolean isUserLoggedIn(WebDriver wd, WebElement userElement) {
320384
.anyMatch(e -> "Signed in".equalsIgnoreCase(e.getText()));
321385
}
322386

387+
private static WebElement findElement(WebDriver wd, By by) {
388+
return wd.findElements(by).stream().findFirst().orElse(null);
389+
}
390+
391+
private static WebElement findElementContains(WebDriver wd, By by, String text) {
392+
return wd.findElements(by).stream()
393+
.filter(e -> StringUtils.containsIgnoreCase(e.getText(), text))
394+
.findFirst()
395+
.orElse(null);
396+
}
397+
323398
private static WebElement findElement(WebDriver wd, By by, String text) {
324399
return wd.findElements(by).stream()
325400
.filter(e -> text.equalsIgnoreCase(e.getText()))
@@ -372,4 +447,28 @@ public String toString() {
372447
return String.format("element '%s' with text '%s' is not present", locator, text);
373448
}
374449
}
450+
451+
private static class ElemenContainsText implements ExpectedCondition<WebElement> {
452+
453+
private final By locator;
454+
private final String text;
455+
456+
ElemenContainsText(By locator, String text) {
457+
this.locator = locator;
458+
this.text = text;
459+
}
460+
461+
@Override
462+
public WebElement apply(WebDriver driver) {
463+
return driver.findElements(locator).stream()
464+
.filter(e -> StringUtils.containsIgnoreCase(e.getText(), text))
465+
.findFirst()
466+
.orElse(null);
467+
}
468+
469+
@Override
470+
public String toString() {
471+
return String.format("element '%s' containing text '%s' is not present", locator, text);
472+
}
473+
}
375474
}

addOns/authhelper/src/main/resources/org/zaproxy/addon/authhelper/resources/Messages.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ authhelper.auth.method.diags.steps.ms.clickbutton = [MS] Click Button
7171
authhelper.auth.method.diags.steps.ms.clickkmsi = [MS] Click KMSI
7272
authhelper.auth.method.diags.steps.ms.clickproofdone = [MS] Click Proof Done
7373
authhelper.auth.method.diags.steps.ms.clickproofredirect = [MS] Click Proof Redirect
74+
authhelper.auth.method.diags.steps.ms.clickprooftotperror = [MS] TOTP Error
75+
authhelper.auth.method.diags.steps.ms.clickprooftotpverify = [MS] TOTP Verify
7476
authhelper.auth.method.diags.steps.ms.missingbutton = [MS] Missing Button
7577
authhelper.auth.method.diags.steps.ms.missingpassword = [MS] Missing Password Field
7678
authhelper.auth.method.diags.steps.ms.missingusername = [MS] Missing Username Field

0 commit comments

Comments
 (0)