From d533e6e52ae398847ffb058f5aa9d2fadba14c84 Mon Sep 17 00:00:00 2001 From: zimsuchitgupta Date: Wed, 7 Aug 2024 15:19:49 +0530 Subject: [PATCH] ZCS-15588: Updated ChangePasswordRequest to support zimbraPasswordMustChange flow --- .../zimbra/common/soap/AccountConstants.java | 1 + .../soap/account/message/AuthResponse.java | 12 +++++ .../message/ChangePasswordRequest.java | 7 +++ .../service/account/ChangePasswordTest.java | 37 ++++++++++++-- .../com/zimbra/cs/service/account/Auth.java | 33 +++++++++++- .../cs/service/account/ChangePassword.java | 51 +++++++++++++------ 6 files changed, 122 insertions(+), 19 deletions(-) diff --git a/common/src/java/com/zimbra/common/soap/AccountConstants.java b/common/src/java/com/zimbra/common/soap/AccountConstants.java index e72e49c0a9f..ee6f8b526f0 100644 --- a/common/src/java/com/zimbra/common/soap/AccountConstants.java +++ b/common/src/java/com/zimbra/common/soap/AccountConstants.java @@ -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"; diff --git a/soap/src/java/com/zimbra/soap/account/message/AuthResponse.java b/soap/src/java/com/zimbra/soap/account/message/AuthResponse.java index d28df8b9a1f..206bce2005c 100644 --- a/soap/src/java/com/zimbra/soap/account/message/AuthResponse.java +++ b/soap/src/java/com/zimbra/soap/account/message/AuthResponse.java @@ -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() { } @@ -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; diff --git a/soap/src/java/com/zimbra/soap/account/message/ChangePasswordRequest.java b/soap/src/java/com/zimbra/soap/account/message/ChangePasswordRequest.java index baad2b21ef3..24d3ec02c61 100644 --- a/soap/src/java/com/zimbra/soap/account/message/ChangePasswordRequest.java +++ b/soap/src/java/com/zimbra/soap/account/message/ChangePasswordRequest.java @@ -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; /** @@ -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() { } @@ -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; } + } diff --git a/store/src/java-test/com/zimbra/cs/service/account/ChangePasswordTest.java b/store/src/java-test/com/zimbra/cs/service/account/ChangePasswordTest.java index 32eab58c09f..582173dcfbd 100644 --- a/store/src/java-test/com/zimbra/cs/service/account/ChangePasswordTest.java +++ b/store/src/java-test/com/zimbra/cs/service/account/ChangePasswordTest.java @@ -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; @@ -32,13 +35,16 @@ public class ChangePasswordTest { private static final String USERNAME_1 = "ron@zcs.fazigu.org"; private static final String USERNAME_2 = "rob@zcs.fazigu.org"; + private static final String USERNAME_3 = "rock@zcs.fazigu.org"; 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 { @@ -57,6 +63,11 @@ public void setUp() throws Exception { final Map attrs2 = new HashMap<>(1); attrs2.put(Provisioning.A_zimbraId, LdapUtil.generateUUID()); account2 = prov.createAccount(USERNAME_2, PASSWORD_2, attrs2); + + final Map 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 @@ -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 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 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()); } } diff --git a/store/src/java/com/zimbra/cs/service/account/Auth.java b/store/src/java/com/zimbra/cs/service/account/Auth.java index b45918b1b53..6aedd458322 100644 --- a/store/src/java/com/zimbra/cs/service/account/Auth.java +++ b/store/src/java/com/zimbra/cs/service/account/Auth.java @@ -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; @@ -333,7 +334,14 @@ public Element handle(Element request, Map 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) { @@ -475,6 +483,29 @@ private Element needTwoFactorAuth(Map 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 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) { diff --git a/store/src/java/com/zimbra/cs/service/account/ChangePassword.java b/store/src/java/com/zimbra/cs/service/account/ChangePassword.java index d3e6b355d83..491744f6f80 100644 --- a/store/src/java/com/zimbra/cs/service/account/ChangePassword.java +++ b/store/src/java/com/zimbra/cs/service/account/ChangePassword.java @@ -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; @@ -50,7 +51,10 @@ public Element handle(Element request, Map 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; @@ -58,6 +62,7 @@ public Element handle(Element request, Map context) throws Servi 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) @@ -72,22 +77,44 @@ public Element handle(Element request, Map 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 { @@ -96,7 +123,7 @@ public Element handle(Element request, Map 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); } @@ -106,12 +133,6 @@ public Element handle(Element request, Map context) throws Servi @Override public boolean needsAuth(Map 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; } }