This is an automated email from the ASF dual-hosted git repository.
mridulpathak pushed a commit to branch release24.09
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git
The following commit(s) were added to refs/heads/release24.09 by this push:
new a123869b0b Improved: Scope password-reset JWTs to their purpose,
confined to the password-change flow
a123869b0b is described below
commit a123869b0b324a777f442ba8885142615474a7ae
Author: Mridul Pathak <[email protected]>
AuthorDate: Tue Oct 13 15:28:50 2026 +0530
Improved: Scope password-reset JWTs to their purpose, confined to the
password-change flow
- SecurityUtil now generates and validates JWTs with a purpose claim (login
vs password_reset), mirroring the existing refresh/access token distinction in
JWTManager
- The password-reset email token is issued with the password_reset purpose
instead of a general login-scoped token
- A session established from a password-reset-purpose token is confined by
checkLogin() to the passwordChange, updatePassword, and logout requests, and is
promoted to a normal session only once the password has actually been changed
- Declared passwordResetOnly (OUT) on the userLogin service and login.token
(IN) on the updatePassword service; both attributes were silently dropped by
the service engine because they were not declared, which also means the
existing admin-bypass-current-password token check in updatePassword was
non-functional before this change
---
.../ofbiz/securityext/login/LoginEvents.java | 5 ++--
framework/common/servicedef/services.xml | 8 +++++
.../apache/ofbiz/common/login/LoginServices.java | 11 ++++++-
.../org/apache/ofbiz/security/SecurityUtil.java | 35 +++++++++++++++++++---
.../apache/ofbiz/webapp/control/LoginWorker.java | 33 +++++++++++++++++++-
5 files changed, 84 insertions(+), 8 deletions(-)
diff --git
a/applications/securityext/src/main/java/org/apache/ofbiz/securityext/login/LoginEvents.java
b/applications/securityext/src/main/java/org/apache/ofbiz/securityext/login/LoginEvents.java
index a0579d286b..10badd03ac 100644
---
a/applications/securityext/src/main/java/org/apache/ofbiz/securityext/login/LoginEvents.java
+++
b/applications/securityext/src/main/java/org/apache/ofbiz/securityext/login/LoginEvents.java
@@ -228,8 +228,9 @@ public class LoginEvents {
.map(email -> email.getString("infoString"))
.collect(Collectors.joining(","));
- //Generate a JWT with default retention time
- String jwtToken =
SecurityUtil.generateJwtToAuthenticateUserLogin(delegator, userLoginId);
+ //Generate a JWT with default retention time, scoped to resetting
the password only
+ String jwtToken =
SecurityUtil.generateJwtToAuthenticateUserLogin(delegator, userLoginId,
+ SecurityUtil.JWT_PURPOSE_PASSWORD_RESET);
// get the ProductStore email settings
GenericValue productStoreEmail = null;
diff --git a/framework/common/servicedef/services.xml
b/framework/common/servicedef/services.xml
index d23c102779..6833dbd94d 100644
--- a/framework/common/servicedef/services.xml
+++ b/framework/common/servicedef/services.xml
@@ -393,6 +393,10 @@ under the License.
<description>Used to Automatically Authenticate a username/password;
create a UserLogin object</description>
<implements service="authenticationInterface"/>
<attribute name="request" mode="IN"
type="javax.servlet.http.HttpServletRequest" optional="true"/>
+ <attribute name="passwordResetOnly" type="String" mode="OUT"
optional="true">
+ <description>Set to "Y" when login succeeded only via a
password-reset-purpose JWT, meaning the
+ resulting session must be confined to completing that reset,
never treated as a full login.</description>
+ </attribute>
</service>
<service name="userImpersonate" engine="java"
location="org.apache.ofbiz.common.login.LoginServices" invoke="userImpersonate"
auth="true">
<description>Used to Automatically Authenticate a username/password;
create a UserLogin object</description>
@@ -432,6 +436,10 @@ under the License.
<attribute name="newPasswordVerify" type="String" mode="IN"
optional="true"/>
<attribute name="passwordHint" type="String" mode="IN"
optional="true"/>
<attribute name="requirePasswordChange" type="String" mode="IN"
optional="true"/>
+ <attribute name="login.token" type="String" mode="IN" optional="true">
+ <description>A password-reset-purpose JWT authorizing this user to
set a new password without
+ supplying their (forgotten) current password.</description>
+ </attribute>
<attribute name="updatedUserLogin"
type="org.apache.ofbiz.entity.GenericValue" mode="OUT" optional="false"/>
</service>
<service name="updateUserLoginSecurity" engine="java"
default-entity-name="UserLogin"
diff --git
a/framework/common/src/main/java/org/apache/ofbiz/common/login/LoginServices.java
b/framework/common/src/main/java/org/apache/ofbiz/common/login/LoginServices.java
index c125a39d8d..2a1f43543f 100644
---
a/framework/common/src/main/java/org/apache/ofbiz/common/login/LoginServices.java
+++
b/framework/common/src/main/java/org/apache/ofbiz/common/login/LoginServices.java
@@ -231,12 +231,17 @@ public class LoginServices {
// resolve the key for decrypt the token and control
the validity
boolean jwtTokenValid =
SecurityUtil.authenticateUserLoginByJWT(delegator, username, jwtToken);
+ // a password-reset token must never grant a full
login session; it is only accepted here,
+ // flagged as such, so the caller can confine the
resulting session to completing the reset
+ boolean jwtPasswordResetTokenValid = !jwtTokenValid &&
jwtToken != null
+ &&
SecurityUtil.authenticateUserLoginByJWT(delegator, username, jwtToken,
SecurityUtil.JWT_PURPOSE_PASSWORD_RESET);
// if the password.accept.encrypted.and.plain property
in security is set to true allow plain or encrypted passwords
// if this is a system account don't bother checking
the passwords
// if externalAuth passed; this is run as well
if ((!authFatalError && externalAuth) || (useTomcatSSO
&& tomcatSSOLogin(request, username, password))
|| (jwtToken != null && jwtTokenValid)
+ || jwtPasswordResetTokenValid
|| (password != null &&
checkPassword(userLogin.getString("currentPassword"), useEncryption,
password))) {
Debug.logVerbose("[LoginServices.userLogin] :
Password Matched or Token Validated", MODULE);
@@ -268,6 +273,9 @@ public class LoginServices {
result.put("userLogin", userLogin);
result.put(ModelService.RESPONSE_MESSAGE,
ModelService.RESPOND_SUCCESS);
+ if (jwtPasswordResetTokenValid) {
+ result.put("passwordResetOnly", "Y");
+ }
} else {
// password is incorrect, but this may be the
result of a stale cache entry,
// so lets clear the cache and try again if this
is the first pass
@@ -815,7 +823,8 @@ public class LoginServices {
return ServiceUtil.returnError(errMsg);
}
if (UtilValidate.isNotEmpty(context.get("login.token"))) {
- adminUser = SecurityUtil.authenticateUserLoginByJWT(delegator,
userLoginId, (String) context.get("login.token"));
+ adminUser = SecurityUtil.authenticateUserLoginByJWT(delegator,
userLoginId, (String) context.get("login.token"),
+ SecurityUtil.JWT_PURPOSE_PASSWORD_RESET);
}
} else {
adminUser = true;
diff --git
a/framework/security/src/main/java/org/apache/ofbiz/security/SecurityUtil.java
b/framework/security/src/main/java/org/apache/ofbiz/security/SecurityUtil.java
index f9025770cb..b00d7e1cd2 100644
---
a/framework/security/src/main/java/org/apache/ofbiz/security/SecurityUtil.java
+++
b/framework/security/src/main/java/org/apache/ofbiz/security/SecurityUtil.java
@@ -48,6 +48,12 @@ import org.apache.ofbiz.webapp.control.JWTManager;
public final class SecurityUtil {
private static final String MODULE = SecurityUtil.class.getName();
+
+ /** JWT purpose claim value for a token that authorizes a full login
session. */
+ public static final String JWT_PURPOSE_LOGIN = "login";
+ /** JWT purpose claim value for a token that only authorizes resetting a
forgotten password. */
+ public static final String JWT_PURPOSE_PASSWORD_RESET = "password_reset";
+
private static final List<String> ADMIN_PERMISSIONS = UtilMisc.toList(
"IMPERSONATE_ADMIN",
"ARTIFACT_INFO_VIEW",
@@ -212,27 +218,48 @@ public final class SecurityUtil {
}
/**
- * Return a JWToken for authenticate a userLogin with salt the token by
userLoginId and currentPassword
+ * Return a JWToken for authenticate a userLogin with salt the token by
userLoginId and currentPassword.
+ * The token is scoped to {@link #JWT_PURPOSE_LOGIN}, so it may be used to
establish a full login session.
*/
public static String generateJwtToAuthenticateUserLogin(Delegator
delegator, String userLoginId)
throws GenericEntityException {
+ return generateJwtToAuthenticateUserLogin(delegator, userLoginId,
JWT_PURPOSE_LOGIN);
+ }
+
+ /**
+ * Return a JWToken for authenticate a userLogin with salt the token by
userLoginId and currentPassword,
+ * scoped to the given purpose (e.g. {@link #JWT_PURPOSE_LOGIN} or {@link
#JWT_PURPOSE_PASSWORD_RESET}) so
+ * it is only accepted where that purpose is expected.
+ */
+ public static String generateJwtToAuthenticateUserLogin(Delegator
delegator, String userLoginId, String purpose)
+ throws GenericEntityException {
GenericValue userLogin =
EntityQuery.use(delegator).from("UserLogin").where("userLoginId",
userLoginId).queryOne();
- Map<String, String> claims = UtilMisc.toMap("userLoginId",
userLogin.getString("userLoginId"));
+ Map<String, String> claims = UtilMisc.toMap("userLoginId",
userLogin.getString("userLoginId"), "purpose", purpose);
return JWTManager.createJwt(delegator, claims,
userLogin.getString("userLoginId") +
userLogin.getString("currentPassword"), -1);
}
/**
- * For a jwtToken and userLoginId check the coherence between them
+ * For a jwtToken and userLoginId check the coherence between them, for a
token scoped to {@link #JWT_PURPOSE_LOGIN}.
*/
public static boolean authenticateUserLoginByJWT(Delegator delegator,
String userLoginId, String jwtToken) {
+ return authenticateUserLoginByJWT(delegator, userLoginId, jwtToken,
JWT_PURPOSE_LOGIN);
+ }
+
+ /**
+ * For a jwtToken and userLoginId check the coherence between them, and
that the token was issued for the
+ * given purpose. A token issued for one purpose (e.g. resetting a
forgotten password) must never be accepted
+ * for another (e.g. establishing a full login session).
+ */
+ public static boolean authenticateUserLoginByJWT(Delegator delegator,
String userLoginId, String jwtToken, String purpose) {
if (UtilValidate.isNotEmpty(jwtToken)) {
try {
GenericValue userLogin =
EntityQuery.use(delegator).from("UserLogin").where("userLoginId",
userLoginId).queryOne();
if (userLoginId != null) {
Map<String, Object> claims =
JWTManager.validateToken(delegator, jwtToken,
userLogin.getString("userLoginId") +
userLogin.getString("currentPassword"));
- return (!ServiceUtil.isError(claims)) &&
userLoginId.equals(claims.get("userLoginId"));
+ return (!ServiceUtil.isError(claims)) &&
userLoginId.equals(claims.get("userLoginId"))
+ && purpose.equals(claims.get("purpose"));
}
} catch (GenericEntityException e) {
Debug.logWarning("failed to validate a jwToken for user " +
userLoginId, MODULE);
diff --git
a/framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/LoginWorker.java
b/framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/LoginWorker.java
index 7d24627507..09923db18e 100644
---
a/framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/LoginWorker.java
+++
b/framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/LoginWorker.java
@@ -34,6 +34,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.ServiceLoader;
+import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -104,6 +105,12 @@ public final class LoginWorker {
private static final String KEY_VALUE =
UtilProperties.getPropertyValue(SEC_PROPERTIES, "login.secret_key_string");
private static final WebAppCache WEBAPPS = WebAppCache.getShared();
+ // Session attribute marking a session established from a password-reset
token rather than a real login.
+ // Such a session must never be treated as equivalent to a full login:
checkLogin() confines it to the
+ // small set of requests needed to complete the reset.
+ private static final String PASSWORD_RESET_ONLY_SESSION_ATTR =
"_PASSWORD_RESET_ONLY_";
+ private static final Set<String> PASSWORD_RESET_ONLY_ALLOWED_REQUESTS =
UtilMisc.toSet("passwordChange", "updatePassword", "logout");
+
protected LoginWorker() { }
public static StringWrapper makeLoginUrl(PageContext pageContext) {
@@ -372,6 +379,14 @@ public final class LoginWorker {
}
}
+ // a session established from a password-reset token must never be
treated as equivalent to a full
+ // login: confine it to the small set of requests needed to complete
the reset
+ if ("Y".equals(session.getAttribute(PASSWORD_RESET_ONLY_SESSION_ATTR))
+ &&
!PASSWORD_RESET_ONLY_ALLOWED_REQUESTS.contains(RequestHandler.getRequestUri(request.getPathInfo())))
{
+ response.setStatus(HttpStatus.SC_UNAUTHORIZED);
+ return "error";
+ }
+
//Allow loggingOut when impersonated
boolean isLoggingOut =
"logout".equals(RequestHandler.getRequestUri(request.getPathInfo()));
//Check if the user has an impersonation in process
@@ -542,7 +557,13 @@ public final class LoginWorker {
if
(ModelService.RESPOND_SUCCESS.equals(result.get(ModelService.RESPONSE_MESSAGE)))
{
GenericValue userLogin = (GenericValue) result.get("userLogin");
- if (userLogin != null &&
"Y".equals(userLogin.getString("requirePasswordChange"))
+ // a session established with a password-reset token must be
confined to completing that reset;
+ // see the allow-list enforced in checkLogin()
+ boolean passwordResetOnly =
"Y".equals(result.get("passwordResetOnly"));
+ if (passwordResetOnly) {
+ session.setAttribute(PASSWORD_RESET_ONLY_SESSION_ATTR, "Y");
+ }
+ if ((passwordResetOnly || (userLogin != null &&
"Y".equals(userLogin.getString("requirePasswordChange"))))
&&
UtilValidate.isNotEmpty(request.getParameter("newPassword"))
&&
UtilValidate.isNotEmpty(request.getParameter("newPasswordVerify"))) {
Map<String, Object> inMap = UtilMisc.<String, Object>toMap(
@@ -585,6 +606,10 @@ public final class LoginWorker {
request.setAttribute("_ERROR_MESSAGE_", errMsg);
return "error";
}
+ if (passwordResetOnly) {
+ // the reset is now complete; let the normal login
flow proceed with a full session
+
session.removeAttribute(PASSWORD_RESET_ONLY_SESSION_ATTR);
+ }
}
}
if (setupNewDelegatorEtc) {
@@ -594,6 +619,12 @@ public final class LoginWorker {
// check to see if a password change is required for the user
Map<String, Object> userLoginSession =
checkMap(result.get("userLoginSession"), String.class, Object.class);
+ if
("Y".equals(session.getAttribute(PASSWORD_RESET_ONLY_SESSION_ATTR))) {
+ // establish only the minimal session identity needed to
complete the reset; skip the
+ // remember-me cookies and after-login events a full login
would otherwise trigger
+ doBasicLogin(userLogin, request, response);
+ return "requirePasswordChange";
+ }
if (userLogin != null &&
"Y".equals(userLogin.getString("requirePasswordChange"))) {
return "requirePasswordChange";
}