diff --git a/api/build.gradle b/api/build.gradle index 067504192a..f16fe21f28 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -33,6 +33,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-webflux' api 'org.springframework.boot:spring-boot-starter-validation' api 'org.springframework.boot:spring-boot-starter-data-r2dbc' + api 'org.springframework.session:spring-session-core' // Spring Security api 'org.springframework.boot:spring-boot-starter-security' diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 17af565dc0..9c8a053ef7 100644 --- a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -5,8 +5,11 @@ import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.session.SessionProperties; +import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; @@ -23,6 +26,8 @@ import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository; import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher; +import org.springframework.session.MapSession; +import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.core.extension.service.RoleService; @@ -41,6 +46,8 @@ import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher; import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager; import run.halo.app.security.authorization.RequestInfoAuthorizationManager; +import run.halo.app.security.session.InMemoryReactiveIndexedSessionRepository; +import run.halo.app.security.session.ReactiveIndexedSessionRepository; /** * Security configuration for WebFlux. @@ -48,6 +55,7 @@ * @author johnniang */ @Configuration +@EnableSpringWebSession @EnableWebFluxSecurity @RequiredArgsConstructor public class WebServerSecurityConfig { @@ -131,6 +139,17 @@ ServerSecurityContextRepository securityContextRepository() { return new WebSessionServerSecurityContextRepository(); } + @Bean + public ReactiveIndexedSessionRepository reactiveSessionRepository( + SessionProperties sessionProperties, + ServerProperties serverProperties) { + var repository = new InMemoryReactiveIndexedSessionRepository(new ConcurrentHashMap<>()); + var timeout = sessionProperties.determineTimeout( + () -> serverProperties.getReactive().getSession().getTimeout()); + repository.setDefaultMaxInactiveInterval(timeout); + return repository; + } + @Bean DefaultUserDetailService userDetailsService(UserService userService, RoleService roleService) { diff --git a/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java index 50c20bce63..7ba492b78c 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java @@ -7,6 +7,7 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +20,7 @@ import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; +import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; @@ -38,6 +40,7 @@ public class UserServiceImpl implements UserService { private final SystemConfigurableEnvironmentFetcher environmentFetcher; + private final ApplicationEventPublisher eventPublisher; @Override public Mono getUser(String username) { @@ -59,7 +62,8 @@ public Mono updatePassword(String username, String newPassword) { .flatMap(user -> { user.getSpec().setPassword(newPassword); return client.update(user); - }); + }) + .doOnNext(user -> publishPasswordChangedEvent(username)); } @Override @@ -76,7 +80,8 @@ public Mono updateWithRawPassword(String username, String rawPassword) { .flatMap(user -> { user.getSpec().setPassword(passwordEncoder.encode(rawPassword)); return client.update(user); - }); + }) + .doOnNext(user -> publishPasswordChangedEvent(username)); } @Override @@ -179,7 +184,6 @@ public Mono createUser(User user, Set roleNames) { @Override public Mono confirmPassword(String username, String rawPassword) { - return getUser(username) .filter(user -> { if (!StringUtils.hasText(user.getSpec().getPassword())) { @@ -193,4 +197,8 @@ public Mono confirmPassword(String username, String rawPassword) { }) .hasElement(); } + + void publishPasswordChangedEvent(String username) { + eventPublisher.publishEvent(new PasswordChangedEvent(this, username)); + } } diff --git a/application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java b/application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java new file mode 100644 index 0000000000..532fea8baa --- /dev/null +++ b/application/src/main/java/run/halo/app/event/user/PasswordChangedEvent.java @@ -0,0 +1,14 @@ +package run.halo.app.event.user; + +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class PasswordChangedEvent extends ApplicationEvent { + private final String username; + + public PasswordChangedEvent(Object source, String username) { + super(source); + this.username = username; + } +} diff --git a/application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java b/application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java new file mode 100644 index 0000000000..493a25daa0 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepository.java @@ -0,0 +1,138 @@ +package run.halo.app.security.session; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.IndexResolver; +import org.springframework.session.MapSession; +import org.springframework.session.PrincipalNameIndexResolver; +import org.springframework.session.ReactiveMapSessionRepository; +import org.springframework.session.Session; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class InMemoryReactiveIndexedSessionRepository extends ReactiveMapSessionRepository + implements ReactiveIndexedSessionRepository, DisposableBean { + + final IndexResolver indexResolver = + new DelegatingIndexResolver<>(new PrincipalNameIndexResolver<>(PRINCIPAL_NAME_INDEX_NAME)); + + private final ConcurrentMap> sessionIdIndexMap = + new ConcurrentHashMap<>(); + private final ConcurrentMap> indexSessionIdMap = + new ConcurrentHashMap<>(); + + /** + * Prevent other requests from being parsed and acquiring the session during its deletion, + * which could result in an unintended renewal. Currently, it acts as a buffer, and having a + * slightly prolonged expiration period is sufficient. + */ + private final Cache invalidateSessionIds = CacheBuilder.newBuilder() + .expireAfterWrite(Duration.ofMinutes(10)) + .maximumSize(10_000) + .build(); + + public InMemoryReactiveIndexedSessionRepository(Map sessions) { + super(sessions); + } + + @Override + public Mono save(MapSession session) { + if (invalidateSessionIds.getIfPresent(session.getId()) != null) { + return this.deleteById(session.getId()); + } + return super.save(session) + .then(updateIndex(session)); + } + + @Override + public Mono deleteById(String id) { + return removeIndex(id) + .then(Mono.defer(() -> { + invalidateSessionIds.put(id, true); + return super.deleteById(id); + })); + } + + @Override + public Mono> findByIndexNameAndIndexValue(String indexName, + String indexValue) { + var indexKey = new IndexKey(indexName, indexValue); + return Flux.fromStream((() -> indexSessionIdMap.getOrDefault(indexKey, Set.of()).stream())) + .flatMap(this::findById) + .collectMap(Session::getId); + } + + @Override + public Mono> findByPrincipalName(String principalName) { + return this.findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName); + } + + @Override + public void destroy() { + sessionIdIndexMap.clear(); + indexSessionIdMap.clear(); + invalidateSessionIds.invalidateAll(); + } + + Mono removeIndex(String sessionId) { + return getIndexes(sessionId) + .doOnNext(indexKey -> indexSessionIdMap.computeIfPresent(indexKey, + (key, sessionIdSet) -> { + sessionIdSet.remove(sessionId); + return sessionIdSet.isEmpty() ? null : sessionIdSet; + }) + ) + .then(Mono.defer(() -> { + sessionIdIndexMap.remove(sessionId); + return Mono.empty(); + })) + .then(); + } + + Mono updateIndex(MapSession session) { + return removeIndex(session.getId()) + .then(Mono.defer(() -> { + indexResolver.resolveIndexesFor(session) + .forEach((name, value) -> { + IndexKey indexKey = new IndexKey(name, value); + indexSessionIdMap.computeIfAbsent(indexKey, + unusedSet -> ConcurrentHashMap.newKeySet()) + .add(session.getId()); + // Update sessionIdIndexMap + sessionIdIndexMap.computeIfAbsent(session.getId(), + unusedSet -> ConcurrentHashMap.newKeySet()) + .add(indexKey); + }); + return Mono.empty(); + })) + .then(); + } + + Flux getIndexes(String sessionId) { + return Flux.fromIterable(sessionIdIndexMap.getOrDefault(sessionId, Set.of())); + } + + /** + * For testing purpose. + */ + ConcurrentMap> getSessionIdIndexMap() { + return sessionIdIndexMap; + } + + /** + * For testing purpose. + */ + ConcurrentMap> getIndexSessionIdMap() { + return indexSessionIdMap; + } + + record IndexKey(String attributeName, String attributeValue) { + } +} diff --git a/application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java b/application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java new file mode 100644 index 0000000000..cc41a45def --- /dev/null +++ b/application/src/main/java/run/halo/app/security/session/ReactiveIndexedSessionRepository.java @@ -0,0 +1,9 @@ +package run.halo.app.security.session; + +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; + +public interface ReactiveIndexedSessionRepository + extends ReactiveSessionRepository, ReactiveFindByIndexNameSessionRepository { +} diff --git a/application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java b/application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java new file mode 100644 index 0000000000..225feffc37 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/session/SessionInvalidationListener.java @@ -0,0 +1,38 @@ +package run.halo.app.security.session; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.session.ReactiveFindByIndexNameSessionRepository; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.Session; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Flux; +import run.halo.app.event.user.PasswordChangedEvent; + +@Component +@RequiredArgsConstructor +public class SessionInvalidationListener { + + private final ReactiveFindByIndexNameSessionRepository + indexedSessionRepository; + private final ReactiveSessionRepository sessionRepository; + + @Async + @EventListener + public void onPasswordChanged(PasswordChangedEvent event) { + String username = event.getUsername(); + // Invalidate session + invalidateUserSessions(username); + } + + private void invalidateUserSessions(String username) { + indexedSessionRepository.findByPrincipalName(username) + .map(Map::keySet) + .flatMapMany(Flux::fromIterable) + .flatMap(sessionRepository::deleteById) + .then() + .block(); + } +} diff --git a/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java index e7ee9b1e2c..d1de208a4e 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java @@ -28,6 +28,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -35,6 +36,7 @@ import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; +import run.halo.app.event.user.PasswordChangedEvent; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; @@ -57,6 +59,9 @@ class UserServiceImplTest { @Mock PasswordEncoder passwordEncoder; + @Mock + ApplicationEventPublisher eventPublisher; + @InjectMocks UserServiceImpl userService; @@ -99,6 +104,8 @@ void shouldUpdatePasswordIfUserFoundInExtension() { var user = (User) extension; return "new-fake-password".equals(user.getSpec().getPassword()); })); + + verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @Test @@ -240,6 +247,7 @@ void shouldUpdatePasswordWithDifferentPassword() { var user = (User) extension; return "encoded-new-password".equals(user.getSpec().getPassword()); })); + verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @Test @@ -262,6 +270,7 @@ void shouldUpdatePasswordIfNoPasswordBefore() { return "encoded-new-password".equals(user.getSpec().getPassword()); })); verify(client).get(User.class, "fake-user"); + verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class)); } @Test @@ -281,6 +290,7 @@ void shouldDoNothingIfPasswordNotChanged() { verify(passwordEncoder, never()).encode(any()); verify(client, never()).update(any()); verify(client).get(User.class, "fake-user"); + verify(eventPublisher, times(0)).publishEvent(any(PasswordChangedEvent.class)); } @Test diff --git a/application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java b/application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java new file mode 100644 index 0000000000..d944af1606 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/session/InMemoryReactiveIndexedSessionRepositoryTest.java @@ -0,0 +1,107 @@ +package run.halo.app.security.session; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.session.ReactiveFindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +/** + * Tests for {@link InMemoryReactiveIndexedSessionRepository}. + * + * @author guqing + * @since 2.15.0 + */ +class InMemoryReactiveIndexedSessionRepositoryTest { + private InMemoryReactiveIndexedSessionRepository sessionRepository; + + @BeforeEach + void setUp() { + sessionRepository = new InMemoryReactiveIndexedSessionRepository(new ConcurrentHashMap<>()); + } + + @Test + void principalNameIndexTest() { + sessionRepository.createSession() + .doOnNext(session -> { + session.setAttribute(PRINCIPAL_NAME_INDEX_NAME, + "test"); + }) + .map(session -> sessionRepository.indexResolver.resolveIndexesFor(session)) + .as(StepVerifier::create) + .consumeNextWith(map -> { + assertThat(map).containsEntry( + PRINCIPAL_NAME_INDEX_NAME, + "test"); + }); + + sessionRepository.findByPrincipalName("test") + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + sessionRepository.findByIndexNameAndIndexValue( + PRINCIPAL_NAME_INDEX_NAME, "test") + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + } + + @Test + void saveTest() { + var indexKey = createSession("fake-session-1", "test"); + + assertThat(sessionRepository.getSessionIdIndexMap()).hasSize(1); + assertThat( + sessionRepository.getSessionIdIndexMap().containsValue(Set.of(indexKey))).isTrue(); + + assertThat(sessionRepository.getIndexSessionIdMap()).hasSize(1); + assertThat(sessionRepository.getIndexSessionIdMap().containsKey(indexKey)).isTrue(); + assertThat(sessionRepository.getIndexSessionIdMap().get(indexKey)).isEqualTo( + Set.of("fake-session-1")); + } + + @Test + void saveToUpdateTest() { + // same session id will update the index + createSession("fake-session-1", "test"); + var indexKey2 = createSession("fake-session-1", "test2"); + + assertThat(sessionRepository.getSessionIdIndexMap()).hasSize(1); + assertThat( + sessionRepository.getSessionIdIndexMap().containsValue(Set.of(indexKey2))).isTrue(); + + assertThat(sessionRepository.getIndexSessionIdMap()).hasSize(1); + assertThat(sessionRepository.getIndexSessionIdMap().containsKey(indexKey2)).isTrue(); + assertThat(sessionRepository.getIndexSessionIdMap().get(indexKey2)).isEqualTo( + Set.of("fake-session-1")); + } + + @Test + void deleteByIdTest() { + createSession("fake-session-2", "test1"); + sessionRepository.deleteById("fake-session-2") + .as(StepVerifier::create) + .verifyComplete(); + assertThat(sessionRepository.getSessionIdIndexMap()).isEmpty(); + assertThat(sessionRepository.getIndexSessionIdMap()).isEmpty(); + } + + InMemoryReactiveIndexedSessionRepository.IndexKey createSession(String sessionId, + String principalName) { + var indexKey = new InMemoryReactiveIndexedSessionRepository.IndexKey( + PRINCIPAL_NAME_INDEX_NAME, principalName); + sessionRepository.createSession() + .doOnNext(session -> { + session.setAttribute(indexKey.attributeName(), indexKey.attributeValue()); + session.setId(sessionId); + }) + .flatMap(sessionRepository::save) + .as(StepVerifier::create) + .verifyComplete(); + return indexKey; + } +} diff --git a/ui/uc-src/modules/profile/components/PasswordChangeModal.vue b/ui/uc-src/modules/profile/components/PasswordChangeModal.vue index d99c80b264..ca3945d062 100644 --- a/ui/uc-src/modules/profile/components/PasswordChangeModal.vue +++ b/ui/uc-src/modules/profile/components/PasswordChangeModal.vue @@ -73,7 +73,7 @@ const handleChangePassword = async () => { changeOwnPasswordRequest, }); - onVisibleChange(false); + window.location.reload(); } catch (e) { console.error(e); } finally {