From 4d9c6113935aad85e06d5a388b279447c875de9a Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Tue, 7 Mar 2023 16:39:32 +0100 Subject: [PATCH 1/2] [security] Implement basic JWT authentication --- herddb-core/pom.xml | 12 + .../herddb/security/jwt/AuthTokenUtils.java | 119 ++++++++++ .../security/jwt/TokenAuthenticator.java | 209 ++++++++++++++++++ .../src/main/java/herddb/server/Server.java | 37 +++- .../herddb/server/ServerConfiguration.java | 6 + .../src/main/resources/conf/server.properties | 21 ++ pom.xml | 16 ++ 7 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java create mode 100644 herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java 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..e492f61bf --- /dev/null +++ b/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java @@ -0,0 +1,119 @@ +/* + 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.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())) { + // 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..3c1826c06 --- /dev/null +++ b/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java @@ -0,0 +1,209 @@ +/* + 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.jtw"; + + // 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 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 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 { + 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 From 9fddfc7c18b3da938092b847d941c9b1ed431fdd Mon Sep 17 00:00:00 2001 From: Enrico Olivelli Date: Tue, 7 Mar 2023 16:57:44 +0100 Subject: [PATCH 2/2] fixes --- .../src/main/java/herddb/security/jwt/AuthTokenUtils.java | 3 ++- .../src/main/java/herddb/security/jwt/TokenAuthenticator.java | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java b/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java index e492f61bf..ad28fb283 100644 --- a/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java +++ b/herddb-core/src/main/java/herddb/security/jwt/AuthTokenUtils.java @@ -27,6 +27,7 @@ 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; @@ -103,7 +104,7 @@ 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())) { + } else if (Base64.isBase64(keyConfUrl.getBytes(StandardCharsets.UTF_8))) { // Assume the key content was passed in base64 try { return Decoders.BASE64.decode(keyConfUrl); diff --git a/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java b/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java index 3c1826c06..017b4905b 100644 --- a/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java +++ b/herddb-core/src/main/java/herddb/security/jwt/TokenAuthenticator.java @@ -40,7 +40,7 @@ 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.jtw"; + static final String CONF_TOKEN_SETTING_PREFIX_DEFAULT = "server.token."; // When symmetric key is configured static final String CONF_TOKEN_SECRET_KEY = "tokenSecretKey"; @@ -161,6 +161,8 @@ private String getTokenRoleClaim(ServerConfiguration conf) throws IOException { * 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)) {