From 4ad97cd58ea78485650dee803b879ecc3f04e4b1 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Mon, 10 Mar 2025 23:15:02 +0800 Subject: [PATCH] feat: add support for disabling/enabling user accounts (#7273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area ui /milestone 2.20.x #### What this PR does / why we need it: Add support for disabling/enabling user accounts image #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/7250 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 支持在管理控制台禁用指定用户 ``` --- api-docs/openapi/v3_0/aggregated.json | 64 +++++ .../v3_0/apis_console.api_v1alpha1.json | 64 +++++ .../app/core/user/service/UserService.java | 5 + .../app/security/device/DeviceService.java | 2 + .../endpoint/console/ConsoleUserEndpoint.java | 109 +++++++++ .../user/service/impl/UserServiceImpl.java | 26 ++ .../run/halo/app/infra/SchemeInitializer.java | 8 + .../login/UsernamePasswordHandler.java | 6 +- .../security/device/DeviceServiceImpl.java | 17 ++ .../templates/gateway_fragments/login.html | 3 + .../gateway_fragments/login.properties | 1 + .../gateway_fragments/login_en.properties | 1 + .../gateway_fragments/login_es.properties | 1 + .../gateway_fragments/login_zh_TW.properties | 1 + .../modules/system/users/UserDetail.vue | 46 +++- .../modules/system/users/UserList.vue | 191 +-------------- .../system/users/components/UserListItem.vue | 224 ++++++++++++++++++ .../system/users/composables/use-user.ts | 63 ++++- .../src/api/user-v1alpha1-console-api.ts | 174 ++++++++++++++ ui/src/locales/_missing_translations_es.yaml | 9 + ui/src/locales/en.yaml | 10 + ui/src/locales/zh-CN.yaml | 8 + ui/src/locales/zh-TW.yaml | 8 + 23 files changed, 850 insertions(+), 191 deletions(-) create mode 100644 application/src/main/java/run/halo/app/core/endpoint/console/ConsoleUserEndpoint.java create mode 100644 ui/console-src/modules/system/users/components/UserListItem.vue diff --git a/api-docs/openapi/v3_0/aggregated.json b/api-docs/openapi/v3_0/aggregated.json index b44e99c40..7a721359d 100644 --- a/api-docs/openapi/v3_0/aggregated.json +++ b/api-docs/openapi/v3_0/aggregated.json @@ -7556,6 +7556,70 @@ ] } }, + "/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable": { + "post": { + "description": "Disable user by username", + "operationId": "DisableUser", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "The user has been disabled." + } + }, + "tags": [ + "UserV1alpha1Console" + ] + } + }, + "/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable": { + "post": { + "description": "Enable user by username", + "operationId": "EnableUser", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "The user has been enabled." + } + }, + "tags": [ + "UserV1alpha1Console" + ] + } + }, "/apis/content.halo.run/v1alpha1/categories": { "get": { "description": "List Category", diff --git a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json index c24c2614c..4db59be07 100644 --- a/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json +++ b/api-docs/openapi/v3_0/apis_console.api_v1alpha1.json @@ -3374,6 +3374,70 @@ "NotifierV1alpha1Console" ] } + }, + "/apis/console.api.security.halo.run/v1alpha1/users/{username}/disable": { + "post": { + "description": "Disable user by username", + "operationId": "DisableUser", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "The user has been disabled." + } + }, + "tags": [ + "UserV1alpha1Console" + ] + } + }, + "/apis/console.api.security.halo.run/v1alpha1/users/{username}/enable": { + "post": { + "description": "Enable user by username", + "operationId": "EnableUser", + "parameters": [ + { + "description": "Username", + "in": "path", + "name": "username", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "The user has been enabled." + } + }, + "tags": [ + "UserV1alpha1Console" + ] + } } }, "components": { diff --git a/api/src/main/java/run/halo/app/core/user/service/UserService.java b/api/src/main/java/run/halo/app/core/user/service/UserService.java index 58039c2f2..cc603378b 100644 --- a/api/src/main/java/run/halo/app/core/user/service/UserService.java +++ b/api/src/main/java/run/halo/app/core/user/service/UserService.java @@ -26,4 +26,9 @@ public interface UserService { Flux listByEmail(String email); String encryptPassword(String rawPassword); + + Mono disable(String username); + + Mono enable(String username); + } diff --git a/api/src/main/java/run/halo/app/security/device/DeviceService.java b/api/src/main/java/run/halo/app/security/device/DeviceService.java index ba52cd2c4..42a55b443 100644 --- a/api/src/main/java/run/halo/app/security/device/DeviceService.java +++ b/api/src/main/java/run/halo/app/security/device/DeviceService.java @@ -11,4 +11,6 @@ public interface DeviceService { Mono changeSessionId(ServerWebExchange exchange); Mono revoke(String principalName, String deviceId); + + Mono revoke(String username); } diff --git a/application/src/main/java/run/halo/app/core/endpoint/console/ConsoleUserEndpoint.java b/application/src/main/java/run/halo/app/core/endpoint/console/ConsoleUserEndpoint.java new file mode 100644 index 000000000..fc78da838 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/endpoint/console/ConsoleUserEndpoint.java @@ -0,0 +1,109 @@ +package run.halo.app.core.endpoint.console; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.util.Objects; +import org.springdoc.core.fn.builders.parameter.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.core.user.service.UserService; +import run.halo.app.extension.GroupVersion; + +/** + * User endpoint for console. + * + * @author johnniang + */ +@Component +class ConsoleUserEndpoint implements CustomEndpoint { + + private final UserService userService; + + ConsoleUserEndpoint(UserService userService) { + this.userService = userService; + } + + @Override + public RouterFunction endpoint() { + var tag = "UserV1alpha1Console"; + return RouterFunctions.nest(RequestPredicates.path("/users"), SpringdocRouteBuilder.route() + .POST("/{username}/disable", this::handleDisableUser, ops -> { + ops.operationId("DisableUser") + .tag(tag) + .description("Disable user by username") + .parameter(Builder.parameterBuilder() + .name("username") + .in(ParameterIn.PATH) + .description("Username") + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(User.class) + .description("The user has been disabled.") + ); + }) + .POST("/{username}/enable", this::handleEnableUser, ops -> { + ops.operationId("EnableUser") + .tag(tag) + .description("Enable user by username") + .parameter(Builder.parameterBuilder() + .name("username") + .in(ParameterIn.PATH) + .description("Username") + .required(true) + .implementation(String.class) + ) + .response(responseBuilder() + .implementation(User.class) + .description("The user has been enabled.") + ); + }) + .build()); + } + + private Mono handleEnableUser(ServerRequest request) { + return userService.enable(request.pathVariable("username")) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The user was not found or has been enabled.")) + ) + .flatMap(user -> ServerResponse.ok().bodyValue(user)); + } + + private Mono handleDisableUser(ServerRequest request) { + var username = request.pathVariable("username"); + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The current user is not authenticated.")) + ) + .filter(currentUsername -> !Objects.equals(currentUsername, username)) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The user is the current user, can't disable it." + ))) + .then(Mono.defer(() -> userService.disable(username))) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The user was not found or has been disabled.")) + ) + .flatMap(user -> ServerResponse.ok().bodyValue(user)); + } + + @Override + public GroupVersion groupVersion() { + return new GroupVersion("console.api.security.halo.run", "v1alpha1"); + } +} diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java index 86a441424..389eb0c69 100644 --- a/application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/user/service/impl/UserServiceImpl.java @@ -15,6 +15,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.reactive.TransactionalOperator; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -45,6 +47,7 @@ import run.halo.app.infra.exception.EmailVerificationFailed; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.plugin.extensionpoint.ExtensionGetter; +import run.halo.app.security.device.DeviceService; @Service @RequiredArgsConstructor @@ -66,6 +69,10 @@ public class UserServiceImpl implements UserService { private final ExtensionGetter extensionGetter; + private final DeviceService deviceService; + + private final ReactiveTransactionManager transactionManager; + private Clock clock = Clock.systemUTC(); void setClock(Clock clock) { @@ -276,6 +283,25 @@ public class UserServiceImpl implements UserService { return passwordEncoder.encode(rawPassword); } + @Override + public Mono disable(String username) { + var tx = TransactionalOperator.create(transactionManager); + return client.fetch(User.class, username) + .filter(user -> !Boolean.TRUE.equals(user.getSpec().getDisabled())) + .flatMap(user -> deviceService.revoke(username).thenReturn(user)) + .doOnNext(user -> user.getSpec().setDisabled(true)) + .flatMap(client::update) + .as(tx::transactional); + } + + @Override + public Mono enable(String username) { + return client.fetch(User.class, username) + .filter(user -> Boolean.TRUE.equals(user.getSpec().getDisabled())) + .doOnNext(user -> user.getSpec().setDisabled(false)) + .flatMap(client::update); + } + void publishPasswordChangedEvent(String username) { eventPublisher.publishEvent(new PasswordChangedEvent(this, username)); } diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index fc22f4b47..2737dde1e 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -9,6 +9,7 @@ import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute import com.fasterxml.jackson.core.type.TypeReference; import java.time.Instant; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -177,6 +178,13 @@ public class SchemeInitializer implements ApplicationListener + Objects.requireNonNullElse(user.getSpec().getDisabled(), Boolean.FALSE) + .toString()) + ) + ); }); schemeManager.register(ReverseProxy.class); schemeManager.register(Setting.class); diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java index dd20aa701..60e3c517d 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java @@ -10,6 +10,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.CredentialsContainer; @@ -65,7 +66,10 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .switchIfEmpty(Mono.defer( () -> { - URI location = URI.create("/login?error&method=local"); + var location = URI.create("/login?error&method=local"); + if (exception instanceof DisabledException) { + location = URI.create("/login?error=account-disabled&method=local"); + } if (exception instanceof BadCredentialsException) { location = URI.create("/login?error=invalid-credential&method=local"); } diff --git a/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java b/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java index cc7a19a01..de2c1b130 100644 --- a/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java +++ b/application/src/main/java/run/halo/app/security/device/DeviceServiceImpl.java @@ -1,5 +1,6 @@ package run.halo.app.security.device; +import static run.halo.app.extension.ExtensionUtil.defaultSort; import static run.halo.app.infra.utils.IpAddressUtils.getClientIp; import static run.halo.app.security.authentication.rememberme.PersistentTokenBasedRememberMeServices.REMEMBER_ME_SERIES_REQUEST_NAME; @@ -24,8 +25,10 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.core.extension.Device; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.index.query.QueryFactory; import run.halo.app.security.authentication.rememberme.PersistentRememberMeTokenRepository; @Slf4j @@ -132,6 +135,20 @@ public class DeviceServiceImpl implements DeviceService { .flatMap(revoked -> sessionRepository.deleteById(revoked.getSpec().getSessionId())); } + @Override + public Mono revoke(String username) { + var listOptions = ListOptions.builder() + .andQuery(QueryFactory.equal("spec.principalName", username)) + .build(); + return client.listAll(Device.class, listOptions, defaultSort()) + .flatMap(this::removeRememberMeToken) + .flatMap(device -> sessionRepository.deleteById(device.getSpec().getSessionId()) + .thenReturn(device) + ) + .flatMap(client::delete) + .then(); + } + private Mono removeRememberMeToken(Device device) { var seriesId = device.getSpec().getRememberMeSeriesId(); if (StringUtils.isBlank(seriesId)) { diff --git a/application/src/main/resources/templates/gateway_fragments/login.html b/application/src/main/resources/templates/gateway_fragments/login.html index bf7eda62a..43ef48ebc 100644 --- a/application/src/main/resources/templates/gateway_fragments/login.html +++ b/application/src/main/resources/templates/gateway_fragments/login.html @@ -13,6 +13,9 @@ + + +