diff --git a/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java b/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java index 74a13df189..8b81d58fd0 100644 --- a/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java +++ b/openam-core/src/main/java/com/sun/identity/idm/AMIdentity.java @@ -29,6 +29,7 @@ */ package com.sun.identity.idm; +import java.security.AccessController; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -40,6 +41,8 @@ import org.forgerock.i18n.LocalizedIllegalArgumentException; import org.forgerock.openam.auditors.RepoAuditor; import org.forgerock.openam.ldap.LDAPUtils; +import org.forgerock.openam.session.SessionCache; +import org.forgerock.openam.session.service.access.SessionQueryManager; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.RDN; @@ -51,6 +54,7 @@ import com.sun.identity.common.CaseInsensitiveHashMap; import com.sun.identity.common.CaseInsensitiveHashSet; import com.sun.identity.idm.common.IdRepoUtils; +import com.sun.identity.security.AdminTokenAction; import com.sun.identity.setup.AMSetupServlet; import com.sun.identity.shared.Constants; import com.sun.identity.shared.debug.Debug; @@ -459,6 +463,10 @@ public void setAttributes(Map attrMap) throws IdRepoException, SSOException /** * Changes password for the identity. * + *

For a server-side user changing their own password, other stateful sessions are revoked while the current + * master session is preserved. Revocation failures are logged without failing the password change. This does not + * cover an administrator changing another user's password or Remote SDK requests handled directly by IdServices.

+ * * @param oldPassword old password * @param newPassword new password * @throws IdRepoException If there are repository related error conditions. @@ -471,6 +479,22 @@ public void changePassword(String oldPassword, String newPassword) IdServices idServices = IdServicesFactory.getDataStoreServices(); idServices.changePassword(token, type, name, oldPassword, newPassword, orgName, getDN()); + + try { + String callerId = token.getProperty(Constants.UNIVERSAL_IDENTIFIER); + if (IdType.USER.equals(type) && callerId != null + && DN.valueOf(callerId).equals(DN.valueOf(univIdWithoutDN))) { + String currentSessionId = token.getTokenID().toString(); + if (token.isTokenRestricted()) { + currentSessionId = token.dereferenceRestrictedTokenID( + AccessController.doPrivileged(AdminTokenAction.getInstance()), currentSessionId); + } + new PasswordChangeSessionRevoker(InjectorHolder.getInstance(SessionQueryManager.class), + SessionCache.getInstance()).revoke(callerId, currentSessionId); + } + } catch (Exception e) { + debug.warning("Unable to revoke sessions after password change", e); + } RepoAuditor auditor = newAuditor(token, orgName, univIdWithoutDN, null); if(auditor != null) { diff --git a/openam-core/src/main/java/com/sun/identity/idm/PasswordChangeSessionRevoker.java b/openam-core/src/main/java/com/sun/identity/idm/PasswordChangeSessionRevoker.java new file mode 100644 index 0000000000..38644afb9e --- /dev/null +++ b/openam-core/src/main/java/com/sun/identity/idm/PasswordChangeSessionRevoker.java @@ -0,0 +1,48 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package com.sun.identity.idm; + +import com.iplanet.dpro.session.Session; +import com.iplanet.dpro.session.SessionException; +import com.iplanet.dpro.session.SessionID; +import com.sun.identity.shared.debug.Debug; +import org.forgerock.openam.session.SessionCache; +import org.forgerock.openam.session.service.access.SessionQueryManager; + +final class PasswordChangeSessionRevoker { + private static final Debug DEBUG = Debug.getInstance("amIdm"); + private final SessionQueryManager query; + private final SessionCache cache; + + PasswordChangeSessionRevoker(SessionQueryManager query, SessionCache cache) { + this.query = query; + this.cache = cache; + } + + void revoke(String userId, String currentSessionId) throws SessionException { + for (String sessionId : query.getAllSessionsByUUID(userId).keySet()) { + if (sessionId.equals(currentSessionId)) { + continue; + } + try { + Session session = cache.getSession(new SessionID(sessionId), false, false); + session.destroySession(session); + } catch (Exception e) { + DEBUG.warning("Unable to revoke a session after password change", e); + } + } + } +} diff --git a/openam-core/src/test/java/com/sun/identity/idm/AMIdentityPasswordChangeTest.java b/openam-core/src/test/java/com/sun/identity/idm/AMIdentityPasswordChangeTest.java new file mode 100644 index 0000000000..996dc13391 --- /dev/null +++ b/openam-core/src/test/java/com/sun/identity/idm/AMIdentityPasswordChangeTest.java @@ -0,0 +1,224 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package com.sun.identity.idm; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import java.util.HashMap; +import java.util.Map; + +import com.iplanet.am.util.SystemProperties; +import com.iplanet.dpro.session.Session; +import com.iplanet.dpro.session.SessionID; +import com.iplanet.dpro.session.operations.ServerSessionOperationStrategy; +import com.iplanet.dpro.session.operations.strategies.LocalOperations; +import com.iplanet.dpro.session.service.InternalSession; +import com.iplanet.dpro.session.service.InternalSessionEventBroker; +import com.iplanet.dpro.session.service.SessionServerConfig; +import com.iplanet.dpro.session.service.SessionState; +import com.iplanet.dpro.session.share.SessionInfo; +import com.iplanet.dpro.session.utils.SessionInfoFactory; +import com.iplanet.sso.SSOException; +import com.iplanet.sso.SSOToken; +import com.iplanet.sso.SSOTokenID; +import com.iplanet.sso.SSOTokenManager; +import com.sun.identity.security.AdminTokenAction; +import com.sun.identity.setup.AMSetupServlet; +import com.sun.identity.shared.Constants; +import com.sun.identity.shared.debug.Debug; +import org.forgerock.guice.core.InjectorHolder; +import org.forgerock.openam.session.SessionCache; +import org.forgerock.openam.session.SessionCookies; +import org.forgerock.openam.session.SessionCuller; +import org.forgerock.openam.session.SessionPollerPool; +import org.forgerock.openam.session.SessionServiceURLService; +import org.forgerock.openam.session.authorisation.SessionChangeAuthorizer; +import org.forgerock.openam.session.service.SessionAccessManager; +import org.forgerock.openam.session.service.access.SessionQueryManager; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.testng.PowerMockTestCase; +import org.powermock.reflect.Whitebox; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +@PrepareForTest({SystemProperties.class, InjectorHolder.class, IdServicesFactory.class, + AdminTokenAction.class, AMSetupServlet.class, SessionCache.class}) +@PowerMockIgnore({"jdk.internal.reflect.*", "jakarta.servlet.*"}) +public class AMIdentityPasswordChangeTest extends PowerMockTestCase { + private static final String USER_ID = "id=amadmin,ou=user,dc=config"; + + private final Map storedSessions = new HashMap<>(); + private SessionCache cache; + private SessionAccessManager access; + private SSOToken caller; + private SSOToken appToken; + private IdServices idServices; + private SessionQueryManager query; + private AMIdentity identity; + + @BeforeMethod + public void setup() throws Exception { + storedSessions.clear(); + PowerMockito.mockStatic(SystemProperties.class, InjectorHolder.class, IdServicesFactory.class, + AdminTokenAction.class, AMSetupServlet.class); + when(SystemProperties.isServerMode()).thenReturn(true); + when(SystemProperties.getAsBoolean(Session.CACHED_BASE_POLLING_PROPERTY, false)).thenReturn(true); + + SessionPollerPool poller = mock(SessionPollerPool.class); + when(poller.isPollingEnabled()).thenReturn(true); + when(poller.getCacheBasedPolling()).thenReturn(true); + cache = Whitebox.invokeConstructor(SessionCache.class, poller, mock(Debug.class)); + PowerMockito.mockStatic(SessionCache.class); + when(SessionCache.getInstance()).thenReturn(cache); + // Culling is independent of revocation; avoid starting timer jobs in this test. + PowerMockito.whenNew(SessionCuller.class).withAnyArguments().thenReturn(mock(SessionCuller.class)); + + access = mock(SessionAccessManager.class); + when(access.getInternalSession(any(SessionID.class))) + .thenAnswer(invocation -> storedSessions.get(invocation.getArgument(0))); + doAnswer(invocation -> { + InternalSession session = invocation.getArgument(0); + storedSessions.remove(session.getID()); + return null; + }).when(access).removeInternalSession(any(InternalSession.class)); + + SessionChangeAuthorizer authorizer = spy(Whitebox.invokeConstructor(SessionChangeAuthorizer.class, + mock(Debug.class), mock(SSOTokenManager.class))); + // Keep the real VALID-state check; delegation itself is outside this fixture. + doReturn(true).when(authorizer).hasTopLevelAdminRole(any(SessionID.class)); + query = mock(SessionQueryManager.class); + LocalOperations local = Whitebox.invokeConstructor(LocalOperations.class, mock(Debug.class), access, + query, new SessionInfoFactory(), mock(SessionServerConfig.class), + mock(InternalSessionEventBroker.class), authorizer); + ServerSessionOperationStrategy strategy = mock(ServerSessionOperationStrategy.class); + when(strategy.getOperation(any(SessionID.class))).thenReturn(local); + when(InjectorHolder.getInstance(ServerSessionOperationStrategy.class)).thenReturn(strategy); + when(InjectorHolder.getInstance(SessionCache.class)).thenReturn(cache); + when(InjectorHolder.getInstance(SessionCookies.class)).thenReturn(mock(SessionCookies.class)); + when(InjectorHolder.getInstance(SessionServiceURLService.class)) + .thenReturn(mock(SessionServiceURLService.class)); + when(InjectorHolder.getInstance(SessionQueryManager.class)).thenReturn(query); + + appToken = mock(SSOToken.class); + AdminTokenAction adminAction = mock(AdminTokenAction.class); + when(AdminTokenAction.getInstance()).thenReturn(adminAction); + when(adminAction.run()).thenReturn(appToken); + + idServices = mock(IdServices.class); + when(IdServicesFactory.getDataStoreServices()).thenReturn(idServices); + caller = mock(SSOToken.class); + when(caller.getProperty(Constants.UNIVERSAL_IDENTIFIER)).thenReturn(USER_ID); + SSOTokenID callerId = mock(SSOTokenID.class); + when(callerId.toString()).thenReturn("current"); + when(caller.getTokenID()).thenReturn(callerId); + identity = new AMIdentity(caller); + + addStoredSession("current"); + addStoredSession("other"); + Map sessions = new HashMap<>(); + sessions.put("current", 1L); + sessions.put("other", 1L); + when(query.getAllSessionsByUUID(USER_ID)).thenReturn(sessions); + } + + @Test + public void revokesUncachedSessionWithoutResettingIdleTime() throws Exception { + assertFalse(cache.hasSession(new SessionID("other"))); + InternalSession other = storedSessions.get(new SessionID("other")); + + identity.changePassword("old", "new"); + + assertFalse(storedSessions.containsKey(new SessionID("other"))); + assertTrue(storedSessions.containsKey(new SessionID("current"))); + verify(idServices).changePassword(caller, IdType.USER, "amadmin", "old", "new", "dc=config", null); + verify(other, never()).setLatestAccessTime(); + } + + @Test + public void keepsMasterSessionWhenCallerUsesRestrictedToken() throws Exception { + when(caller.getTokenID().toString()).thenReturn("restricted"); + when(caller.isTokenRestricted()).thenReturn(true); + when(caller.dereferenceRestrictedTokenID(appToken, "restricted")).thenReturn("current"); + // Isolate the ID mismatch from the separate cache-miss bug. + cache.getSession(new SessionID("current"), false, false); + cache.getSession(new SessionID("other"), false, false); + + identity.changePassword("old", "new"); + + assertTrue(storedSessions.containsKey(new SessionID("current"))); + assertFalse(storedSessions.containsKey(new SessionID("other"))); + } + + @Test + public void doesNotRevokeSessionsWhenRestrictedTokenCannotBeResolved() throws Exception { + when(caller.isTokenRestricted()).thenReturn(true); + when(caller.dereferenceRestrictedTokenID(appToken, "current")) + .thenThrow(new SSOException("unavailable")); + cache.getSession(new SessionID("current"), false, false); + cache.getSession(new SessionID("other"), false, false); + + identity.changePassword("old", "new"); + + assertEquals(storedSessions.size(), 2); + verify(idServices).changePassword(caller, IdType.USER, "amadmin", "old", "new", "dc=config", null); + verifyZeroInteractions(query); + } + + @Test + public void doesNotRevokeSessionsWhenChangingAnotherUsersPassword() throws Exception { + AMIdentity otherIdentity = new AMIdentity(caller, "id=another,ou=user,dc=config"); + + otherIdentity.changePassword("old", "new"); + + assertEquals(storedSessions.size(), 2); + verify(idServices).changePassword(caller, IdType.USER, "another", "old", "new", "dc=config", null); + verifyZeroInteractions(query); + } + + @Test(expectedExceptions = SSOException.class) + public void doesNotRevokeSessionsWhenPasswordChangeFails() throws Exception { + doThrow(new SSOException("password change failed")).when(idServices) + .changePassword(caller, IdType.USER, "amadmin", "old", "new", "dc=config", null); + + try { + identity.changePassword("old", "new"); + } finally { + assertEquals(storedSessions.size(), 2); + verifyZeroInteractions(query); + } + } + + private void addStoredSession(String id) throws Exception { + SessionID sessionId = new SessionID(id); + InternalSession internal = mock(InternalSession.class); + when(internal.getID()).thenReturn(sessionId); + when(internal.getSessionID()).thenReturn(sessionId); + when(internal.getState()).thenReturn(SessionState.VALID); + SessionInfo info = new SessionInfo(); + info.setSessionType("user"); + info.setState("valid"); + info.setClientID(USER_ID); + info.setClientDomain("dc=config"); + info.setMaxTime(60); + info.setMaxIdle(30); + info.setMaxCaching(3); + when(internal.toSessionInfo()).thenReturn(info); + storedSessions.put(sessionId, internal); + } +} diff --git a/openam-core/src/test/java/com/sun/identity/idm/PasswordChangeSessionRevokerTest.java b/openam-core/src/test/java/com/sun/identity/idm/PasswordChangeSessionRevokerTest.java new file mode 100644 index 0000000000..bc57e29695 --- /dev/null +++ b/openam-core/src/test/java/com/sun/identity/idm/PasswordChangeSessionRevokerTest.java @@ -0,0 +1,89 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package com.sun.identity.idm; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.assertEquals; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.iplanet.dpro.session.Session; +import com.iplanet.dpro.session.SessionException; +import com.iplanet.dpro.session.SessionID; +import org.forgerock.openam.session.SessionCache; +import org.forgerock.openam.session.service.access.SessionQueryManager; +import org.mockito.ArgumentCaptor; +import org.testng.annotations.Test; + +public class PasswordChangeSessionRevokerTest { + @Test + public void revokesOtherSessionsAndKeepsCurrentSession() throws Exception { + SessionQueryManager query = mock(SessionQueryManager.class); + SessionCache cache = mock(SessionCache.class); + Session other = mock(Session.class); + Map sessions = new HashMap<>(); + sessions.put("current", 1L); + sessions.put("other", 1L); + when(query.getAllSessionsByUUID("user")).thenReturn(sessions); + when(cache.getSession(any(), eq(false), eq(false))).thenReturn(other); + + new PasswordChangeSessionRevoker(query, cache).revoke("user", "current"); + + ArgumentCaptor requestedId = ArgumentCaptor.forClass(SessionID.class); + verify(cache).getSession(requestedId.capture(), eq(false), eq(false)); + assertEquals(requestedId.getValue().toString(), "other"); + verify(other).destroySession(other); + } + + @Test + public void continuesAfterOneSessionCannotBeDestroyed() throws Exception { + SessionQueryManager query = mock(SessionQueryManager.class); + SessionCache cache = mock(SessionCache.class); + Session other = mock(Session.class); + Map sessions = new HashMap<>(); + sessions.put("one", 1L); + sessions.put("two", 1L); + when(query.getAllSessionsByUUID("user")).thenReturn(sessions); + when(cache.getSession(any(), eq(false), eq(false))) + .thenThrow(new SessionException("failed")).thenReturn(other); + + new PasswordChangeSessionRevoker(query, cache).revoke("user", "current"); + + verify(other).destroySession(other); + } + + @Test + public void continuesAfterDestroySessionFails() throws Exception { + SessionQueryManager query = mock(SessionQueryManager.class); + SessionCache cache = mock(SessionCache.class); + Session first = mock(Session.class); + Session second = mock(Session.class); + Map sessions = new LinkedHashMap<>(); + sessions.put("first", 1L); + sessions.put("second", 1L); + when(query.getAllSessionsByUUID("user")).thenReturn(sessions); + when(cache.getSession(new SessionID("first"), false, false)).thenReturn(first); + when(cache.getSession(new SessionID("second"), false, false)).thenReturn(second); + doThrow(new SessionException("failed")).when(first).destroySession(first); + + new PasswordChangeSessionRevoker(query, cache).revoke("user", "current"); + + verify(first).destroySession(first); + verify(second).destroySession(second); + } +}