mirror of https://github.com/halo-dev/halo
Create super admin initializer and run extension controllers conditionally (#2248)
#### What type of PR is this? /kind improvement /kind failing-test /area core /milestone 2.0 #### What this PR does / why we need it: Before this PR, our unit tests were flaky to run. After my inspection, I found that extension controllers will run asynchronously at every unit test that is annotated `@SpringBootTest` annotation. Please see the log of failing test: ```java ExtensionConfigurationTest > shouldReturnNotFoundWhenSchemeNotRegistered() FAILED java.lang.AssertionError at ExtensionConfigurationTest.java:72 ``` So this PR makes Halo create super admin initializer and run extension controllers conditionally, especially in tests. You can configure the following property to disable super admin initialization and extension controllers running: ```yaml halo: security: initializer: disabled: true extension: controller: disabled: true ``` BTW, we can configure the initial username and password for super administrator: ```yaml halo: security: initializer: super-admin-username: admin super-admin-password: P@88w0rd ``` #### Which issue(s) this PR fixes: Fixes # #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```pull/2255/head
parent
7000885133
commit
ca3cff277a
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.config;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
|
@ -25,6 +26,7 @@ import run.halo.app.extension.SchemeWatcherManager;
|
|||
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.ControllerManager;
|
||||
import run.halo.app.extension.store.ExtensionStoreClient;
|
||||
import run.halo.app.plugin.HaloPluginManager;
|
||||
import run.halo.app.plugin.resources.JsBundleRuleProvider;
|
||||
|
@ -54,36 +56,49 @@ public class ExtensionConfiguration {
|
|||
return new DefaultSchemeWatcherManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller userController(ExtensionClient client) {
|
||||
return new ControllerBuilder("user-controller", client)
|
||||
.reconciler(new UserReconciler(client))
|
||||
.extension(new User())
|
||||
.build();
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(name = "halo.extension.controller.disabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
static class ExtensionControllerConfiguration {
|
||||
|
||||
@Bean
|
||||
ControllerManager controllerManager() {
|
||||
return new ControllerManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller userController(ExtensionClient client) {
|
||||
return new ControllerBuilder("user-controller", client)
|
||||
.reconciler(new UserReconciler(client))
|
||||
.extension(new User())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller roleController(ExtensionClient client, RoleService roleService) {
|
||||
return new ControllerBuilder("role-controller", client)
|
||||
.reconciler(new RoleReconciler(client, roleService))
|
||||
.extension(new Role())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller roleBindingController(ExtensionClient client) {
|
||||
return new ControllerBuilder("role-binding-controller", client)
|
||||
.reconciler(new RoleBindingReconciler(client))
|
||||
.extension(new RoleBinding())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller pluginController(ExtensionClient client, HaloPluginManager haloPluginManager,
|
||||
JsBundleRuleProvider jsBundleRule) {
|
||||
return new ControllerBuilder("plugin-controller", client)
|
||||
.reconciler(new PluginReconciler(client, haloPluginManager, jsBundleRule))
|
||||
.extension(new Plugin())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller roleController(ExtensionClient client, RoleService roleService) {
|
||||
return new ControllerBuilder("role-controller", client)
|
||||
.reconciler(new RoleReconciler(client, roleService))
|
||||
.extension(new Role())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller roleBindingController(ExtensionClient client) {
|
||||
return new ControllerBuilder("role-binding-controller", client)
|
||||
.reconciler(new RoleBindingReconciler(client))
|
||||
.extension(new RoleBinding())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Controller pluginController(ExtensionClient client, HaloPluginManager haloPluginManager,
|
||||
JsBundleRuleProvider jsBundleRule) {
|
||||
return new ControllerBuilder("plugin-controller", client)
|
||||
.reconciler(new PluginReconciler(client, haloPluginManager, jsBundleRule))
|
||||
.extension(new Plugin())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.nimbusds.jose.jwk.RSAKey;
|
|||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
@ -33,8 +34,11 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
|||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.properties.JwtProperties;
|
||||
import run.halo.app.security.DefaultUserDetailService;
|
||||
import run.halo.app.security.SuperAdminInitializer;
|
||||
import run.halo.app.security.authentication.jwt.LoginAuthenticationFilter;
|
||||
import run.halo.app.security.authentication.jwt.LoginAuthenticationManager;
|
||||
import run.halo.app.security.authorization.RequestInfoAuthorizationManager;
|
||||
|
@ -143,4 +147,13 @@ public class WebServerSecurityConfig {
|
|||
return new NimbusJwtEncoder(jwks);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "halo.security.initializer.disabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
SuperAdminInitializer superAdminInitializer(ExtensionClient client, HaloProperties halo) {
|
||||
return new SuperAdminInitializer(client,
|
||||
passwordEncoder(),
|
||||
halo.getSecurity().getInitializer());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,18 @@
|
|||
package run.halo.app.extension.controller;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ControllerManager implements ApplicationListener<ApplicationReadyEvent>,
|
||||
DisposableBean {
|
||||
ApplicationContextAware, DisposableBean {
|
||||
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
public ControllerManager(ApplicationContext applicationContext) {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
|
@ -39,4 +35,8 @@ public class ControllerManager implements ApplicationListener<ApplicationReadyEv
|
|||
log.info("Shutdown {} controllers.", controllers.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package run.halo.app.infra.properties;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ExtensionProperties {
|
||||
|
||||
private Controller controller = new Controller();
|
||||
|
||||
@Data
|
||||
public static class Controller {
|
||||
|
||||
private boolean disabled;
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package run.halo.app.infra.properties;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
@ -12,5 +13,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||
@ConfigurationProperties(prefix = "halo")
|
||||
public class HaloProperties {
|
||||
|
||||
private Set<String> initialExtensionLocations;
|
||||
private Set<String> initialExtensionLocations = new HashSet<>();
|
||||
|
||||
private final ExtensionProperties extension = new ExtensionProperties();
|
||||
|
||||
private final SecurityProperties security = new SecurityProperties();
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
package run.halo.app.infra.properties;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SecurityProperties {
|
||||
|
||||
private final Initializer initializer = new Initializer();
|
||||
|
||||
@Data
|
||||
public static class Initializer {
|
||||
|
||||
private boolean disabled;
|
||||
|
||||
private String superAdminUsername = "admin";
|
||||
|
||||
private String superAdminPassword;
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -9,7 +9,7 @@ import org.apache.commons.lang3.RandomStringUtils;
|
|||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.Role.PolicyRule;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
|
@ -19,22 +19,28 @@ import run.halo.app.core.extension.User;
|
|||
import run.halo.app.core.extension.User.UserSpec;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.infra.properties.SecurityProperties.Initializer;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SuperAdminInitializer implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
private static final String SUPER_ROLE_NAME = "super-role";
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public SuperAdminInitializer(ExtensionClient client, PasswordEncoder passwordEncoder) {
|
||||
private final Initializer initializer;
|
||||
|
||||
public SuperAdminInitializer(ExtensionClient client, PasswordEncoder passwordEncoder,
|
||||
Initializer initializer) {
|
||||
this.client = client;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.initializer = initializer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
client.fetch(User.class, "admin").ifPresentOrElse(user -> {
|
||||
client.fetch(User.class, initializer.getSuperAdminUsername()).ifPresentOrElse(user -> {
|
||||
// do nothing if admin has been initialized
|
||||
}, () -> {
|
||||
var admin = createAdmin();
|
||||
|
@ -48,7 +54,9 @@ public class SuperAdminInitializer implements ApplicationListener<ApplicationRea
|
|||
|
||||
RoleBinding bindAdminAndSuperRole(User admin, Role superRole) {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName("admin-super-role-binding");
|
||||
String name =
|
||||
String.join("-", initializer.getSuperAdminUsername(), SUPER_ROLE_NAME, "binding");
|
||||
metadata.setName(name);
|
||||
var roleRef = new RoleRef();
|
||||
roleRef.setName(superRole.getMetadata().getName());
|
||||
roleRef.setApiGroup(superRole.groupVersionKind().group());
|
||||
|
@ -69,7 +77,7 @@ public class SuperAdminInitializer implements ApplicationListener<ApplicationRea
|
|||
|
||||
Role createSuperRole() {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName("super-role");
|
||||
metadata.setName(SUPER_ROLE_NAME);
|
||||
Map<String, String> annotations = new HashMap<>();
|
||||
annotations.put(Role.UI_PERMISSIONS_ANNO, "[\"*\"]");
|
||||
metadata.setAnnotations(annotations);
|
||||
|
@ -89,7 +97,7 @@ public class SuperAdminInitializer implements ApplicationListener<ApplicationRea
|
|||
|
||||
User createAdmin() {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName("admin");
|
||||
metadata.setName(initializer.getSuperAdminUsername());
|
||||
|
||||
var spec = new UserSpec();
|
||||
spec.setDisplayName("Administrator");
|
||||
|
@ -97,15 +105,22 @@ public class SuperAdminInitializer implements ApplicationListener<ApplicationRea
|
|||
spec.setRegisteredAt(Instant.now());
|
||||
spec.setTwoFactorAuthEnabled(false);
|
||||
spec.setEmail("admin@halo.run");
|
||||
// generate password
|
||||
var randomPassword = RandomStringUtils.randomAlphanumeric(16);
|
||||
log.info("=== Generated random password: {} for initial user: {} ===",
|
||||
randomPassword, metadata.getName());
|
||||
spec.setPassword(passwordEncoder.encode(randomPassword));
|
||||
spec.setPassword(passwordEncoder.encode(getPassword()));
|
||||
|
||||
var user = new User();
|
||||
user.setMetadata(metadata);
|
||||
user.setSpec(spec);
|
||||
return user;
|
||||
}
|
||||
|
||||
private String getPassword() {
|
||||
var password = this.initializer.getSuperAdminPassword();
|
||||
if (!StringUtils.hasText(password)) {
|
||||
// generate password
|
||||
password = RandomStringUtils.randomAlphanumeric(16);
|
||||
log.info("=== Generated random password: {} for super administrator: {} ===",
|
||||
password, this.initializer.getSuperAdminUsername());
|
||||
}
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,13 +10,16 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabas
|
|||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
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.extension.ExtensionClient;
|
||||
|
||||
@SpringBootTest
|
||||
@SpringBootTest(properties = {"halo.security.initializer.disabled=false",
|
||||
"halo.security.initializer.super-admin-username=fake-admin",
|
||||
"halo.security.initializer.super-admin-password=fake-password"})
|
||||
@AutoConfigureWebTestClient
|
||||
@AutoConfigureTestDatabase
|
||||
class SuperAdminInitializerTest {
|
||||
|
@ -28,11 +31,15 @@ class SuperAdminInitializerTest {
|
|||
@Autowired
|
||||
WebTestClient webClient;
|
||||
|
||||
@Autowired
|
||||
PasswordEncoder encoder;
|
||||
|
||||
@Test
|
||||
void checkSuperAdminInitialization() {
|
||||
verify(client, times(1)).create(argThat(extension -> {
|
||||
if (extension instanceof User user) {
|
||||
return "admin".equals(user.getMetadata().getName());
|
||||
return "fake-admin".equals(user.getMetadata().getName())
|
||||
&& encoder.matches("fake-password", user.getSpec().getPassword());
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
|
@ -44,7 +51,7 @@ class SuperAdminInitializerTest {
|
|||
}));
|
||||
verify(client, times(1)).create(argThat(extension -> {
|
||||
if (extension instanceof RoleBinding roleBinding) {
|
||||
return "admin-super-role-binding".equals(roleBinding.getMetadata().getName());
|
||||
return "fake-admin-super-role-binding".equals(roleBinding.getMetadata().getName());
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
|
|
|
@ -19,10 +19,15 @@ spring:
|
|||
|
||||
halo:
|
||||
security:
|
||||
initializer:
|
||||
disabled: true
|
||||
oauth2:
|
||||
jwt:
|
||||
public-key-location: classpath:app.pub
|
||||
private-key-location: classpath:app.key
|
||||
extension:
|
||||
controller:
|
||||
disabled: true
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
|
|
Loading…
Reference in New Issue