diff --git a/herddb-core/pom.xml b/herddb-core/pom.xml
index 9d7d46514..ff6627c7d 100644
--- a/herddb-core/pom.xml
+++ b/herddb-core/pom.xml
@@ -138,6 +138,18 @@
commons-io
test
+
+ io.jsonwebtoken
+ jjwt-api
+
+
+ io.jsonwebtoken
+ jjwt-impl
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+
org.apache.calcite
diff --git a/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java b/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java
new file mode 100644
index 000000000..ad28fb283
--- /dev/null
+++ b/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java
@@ -0,0 +1,120 @@
+/*
+ Licensed to Diennea S.r.l. under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. Diennea S.r.l. licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+ */
+package herddb.security.jwt;
+
+import io.jsonwebtoken.JwtBuilder;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.io.DecodingException;
+import io.jsonwebtoken.io.Encoders;
+import io.jsonwebtoken.security.Keys;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.Key;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Date;
+import java.util.Optional;
+import javax.crypto.SecretKey;
+import org.apache.commons.codec.binary.Base64;
+
+public final class AuthTokenUtils {
+
+ private AuthTokenUtils() {
+ }
+
+ public static SecretKey createSecretKey(SignatureAlgorithm signatureAlgorithm) {
+ return Keys.secretKeyFor(signatureAlgorithm);
+ }
+
+ public static SecretKey decodeSecretKey(byte[] secretKey) {
+ return Keys.hmacShaKeyFor(secretKey);
+ }
+
+ public static PrivateKey decodePrivateKey(byte[] key, SignatureAlgorithm algType) throws IOException {
+ try {
+ PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(key);
+ KeyFactory kf = KeyFactory.getInstance(keyTypeForSignatureAlgorithm(algType));
+ return kf.generatePrivate(spec);
+ } catch (Exception e) {
+ throw new IOException("Failed to decode private key", e);
+ }
+ }
+
+
+ public static PublicKey decodePublicKey(byte[] key, SignatureAlgorithm algType) throws IOException {
+ try {
+ X509EncodedKeySpec spec = new X509EncodedKeySpec(key);
+ KeyFactory kf = KeyFactory.getInstance(keyTypeForSignatureAlgorithm(algType));
+ return kf.generatePublic(spec);
+ } catch (Exception e) {
+ throw new IOException("Failed to decode public key", e);
+ }
+ }
+
+ private static String keyTypeForSignatureAlgorithm(SignatureAlgorithm alg) {
+ if (alg.getFamilyName().equals("RSA")) {
+ return "RSA";
+ } else if (alg.getFamilyName().equals("ECDSA")) {
+ return "EC";
+ } else {
+ String msg = "The " + alg.name() + " algorithm does not support Key Pairs.";
+ throw new IllegalArgumentException(msg);
+ }
+ }
+
+ public static String encodeKeyBase64(Key key) {
+ return Encoders.BASE64.encode(key.getEncoded());
+ }
+
+ public static String createToken(Key signingKey, String subject, Optional expiryTime) {
+ JwtBuilder builder = Jwts.builder()
+ .setSubject(subject)
+ .signWith(signingKey);
+
+ expiryTime.ifPresent(builder::setExpiration);
+
+ return builder.compact();
+ }
+
+ public static byte[] readKeyFromUrl(String keyConfUrl) throws IOException {
+ if (Files.exists(Paths.get(keyConfUrl))) {
+ // Assume the key content was passed in a valid file path
+ return Files.readAllBytes(Paths.get(keyConfUrl));
+ } else if (Base64.isBase64(keyConfUrl.getBytes(StandardCharsets.UTF_8))) {
+ // Assume the key content was passed in base64
+ try {
+ return Decoders.BASE64.decode(keyConfUrl);
+ } catch (DecodingException e) {
+ String msg = "Illegal base64 character or Key file " + keyConfUrl + " doesn't exist";
+ throw new IOException(msg, e);
+ }
+ } else {
+ String msg = "Secret/Public Key file " + keyConfUrl + " doesn't exist";
+ throw new IllegalArgumentException(msg);
+ }
+ }
+}
diff --git a/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java b/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java
new file mode 100644
index 000000000..017b4905b
--- /dev/null
+++ b/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java
@@ -0,0 +1,211 @@
+/*
+ Licensed to Diennea S.r.l. under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. Diennea S.r.l. licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+ */
+package herddb.security.jwt;
+
+import herddb.security.UserManager;
+import herddb.server.ServerConfiguration;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwt;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.JwtParser;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.RequiredTypeException;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.SignatureException;
+import java.io.IOException;
+import java.security.Key;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.apache.commons.lang3.StringUtils;
+
+public class TokenAuthenticator extends UserManager {
+
+ private static final Logger LOG = Logger.getLogger(TokenAuthenticator.class.getName());
+ static final String CONF_TOKEN_SETTING_PREFIX = "server.tokenSettingPrefix";
+ static final String CONF_TOKEN_SETTING_PREFIX_DEFAULT = "server.token.";
+
+ // When symmetric key is configured
+ static final String CONF_TOKEN_SECRET_KEY = "tokenSecretKey";
+
+ // When public/private key pair is configured
+ static final String CONF_TOKEN_PUBLIC_KEY = "tokenPublicKey";
+
+ // The token's claim that corresponds to the "role" string
+ static final String CONF_TOKEN_AUTH_CLAIM = "tokenAuthClaim";
+
+ // When using public key's, the algorithm of the key
+ static final String CONF_TOKEN_PUBLIC_ALG = "tokenPublicAlg";
+
+ // The token audience "claim" name, e.g. "aud", that will be used to get the audience from token.
+ static final String CONF_TOKEN_AUDIENCE_CLAIM = "tokenAudienceClaim";
+
+ // The token audience stands for this server. The field `tokenAudienceClaim` of a valid token, need contains this.
+ static final String CONF_TOKEN_AUDIENCE = "tokenAudience";
+
+ static final String TOKEN = "token";
+ private String confTokenSecretKeySettingName;
+ private String confTokenPublicKeySettingName;
+ private String confTokenAuthClaimSettingName;
+ private String confTokenPublicAlgSettingName;
+ private String confTokenAudienceClaimSettingName;
+ private String confTokenAudienceSettingName;
+ private Key validationKey;
+ private String roleClaim;
+ private SignatureAlgorithm publicKeyAlg;
+ private String audienceClaim;
+ private String audience;
+ private JwtParser parser;
+
+ public TokenAuthenticator(ServerConfiguration config) throws Exception {
+ String prefix = config.getString(CONF_TOKEN_SETTING_PREFIX, CONF_TOKEN_SETTING_PREFIX_DEFAULT);
+ this.confTokenSecretKeySettingName = prefix + CONF_TOKEN_SECRET_KEY;
+ this.confTokenPublicKeySettingName = prefix + CONF_TOKEN_PUBLIC_KEY;
+ this.confTokenAuthClaimSettingName = prefix + CONF_TOKEN_AUTH_CLAIM;
+ this.confTokenPublicAlgSettingName = prefix + CONF_TOKEN_PUBLIC_ALG;
+ this.confTokenAudienceClaimSettingName = prefix + CONF_TOKEN_AUDIENCE_CLAIM;
+ this.confTokenAudienceSettingName = prefix + CONF_TOKEN_AUDIENCE;
+ this.publicKeyAlg = getPublicKeyAlgType(config);
+ this.validationKey = getValidationKey(config);
+ this.roleClaim = getTokenRoleClaim(config);
+ this.audienceClaim = getTokenAudienceClaim(config);
+ this.audience = getTokenAudience(config);
+
+ this.parser = Jwts.parserBuilder().setSigningKey(this.validationKey).build();
+
+ if (audienceClaim != null && audience == null) {
+ throw new IllegalArgumentException("Token Audience Claim [" + audienceClaim
+ + "] configured, but Audience stands for this server not.");
+ }
+ }
+
+ @Override
+ public String getExpectedPassword(String username) throws IOException {
+ throw new IOException("Unsupported with JWT authentication");
+ }
+
+ @Override
+ public void authenticate(String username, char[] pwd) throws IOException {
+ Jwt, Claims> jwt = parser.parseClaimsJws(new String(pwd));
+ if (audienceClaim != null) {
+ Object object = jwt.getBody().get(audienceClaim);
+ if (object == null) {
+ throw new JwtException("Found null Audience in token, for claimed field: " + audienceClaim);
+ }
+
+ if (object instanceof List) {
+ List audiences = (List) object;
+ // audience not contains this server, throw exception.
+ if (audiences.stream().noneMatch(audienceInToken -> audienceInToken.equals(audience))) {
+ throw new IOException("Audiences in token: [" + String.join(", ", audiences)
+ + "] not contains this server: " + audience);
+ }
+ } else if (object instanceof String) {
+ if (!object.equals(audience)) {
+ throw new IOException("Audiences in token: [" + object
+ + "] not contains this server: " + audience);
+ }
+ } else {
+ // should not reach here.
+ throw new IOException("Audiences in token is not in expected format: " + object);
+ }
+ }
+
+ String role = getPrincipal(jwt);
+ if (role == null) {
+ throw new IOException("Found null role in token, for claimed field: " + roleClaim);
+ }
+ LOG.log(Level.INFO, "Authenticated user {0} with role {1}", new Object[]{username, role});
+
+ }
+
+ private String getPrincipal(Jwt, Claims> jwt) {
+ try {
+ return jwt.getBody().get(roleClaim, String.class);
+ } catch (RequiredTypeException requiredTypeException) {
+ List list = jwt.getBody().get(roleClaim, List.class);
+ if (list != null && !list.isEmpty() && list.get(0) instanceof String) {
+ return (String) list.get(0);
+ }
+ return null;
+ }
+ }
+
+ private String getTokenRoleClaim(ServerConfiguration conf) throws IOException {
+ String tokenAuthClaim = conf.getString(confTokenAuthClaimSettingName, "");
+ if (StringUtils.isNotBlank(tokenAuthClaim)) {
+ return tokenAuthClaim;
+ } else {
+ return Claims.SUBJECT;
+ }
+ }
+
+ /**
+ * Try to get the validation key for tokens from several possible config options.
+ */
+ private Key getValidationKey(ServerConfiguration conf) throws IOException {
+ LOG.log(Level.INFO, "Trying to get validation key for token authentication from {0} and {1}",
+ new Object[] {confTokenSecretKeySettingName, confTokenPublicKeySettingName});
+ String tokenSecretKey = conf.getString(confTokenSecretKeySettingName, "");
+ String tokenPublicKey = conf.getString(confTokenPublicKeySettingName, "");
+ if (StringUtils.isNotBlank(tokenSecretKey)) {
+ final byte[] validationKey = AuthTokenUtils.readKeyFromUrl(tokenSecretKey);
+ return AuthTokenUtils.decodeSecretKey(validationKey);
+ } else if (StringUtils.isNotBlank(tokenPublicKey)) {
+ final byte[] validationKey = AuthTokenUtils.readKeyFromUrl(tokenPublicKey);
+ return AuthTokenUtils.decodePublicKey(validationKey, publicKeyAlg);
+ } else {
+ throw new IOException("No secret key was provided for token authentication");
+ }
+ }
+
+ private SignatureAlgorithm getPublicKeyAlgType(ServerConfiguration conf) throws IllegalArgumentException {
+ String tokenPublicAlg = conf.getString(confTokenPublicAlgSettingName, "");
+ if (StringUtils.isNotBlank(tokenPublicAlg)) {
+ try {
+ return SignatureAlgorithm.forName(tokenPublicAlg);
+ } catch (SignatureException ex) {
+ throw new IllegalArgumentException("invalid algorithm provided " + tokenPublicAlg, ex);
+ }
+ } else {
+ return SignatureAlgorithm.RS256;
+ }
+ }
+
+ // get Token Audience Claim from configuration, if not configured return null.
+ private String getTokenAudienceClaim(ServerConfiguration conf) throws IllegalArgumentException {
+ String tokenAudienceClaim = conf.getString(confTokenAudienceClaimSettingName, "");
+ if (StringUtils.isNotBlank(tokenAudienceClaim)) {
+ return tokenAudienceClaim;
+ } else {
+ return null;
+ }
+ }
+
+ // get Token Audience that stands for this server from configuration, if not configured return null.
+ private String getTokenAudience(ServerConfiguration conf) throws IllegalArgumentException {
+ String tokenAudience = conf.getString(confTokenAudienceSettingName, "");
+ if (StringUtils.isNotBlank(tokenAudience)) {
+ return tokenAudience;
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/herddb-core/src/main/java/herddb/server/Server.java b/herddb-core/src/main/java/herddb/server/Server.java
index 7930a3ea4..bfa0c5018 100644
--- a/herddb-core/src/main/java/herddb/server/Server.java
+++ b/herddb-core/src/main/java/herddb/server/Server.java
@@ -48,6 +48,7 @@
import herddb.network.netty.NetworkUtils;
import herddb.security.SimpleSingleUserManager;
import herddb.security.UserManager;
+import herddb.security.jwt.TokenAuthenticator;
import herddb.storage.DataStorageManager;
import herddb.utils.Version;
import java.io.IOException;
@@ -128,18 +129,34 @@ public Server(ServerConfiguration configuration, StatsLogger statsLogger) {
}
this.dataDirectory = this.baseDirectory.resolve(configuration.getString(ServerConfiguration.PROPERTY_DATADIR, ServerConfiguration.PROPERTY_DATADIR_DEFAULT));
this.tmpDirectory = this.baseDirectory.resolve(configuration.getString(ServerConfiguration.PROPERTY_TMPDIR, ServerConfiguration.PROPERTY_TMPDIR_DEFAULT));
- String usersfile = configuration.getString(ServerConfiguration.PROPERTY_USERS_FILE, ServerConfiguration.PROPERTY_USERS_FILE_DEFAULT);
- if (usersfile.isEmpty()) {
- this.userManager = new SimpleSingleUserManager(configuration);
- } else {
- try {
- Path userDirectoryFile = baseDirectory.resolve(usersfile).toAbsolutePath();
- LOGGER.log(Level.INFO, "Reading users from file " + userDirectoryFile);
- this.userManager = new FileBasedUserManager(userDirectoryFile);
- } catch (IOException error) {
- throw new RuntimeException(error);
+
+ String userManagerType = configuration.getString(ServerConfiguration.PROPERTY_USERS_MANAGER, ServerConfiguration.PROPERTY_USERS_MANAGER_DEFAULT);
+ switch (userManagerType) {
+ case ServerConfiguration.PROPERTY_USERS_MANAGER_FILE: {
+ String usersfile = configuration.getString(ServerConfiguration.PROPERTY_USERS_FILE, ServerConfiguration.PROPERTY_USERS_FILE_DEFAULT);
+ if (usersfile.isEmpty()) {
+ this.userManager = new SimpleSingleUserManager(configuration);
+ } else {
+ try {
+ Path userDirectoryFile = baseDirectory.resolve(usersfile).toAbsolutePath();
+ LOGGER.log(Level.INFO, "Reading users from file " + userDirectoryFile);
+ this.userManager = new FileBasedUserManager(userDirectoryFile);
+ } catch (IOException error) {
+ throw new RuntimeException(error);
+ }
+ }
+ break;
+ }
+ case ServerConfiguration.PROPERTY_USERS_MANAGER_TOKEN: {
+ try {
+ this.userManager = new TokenAuthenticator(configuration);
+ } catch (Exception error) {
+ throw new RuntimeException(error);
+ }
+ break;
}
}
+
this.metadataStorageManager = buildMetadataStorageManager();
String host = configuration.getString(ServerConfiguration.PROPERTY_HOST, ServerConfiguration.PROPERTY_HOST_DEFAULT);
int port = configuration.getInt(ServerConfiguration.PROPERTY_PORT, ServerConfiguration.PROPERTY_PORT_DEFAULT);
diff --git a/herddb-core/src/main/java/herddb/server/ServerConfiguration.java b/herddb-core/src/main/java/herddb/server/ServerConfiguration.java
index 2ad8532be..de452517d 100644
--- a/herddb-core/src/main/java/herddb/server/ServerConfiguration.java
+++ b/herddb-core/src/main/java/herddb/server/ServerConfiguration.java
@@ -241,6 +241,12 @@ public final class ServerConfiguration {
public static final String PROPERTY_READLOCK_TIMEOUT = "server.tablemanager.readlocktimeout";
public static final int PROPERTY_READLOCK_TIMEOUT_DEFAULT = 60 * 30;
+ public static final String PROPERTY_USERS_MANAGER = "server.users.manager";
+
+ public static final String PROPERTY_USERS_MANAGER_FILE = "file";
+ public static final String PROPERTY_USERS_MANAGER_TOKEN = "token";
+ public static final String PROPERTY_USERS_MANAGER_DEFAULT = PROPERTY_USERS_MANAGER_FILE;
+
public static final String PROPERTY_USERS_FILE = "server.users.file";
public static final String PROPERTY_USERS_FILE_DEFAULT = "";
diff --git a/herddb-services/src/main/resources/conf/server.properties b/herddb-services/src/main/resources/conf/server.properties
index 91e95eaa5..a78a0d1c0 100644
--- a/herddb-services/src/main/resources/conf/server.properties
+++ b/herddb-services/src/main/resources/conf/server.properties
@@ -171,9 +171,30 @@ server.bookkeeper.port=0
# use this option to debug boot problems
server.halt.on.tablespace.boot.error=true
+# user manager (it can be 'file' or 'token')
+server.users.manager=file
+
# users file, you'd better to set UNIX permissions properly
server.users.file=../conf/users
+# When symmetric key is configured, this is the key
+server.token.tokenSecretKey=
+
+# When public/private key pair is configured this is the public key used for validation
+server.token.tokenPublicKey=
+
+# The token's claim that corresponds to the "role" string
+server.token.tokenAuthClaim=
+
+# When using public key's, the algorithm of the key
+server.token.tokenPublicAlg=
+
+# The token audience "claim" name, e.g. "aud", that will be used to get the audience from token.
+server.token.tokenAudienceClaim=
+
+# The token audience stands for this server. The field `tokenAudienceClaim` of a valid token, need contains this.
+server.token.tokenAudience=
+
# overall limit on memory usage. it defaults to maximum heap size configured on the JVM
#server.memory.max.limit=
diff --git a/pom.xml b/pom.xml
index c72a13d34..b2f9d6d4a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -106,6 +106,7 @@
1.0
4.7.3
4.0.4
+ 0.11.1
1.9.0
10.4
@@ -196,6 +197,21 @@
calcite-linq4j
${libs.calcite}
+
+ io.jsonwebtoken
+ jjwt-api
+ ${libs.jsonwebtoken}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${libs.jsonwebtoken}
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${libs.jsonwebtoken}
+
org.apache.calcite
calcite-core