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/common/src/java/com/zimbra/common/soap/AdminConstants.java b/common/src/java/com/zimbra/common/soap/AdminConstants.java
index 09469853c6f..5b72e6cd7a5 100644
--- a/common/src/java/com/zimbra/common/soap/AdminConstants.java
+++ b/common/src/java/com/zimbra/common/soap/AdminConstants.java
@@ -42,6 +42,8 @@ public final class AdminConstants {
public static final String E_AUTH_REQUEST = "AuthRequest";
public static final String E_AUTH_RESPONSE = "AuthResponse";
+ public static final String E_CHANGE_PASSWORD_REQUEST = "ChangePasswordRequest";
+ public static final String E_CHANGE_PASSWORD_RESPONSE = "ChangePasswordResponse";
public static final String E_CREATE_ACCOUNT_REQUEST = "CreateAccountRequest";
public static final String E_CREATE_ACCOUNT_RESPONSE = "CreateAccountResponse";
public static final String E_CREATE_GAL_SYNC_ACCOUNT_REQUEST = "CreateGalSyncAccountRequest";
@@ -599,6 +601,8 @@ public final class AdminConstants {
public static final QName SEARCH_ACCOUNTS_RESPONSE = QName.get(E_SEARCH_ACCOUNTS_RESPONSE, NAMESPACE);
public static final QName RENAME_ACCOUNT_REQUEST = QName.get(E_RENAME_ACCOUNT_REQUEST, NAMESPACE);
public static final QName RENAME_ACCOUNT_RESPONSE = QName.get(E_RENAME_ACCOUNT_RESPONSE, NAMESPACE);
+ public static final QName CHANGE_PASSWORD_REQUEST = QName.get(E_CHANGE_PASSWORD_REQUEST, NAMESPACE);
+ public static final QName CHANGE_PASSWORD_RESPONSE = QName.get(E_CHANGE_PASSWORD_RESPONSE, NAMESPACE);
public static final QName CHANGE_PRIMARY_EMAIL_REQUEST = QName.get(E_CHANGE_PRIMARY_EMAIL_REQUEST, NAMESPACE);
public static final QName CHANGE_PRIMARY_EMAIL_RESPONSE = QName.get(E_CHANGE_PRIMARY_EMAIL_RESPONSE, NAMESPACE);
diff --git a/soap/src/java/com/zimbra/soap/JaxbUtil.java b/soap/src/java/com/zimbra/soap/JaxbUtil.java
index f2217eb5d08..2ba30172be2 100644
--- a/soap/src/java/com/zimbra/soap/JaxbUtil.java
+++ b/soap/src/java/com/zimbra/soap/JaxbUtil.java
@@ -252,6 +252,8 @@ public final class JaxbUtil {
com.zimbra.soap.admin.message.CancelPendingAccountOnlyRemoteWipeResponse.class,
com.zimbra.soap.admin.message.CancelPendingRemoteWipeRequest.class,
com.zimbra.soap.admin.message.CancelPendingRemoteWipeResponse.class,
+ com.zimbra.soap.admin.message.ChangePasswordRequest.class,
+ com.zimbra.soap.admin.message.ChangePasswordResponse.class,
com.zimbra.soap.admin.message.CheckAuthConfigRequest.class,
com.zimbra.soap.admin.message.CheckAuthConfigResponse.class,
com.zimbra.soap.admin.message.CheckBlobConsistencyRequest.class,
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..e64842a2634 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 ZmBoolean resetPassword;
+
public AuthResponse() {
}
@@ -280,6 +283,16 @@ 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 ZmBoolean getResetPassword() {
+ return resetPassword;
+ }
+
+ public AuthResponse setResetPassword(boolean resetPassword) {
+ this.resetPassword = ZmBoolean.fromBool(resetPassword);
+ return this;
+ }
+
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/soap/src/java/com/zimbra/soap/admin/message/AuthResponse.java b/soap/src/java/com/zimbra/soap/admin/message/AuthResponse.java
index b77ebff363f..6712c8749e8 100644
--- a/soap/src/java/com/zimbra/soap/admin/message/AuthResponse.java
+++ b/soap/src/java/com/zimbra/soap/admin/message/AuthResponse.java
@@ -63,6 +63,9 @@ public class AuthResponse {
@XmlElement(name=AccountConstants.E_TWO_FACTOR_AUTH_REQUIRED, required=false)
private ZmBoolean twoFactorAuthRequired;
+ @XmlElement(name=AccountConstants.E_RESET_PWD, required=false)
+ private ZmBoolean resetPassword;
+
public AuthResponse() {
}
@@ -105,4 +108,14 @@ public void setCsrfToken(String csrfToken) {
@GraphQLQuery(name="twoFactorAuthRequired", description="Denotes if two factor authentication is required")
public ZmBoolean getTwoFactorAuthRequired() { return twoFactorAuthRequired; }
public AuthResponse setTwoFactorAuthRequired(boolean bool) { this.twoFactorAuthRequired = ZmBoolean.fromBool(bool); return this; }
+
+ @GraphQLQuery(name="resetPassword", description="if true then auth token will be used to change password")
+ public ZmBoolean getResetPassword() {
+ return resetPassword;
+ }
+
+ public AuthResponse setResetPassword(boolean resetPassword) {
+ this.resetPassword = ZmBoolean.fromBool(resetPassword);
+ return this;
+ }
}
diff --git a/soap/src/java/com/zimbra/soap/admin/message/ChangePasswordRequest.java b/soap/src/java/com/zimbra/soap/admin/message/ChangePasswordRequest.java
new file mode 100644
index 00000000000..d566afe03eb
--- /dev/null
+++ b/soap/src/java/com/zimbra/soap/admin/message/ChangePasswordRequest.java
@@ -0,0 +1,113 @@
+/*
+ * ***** BEGIN LICENSE BLOCK *****
+ * Zimbra Collaboration Suite Server
+ * Copyright (C) 2024 Synacor, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software Foundation,
+ * version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License along with this program.
+ * If not, see .
+ * ***** END LICENSE BLOCK *****
+ */
+package com.zimbra.soap.admin.message;
+
+import com.zimbra.common.soap.AccountConstants;
+import com.zimbra.common.soap.AdminConstants;
+import com.zimbra.soap.account.type.AuthToken;
+import com.zimbra.soap.type.AccountSelector;
+
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlType;
+
+@XmlRootElement(name= AdminConstants.E_CHANGE_PASSWORD_REQUEST)
+@XmlType(propOrder = {})
+public class ChangePasswordRequest {
+ /**
+ * @zm-api-field-description Details of the account
+ */
+ @XmlElement(name=AccountConstants.E_ACCOUNT, required=true)
+ private AccountSelector account;
+ /**
+ * @zm-api-field-description Old password
+ */
+ @XmlElement(name=AccountConstants.E_OLD_PASSWORD, required=true)
+ private String oldPassword;
+ /**
+ * @zm-api-field-description New Password to assign
+ */
+ @XmlElement(name=AccountConstants.E_PASSWORD, required=true)
+ private String password;
+ /**
+ * @zm-api-field-tag virtual-host
+ * @zm-api-field-description if specified virtual-host is used to determine the domain of the account name,
+ * if it does not include a domain component. For example, if the domain foo.com has a zimbraVirtualHostname of
+ * "mail.foo.com", and an auth request comes in for "joe" with a virtualHost of "mail.foo.com", then the request
+ * will be equivalent to logging in with "joe@foo.com".
+ */
+ @XmlElement(name=AccountConstants.E_VIRTUAL_HOST, required=false)
+ private String virtualHost;
+
+ @XmlElement(name=AccountConstants.E_DRYRUN, required=false)
+ private boolean dryRun;
+
+ @XmlElement(name=AccountConstants.E_AUTH_TOKEN /* authToken */, required=false)
+ private AuthToken authToken;
+
+ public ChangePasswordRequest() {
+ }
+
+ public ChangePasswordRequest(AccountSelector account, String oldPassword, String newPassword) {
+ setAccount(account);
+ setOldPassword(oldPassword);
+ setPassword(newPassword);
+ }
+
+ public ChangePasswordRequest(AccountSelector account, String oldPassword, String newPassword, boolean dryRun) {
+ setAccount(account);
+ setOldPassword(oldPassword);
+ setPassword(newPassword);
+ setDryRun(dryRun);
+ }
+
+ public AccountSelector getAccount() { return account; }
+ public String oldPassword() { return oldPassword; }
+ public String getPassword() { return password; }
+ public String getVirtualHost() { return virtualHost; }
+
+ public ChangePasswordRequest setAccount(AccountSelector account) {
+ this.account = account;
+ return this;
+ }
+
+ public ChangePasswordRequest setOldPassword(String oldPassword) {
+ this.oldPassword = oldPassword;
+ return this;
+ }
+
+ public ChangePasswordRequest setPassword(String password) {
+ this.password = password;
+ return this;
+ }
+
+ public ChangePasswordRequest setVirtualHost(String host) {
+ virtualHost = host;
+ return this;
+ }
+
+ public boolean isDryRun() {
+ return dryRun;
+ }
+
+ 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/soap/src/java/com/zimbra/soap/admin/message/ChangePasswordResponse.java b/soap/src/java/com/zimbra/soap/admin/message/ChangePasswordResponse.java
new file mode 100644
index 00000000000..19d8770420b
--- /dev/null
+++ b/soap/src/java/com/zimbra/soap/admin/message/ChangePasswordResponse.java
@@ -0,0 +1,65 @@
+/*
+ * ***** BEGIN LICENSE BLOCK *****
+ * Zimbra Collaboration Suite Server
+ * Copyright (C) 2024 Synacor, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software Foundation,
+ * version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License along with this program.
+ * If not, see .
+ * ***** END LICENSE BLOCK *****
+ */
+package com.zimbra.soap.admin.message;
+
+import com.zimbra.common.gql.GqlConstants;
+import com.zimbra.common.soap.AccountConstants;
+import com.zimbra.common.soap.AdminConstants;
+import com.zimbra.soap.json.jackson.annotate.ZimbraJsonAttribute;
+import io.leangen.graphql.annotations.GraphQLQuery;
+import io.leangen.graphql.annotations.types.GraphQLType;
+
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlType;
+
+@XmlRootElement(name= AdminConstants.E_CHANGE_PASSWORD_RESPONSE)
+@GraphQLType(name= GqlConstants.CLASS_CHANGE_PASSWORD_RESPONSE, description="The response to change password request.")
+@XmlType(propOrder = {})
+public class ChangePasswordResponse {
+
+ /**
+ * @zm-api-field-tag new-auth-token
+ * @zm-api-field-description New authToken, as old authToken is invalidated on password change.
+ */
+ @XmlElement(name=AccountConstants.E_AUTH_TOKEN /* authToken */, required=true)
+ private String authToken;
+ /**
+ * @zm-api-field-description Life time associated with {new-auth-token}
+ */
+ @ZimbraJsonAttribute
+ @XmlElement(name=AccountConstants.E_LIFETIME /* lifetime */, required=true)
+ private long lifetime;
+
+ public ChangePasswordResponse() {
+ }
+
+ @GraphQLQuery(name=GqlConstants.AUTH_TOKEN, description="Auth token based on the new password")
+ public String getAuthToken() { return authToken; }
+ @GraphQLQuery(name=GqlConstants.LIFETIME, description="Life time of the auth token")
+ public long getLifetime() { return lifetime; }
+
+ public ChangePasswordResponse setAuthToken(String authToken) {
+ this.authToken = authToken;
+ return this;
+ }
+
+ public ChangePasswordResponse setLifetime(long lifetime) {
+ this.lifetime = lifetime;
+ 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/AccountDocumentHandler.java b/store/src/java/com/zimbra/cs/service/account/AccountDocumentHandler.java
index 5aa3cbc61ac..7fe49050532 100644
--- a/store/src/java/com/zimbra/cs/service/account/AccountDocumentHandler.java
+++ b/store/src/java/com/zimbra/cs/service/account/AccountDocumentHandler.java
@@ -50,34 +50,7 @@ protected Element proxyIfNecessary(Element request, Map context)
throw e;
}
}
-
- /*
- * bug 27389
- */
- protected boolean checkPasswordSecurity(Map context) throws ServiceException {
- HttpServletRequest req = (HttpServletRequest)context.get(SoapServlet.SERVLET_REQUEST);
- boolean isHttps = req.getScheme().equals("https");
- if (isHttps)
- return true;
-
- // clear text
- Server server = Provisioning.getInstance().getLocalServer();
- String modeString = server.getAttr(Provisioning.A_zimbraMailMode, null);
- if (modeString == null) {
- // not likely, but just log and let it through
- ZimbraLog.soap.warn("missing " + Provisioning.A_zimbraMailMode +
- " for checking password security, allowing the request");
- return true;
- }
-
- MailMode mailMode = Provisioning.MailMode.fromString(modeString);
- if (mailMode == MailMode.mixed &&
- !server.getBooleanAttr(Provisioning.A_zimbraMailClearTextPasswordEnabled, true))
- return false;
- else
- return true;
- }
-
+
protected Set getReqAttrs(Element request, AttributeClass klass) throws ServiceException {
String attrsStr = request.getAttribute(AccountConstants.A_ATTRS, null);
if (attrsStr == null) {
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..dcdb51b0b70 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,16 @@ 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 {
+ throw ase;
+ }
+ }
} 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 +485,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;
}
}
diff --git a/store/src/java/com/zimbra/cs/service/admin/AdminDocumentHandler.java b/store/src/java/com/zimbra/cs/service/admin/AdminDocumentHandler.java
index a41784bc7c4..a55c0249df0 100644
--- a/store/src/java/com/zimbra/cs/service/admin/AdminDocumentHandler.java
+++ b/store/src/java/com/zimbra/cs/service/admin/AdminDocumentHandler.java
@@ -57,6 +57,7 @@
import com.zimbra.cs.account.names.NameUtil;
import com.zimbra.cs.session.Session;
import com.zimbra.soap.DocumentHandler;
+import com.zimbra.soap.SoapServlet;
import com.zimbra.soap.ZimbraSoapContext;
import com.zimbra.soap.admin.type.CosSelector;
import com.zimbra.soap.admin.type.CosSelector.CosBy;
@@ -64,6 +65,8 @@
import com.zimbra.soap.admin.type.ServerSelector;
import com.zimbra.soap.type.AccountSelector;
+import javax.servlet.http.HttpServletRequest;
+
/**
* @since Oct 4, 2004
* @author schemers
diff --git a/store/src/java/com/zimbra/cs/service/admin/AdminService.java b/store/src/java/com/zimbra/cs/service/admin/AdminService.java
index 09413c2bb93..918f2f7cca9 100644
--- a/store/src/java/com/zimbra/cs/service/admin/AdminService.java
+++ b/store/src/java/com/zimbra/cs/service/admin/AdminService.java
@@ -58,6 +58,7 @@ public void registerHandlers(DocumentDispatcher dispatcher) {
dispatcher.registerHandler(AdminConstants.REMOVE_ACCOUNT_ALIAS_REQUEST, new RemoveAccountAlias());
dispatcher.registerHandler(AdminConstants.SEARCH_ACCOUNTS_REQUEST, new SearchAccounts());
dispatcher.registerHandler(AdminConstants.RENAME_ACCOUNT_REQUEST, new RenameAccount());
+ dispatcher.registerHandler(AdminConstants.CHANGE_PASSWORD_REQUEST, new ChangePassword());
dispatcher.registerHandler(AdminConstants.CHANGE_PRIMARY_EMAIL_REQUEST, new ChangePrimaryEmail());
dispatcher.registerHandler(AdminConstants.RESET_ACCOUNT_PASSWORD_REQUEST, new ResetAccountPassword());
diff --git a/store/src/java/com/zimbra/cs/service/admin/Auth.java b/store/src/java/com/zimbra/cs/service/admin/Auth.java
index 6efc5f7ed6a..a823b9a336f 100644
--- a/store/src/java/com/zimbra/cs/service/admin/Auth.java
+++ b/store/src/java/com/zimbra/cs/service/admin/Auth.java
@@ -105,11 +105,19 @@ public Element handle(Element request, Map context) throws Servi
Map authCtxt = AccountUtil.getAdminAuthContext(context, acctName, zsc);
context.put(Provisioning.AUTH_MODE_KEY, AuthMode.PASSWORD);
-
- prov.authAccount(acct, password, AuthContext.Protocol.soap, authCtxt);
+ Usage usage = Usage.AUTH;
+ try {
+ prov.authAccount(acct, password, 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.");
+ usage = Usage.RESET_PASSWORD;
+ } else {
+ throw ase;
+ }
+ }
AuthMech authedByMech = (AuthMech) authCtxt.get(AuthContext.AC_AUTHED_BY_MECH);
- Usage usage = Usage.AUTH;
TokenType tokenType = null;
if (AccountUtil.isTwoFactorAccount(acct)) {
@@ -157,7 +165,9 @@ private Element doResponse(Element request, AuthToken at, ZimbraSoapContext zsc,
Element response = zsc.createElement(AdminConstants.AUTH_RESPONSE);
response.addAttribute(AdminConstants.E_LIFETIME, at.getExpires() - System.currentTimeMillis(), Element.Disposition.CONTENT);
at.setCsrfTokenEnabled(csrfSupport);
- if (at.getUsage() == Usage.TWO_FACTOR_AUTH) {
+ if (at.getUsage() == Usage.RESET_PASSWORD) {
+ response.addUniqueElement(AccountConstants.E_RESET_PWD).setText("true");
+ } else if (at.getUsage() == Usage.TWO_FACTOR_AUTH) {
response.addUniqueElement(AccountConstants.E_TWO_FACTOR_AUTH_REQUIRED).setText("true");
AccountUtil.addTwoFactorAttributes(response, acct);
} else {
diff --git a/store/src/java/com/zimbra/cs/service/admin/ChangePassword.java b/store/src/java/com/zimbra/cs/service/admin/ChangePassword.java
new file mode 100644
index 00000000000..d9f282f6868
--- /dev/null
+++ b/store/src/java/com/zimbra/cs/service/admin/ChangePassword.java
@@ -0,0 +1,134 @@
+/*
+ * ***** BEGIN LICENSE BLOCK *****
+ * Zimbra Collaboration Suite Server
+ * Copyright (C) 2024 Synacor, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software Foundation,
+ * version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the GNU General Public License for more details.
+ * You should have received a copy of the GNU General Public License along with this program.
+ * If not, see .
+ * ***** END LICENSE BLOCK *****
+ */
+package com.zimbra.cs.service.admin;
+
+import com.zimbra.common.account.Key;
+import com.zimbra.common.service.ServiceException;
+import com.zimbra.common.soap.AccountConstants;
+import com.zimbra.common.soap.AdminConstants;
+import com.zimbra.common.soap.Element;
+import com.zimbra.common.util.StringUtil;
+import com.zimbra.cs.account.Account;
+import com.zimbra.cs.account.AccountServiceException;
+import com.zimbra.cs.account.AuthToken;
+import com.zimbra.cs.account.AuthTokenException;
+import com.zimbra.cs.account.Domain;
+import com.zimbra.cs.account.Provisioning;
+import com.zimbra.cs.service.AuthProvider;
+import com.zimbra.soap.ZimbraSoapContext;
+
+import java.util.Map;
+
+public class ChangePassword extends AdminDocumentHandler {
+ @Override
+ public Element handle(Element request, Map context) throws ServiceException {
+ if (!checkPasswordSecurity(context)) {
+ throw ServiceException.INVALID_REQUEST("clear text password is not allowed", null);
+ }
+
+ ZimbraSoapContext zsc = getZimbraSoapContext(context);
+ 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) {
+ name = name + "@" + d.getName();
+ }
+ }
+
+ String text = request.getAttribute(AccountConstants.E_DRYRUN, null);
+ boolean dryRun = false;
+ if (!StringUtil.isNullOrEmpty(text)) {
+ if (text.equals("1") || text.equalsIgnoreCase("true")) {
+ dryRun = true;
+ }
+ }
+
+ AuthToken at = zsc.getAuthToken();
+ Account acct = prov.get(Key.AccountBy.name, name, at);
+ if (acct == null) {
+ throw AccountServiceException.AuthFailedServiceException.AUTH_FAILED(name, namePassedIn, "account not found");
+ }
+
+ AuthToken.Usage usage = AuthToken.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 = AuthToken.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 AccountServiceException.AuthFailedServiceException.AUTH_FAILED(name, namePassedIn, "account not found");
+ }
+ String oldPassword = request.getAttribute(AccountConstants.E_OLD_PASSWORD);
+ String newPassword = request.getAttribute(AccountConstants.E_PASSWORD);
+
+ boolean mustChange = acct.getBooleanAttr(Provisioning.A_zimbraPasswordMustChange, false);
+ if (mustChange && AuthToken.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())) {
+ prov.setPassword(acct, newPassword, true);
+ acct.setVirtualAccountInitialPasswordSet(true);
+ } else {
+ prov.changePassword(acct, oldPassword, newPassword, dryRun);
+ }
+
+ Element response = zsc.createElement(AdminConstants.CHANGE_PASSWORD_RESPONSE);
+ if (!dryRun) {
+ at = AuthProvider.getAuthToken(acct);
+ at.encodeAuthResp(response, true);
+ response.addAttribute(AccountConstants.E_LIFETIME, at.getExpires() - System.currentTimeMillis(), Element.Disposition.CONTENT);
+ }
+
+ return response;
+ }
+
+ @Override
+ public boolean needsAdminAuth(Map context) {
+ return false;
+ }
+
+ @Override
+ public boolean needsAuth(Map context) {
+ return false;
+ }
+}
diff --git a/store/src/java/com/zimbra/cs/servlet/util/AuthUtil.java b/store/src/java/com/zimbra/cs/servlet/util/AuthUtil.java
index e12f0a11f1b..205c37f87ef 100644
--- a/store/src/java/com/zimbra/cs/servlet/util/AuthUtil.java
+++ b/store/src/java/com/zimbra/cs/servlet/util/AuthUtil.java
@@ -391,7 +391,7 @@ public static AuthToken getAuthToken(Element request, ZimbraSoapContext zsc)
public static AuthToken getAuthToken(Account acct, boolean isAdmin, Usage usage, TokenType tokenType,
AuthMech authedByMech) throws AuthProviderException {
AuthToken at = null;
- if (Usage.TWO_FACTOR_AUTH == usage) {
+ if (Usage.TWO_FACTOR_AUTH == usage || Usage.RESET_PASSWORD == usage) {
at = AuthProvider.getAuthToken(acct, isAdmin, usage, tokenType);
} else {
at = AuthProvider.getAuthToken(acct, isAdmin, authedByMech);
diff --git a/store/src/java/com/zimbra/soap/DocumentHandler.java b/store/src/java/com/zimbra/soap/DocumentHandler.java
index 952d1170c37..2eec72a3cf2 100644
--- a/store/src/java/com/zimbra/soap/DocumentHandler.java
+++ b/store/src/java/com/zimbra/soap/DocumentHandler.java
@@ -709,4 +709,29 @@ public static void resetLocalHost() {
LOCAL_HOST = "";
LOCAL_HOST_ID = "";
}
+
+ protected boolean checkPasswordSecurity(Map context) throws ServiceException {
+ HttpServletRequest req = (HttpServletRequest)context.get(SoapServlet.SERVLET_REQUEST);
+ boolean isHttps = req.getScheme().equals("https");
+ if (isHttps) {
+ return true;
+ }
+
+ Server server = Provisioning.getInstance().getLocalServer();
+ String modeString = server.getAttr(Provisioning.A_zimbraMailMode, null);
+ if (modeString == null) {
+ // not likely, but just log and let it through
+ ZimbraLog.soap.warn("missing " + Provisioning.A_zimbraMailMode +
+ " for checking password security, allowing the request");
+ return true;
+ }
+
+ Provisioning.MailMode mailMode = Provisioning.MailMode.fromString(modeString);
+ if (mailMode == Provisioning.MailMode.mixed &&
+ !server.getBooleanAttr(Provisioning.A_zimbraMailClearTextPasswordEnabled, true)) {
+ return false;
+ } else {
+ return true;
+ }
+ }
}