Skip to content

Commit

Permalink
ZCS-15588: Updated ChangePasswordRequest to support zimbraPasswordMus…
Browse files Browse the repository at this point in the history
…tChange flow
  • Loading branch information
zimsuchitgupta authored and dasiyogesh committed Jan 27, 2025
1 parent 9e1d24c commit d533e6e
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ public class AccountConstants {
public static final String A_NUM_OTHER_TRUSTED_DEVICES = "nOtherDevices";
public static final String E_DEVICE_ID = "deviceId";
public static final String A_GENERATE_DEVICE_ID = "generateDeviceId";
public static final String E_RESET_PWD = "resetPassword";
public static final String E_TWO_FACTOR_AUTH_REQUIRED = "twoFactorAuthRequired";
public static final String E_TRUSTED_DEVICES_ENABLED = "trustedDevicesEnabled";
public static final String E_TWO_FACTOR_METHOD_APP = "app";
Expand Down
12 changes: 12 additions & 0 deletions soap/src/java/com/zimbra/soap/account/message/AuthResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ public class AuthResponse {
@XmlElement(name=AccountConstants.E_PREF_PASSWORD_RECOVERY_ADDRESS, required=false)
private String prefPasswordRecoveryAddress;

@XmlElement(name=AccountConstants.E_RESET_PWD, required=false)
private String resetPassword;

public AuthResponse() {
}

Expand Down Expand Up @@ -280,6 +283,15 @@ public String getTrustedToken() {
public ZmBoolean getTrustedDevicesEnabled() { return trustedDevicesEnabled; }
public AuthResponse setTrustedDevicesEnabled(boolean bool) { this.trustedDevicesEnabled = ZmBoolean.fromBool(bool); return this; }

@GraphQLQuery(name="resetPassword", description="if true then auth token will be used to change password")
public String getResetPassword() {
return resetPassword;
}

public void setResetPassword(String resetPassword) {
this.resetPassword = resetPassword;
}

public AuthResponse addTwoFactorAuthMethodAllowed(String method) {
this.twoFactorAuthMethodAllowed.add(method);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import javax.xml.bind.annotation.XmlType;

import com.zimbra.common.soap.AccountConstants;
import com.zimbra.soap.account.type.AuthToken;
import com.zimbra.soap.type.AccountSelector;

/**
Expand Down Expand Up @@ -66,6 +67,9 @@ public class ChangePasswordRequest {
@XmlElement(name=AccountConstants.E_DRYRUN, required=false)
private boolean dryRun;

@XmlElement(name=AccountConstants.E_AUTH_TOKEN /* authToken */, required=false)
private AuthToken authToken;

public ChangePasswordRequest() {
}

Expand Down Expand Up @@ -117,5 +121,8 @@ public void setDryRun(boolean dryRun) {
this.dryRun = dryRun;
}

public AuthToken getAuthToken() { return authToken; }
public ChangePasswordRequest setAuthToken(AuthToken authToken) { this.authToken = authToken; return this; }


}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import java.util.HashMap;
import java.util.Map;

import com.zimbra.cs.account.AccountServiceException.AuthFailedServiceException;
import com.zimbra.cs.account.AuthToken;
import com.zimbra.cs.service.AuthProvider;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
Expand All @@ -32,13 +35,16 @@
public class ChangePasswordTest {
private static final String USERNAME_1 = "[email protected]";
private static final String USERNAME_2 = "[email protected]";
private static final String USERNAME_3 = "[email protected]";
private static final String PASSWORD_1 = "H3@pBigPassw0rd";
private static final String PASSWORD_2 = "An0therP@$$w0rd";
private static final String PASSWORD_3 = "An0therP@1$$w0rd";

private static final MockProvisioning prov = new MockProvisioning();

private Account account1;
private Account account2;
private Account account3;

@BeforeClass
public static void init() throws Exception {
Expand All @@ -57,6 +63,11 @@ public void setUp() throws Exception {
final Map<String,Object> attrs2 = new HashMap<>(1);
attrs2.put(Provisioning.A_zimbraId, LdapUtil.generateUUID());
account2 = prov.createAccount(USERNAME_2, PASSWORD_2, attrs2);

final Map<String,Object> attrs3 = new HashMap<>(1);
attrs3.put(Provisioning.A_zimbraId, LdapUtil.generateUUID());
attrs3.put(Provisioning.A_zimbraPasswordMustChange, "TRUE");
account3 = prov.createAccount(USERNAME_3, PASSWORD_3, attrs3);
}

@Test
Expand Down Expand Up @@ -97,18 +108,38 @@ public void testBasicHandlerWrongAuth() throws Exception {
}

@Test
public void testNeedsAuth() throws Exception {
public void testNeedsAuth() {
final ChangePassword handler = new ChangePassword();
final Map<String,Object> context = Collections.emptyMap();

Assert.assertTrue("handler.needsAuth()", handler.needsAuth(context));
Assert.assertFalse("handler.needsAuth()", handler.needsAuth(context));
}

@Test
public void testBasicHandlerWithResetPasswordAuthTokenUsageIfMustChangePasswordIsEnabled() throws Exception {
AuthToken authToken = AuthProvider.getAuthToken(account3, AuthToken.Usage.RESET_PASSWORD , AuthToken.TokenType.AUTH);
final ChangePasswordRequest request = new ChangePasswordRequest()
.setAccount(AccountSelector.fromName(USERNAME_3)) // Not the authed user from context below
.setOldPassword(PASSWORD_3)
.setPassword(PASSWORD_3)
.setAuthToken(new com.zimbra.soap.account.type.AuthToken(authToken.getEncoded(), false));

final ChangePassword handler = new ChangePassword();
final Map<String,Object> context = ServiceTestUtil.getRequestContext(account3);

final Element response = handler.handle(JaxbUtil.jaxbToElement(request), context);
Assert.assertNotNull("response", response);

String at = response.getAttribute(AccountConstants.E_AUTH_TOKEN);
Assert.assertNotNull("authtoken", at);
}

@After
public void tearDown() throws Exception {
MailboxTestUtil.clearData();
prov.deleteAccount(account1.getId());
prov.deleteAccount(account1.getId());
prov.deleteAccount(account2.getId());
prov.deleteAccount(account3.getId());
}
}

33 changes: 32 additions & 1 deletion store/src/java/com/zimbra/cs/service/account/Auth.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
package com.zimbra.cs.service.account;

import com.zimbra.common.localconfig.LC;
import com.zimbra.cs.service.AuthProviderException;
import io.jsonwebtoken.Claims;

import java.util.Arrays;
Expand Down Expand Up @@ -333,7 +334,14 @@ public Element handle(Element request, Map<String, Object> context) throws Servi
}
} else {
if (password != null || recoveryCode != null) {
prov.authAccount(acct, code, AuthContext.Protocol.soap, authCtxt);
try {
prov.authAccount(acct, code, AuthContext.Protocol.soap, authCtxt);
} catch (AccountServiceException ase) {
if (AccountServiceException.CHANGE_PASSWORD.equals(ase.getCode())) {
ZimbraLog.account.info("zimbraPasswordMustChange is enabled so creating a auth-token used to change password.");
return needResetPassword(context, request, acct, twoFactorManager, zsc, tokenType);
}
}
} else {
// it's ok to not have a password if the client is using a 2FA auth token for the 2nd step of 2FA
if (!twoFactorAuthWithToken) {
Expand Down Expand Up @@ -475,6 +483,29 @@ private Element needTwoFactorAuth(Map<String, Object> context, Element requestEl
}
}

/**
* This method is used to create a temporary auth token with usage RESET_PASSWORD.
* This auth token further be used for changing the password.
* This will be executed iff zimbraPasswordMustChange is set to true
* @param context
* @param requestElement
* @param account
* @param auth
* @param zsc
* @param tokenType
* @return response
* @throws ServiceException
*/
private Element needResetPassword(Map<String, Object> context, Element requestElement, Account account, TwoFactorAuth auth,
ZimbraSoapContext zsc, TokenType tokenType) throws ServiceException {
Element response = zsc.createElement(AccountConstants.AUTH_RESPONSE);
AuthToken authToken = AuthProvider.getAuthToken(account, Usage.RESET_PASSWORD , tokenType);
response.addAttribute(AccountConstants.E_LIFETIME, authToken.getExpires() - System.currentTimeMillis(), Element.Disposition.CONTENT);
response.addUniqueElement(AccountConstants.E_RESET_PWD).setText("true");
authToken.encodeAuthResp(response, false);
return response;
}

private String getTwoFactorAuthRequiredSetupErrorMessage(Account account) {
String[] twoFactorAuthMethodAllowed = account.getTwoFactorAuthMethodAllowed();
if (twoFactorAuthMethodAllowed == null || twoFactorAuthMethodAllowed.length == 0) {
Expand Down
51 changes: 36 additions & 15 deletions store/src/java/com/zimbra/cs/service/account/ChangePassword.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@
import com.zimbra.common.soap.AccountConstants;
import com.zimbra.common.soap.Element;
import com.zimbra.common.util.StringUtil;
import com.zimbra.common.util.ZimbraLog;
import com.zimbra.cs.account.Account;
import com.zimbra.cs.account.AccountServiceException.AuthFailedServiceException;
import com.zimbra.cs.account.AuthToken;
import com.zimbra.cs.account.AuthToken.Usage;
import com.zimbra.cs.account.AuthTokenException;
import com.zimbra.cs.account.Domain;
import com.zimbra.cs.account.Provisioning;
import com.zimbra.cs.service.AuthProvider;
Expand All @@ -50,14 +51,18 @@ public Element handle(Element request, Map<String, Object> context) throws Servi
}

ZimbraSoapContext zsc = getZimbraSoapContext(context);
Provisioning prov = Provisioning.getInstance();
Element authTokenEl = request.getOptionalElement(AccountConstants.E_AUTH_TOKEN);
if (authTokenEl == null && zsc.getAuthToken() == null) {
throw ServiceException.INVALID_REQUEST("invalid request parameter", null);
}

String namePassedIn = request.getAttribute(AccountConstants.E_ACCOUNT);
String name = namePassedIn;

Element virtualHostEl = request.getOptionalElement(AccountConstants.E_VIRTUAL_HOST);
String virtualHost = virtualHostEl == null ? null : virtualHostEl.getText().toLowerCase();

Provisioning prov = Provisioning.getInstance();
if (virtualHost != null && name.indexOf('@') == -1) {
Domain d = prov.get(Key.DomainBy.virtualHostname, virtualHost);
if (d != null)
Expand All @@ -72,22 +77,44 @@ public Element handle(Element request, Map<String, Object> context) throws Servi
}
}

Account acct = prov.get(AccountBy.name, name, zsc.getAuthToken());
AuthToken at = zsc.getAuthToken();
Account acct = prov.get(AccountBy.name, name, at);
if (acct == null) {
throw AuthFailedServiceException.AUTH_FAILED(name, namePassedIn, "account not found");
}

if (!canAccessAccount(zsc, acct)) {
Usage usage = Usage.AUTH;
if (authTokenEl != null) {
try {
at = AuthProvider.getAuthToken(authTokenEl, acct);
} catch (AuthTokenException e) {
throw ServiceException.AUTH_REQUIRED();
}
if (at == null) {
throw ServiceException.AUTH_REQUIRED("invalid auth token");
}
usage = Usage.RESET_PASSWORD;
} else if (!canAccessAccount(zsc, acct)) {
throw ServiceException.PERM_DENIED("cannot access account");
}

acct = AuthProvider.validateAuthToken(prov, at, false, usage);
if (acct == null) {
throw AuthFailedServiceException.AUTH_FAILED(name, namePassedIn, "account not found");
}
String oldPassword = request.getAttribute(AccountConstants.E_OLD_PASSWORD);
String newPassword = request.getAttribute(AccountConstants.E_PASSWORD);

if (acct.isIsExternalVirtualAccount() && StringUtil.isNullOrEmpty(oldPassword)
boolean mustChange = acct.getBooleanAttr(Provisioning.A_zimbraPasswordMustChange, false);
if (mustChange && Usage.RESET_PASSWORD == at.getUsage()) {
prov.changePassword(acct, oldPassword, newPassword, dryRun);
try {
at.deRegister();
} catch (AuthTokenException e) {
throw ServiceException.FAILURE("cannot de-register reset password auth token", e);
}
} else if (acct.isIsExternalVirtualAccount() && StringUtil.isNullOrEmpty(oldPassword)
&& !acct.isVirtualAccountInitialPasswordSet() && acct.getId().equals(zsc.getAuthtokenAccountId())) {
// need a valid auth token in this case
AuthProvider.validateAuthToken(prov, zsc.getAuthToken(), false);
prov.setPassword(acct, newPassword, true);
acct.setVirtualAccountInitialPasswordSet(true);
} else {
Expand All @@ -96,7 +123,7 @@ public Element handle(Element request, Map<String, Object> context) throws Servi

Element response = zsc.createElement(AccountConstants.CHANGE_PASSWORD_RESPONSE);
if (!dryRun) {
AuthToken at = AuthProvider.getAuthToken(acct);
at = AuthProvider.getAuthToken(acct);
at.encodeAuthResp(response, false);
response.addAttribute(AccountConstants.E_LIFETIME, at.getExpires() - System.currentTimeMillis(), Element.Disposition.CONTENT);
}
Expand All @@ -106,12 +133,6 @@ public Element handle(Element request, Map<String, Object> context) throws Servi

@Override
public boolean needsAuth(Map<String, Object> context) {
// Because the user might have 2FA or some other enhanced
// authentication enabled, it's easier to lock this endpoint down and
// require that the user has already fully authenticated before we hand
// them the means to do that. We cannot rely on the Provisioning
// changePassword check alone, or we bypass 2FA protection.

return true;
return false;
}
}

0 comments on commit d533e6e

Please sign in to comment.