feat(security)!: make basic auth required on OSS (#9688)

* feat(security)!: make basic auth required on OSS

* clean(security)!: put the auth filter code into a publisher

* clean(security)!: add unit tests

* fix(core): merge

---------

Co-authored-by: nKwiatkowski <nkwiatkowski@kestra.io>
This commit is contained in:
Nicolas K.
2025-07-08 10:41:47 +02:00
committed by GitHub
parent 6a4397fdfd
commit eafaf32938
16 changed files with 268 additions and 299 deletions

View File

@@ -74,9 +74,6 @@ kestra:
path: /tmp/kestra-wd/tmp
anonymous-usage-report:
enabled: false
server:
basic-auth:
enabled: false
datasources:
postgres:

View File

@@ -130,9 +130,6 @@ datasources:
username: kestra
password: k3str4
kestra:
server:
basic-auth:
enabled: false
encryption:
secret-key: 3ywuDa/Ec61VHkOX3RlI9gYq7CaD0mv0Pf3DHtAXA6U=
repository:

View File

@@ -184,7 +184,6 @@ kestra:
server:
basic-auth:
enabled: false
# These URLs will not be authenticated, by default we open some of the Micronaut default endpoints but not all for security reasons
open-urls:
- "/ping"

View File

@@ -57,7 +57,6 @@ services:
kestra:
server:
basic-auth:
enabled: false
username: "admin@kestra.io" # it must be a valid email address
password: kestra
repository:

View File

@@ -14,9 +14,6 @@ kestra:
secret-key: not_really_a_secret_only_for_unit_test
anonymous-usage-report:
enabled: false
server:
basic-auth:
enabled: false
datasources:
postgres:
url: jdbc:postgresql://postgres:5432/kestra_unit

View File

@@ -90,7 +90,7 @@ public class MiscController {
@Get("/configs")
@ExecuteOn(TaskExecutors.IO)
@Operation(tags = {"Misc"}, summary = "Retrieve the instance configuration.", description = "Global endpoint available to all users.")
public Configuration getConfiguration() throws JsonProcessingException {
public Configuration getConfiguration() throws JsonProcessingException {
Configuration.ConfigurationBuilder<?, ?> builder = Configuration
.builder()
.uuid(instanceService.fetch())
@@ -104,8 +104,7 @@ public class MiscController {
.preview(Preview.builder()
.initial(this.initialPreviewRows)
.max(this.maxPreviewRows)
.build()
).isBasicAuthEnabled(basicAuthService.isEnabled())
.build())
.isAiEnabled(applicationContext.containsBean(AiController.class))
.systemNamespace(namespaceUtils.getSystemFlowNamespace())
.resourceToFilters(QueryFilter.Resource.asResourceList())
@@ -174,8 +173,6 @@ public class MiscController {
Preview preview;
Boolean isBasicAuthEnabled;
String systemNamespace;
List<String> hiddenLabelsPrefixes;

View File

@@ -17,12 +17,12 @@ import io.micronaut.web.router.RouteMatchUtils;
import jakarta.inject.Inject;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Base64;
import java.util.Collection;
import java.util.Optional;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@Filter("/**")
@Requires(property = "kestra.server-type", pattern = "(WEBSERVER|STANDALONE)")
@@ -42,15 +42,10 @@ public class AuthenticationFilter implements HttpServerFilter {
@Override
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
return Mono.fromCallable(() -> basicAuthService.isEnabled())
return Mono.fromCallable(basicAuthService::configuration)
.subscribeOn(Schedulers.boundedElastic())
.flux()
.switchMap(enabled -> {
if (!enabled) {
return chain.proceed(request);
}
BasicAuthService.SaltedBasicAuthConfiguration basicAuthConfiguration = this.basicAuthService.configuration();
.flatMap(basicAuthConfiguration -> {
boolean isOpenUrl = Optional.ofNullable(basicAuthConfiguration.getOpenUrls())
.map(Collection::stream)
.map(stream -> stream.anyMatch(s -> request.getPath().startsWith(s)))
@@ -68,13 +63,14 @@ public class AuthenticationFilter implements HttpServerFilter {
if (basicAuth.isEmpty() ||
!basicAuth.get().username().equals(basicAuthConfiguration.getUsername()) ||
!AuthUtils.encodePassword(basicAuthConfiguration.getSalt(), basicAuth.get().password()).equals(basicAuthConfiguration.getPassword())
!AuthUtils.encodePassword(basicAuthConfiguration.getSalt(),
basicAuth.get().password()).equals(basicAuthConfiguration.getPassword())
) {
return Flux.just(HttpResponse.unauthorized().header("WWW-Authenticate", PREFIX + " realm=" + basicAuthConfiguration.getRealm()));
return Flux.just(HttpResponse.unauthorized());
}
return chain.proceed(request);
});
}) ;
}
@SuppressWarnings("rawtypes")

View File

@@ -50,22 +50,21 @@ public class BasicAuthService {
@PostConstruct
private void init() {
if (Boolean.TRUE.equals(this.basicAuthConfiguration.getEnabled())) {
this.save(this.basicAuthConfiguration);
} else if (Boolean.FALSE.equals(this.basicAuthConfiguration.getEnabled())) {
this.unsecure();
if (basicAuthConfiguration == null){
settingRepository.save(Setting.builder()
.key(BASIC_AUTH_SETTINGS_KEY)
.value(new BasicAuthConfiguration())
.build());
} else if (basicAuthConfiguration.getUsername() == null || basicAuthConfiguration.getPassword() == null){
settingRepository.save(Setting.builder()
.key(BASIC_AUTH_SETTINGS_KEY)
.value(basicAuthConfiguration)
.build());
} else {
save(basicAuthConfiguration);
}
}
public boolean isEnabled() {
BasicAuthConfiguration basicAuthConfiguration = configuration();
if (basicAuthConfiguration == null) {
return false;
}
return Boolean.TRUE.equals(basicAuthConfiguration.getEnabled()) && basicAuthConfiguration.getUsername() != null && basicAuthConfiguration.getPassword() != null;
}
public void save(BasicAuthConfiguration basicAuthConfiguration) {
save(null, basicAuthConfiguration);
}
@@ -75,6 +74,10 @@ public class BasicAuthService {
throw new IllegalArgumentException("Invalid username for Basic Authentication. Please provide a valid email address.");
}
if (basicAuthConfiguration.getUsername() == null) {
throw new IllegalArgumentException("No user name set for Basic Authentication. Please provide a user name.");
}
if (basicAuthConfiguration.getPassword() == null) {
throw new IllegalArgumentException("No password set for Basic Authentication. Please provide a password.");
}
@@ -114,18 +117,6 @@ public class BasicAuthService {
}
}
public void unsecure() {
BasicAuthConfiguration configuration = configuration();
if (configuration == null || Boolean.FALSE.equals(configuration.getEnabled())) {
return;
}
settingRepository.save(Setting.builder()
.key(BASIC_AUTH_SETTINGS_KEY)
.value(configuration.withEnabled(false))
.build());
}
public SaltedBasicAuthConfiguration configuration() {
return settingRepository.findByKey(BASIC_AUTH_SETTINGS_KEY)
.map(Setting::getValue)
@@ -138,8 +129,6 @@ public class BasicAuthService {
@EqualsAndHashCode
@ConfigurationProperties("kestra.server.basic-auth")
public static class BasicAuthConfiguration {
@With
private Boolean enabled;
private String username;
protected String password;
private String realm;
@@ -148,13 +137,11 @@ public class BasicAuthService {
@SuppressWarnings("MnInjectionPoints")
@ConfigurationInject
public BasicAuthConfiguration(
@Nullable Boolean enabled,
@Nullable String username,
@Nullable String password,
@Nullable String realm,
@Nullable List<String> openUrls
) {
this.enabled = enabled;
this.username = username;
this.password = password;
this.realm = Optional.ofNullable(realm).orElse("Kestra");
@@ -165,12 +152,11 @@ public class BasicAuthService {
String username,
String password
) {
this(true, username, password, null, null);
this(username, password, null, null);
}
public BasicAuthConfiguration(BasicAuthConfiguration basicAuthConfiguration) {
if (basicAuthConfiguration != null) {
this.enabled = basicAuthConfiguration.getEnabled();
this.username = basicAuthConfiguration.getUsername();
this.password = basicAuthConfiguration.getPassword();
this.realm = basicAuthConfiguration.getRealm();
@@ -181,7 +167,6 @@ public class BasicAuthService {
@VisibleForTesting
BasicAuthConfiguration withUsernamePassword(String username, String password) {
return new BasicAuthConfiguration(
this.enabled,
username,
password,
this.realm,

View File

@@ -1,33 +0,0 @@
package io.kestra.webserver.controllers.api;
import io.kestra.webserver.services.BasicAuthService;
import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import io.kestra.core.junit.annotations.KestraTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
@Property(name = "kestra.server.basic-auth.enabled", value = "true")
class MiscControllerSecuredTest {
@Inject
@Client("/")
ReactorHttpClient client;
@Inject
private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
@Test
void getConfiguration() {
var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/configs").basicAuth(
basicAuthConfiguration.getUsername(),
basicAuthConfiguration.getPassword()
), MiscController.Configuration.class);
assertThat(response.getIsBasicAuthEnabled()).isTrue();
}
}

View File

@@ -1,7 +1,10 @@
package io.kestra.webserver.controllers.api;
import static org.assertj.core.api.Assertions.assertThat;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.webserver.services.BasicAuthService;
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
@@ -11,8 +14,6 @@ import jakarta.inject.Inject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
@Property(name = "kestra.system-flows.namespace", value = "some.system.ns")
class MiscControllerTest {
@@ -23,6 +24,9 @@ class MiscControllerTest {
@Inject
BasicAuthService basicAuthService;
@Inject
BasicAuthConfiguration basicAuthConfiguration;
@Test
void ping() {
var response = client.toBlocking().retrieve("/ping", String.class);
@@ -38,7 +42,6 @@ class MiscControllerTest {
assertThat(response.getUuid()).isNotNull();
assertThat(response.getIsTaskRunEnabled()).isFalse();
assertThat(response.getIsAnonymousUsageEnabled()).isTrue();
assertThat(response.getIsBasicAuthEnabled()).isFalse();
assertThat(response.getIsAiEnabled()).isFalse();
assertThat(response.getSystemNamespace()).isEqualTo("some.system.ns");
}
@@ -70,7 +73,7 @@ class MiscControllerTest {
MiscController.Configuration.class)
);
} finally {
basicAuthService.unsecure();
basicAuthService.save(basicAuthConfiguration);
}
}
}

View File

@@ -1,36 +1,36 @@
package io.kestra.webserver.controllers.api;
import io.kestra.core.Helpers;
import io.kestra.core.models.collectors.Usage;
import io.micronaut.http.HttpRequest;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import org.junit.jupiter.api.Test;
import java.net.URISyntaxException;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.collectors.Usage;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
@KestraTest
class MiscUsageControllerTest {
@Inject
@Client("/")
ReactorHttpClient client;
@Test
void usages() throws URISyntaxException {
Helpers.runApplicationContext(new String[]{"test"}, Map.of("kestra.server-type", "STANDALONE"), (applicationContext, embeddedServer) -> {
try (ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL())) {
void usages() {
var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/main/usages/all"), Usage.class);
var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/main/usages/all"), Usage.class);
assertThat(response.getUuid()).isNotNull();
assertThat(response.getVersion()).isNotNull();
assertThat(response.getStartTime()).isNotNull();
assertThat(response.getEnvironments()).contains("test");
assertThat(response.getStartTime()).isNotNull();
assertThat(response.getHost().getUuid()).isNotNull();
assertThat(response.getHost().getHardware().getLogicalProcessorCount()).isNotNull();
assertThat(response.getHost().getJvm().getName()).isNotNull();
assertThat(response.getHost().getOs().getFamily()).isNotNull();
assertThat(response.getConfigurations().getRepositoryType()).isEqualTo("h2");
assertThat(response.getConfigurations().getQueueType()).isEqualTo("h2");
}
});
assertThat(response.getUuid()).isNotNull();
assertThat(response.getVersion()).isNotNull();
assertThat(response.getStartTime()).isNotNull();
assertThat(response.getEnvironments()).contains("test");
assertThat(response.getStartTime()).isNotNull();
assertThat(response.getHost().getUuid()).isNotNull();
assertThat(response.getHost().getHardware().getLogicalProcessorCount()).isNotNull();
assertThat(response.getHost().getJvm().getName()).isNotNull();
assertThat(response.getHost().getOs().getFamily()).isNotNull();
assertThat(response.getConfigurations().getRepositoryType()).isEqualTo("h2");
assertThat(response.getConfigurations().getQueueType()).isEqualTo("h2");
}
}

View File

@@ -5,23 +5,30 @@ import io.kestra.core.docs.DocumentationWithSchema;
import io.kestra.core.docs.InputType;
import io.kestra.core.docs.Plugin;
import io.kestra.core.docs.PluginIcon;
import io.kestra.core.junit.annotations.KestraTest;
import io.kestra.core.models.annotations.PluginSubGroup;
import io.kestra.plugin.core.debug.Return;
import io.kestra.plugin.core.log.Log;
import io.micronaut.core.type.Argument;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@KestraTest
class PluginControllerTest {
@Inject
@Client("/")
ReactorHttpClient client;
public static final String PATH = "/api/v1/plugins";
@BeforeAll
@@ -30,205 +37,170 @@ class PluginControllerTest {
}
@Test
void plugins() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
void plugins() {
List<Plugin> list = client.toBlocking().retrieve(
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
List<Plugin> list = client.toBlocking().retrieve(
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
assertThat(list.size()).isEqualTo(2);
assertThat(list.size()).isEqualTo(2);
Plugin template = list.stream()
.filter(plugin -> plugin.getTitle().equals("plugin-template-test"))
.findFirst()
.orElseThrow();
Plugin template = list.stream()
.filter(plugin -> plugin.getTitle().equals("plugin-template-test"))
.findFirst()
.orElseThrow();
assertThat(template.getTitle()).isEqualTo("plugin-template-test");
assertThat(template.getGroup()).isEqualTo("io.kestra.plugin.templates");
assertThat(template.getDescription()).isEqualTo("Plugin template for Kestra");
assertThat(template.getTitle()).isEqualTo("plugin-template-test");
assertThat(template.getGroup()).isEqualTo("io.kestra.plugin.templates");
assertThat(template.getDescription()).isEqualTo("Plugin template for Kestra");
assertThat(template.getTasks().size()).isEqualTo(1);
assertThat(template.getTasks().getFirst()).isEqualTo("io.kestra.plugin.templates.ExampleTask");
assertThat(template.getTasks().size()).isEqualTo(1);
assertThat(template.getTasks().getFirst()).isEqualTo("io.kestra.plugin.templates.ExampleTask");
assertThat(template.getGuides().size()).isEqualTo(2);
assertThat(template.getGuides().getFirst()).isEqualTo("authentication");
assertThat(template.getGuides().size()).isEqualTo(2);
assertThat(template.getGuides().getFirst()).isEqualTo("authentication");
Plugin core = list.stream()
.filter(plugin -> plugin.getTitle().equals("core"))
.findFirst()
.orElseThrow();
Plugin core = list.stream()
.filter(plugin -> plugin.getTitle().equals("core"))
.findFirst()
.orElseThrow();
assertThat(core.getCategories()).containsExactlyInAnyOrder(PluginSubGroup.PluginCategory.STORAGE, PluginSubGroup.PluginCategory.CORE);
assertThat(core.getCategories()).containsExactlyInAnyOrder(PluginSubGroup.PluginCategory.STORAGE, PluginSubGroup.PluginCategory.CORE);
// classLoader can lead to duplicate plugins for the core, just verify that the response is still the same
list = client.toBlocking().retrieve(
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
// classLoader can lead to duplicate plugins for the core, just verify that the response is still the same
list = client.toBlocking().retrieve(
HttpRequest.GET(PATH),
Argument.listOf(Plugin.class)
);
assertThat(list.size()).isEqualTo(2);
});
assertThat(list.size()).isEqualTo(2);
}
@Test
void icons() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
void icons() {
Map<String, PluginIcon> list = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/icons"),
Argument.mapOf(String.class, PluginIcon.class)
);
Map<String, PluginIcon> list = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/icons"),
Argument.mapOf(String.class, PluginIcon.class)
);
assertThat(list.entrySet().stream().filter(e -> e.getKey().equals(Log.class.getName())).findFirst().orElseThrow().getValue().getIcon()).isNotNull();
// test an alias
assertThat(list.entrySet().stream().filter(e -> e.getKey().equals("io.kestra.core.tasks.log.Log")).findFirst().orElseThrow().getValue().getIcon()).isNotNull();
});
assertThat(list.entrySet().stream()
.filter(e -> e.getKey().equals(Log.class.getName()))
.findFirst().orElseThrow().getValue().getIcon()).isNotNull();
// test an alias
assertThat(list.entrySet().stream()
.filter(e -> e.getKey().equals("io.kestra.core.tasks.log.Log"))
.findFirst().orElseThrow().getValue().getIcon()).isNotNull();
}
@SuppressWarnings("unchecked")
@Test
void returnTask() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
void returnTask() {
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/" + Return.class.getName()),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/" + Return.class.getName()),
DocumentationWithSchema.class
);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.debug.Return");
assertThat(doc.getMarkdown()).contains("Return a value for debugging purposes.");
assertThat(doc.getMarkdown()).contains("The templated string to render");
assertThat(doc.getMarkdown()).contains("The generated string");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(1);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
});
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.debug.Return");
assertThat(doc.getMarkdown()).contains("Return a value for debugging purposes.");
assertThat(doc.getMarkdown()).contains("The templated string to render");
assertThat(doc.getMarkdown()).contains("The generated string");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(1);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
}
@SuppressWarnings("unchecked")
@Test
void docs() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
void docs() {
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask"),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask"),
DocumentationWithSchema.class
);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(5);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
});
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
assertThat(((Map<String, Object>) doc.getSchema().getProperties().get("properties")).size()).isEqualTo(5);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
}
@Test
void docWithAlert() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
void docWithAlert() {
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/io.kestra.plugin.core.state.Set"),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/io.kestra.plugin.core.state.Set"),
DocumentationWithSchema.class
);
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.state.Set");
assertThat(doc.getMarkdown()).contains("::: warning\n");
});
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.core.state.Set");
assertThat(doc.getMarkdown()).contains("::: warning\n");
}
@SuppressWarnings("unchecked")
@Test
void taskWithBase() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
void taskWithBase() {
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask?all=true"),
DocumentationWithSchema.class
);
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/io.kestra.plugin.templates.ExampleTask?all=true"),
DocumentationWithSchema.class
);
Map<String, Map<String, Object>> properties = (Map<String, Map<String, Object>>) doc.getSchema().getProperties().get("properties");
Map<String, Map<String, Object>> properties = (Map<String, Map<String, Object>>) doc.getSchema().getProperties().get("properties");
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
assertThat(properties.size()).isEqualTo(17);
assertThat(properties.get("id").size()).isEqualTo(4);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
});
assertThat(doc.getMarkdown()).contains("io.kestra.plugin.templates.ExampleTask");
assertThat(properties.size()).isEqualTo(17);
assertThat(properties.get("id").size()).isEqualTo(4);
assertThat(((Map<String, Object>) doc.getSchema().getOutputs().get("properties")).size()).isEqualTo(1);
}
@Test
void flow() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
Map<String, Object> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/schemas/flow"),
Argument.mapOf(String.class, Object.class)
);
void flow() {
Map<String, Object> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/schemas/flow"),
Argument.mapOf(String.class, Object.class)
);
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.flows.Flow");
});
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.flows.Flow");
}
@Test
void template() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
Map<String, Object> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/schemas/template"),
Argument.mapOf(String.class, Object.class)
);
void template() {
Map<String, Object> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/schemas/template"),
Argument.mapOf(String.class, Object.class)
);
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.templates.Template");
});
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.templates.Template");
}
@Test
void task() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
Map<String, Object> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/schemas/task"),
Argument.mapOf(String.class, Object.class)
);
void task() {
Map<String, Object> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/schemas/task"),
Argument.mapOf(String.class, Object.class)
);
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.tasks.Task");
});
assertThat(doc.get("$ref")).isEqualTo("#/definitions/io.kestra.core.models.tasks.Task");
}
@Test
void inputs() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
List<InputType> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/inputs"),
Argument.listOf(InputType.class)
);
void inputs() {
List<InputType> doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/inputs"),
Argument.listOf(InputType.class)
);
assertThat(doc.size()).isEqualTo(19);
});
assertThat(doc.size()).isEqualTo(19);
}
@SuppressWarnings("unchecked")
@Test
void input() throws URISyntaxException {
Helpers.runApplicationContext((applicationContext, embeddedServer) -> {
ReactorHttpClient client = ReactorHttpClient.create(embeddedServer.getURL());
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/inputs/STRING"),
DocumentationWithSchema.class
);
void input() {
DocumentationWithSchema doc = client.toBlocking().retrieve(
HttpRequest.GET(PATH + "/inputs/STRING"),
DocumentationWithSchema.class
);
assertThat(doc.getSchema().getProperties().size()).isEqualTo(3);
Map<String, Object> properties = (Map<String, Object>) doc.getSchema().getProperties().get("properties");
assertThat(properties.size()).isEqualTo(8);
// assertThat(((Map<String, Object>) properties.get("name")).get("$deprecated"), is(true));
});
assertThat(doc.getSchema().getProperties().size()).isEqualTo(3);
Map<String, Object> properties = (Map<String, Object>) doc.getSchema().getProperties().get("properties");
assertThat(properties.size()).isEqualTo(8);
assertThat(((Map<String, Object>) properties.get("name")).get("$deprecated")).isEqualTo(true);
}
}

View File

@@ -1,22 +1,23 @@
package io.kestra.webserver.filter;
import io.kestra.webserver.services.BasicAuthService;
import io.micronaut.context.annotation.Property;
import io.micronaut.context.annotation.Value;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.reactor.http.client.ReactorHttpClient;
import io.kestra.core.junit.annotations.KestraTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@KestraTest
@Property(name = "kestra.server.basic-auth.enabled", value = "true")
class AuthenticationFilterTest {
@Inject
@Client("/")
@@ -25,10 +26,11 @@ class AuthenticationFilterTest {
@Inject
private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
@Inject
private AuthenticationFilter filter;
@Test
void testUnauthorized() {
assertThrows(HttpClientResponseException.class, () -> client.toBlocking().exchange("/api/v1/configs"));
assertThrows(HttpClientResponseException.class, () -> client.toBlocking()
.exchange(HttpRequest.GET("/api/v1/configs").basicAuth("anonymous", "hacker")));
}
@@ -57,4 +59,35 @@ class AuthenticationFilterTest {
assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode());
}
@Test
void should_unauthorized_with_wrong_username() {
HttpClientResponseException e = assertThrows(HttpClientResponseException.class,
() -> client.toBlocking()
.exchange(HttpRequest.GET("/api/v1/configs").basicAuth(
"incorrect",
basicAuthConfiguration.getPassword()
)));
assertThat(e.getResponse().getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
}
@Test
void should_unauthorized_with_wrong_password() {
HttpClientResponseException e = assertThrows(HttpClientResponseException.class,
() -> client.toBlocking()
.exchange(HttpRequest.GET("/api/v1/configs").basicAuth(
basicAuthConfiguration.getUsername(),
"incorrect"
)));
assertThat(e.getResponse().getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
}
@Test
void should_unauthorized_without_token(){
MutableHttpResponse<?> response = Mono.from(filter.doFilter(
HttpRequest.GET("/api/v1/configs"), null)).block();
assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.UNAUTHORIZED.getCode());
}
}

View File

@@ -0,0 +1,49 @@
package io.kestra.webserver.filter;
import io.kestra.webserver.services.BasicAuthService;
import io.kestra.webserver.services.BasicAuthService.BasicAuthConfiguration;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MutableHttpRequest;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.ClientFilterChain;
import io.micronaut.http.filter.HttpClientFilter;
import io.micronaut.http.filter.ServerFilterPhase;
import jakarta.inject.Inject;
import java.util.Base64;
import org.reactivestreams.Publisher;
@Filter("/**")
@Requires(env = Environment.TEST)
public class TestAuthFilter implements HttpClientFilter {
@Inject
private BasicAuthConfiguration basicAuthConfiguration;
@Inject
private BasicAuthService basicAuthService;
@Override
public Publisher<? extends HttpResponse<?>> doFilter(MutableHttpRequest<?> request,
ClientFilterChain chain) {
//Basic auth may be removed from the database by jdbcTestUtils.drop(); / jdbcTestUtils.migrate();
//We need it back to be able to run the tests and avoid NPE while checking the basic authorization
if (basicAuthService.configuration() == null){
basicAuthService.save(basicAuthConfiguration);
}
//Add basic authorization header if no header are present in the query
if (request.getHeaders().getAuthorization().isEmpty()) {
String token = "Basic " + Base64.getEncoder().encodeToString(
(basicAuthConfiguration.getUsername() + ":" + basicAuthConfiguration.getPassword()).getBytes());
request.getHeaders().add(HttpHeaders.AUTHORIZATION, token);
}
return chain.proceed(request);
}
@Override
public int getOrder() {
return ServerFilterPhase.SECURITY.order() - 1;
}
}

View File

@@ -2,7 +2,6 @@ package io.kestra.webserver.services;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import io.kestra.core.models.Setting;
import io.kestra.core.queues.QueueException;
import io.kestra.core.repositories.SettingRepositoryInterface;
import io.kestra.core.serializers.JacksonMapper;
import io.kestra.core.services.InstanceService;
@@ -41,7 +40,7 @@ class BasicAuthServiceTest {
post(urlEqualTo("/v1/reports/events"))
.willReturn(aResponse().withStatus(200))
);
ctx = ApplicationContext.run(Map.of("kestra.server.basic-auth.enabled", "true"), Environment.TEST);
ctx = ApplicationContext.run(Map.of(), Environment.TEST);
basicAuthService = ctx.getBean(BasicAuthService.class);
basicAuthConfiguration = ctx.getBean(BasicAuthService.BasicAuthConfiguration.class);
@@ -57,16 +56,14 @@ class BasicAuthServiceTest {
}
@Test
void initFromYamlConfig() throws TimeoutException, QueueException {
assertThat(basicAuthService.isEnabled()).isTrue();
void initFromYamlConfig() throws TimeoutException {
assertConfigurationMatchesApplicationYaml();
awaitOssAuthEventApiCall("admin@kestra.io");
}
@Test
void secure() throws TimeoutException, QueueException {
void secure() throws TimeoutException {
IllegalArgumentException illegalArgumentException = Assertions.assertThrows(
IllegalArgumentException.class,
() -> basicAuthService.save(basicAuthConfiguration.withUsernamePassword("not-an-email", "password"))
@@ -80,24 +77,6 @@ class BasicAuthServiceTest {
awaitOssAuthEventApiCall("some@email.com");
}
@Test
void unsecure() {
assertThat(basicAuthService.isEnabled()).isTrue();
BasicAuthService.SaltedBasicAuthConfiguration previousConfiguration = basicAuthService.configuration();
basicAuthService.unsecure();
assertThat(basicAuthService.isEnabled()).isFalse();
BasicAuthService.SaltedBasicAuthConfiguration newConfiguration = basicAuthService.configuration();
assertThat(newConfiguration.getEnabled()).isFalse();
assertThat(newConfiguration.getUsername()).isEqualTo(previousConfiguration.getUsername());
assertThat(newConfiguration.getPassword()).isEqualTo(previousConfiguration.getPassword());
assertThat(newConfiguration.getRealm()).isEqualTo(previousConfiguration.getRealm());
assertThat(newConfiguration.getOpenUrls()).isEqualTo(previousConfiguration.getOpenUrls());
}
private void assertConfigurationMatchesApplicationYaml() {
BasicAuthService.SaltedBasicAuthConfiguration actualConfiguration = basicAuthService.configuration();
BasicAuthService.SaltedBasicAuthConfiguration applicationYamlConfiguration = new BasicAuthService.SaltedBasicAuthConfiguration(

View File

@@ -47,7 +47,6 @@ kestra:
access-log:
enabled: false
basic-auth:
enabled: false
username: admin@kestra.io
password: kestra
open-urls: