This is an automated email from the ASF dual-hosted git repository.

mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 5f6705bc66 Improved: Scope password-reset JWTs to their purpose, 
confined to the password-change flow
5f6705bc66 is described below

commit 5f6705bc66b25e79c4f2396fe90a38f7e649d9b9
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 408bbd7f7c..38415c1d84 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 13c3232d12..4e466bbed6 100644
--- a/framework/common/servicedef/services.xml
+++ b/framework/common/servicedef/services.xml
@@ -417,6 +417,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="jakarta.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>
@@ -457,6 +461,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 d846b189c2..e2bb37057d 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
@@ -818,7 +826,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 27d91889b0..661c623a69 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) {
@@ -374,6 +381,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
@@ -544,7 +559,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(
@@ -587,6 +608,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) {
@@ -596,6 +621,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";
             }

Reply via email to