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
John Niang 2022-07-15 14:43:09 +08:00 committed by GitHub
parent 7000885133
commit ca3cff277a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 54 deletions

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}));

View File

@ -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: