Skip to content

Commit

Permalink
feat(auth): add a header authentification for reverse proxy
Browse files Browse the repository at this point in the history
close #724
  • Loading branch information
tchiotludo committed Oct 24, 2021
1 parent eb330a9 commit b1fdcf4
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 29 deletions.
83 changes: 54 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
- Filter topics with regexp for current groups
- Ldap configuration to match AKHQ groups/roles
- Filter consumer groups with regexp for current groups

## New React UI

Since this is a major rework, the new UI can have some issues, so please [report any issue](https://github.com/tchiotludo/akhq/issues), thanks!
Expand Down Expand Up @@ -291,10 +291,10 @@ These parameters are the default values used in the topic creation page.
* `akhq.topic-data.size`: max record per page (default: 50)
* `akhq.topic-data.poll-timeout`: The time, in milliseconds, spent waiting in poll if data is not available in the buffer (default: 1000).

#### Ui Settings
#### Ui Settings
##### Topics
* `akhq.ui-options.topic.default-view` is default list view (ALL, HIDE_INTERNAL, HIDE_INTERNAL_STREAM, HIDE_STREAM) (default: HIDE_INTERNAL)
* `akhq.ui-options.topic.skip-consumer-groups` hide consumer groups columns on topic list
* `akhq.ui-options.topic.skip-consumer-groups` hide consumer groups columns on topic list
* `akhq.ui-options.topic.skip-last-record` hide the last records on topic list

##### Topic Data
Expand All @@ -303,12 +303,12 @@ These parameters are the default values used in the topic creation page.

#### Protobuf deserialization

To deserialize topics containing data in Protobuf format, you can set topics mapping:
To deserialize topics containing data in Protobuf format, you can set topics mapping:
for each `topic-regex` you can specify `descriptor-file-base64` (descriptor file encoded to Base64 format),
or you can put descriptor files in `descriptors-folder` and specify `descriptor-file` name,
also specify corresponding message types for keys and values.
If, for example, keys are not in Protobuf format, `key-message-type` can be omitted,
the same for `value-message-type`.
or you can put descriptor files in `descriptors-folder` and specify `descriptor-file` name,
also specify corresponding message types for keys and values.
If, for example, keys are not in Protobuf format, `key-message-type` can be omitted,
the same for `value-message-type`.
This configuration can be specified for each Kafka cluster.

Example configuration can look like as follows:
Expand Down Expand Up @@ -415,7 +415,7 @@ Define groups with specific roles for your users
* `attributes.connects-filter-regexp`: Regexp list to filter Connect tasks available for current group
* `attributes.consumer-groups-filter-regexp`: Regexp list to filter Consumer Groups available for current group

:warning: `topics-filter-regexp`, `connects-filter-regexp` and `consumer-groups-filter-regexp` are only used when listing resources.
:warning: `topics-filter-regexp`, `connects-filter-regexp` and `consumer-groups-filter-regexp` are only used when listing resources.
If you have `topics/create` or `connect/create` roles and you try to create a resource that doesn't follow the regexp, that resource **WILL** be created.

3 defaults group are available :
Expand Down Expand Up @@ -446,11 +446,11 @@ akhq.security:
- username: admin
password: "$2a$<hashed password>"
passwordHash: BCRYPT
groups:
groups:
- admin
- username: reader
password: "<SHA-256 hashed password>"
groups:
groups:
- reader
```

Expand Down Expand Up @@ -504,7 +504,7 @@ micronaut:
base: "OU=GroupsOU,dc=example,dc=com"
filter: "member={0}"
```
Replace
Replace
```yaml
attributes:
- "cn"
Expand All @@ -530,9 +530,9 @@ akhq:
topics-filter-regexp:
- "^projectA_topic$" # Individual topic
- "^projectB_.*$" # Topic group
connects-filter-regexp:
connects-filter-regexp:
- "^test.*$"
consumer-groups-filter-regexp:
consumer-groups-filter-regexp:
- "consumer.*"
topic-writer:
name: topic-writer # Group name
Expand All @@ -542,7 +542,7 @@ akhq:
- topic/delete
- topic/config/update
attributes:
topics-filter-regexp:
topics-filter-regexp:
- "test.*"
connects-filter-regexp:
- "^test.*$"
Expand Down Expand Up @@ -614,14 +614,39 @@ akhq:

The username field can be any string field, the roles field has to be a JSON array.


### Header configuration (reverse proxy)

To enable Header authentification in the application, you'll have to configure the header that will resolve users & groups:

```yaml
akhq:
security:
# Header configuration (reverse proxy)
header-auth:
user-header: x-akhq-user # mandatory (the header name that will contain username)
groups-header: x-akhq-group # optional (the header name that will contains groups separated by coma `,`)
users: # optional, the users list allow, if empty we only rely on `groups-header`
- username: header-user # username matching the `user-header` value
groups: # list of group for current users
- topic-reader
- username: header-admin
groups:
- admin
```
* The `user-header` is mandatory in order to map the user with `users` list or to display the user on the ui if no `users` is provided.
* The `groups-header` is optional and can be used in order to inject a list of groups (separated by `,`) for all the users. This list will be merged with `groups` for the current users.
* The `users` is a list of users allowed.

### External roles and attributes mapping

If you managed which topics (or any other resource) in an external system, you have access to 2 more implementations mechanisms to map your authenticated user (from either Local, LDAP or OIDC Authent) into AKHQ roles and attributes:

If you use this mechanism, keep in mind it will take the local user's groups for local Auth, and the external groups for LDAP/OIDC (ie. this will NOT do the mapping between LDAP/OIDC and local groups)

**Default configuration-based**
This is the current implementation and the default one (doesn't break compatibility)
**Default configuration-based**
This is the current implementation and the default one (doesn't break compatibility)
````yaml
akhq:
security:
Expand All @@ -638,7 +663,7 @@ akhq:
oidc: # OIDC users/groups to AKHQ groups mapping
````

**REST API**
**REST API**
````yaml
akhq:
security:
Expand All @@ -659,11 +684,11 @@ In this mode, AKHQ will send to the ``akhq.security.rest.url`` endpoint a POST r
"groups": ["LDAP-GROUP-1", "LDAP-GROUP-2", "LDAP-GROUP-3"]
}
````
and expect the following JSON as response :
and expect the following JSON as response :
````json
{
"roles": ["topic/read", "topic/write", "..."],
"attributes":
"attributes":
{
"topics-filter-regexp": [".*"],
"connects-filter-regexp": [".*"],
Expand All @@ -672,7 +697,7 @@ and expect the following JSON as response :
}
````

**Groovy API**
**Groovy API**
````yaml
akhq:
security:
Expand All @@ -696,13 +721,13 @@ akhq:
}
groups: # anything set here will not be used
````
``akhq.security.groovy.file`` must be a groovy class that implements the interface ClaimProvider :
````java
``akhq.security.groovy.file`` must be a groovy class that implements the interface ClaimProvider :
````java
package org.akhq.utils;
public interface ClaimProvider {

AKHQClaimResponse generateClaim(AKHQClaimRequest request);

class AKHQClaimRequest{
ProviderType providerType;
String providerName;
Expand All @@ -723,13 +748,13 @@ public interface ClaimProvider {

### Debugging authentication

Debugging auth can be done by increasing log level on Micronaut that handle most of the authentication part :
Debugging auth can be done by increasing log level on Micronaut that handle most of the authentication part :
```bash
curl -i -X POST -H "Content-Type: application/json" \
-d '{ "configuredLevel": "TRACE" }' \
http://localhost:28081/loggers/io.micronaut.security
curl -i -X POST -H "Content-Type: application/json" \
-d '{ "configuredLevel": "TRACE" }' \
http://localhost:28081/loggers/org.akhq.configs
Expand Down Expand Up @@ -882,7 +907,7 @@ brief guide. For the following steps, please, make sure you meet these requireme
* IntelliJ IDEA (Community Edition 2020.2) with the following plugins installed:
* Gradle (bundled with IDEA)
* [Lombok](https://plugins.jetbrains.com/plugin/6317-lombok)

First run a Kafka server locally. Therefore, you need to start Zookeeper first by opening a CMD and doing:
```bash
$KAFKA_HOME\bin\windows\zookeeper-server-start.bat config\zookeper.properties
Expand All @@ -907,7 +932,7 @@ akhq:
kafka:
properties:
bootstrap.servers: "localhost:9092"
```
```
/!\ Do not commit this part of `application.yml`. A more secure way to configure your local development Kafka server is
described in the Micronaut doc, chapter ["Application Configuration"](https://docs.micronaut.io/1.3.0.M1/guide/index.html#config).

Expand Down
12 changes: 12 additions & 0 deletions application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,15 @@ akhq:
- username: einstein
groups:
- admin

# Header configuration (reverse proxy)
header-auth:
user-header: x-akhq-user # mandatory (the header name that will contain username)
groups-header: x-akhq-group # optional (the header name that will contains groups separated by coma `,`)
users: # optional, the users list allow, if empty we only rely on `groups-header`
- username: header-user # username matching the `user-header` value
groups: # list of group for current users
- topic-reader
- username: header-admin
groups:
- admin
24 changes: 24 additions & 0 deletions src/main/java/org/akhq/configs/HeaderAuth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.akhq.configs;

import io.micronaut.context.annotation.ConfigurationProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Data
@ConfigurationProperties("akhq.security.header-auth")
public class HeaderAuth {
String userHeader;
String groupsHeader;
List<Users> users;

@Data
public static class Users {
String username;
List<String> groups = new ArrayList<>();
}
}

116 changes: 116 additions & 0 deletions src/main/java/org/akhq/modules/HeaderAuthenticationFetcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package org.akhq.modules;

import io.micronaut.context.annotation.Requires;
import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.authentication.AuthenticationUserDetailsAdapter;
import io.micronaut.security.authentication.Authenticator;
import io.micronaut.security.authentication.UserDetails;
import io.micronaut.security.filters.AuthenticationFetcher;
import io.micronaut.security.token.config.TokenConfiguration;
import io.reactivex.Flowable;
import lombok.extern.slf4j.Slf4j;
import org.akhq.configs.HeaderAuth;
import org.akhq.utils.ClaimProvider;
import org.reactivestreams.Publisher;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.inject.Inject;
import javax.inject.Singleton;

@Requires(beans = HeaderAuth.class)
@Singleton
@Slf4j
public class HeaderAuthenticationFetcher implements AuthenticationFetcher {
@Inject
HeaderAuth headerAuth;

@Inject
Authenticator authenticator;

@Inject
ClaimProvider claimProvider;

@Inject
TokenConfiguration configuration;

@Override
public Publisher<Authentication> fetchAuthentication(HttpRequest<?> request) {
Optional<String> userHeaders = request.getHeaders()
.get(headerAuth.getUserHeader(), String.class);

Optional<String> groupHeaders = headerAuth.getGroupsHeader() != null ?
request.getHeaders().get(headerAuth.getGroupsHeader(), String.class) :
Optional.empty();

if (userHeaders.isEmpty()) {
return Publishers.empty();
}

return Flowable
.fromCallable(() -> {
List<String> strings = groupsMapper(userHeaders.get(), groupHeaders);

if (strings.size() == 0) {
return Optional.<ClaimProvider.AKHQClaimResponse>empty();
}

ClaimProvider.AKHQClaimRequest claim =
ClaimProvider.AKHQClaimRequest.builder()
.providerType(ClaimProvider.ProviderType.HEADER)
.providerName(null)
.username(userHeaders.get())
.groups(strings)
.build();

return Optional.of(claimProvider.generateClaim(claim));

})
.switchMap(t -> {
if (t.isPresent()) {
UserDetails userDetails = new UserDetails(
userHeaders.get(),
t.get().getRoles(),
t.get().getAttributes()
);

return Flowable.just(new AuthenticationUserDetailsAdapter(
userDetails,
configuration.getRolesName(),
configuration.getNameKey()
));
} else {
if (log.isDebugEnabled()) {
log.debug("Could not authenticate {}", userHeaders.get());
}
return Flowable.empty();
}
});
}

private List<String> groupsMapper(String user, Optional<String> groupHeaders) {
if (headerAuth.getUsers() == null || headerAuth.getUsers().size() == 0) {
return groupsSplit(groupHeaders)
.collect(Collectors.toList());
}

return headerAuth
.getUsers()
.stream()
.filter(users -> users.getUsername().equals(user))
.flatMap(users -> Stream.concat(
groupsSplit(groupHeaders),
users.getGroups() != null ? users.getGroups().stream() : Stream.empty()
))
.collect(Collectors.toList());
}

private Stream<String> groupsSplit(Optional<String> groupHeaders) {
return groupHeaders.stream().flatMap(s -> Arrays.stream(s.split(",")));
}
}
1 change: 1 addition & 0 deletions src/main/java/org/akhq/utils/ClaimProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public interface ClaimProvider {
AKHQClaimResponse generateClaim(AKHQClaimRequest request);

enum ProviderType {
HEADER,
BASIC_AUTH,
LDAP,
OIDC
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public AKHQClaimResponse generateClaim(AKHQClaimRequest request) {
List<String> akhqGroups = new ArrayList<>();
switch (request.getProviderType()) {
case BASIC_AUTH:
case HEADER:
// we already have target AKHQ groups
akhqGroups.addAll(request.getGroups());
break;
Expand Down
Loading

0 comments on commit b1fdcf4

Please sign in to comment.