diff --git a/api/pom.xml b/api/pom.xml index 6779383..6df4a64 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -108,6 +108,17 @@ true + + io.vertx + vertx-web + true + + + io.vertx + vertx-auth-common + true + + com.github.ben-manes.caffeine caffeine diff --git a/api/src/main/java/io/bosonnetwork/DefaultNodeConfiguration.java b/api/src/main/java/io/bosonnetwork/DefaultNodeConfiguration.java index e552576..fc52039 100644 --- a/api/src/main/java/io/bosonnetwork/DefaultNodeConfiguration.java +++ b/api/src/main/java/io/bosonnetwork/DefaultNodeConfiguration.java @@ -455,7 +455,7 @@ public Builder vertx(Vertx vertx) { * @throws IllegalStateException if no suitable IPv4 address is found */ public Builder autoHost4() { - InetAddress addr = AddressUtils.getDefaultRouteAddress(Inet6Address.class); + InetAddress addr = AddressUtils.getDefaultRouteAddress(Inet4Address.class); if (addr == null) throw new IllegalStateException("No available IPv4 address"); diff --git a/api/src/main/java/io/bosonnetwork/Node.java b/api/src/main/java/io/bosonnetwork/Node.java index 73b7b2c..519297e 100644 --- a/api/src/main/java/io/bosonnetwork/Node.java +++ b/api/src/main/java/io/bosonnetwork/Node.java @@ -42,6 +42,19 @@ * */ public interface Node extends Identity { + /** + * A constant representing the maximum age for a peer, expressed in milliseconds. + * This value is used to define the threshold after which a peer entity is + * considered outdated or expired. The value is set to 2 hours (120 minutes). + */ + static final int MAX_PEER_AGE = 120 * 60 * 1000; // 2 hours in milliseconds + /** + * A constant representing the maximum age for a value, expressed in milliseconds. + * This value is used to define the threshold after which a value entity is + * considered outdated or expired. The value is set to 2 hours (120 minutes). + */ + static final int MAX_VALUE_AGE = 120 * 60 * 1000; // 2 hours in milliseconds + /** * Gets the ID of the node. * diff --git a/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java b/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java index ba70987..5c0917a 100644 --- a/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java +++ b/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java @@ -47,6 +47,17 @@ public interface ClientAuthenticator { */ CompletableFuture authenticateUser(Id userId, byte[] nonce, byte[] signature); + /** + * Authenticates a user based on their unique identifier. + * + * @param userId the unique identifier of the user to authenticate + * @return a {@link CompletableFuture} that completes with {@code true} if the user + * is successfully authenticated, or {@code false} otherwise + */ + default CompletableFuture authenticateUser(Id userId) { + return authenticateUser(userId, null, null); + } + /** * Authenticates a specific device belonging to a user. * @@ -60,6 +71,20 @@ public interface ClientAuthenticator { */ CompletableFuture authenticateDevice(Id userId, Id deviceId, byte[] nonce, byte[] signature, String address); + /** + * Authenticates a specific device belonging to a user using the provided user ID, device ID, + * and network address. + * + * @param userId the unique identifier of the user who owns the device + * @param deviceId the unique identifier of the device attempting to authenticate + * @param address the network address (e.g., IP address) from which the device is connecting + * @return a {@link CompletableFuture} that completes with {@code true} if the device is successfully + * authenticated, or {@code false} otherwise + */ + default CompletableFuture authenticateDevice(Id userId, Id deviceId, String address) { + return authenticateDevice(userId, deviceId, null, null, address); + } + /** * Returns a `ClientAuthenticator` instance that allows all authentication attempts. * The returned authenticator verifies the provided signature against the corresponding @@ -73,16 +98,14 @@ static ClientAuthenticator allowAll() { return new ClientAuthenticator() { @Override public CompletableFuture authenticateUser(Id userId, byte[] nonce, byte[] signature) { - return userId.toSignatureKey().verify(nonce, signature) ? - CompletableFuture.completedFuture(true) : - CompletableFuture.completedFuture(false); + boolean isValid = nonce == null || signature == null || userId.toSignatureKey().verify(nonce, signature); + return CompletableFuture.completedFuture(isValid); } @Override public CompletableFuture authenticateDevice(Id userId, Id deviceId, byte[] nonce, byte[] signature, String address) { - return deviceId.toSignatureKey().verify(nonce, signature) ? - CompletableFuture.completedFuture(true) : - CompletableFuture.completedFuture(false); + boolean isValid = nonce == null || signature == null || deviceId.toSignatureKey().verify(nonce, signature); + return CompletableFuture.completedFuture(isValid); } }; } @@ -106,9 +129,8 @@ public CompletableFuture authenticateUser(Id userId, byte[] nonce, byte if (!userDeviceMap.containsKey(userId)) return CompletableFuture.completedFuture(false); - return userId.toSignatureKey().verify(nonce, signature) ? - CompletableFuture.completedFuture(true) : - CompletableFuture.completedFuture(false); + boolean isValid = nonce == null || signature == null || userId.toSignatureKey().verify(nonce, signature); + return CompletableFuture.completedFuture(isValid); } @Override @@ -116,9 +138,8 @@ public CompletableFuture authenticateDevice(Id userId, Id deviceId, byt if (!userDeviceMap.containsKey(userId) || !userDeviceMap.get(userId).contains(deviceId)) return CompletableFuture.completedFuture(false); - return deviceId.toSignatureKey().verify(nonce, signature) ? - CompletableFuture.completedFuture(true) : - CompletableFuture.completedFuture(false); + boolean isValid = nonce == null || signature == null || deviceId.toSignatureKey().verify(nonce, signature); + return CompletableFuture.completedFuture(isValid); } }; } diff --git a/api/src/main/java/io/bosonnetwork/service/Clients.java b/api/src/main/java/io/bosonnetwork/service/Clients.java index 1bd3c19..7d689b4 100644 --- a/api/src/main/java/io/bosonnetwork/service/Clients.java +++ b/api/src/main/java/io/bosonnetwork/service/Clients.java @@ -42,7 +42,7 @@ public interface Clients { * @return a {@link CompletableFuture} that completes with the {@link ClientUser} object if found, * or completes with {@code null} if the user does not exist */ - CompletableFuture getUser(Id userId); + CompletableFuture getUser(Id userId); /** * Checks if a user with the specified ID exists. @@ -59,7 +59,7 @@ public interface Clients { * @param userId the unique identifier of the user whose devices are to be retrieved * @return a {@link CompletableFuture} that completes with a list of {@link ClientDevice} objects belonging to the user */ - CompletableFuture> getDevices(Id userId); + CompletableFuture> getDevices(Id userId); /** * Retrieves the device information for a given device ID. @@ -68,7 +68,7 @@ public interface Clients { * @return a {@link CompletableFuture} that completes with the {@link ClientDevice} object if found, * or completes with {@code null} if the device does not exist */ - CompletableFuture getDevice(Id deviceId); + CompletableFuture getDevice(Id deviceId); /** * Checks if a device with the specified ID exists. diff --git a/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java b/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java index 22f48b8..f6ccbc7 100644 --- a/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java +++ b/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java @@ -45,6 +45,7 @@ public class DefaultServiceContext implements ServiceContext { private final ClientAuthenticator clientAuthenticator; private final ClientAuthorizer clientAuthorizer; private final FederationAuthenticator federationAuthenticator; + private final Clients clients; private final Federation federation; private final Map configuration; private final Path dataDir; @@ -63,13 +64,14 @@ public class DefaultServiceContext implements ServiceContext { * @param dataDir the path to the persistence data directory, or {@code null} if not available */ public DefaultServiceContext(Vertx vertx, Node node, ClientAuthenticator clientAuthenticator, ClientAuthorizer clientAuthorizer, - FederationAuthenticator federationAuthenticator, Federation federation, + FederationAuthenticator federationAuthenticator, Clients clients, Federation federation, Map configuration, Path dataDir) { this.vertx = vertx; this.node = node; this.clientAuthenticator = clientAuthenticator; this.clientAuthorizer = clientAuthorizer; this.federationAuthenticator = federationAuthenticator; + this.clients = clients; this.federation = federation; this.configuration = configuration; this.dataDir = dataDir; @@ -131,6 +133,14 @@ public FederationAuthenticator getFederationAuthenticator() { return federationAuthenticator; } + /** + * {@inheritDoc} + */ + @Override + public Clients getClients() { + return clients; + } + /** * {@inheritDoc} */ diff --git a/api/src/main/java/io/bosonnetwork/service/ServiceContext.java b/api/src/main/java/io/bosonnetwork/service/ServiceContext.java index 55e349b..79b9a19 100644 --- a/api/src/main/java/io/bosonnetwork/service/ServiceContext.java +++ b/api/src/main/java/io/bosonnetwork/service/ServiceContext.java @@ -84,6 +84,14 @@ public interface ServiceContext { */ FederationAuthenticator getFederationAuthenticator(); + /** + * Retrieves the {@link Clients} instance, which provides access to client management functionalities such as + * querying user information, checking for user or device existence, and retrieving associated devices. + * + * @return the {@link Clients} instance used for accessing client-related operations within the service context. + */ + Clients getClients(); + /** * Gets the federation instance. * diff --git a/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java b/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java index 1550aa7..05145fd 100644 --- a/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java +++ b/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java @@ -563,7 +563,7 @@ else if (type == Inet6Address.class) else throw new IllegalArgumentException("Unsupported type: " + type); - socket.connect(new InetSocketAddress(target, 63)); + socket.connect(new InetSocketAddress(target, 53)); InetAddress local = socket.getLocalAddress(); if (type.isInstance(local) && !local.isAnyLocalAddress()) @@ -575,6 +575,29 @@ else if (type == Inet6Address.class) } } + /*/ + public static InetAddress getDefaultRouteAddress(Class type) { + try { + for (NetworkInterface nif : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nif.isUp() || nif.isLoopback() || nif.isVirtual()) + continue; + + for (InetAddress addr : Collections.list(nif.getInetAddresses())) { + if (!type.isInstance(addr)) + continue; + if (addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress()) + continue; + + return addr; + } + } + return null; + } catch (SocketException e) { + throw new RuntimeException("Failed to get default router address", e); + } + } + */ + /** * Converts a socket address to a readable string, with optional alignment. * IPv6 addresses are enclosed in square brackets. diff --git a/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java b/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java new file mode 100644 index 0000000..ea0ae4c --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuth.java @@ -0,0 +1,629 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.web; + +import java.io.IOException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authentication.AuthenticationProvider; +import io.vertx.ext.auth.authentication.CredentialValidationException; +import io.vertx.ext.auth.authentication.Credentials; +import io.vertx.ext.auth.authentication.TokenCredentials; + +import io.bosonnetwork.Id; +import io.bosonnetwork.Identity; +import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.service.ClientAuthenticator; +import io.bosonnetwork.service.ClientDevice; +import io.bosonnetwork.service.ClientUser; +import io.bosonnetwork.service.FederatedNode; +import io.bosonnetwork.service.ServiceInfo; +import io.bosonnetwork.utils.Json; +import io.bosonnetwork.utils.Pair; + +/** + * A Compact Web Token (CWT) authentication provider. + *

+ * This class implements a custom authentication mechanism using a compact token format + * designed for efficiency. The token structure is partially inspired by JWT but simplified + * and using CBOR/base64url encoding. + *

+ *

Token Format

+ *
+ * token = payload.signature
+ * payload = base64url(CBOR(claims))
+ * signature = base64url(ED25519Signature(SHA256(payload)))
+ * 
+ * + *

Claims Definition

+ *

Server issued token claims:

+ *
    + *
  • jti: Token ID (nonce)
  • + *
  • iss: Issuer (null or server node ID)
  • + *
  • sub: Subject (User ID / Federated node ID)
  • + *
  • asc: Associated ID (Node ID / Federated service peer ID)
  • + *
  • exp: Expiration timestamp
  • + *
+ * + *

Client issued token claims:

+ *
    + *
  • jti: Token ID (nonce)
  • + *
  • iss: Issuer (Subject ID, or Associated ID if present)
  • + *
  • aud: Audience (Server Node ID)
  • + *
  • sub: Subject (User ID / Federated node ID)
  • + *
  • asc: Associated ID (Node ID / Federated service peer ID)
  • + *
  • exp: Expiration timestamp
  • + *
+ */ +public class CompactWebTokenAuth implements AuthenticationProvider { + /** Base64 URL encoder without padding. */ + protected static final Base64.Encoder B64encoder = Base64.getUrlEncoder().withoutPadding(); + /** Base64 URL decoder. */ + protected static final Base64.Decoder B64decoder = Base64.getUrlDecoder(); + + private static final long MAX_SERVER_ISSUED_TOKEN_LIFETIME = 14 * 24 * 60 * 60; // 14 days in seconds + private static final long MAX_CLIENT_ISSUED_TOKEN_LIFETIME = 30 * 60; // 30 minutes in seconds + private static final int DEFAULT_LEEWAY = 5 * 60; // 5 minutes in seconds + + private final Identity identity; + private final UserRepository userRepository; + private final long maxServerIssuedTokenLifetime; // seconds + private final long maxClientIssuedTokenLifetime; // seconds + private final int leeway; // seconds + + /** + * Interface for retrieving subject and associated entities. + */ + public interface UserRepository { + /** + * Retrieves the subject (user or node) by ID. + * @param subject the subject ID + * @return a Future containing the subject object (ClientUser, FederatedNode, etc.) or null if not found + */ + Future getSubject(Id subject); + + /** + * Retrieves the associated entity (device, service, etc.) by ID. + * @param subject the subject ID owning the associated entity + * @param associated the associated entity ID + * @return a Future containing the associated object or null if not found + */ + Future getAssociated(Id subject, Id associated); + + static UserRepository fromClientAuthenticator(ClientAuthenticator authenticator) { + return new AuthenticatorUserRepo(authenticator); + } + } + + private static final class AuthenticatorUserRepo implements UserRepository { + private final ClientAuthenticator authenticator; + + private AuthenticatorUserRepo(ClientAuthenticator authenticator) { + this.authenticator = authenticator; + } + + @Override + public Future getSubject(Id userId) { + return Future.fromCompletionStage(authenticator.authenticateUser(userId)) + .map(valid -> valid ? new IdOnlyClientUser(userId) : null); + } + + @Override + public Future getAssociated(Id userId, Id deviceId) { + return Future.fromCompletionStage(authenticator.authenticateDevice(userId, deviceId, null)) + .map(valid -> valid ? new IdOnlyClientDevice(deviceId, userId) : null); + } + + private static final class IdOnlyClientUser implements ClientUser { + private final Id id; + + private IdOnlyClientUser(Id id) { + this.id = id; + } + + @Override + public Id getId() { + return id; + } + + @Override + public boolean verifyPassphrase(String passphrase) { + throw new UnsupportedOperationException(); + } + + @Override + public String getName() { + return null; + } + + @Override + public String getAvatar() { + return null; + } + + @Override + public String getEmail() { + return null; + } + + @Override + public String getBio() { + return null; + } + + @Override + public long getCreated() { + return 0; + } + + @Override + public long getUpdated() { + return 0; + } + + @Override + public boolean isAnnounce() { + return false; + } + + @Override + public long getLastAnnounced() { + return 0; + } + + @Override + public String getPlanName() { + return null; + } + } + + private static final class IdOnlyClientDevice implements ClientDevice { + private final Id id; + private final Id userId; + + private IdOnlyClientDevice(Id id, Id userId) { + this.id = id; + this.userId = userId; + } + @Override + public Id getId() { + return id; + } + @Override + public Id getUserId() { + return userId; + } + @Override + public String getName() { + return null; + } + + @Override + public String getApp() { + return null; + } + + @Override + public long getCreated() { + return 0; + } + + @Override + public long getUpdated() { + return 0; + } + + @Override + public long getLastSeen() { + return 0; + } + + @Override + public String getLastAddress() { + return null; + } + } + } + + private CompactWebTokenAuth(Identity identity, UserRepository userRepository, + long maxServerIssuedTokenLifetime, long maxClientIssuedTokenLifetime, int leeway) { + this.identity = identity; + this.userRepository = userRepository; + this.maxServerIssuedTokenLifetime = maxServerIssuedTokenLifetime; + this.maxClientIssuedTokenLifetime = maxClientIssuedTokenLifetime; + this.leeway = leeway; + } + + /** + * Creates a new instance of CompactWebTokenAuth. + * + * @param identity the identity of the current server node (used for signing and verification) + * @param userRepository the repository to lookup token subjects and associated entities + * @param maxServerIssuedTokenLifetime maximum lifetime for tokens issued by this server (seconds) + * @param maxClientIssuedTokenLifetime maximum lifetime for tokens issued by clients (seconds) + * @param leeway allowed clock skew (seconds) + * @return the authenticator instance + */ + public static CompactWebTokenAuth create(Identity identity, UserRepository userRepository, + long maxServerIssuedTokenLifetime, long maxClientIssuedTokenLifetime, int leeway) { + return new CompactWebTokenAuth(identity, userRepository, + maxServerIssuedTokenLifetime, maxClientIssuedTokenLifetime, leeway); + } + + public static CompactWebTokenAuth create(Identity identity, UserRepository userRepository) { + return new CompactWebTokenAuth(identity, userRepository, + MAX_SERVER_ISSUED_TOKEN_LIFETIME, MAX_CLIENT_ISSUED_TOKEN_LIFETIME, DEFAULT_LEEWAY); + } + + /** + * Authenticates a user using the provided credentials. + *

+ * Expected credentials type is {@link TokenCredentials}. The token is parsed, validated + * (signature, expiration, claims), and resolved against the {@link UserRepository}. + *

+ * + * @param credentials the credentials containing the token + * @return a Future containing the authenticated {@link User} + */ + @Override + public Future authenticate(Credentials credentials) { + final TokenCredentials authInfo; + try { + // cast + try { + authInfo = (TokenCredentials) credentials; + } catch (ClassCastException e) { + throw new CredentialValidationException("Invalid credentials type", e); + } + // check + authInfo.checkValid(null); + } catch (RuntimeException e) { + return Future.failedFuture(e); + } + + final String token = authInfo.getToken(); + final int index = token.indexOf('.'); + if (index <= 0 || index >= token.length() - 1) + return Future.failedFuture("Invalid authorization token: wrong format"); + + final byte[] payload; + final byte[] sig; + final JsonObject claims; + + try { + payload = B64decoder.decode(token.substring(0, index)); + sig = B64decoder.decode(token.substring(index + 1)); + claims = new JsonObject(Json.cborMapper().readValue(payload, Json.mapType())); + } catch (IllegalArgumentException | IOException e) { + return Future.failedFuture("Invalid authorization token: format error"); + } + + // check the timestamps and expiration first + if (!claims.containsKey("exp") || claims.getLong("exp") == null) + return Future.failedFuture("Invalid authorization token: missing expiration"); + long expiration = claims.getLong("exp", 0L); + if (expiration <= 0) + return Future.failedFuture("Invalid authorization token: invalid expiration"); + final long now = System.currentTimeMillis() / 1000; + if (now - leeway >= expiration) + return Future.failedFuture("Invalid authorization token: expired"); + + if (claims.containsKey("iat")) { + long iat = claims.getLong("iat", 0L); + // issued at must be in the past + if (iat > now + leeway) + return Future.failedFuture("Invalid authorization token: invalid issue at"); + } + + if (claims.containsKey("nbf")) { + Long nbf = claims.getLong("nbf", 0L); + // not before must be after now + if (nbf > now + leeway) + return Future.failedFuture("Invalid authorization token: invalid not before"); + } + + final boolean isServerIssued; + + // determine the issuer, audience, subject and associated IDs + final Id issuer; + if (claims.containsKey("iss")) { + byte[] value = claims.getBinary("iss"); + if (value == null || value.length != Id.BYTES) + return Future.failedFuture("Invalid authorization token: invalid issuer"); + + try { + Id id = Id.of(value); + if (id.equals(identity.getId())) { + issuer = identity.getId(); + isServerIssued = true; + } else { + issuer = id; + isServerIssued = false; + } + } catch (IllegalArgumentException e) { + return Future.failedFuture("Invalid authorization token: invalid issuer"); + } + } else { + // default: super node issued token, if iss is omitted + issuer = identity.getId(); + isServerIssued = true; + } + + Id audience = null; + if (claims.containsKey("aud")) { + byte[] value = claims.getBinary("aud"); + if (value == null || value.length != Id.BYTES) + return Future.failedFuture("Invalid authorization token: invalid audience"); + try { + audience = Id.of(value); + } catch (IllegalArgumentException e) { + return Future.failedFuture("Invalid authorization token: invalid audience"); + } + } + + Id subject; + if (claims.containsKey("sub")) { + byte[] value = claims.getBinary("sub"); + if (value == null || value.length != Id.BYTES) + return Future.failedFuture("Invalid authorization token: invalid subject"); + try { + subject = Id.of(value); + } catch (IllegalArgumentException e) { + return Future.failedFuture("Invalid authorization token: invalid subject"); + } + } else { + return Future.failedFuture("Invalid authorization token: missing subject"); + } + + final Id associated; + if (claims.containsKey("asc")) { + byte[] value = claims.getBinary("asc"); + if (value == null || value.length != Id.BYTES) + return Future.failedFuture("Invalid authorization token: invalid associated"); + try { + associated = Id.of(value); + } catch (IllegalArgumentException e) { + return Future.failedFuture("Invalid authorization token: invalid associated"); + } + } else { + associated = null; + } + + // check issuer should be: super node or subject or associated + // audience is mandatory if the token not issued by the super node + if (!isServerIssued) { + // TODO: remove + /*/ + if (associated != null) { + if (!issuer.equals(associated)) + return Future.failedFuture("Invalid authorization token: wrong issuer"); + } else { + if (!issuer.equals(subject)) + return Future.failedFuture("Invalid authorization token: wrong issuer"); + } + */ + if (!issuer.equals(Objects.requireNonNullElse(associated, subject))) + return Future.failedFuture("Invalid authorization token: wrong issuer"); + + if (audience == null) + return Future.failedFuture("Invalid authorization token: missing audience"); + } + + if (audience != null && !audience.equals(identity.getId())) + return Future.failedFuture("Invalid authorization token: wrong audience"); + + // check the expiration time is in the acceptable range + if (isServerIssued) { + if (expiration - now - leeway > maxServerIssuedTokenLifetime) + return Future.failedFuture("Invalid authorization token: life time too long"); + } else { + if (expiration - now - leeway > maxClientIssuedTokenLifetime) + return Future.failedFuture("Invalid authorization token: life time too long"); + } + + // verify the signature + if (!issuer.toSignatureKey().verify(payload, sig)) + return Future.failedFuture("Invalid authorization token: signature verification failed"); + + final String scope = claims.containsKey("scp") ? claims.getString("scp") : null; + + return userRepository.getSubject(subject).compose(s -> { + if (s == null) + return Future.failedFuture("Invalid authorization token: subject not exists"); + + if (associated != null) + return userRepository.getAssociated(subject, associated).compose(a -> { + if (a == null) + return Future.failedFuture("Invalid authorization token: associated not exists"); + + return Future.succeededFuture(Pair.of(s, a)); + }); + else + return Future.succeededFuture(Pair.of(s, null)); + }).map(client -> { + JsonObject principal = new JsonObject(); + + // Optimize: reduction of object instances + if (client.a() instanceof ClientUser u) { + principal.put("username", u.getId().toBase58String()); + principal.put("sub", u.getId()); + principal.put("user", u); + principal.put("plan", u.getPlanName()); + } else if (client.a() instanceof FederatedNode n) { + principal.put("username", n.getId().toBase58String()); + principal.put("sub", n.getId()); + principal.put("node", n); + } else { + principal.put("username", subject.toBase58String()); + principal.put("sub", subject); + principal.put("subjectObject", client.a()); + } + + if (client.b() != null) { + if (client.b() instanceof ClientDevice d) { + principal.put("asc", d.getId()); + principal.put("device", d); + } else if (client.b() instanceof ServiceInfo s) { + principal.put("asc", s.getPeerId()); + principal.put("service", s); + } else { + principal.put("asc", associated); + principal.put("associatedObject", client.b()); + } + } + + if (scope != null) + principal.put("scope", scope); + + // The origin unparsed token + principal.put("access_token", token); + + JsonObject attributes = new JsonObject(); + + attributes.put("jti", claims.getBinary("jti")); + attributes.put("exp", expiration); + if (claims.containsKey("iat")) + attributes.put("iat", claims.getLong("iat")); + if (claims.containsKey("nbf")) + attributes.put("nbf", claims.getLong("nbf")); + + // the origin parse claims + // attributes.put("accessToken", claims); + + return User.create(principal, attributes); + }); + } + + /** + * Generates a new token with specific claims. + * + * @param claims the map of claims to include in the token + * @return the generated token string + * @throws IllegalArgumentException if expiration is invalid + */ + public String generateToken(Map claims) { + Map _claims; + + long now = System.currentTimeMillis() / 1000; + if (claims.containsKey("exp") && claims.get("exp") != null) { + long expiration = (Long) claims.get("exp"); + if (expiration <= 0 || expiration > now + maxServerIssuedTokenLifetime) + throw new IllegalArgumentException("Invalid expiration"); + + _claims = claims; + } else { + _claims = new LinkedHashMap<>(claims); + _claims.put("exp", now + maxServerIssuedTokenLifetime); + } + + final byte[] payload; + try { + payload = Json.cborMapper().writeValueAsBytes(_claims); + } catch (IOException e) { + throw new RuntimeException("INTERNAL ERROR: JSON serialization"); + } + + final byte[] sig = identity.sign(payload); + + return B64encoder.encodeToString(payload) + "." + B64encoder.encodeToString(sig); + } + + /** + * Generates a new token for a subject and optional associated entity. + * + * @param subject the subject ID + * @param associated the associated entity ID (optional, can be null) + * @param scope the scope string (optional, can be null) + * @param expiration the expiration time in seconds (0 for default server lifetime) + * @return the generated token string + * @throws IllegalArgumentException if expiration is invalid + */ + public String generateToken(Id subject, Id associated, String scope, long expiration) { + Objects.requireNonNull(subject); + if (expiration < 0 || expiration > maxServerIssuedTokenLifetime) + throw new IllegalArgumentException("Invalid expiration"); + + if (expiration == 0) + expiration = System.currentTimeMillis() / 1000 + maxServerIssuedTokenLifetime; + + Map claims = new LinkedHashMap<>(5); + claims.put("jti", Random.randomBytes(24)); + claims.put("sub", subject.bytes()); + if (associated != null) + claims.put("asc", associated.bytes()); + if (scope != null && !scope.isEmpty()) + claims.put("scp", scope); + claims.put("exp", expiration); + return generateToken(claims); + } + + /** + * Generates a new token for a subject with standard lifetime. + * + * @param subject the subject ID + * @param associated the associated entity ID (optional) + * @param scope the scope string (optional) + * @return the generated token string + */ + public String generateToken(Id subject, Id associated, String scope) { + return generateToken(subject, associated, scope, 0); + } + + /** + * Generates a new token for a subject with standard lifetime and no scope. + * + * @param subject the subject ID + * @param associated the associated entity ID (optional) + * @return the generated token string + */ + public String generateToken(Id subject, Id associated) { + return generateToken(subject, associated, null, 0); + } + + /** + * Generates a new token for a subject with scope and standard lifetime. + * + * @param subject the subject ID + * @param scope the scope string + * @return the generated token string + */ + public String generateToken(Id subject, String scope) { + return generateToken(subject, null, scope, 0); + } + + /** + * Generates a new token for a subject with standard lifetime and no associated entity or scope. + * + * @param subject the subject ID + * @return the generated token string + */ + public String generateToken(Id subject) { + return generateToken(subject, null, null, 0); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuthHandler.java b/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuthHandler.java new file mode 100644 index 0000000..a43e352 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/web/CompactWebTokenAuthHandler.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.web; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import io.vertx.core.Future; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.audit.Marker; +import io.vertx.ext.auth.audit.SecurityAudit; +import io.vertx.ext.auth.authentication.TokenCredentials; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.AuthenticationHandler; +import io.vertx.ext.web.handler.HttpException; +import io.vertx.ext.web.handler.impl.HTTPAuthorizationHandler; +import io.vertx.ext.web.impl.RoutingContextInternal; + +/** + * An auth handler that provides Compact Web Token (CWT) authentication support. + *

+ * This handler validates the CWT format, signature and optionally verifies that + * the authenticated user has the required scopes. + *

+ */ +public class CompactWebTokenAuthHandler extends HTTPAuthorizationHandler implements AuthenticationHandler { + private final List scopes; + private String delimiter; + + private CompactWebTokenAuthHandler(CompactWebTokenAuth authProvider) { + super(authProvider, Type.BEARER, null); + this.scopes = new ArrayList<>(); + this.delimiter = " "; + } + + private CompactWebTokenAuthHandler(CompactWebTokenAuthHandler base, List scopes, String delimiter) { + super(base.authProvider, Type.BEARER, null); + Objects.requireNonNull(scopes, "scopes cannot be null"); + this.scopes = scopes; + Objects.requireNonNull(delimiter, "delimiter cannot be null"); + this.delimiter = delimiter; + } + + /** + * Create a new CompactWebTokenAuthHandler with the given auth provider. + * + * @param authProvider the CompactWebTokenAuth provider to use for authentication + * @return the auth handler + */ + public static CompactWebTokenAuthHandler create(CompactWebTokenAuth authProvider) { + return new CompactWebTokenAuthHandler(authProvider); + } + + /** + * Authenticates the user based on the provided token. + * + * @param context the routing context + * @return a future containing the authenticated user + */ + @Override + public Future authenticate(RoutingContext context) { + return parseAuthorization(context).compose(token -> { + int segments = 0; + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c == '.') { + if (++segments == 2) + return Future.failedFuture(new HttpException(400, "Too many segments in token")); + continue; + } + if (Character.isLetterOrDigit(c) || c == '-' || c == '_') + continue; + + // invalid character + return Future.failedFuture(new HttpException(400, "Invalid character in token: " + (int) c)); + } + + final TokenCredentials credentials = new TokenCredentials(token); + final SecurityAudit audit = ((RoutingContextInternal) context).securityAudit(); + audit.credentials(credentials); + + return authProvider.authenticate(credentials) + .andThen(op -> audit.audit(Marker.AUTHENTICATION, op.succeeded())) + .recover(err -> Future.failedFuture(new HttpException(401, err))); + }); + } + + @SuppressWarnings("unchecked") + private List getScopesOrSearchMetadata(RoutingContext ctx) { + if (!scopes.isEmpty()) + return scopes; + + final Route currentRoute = ctx.currentRoute(); + if (currentRoute == null) + return Collections.emptyList(); + + final Object value = currentRoute.metadata().get("scopes"); + if (value == null) + return Collections.emptyList(); + + if (value instanceof List l) + return (List) l; + else if (value instanceof String s) { + return Collections.singletonList(s); + } + + throw new IllegalStateException("Invalid type for scopes metadata: " + value.getClass().getName()); + } + + /** + * The default behavior for post-authentication. + * Verifies that the user has the required scopes. + * + * @param ctx the routing context + */ + @Override + public void postAuthentication(RoutingContext ctx) { + final User user = ctx.user(); + if (user == null) { + // bad state + ctx.fail(403, new HttpException(403, "no user in the context")); + return; + } + + // the user is authenticated, however, the user may not have all the required scopes + final List scopes = getScopesOrSearchMetadata(ctx); + + if (!scopes.isEmpty()) { + final String scope = user.principal().getString("scope"); + if (scope == null || scope.isEmpty()) { + ctx.fail(new HttpException(403, "Invalid authorization token: scope claim is required")); + return; + } + + // Use a Set for faster lookups + Set target = new HashSet<>(); + Collections.addAll(target, scope.split(delimiter)); + + if (target.isEmpty()) { + ctx.fail(403, new HttpException(403, "Invalid authorization token: scope undefined")); + return; + } + + for (String scp : scopes) { + if (!target.contains(scp)) { + ctx.fail(403, new HttpException(403, "Invalid authorization token: mismatched scope")); + return; + } + } + } + + ctx.next(); + } + + /** + * Return a new handler with the specified required scope. + * + * @param scope the required scope + * @return a new handler instance + */ + public CompactWebTokenAuthHandler withScope(String scope) { + Objects.requireNonNull(scope, "scope cannot be null"); + List updatedScopes = new ArrayList<>(this.scopes); + updatedScopes.add(scope); + return new CompactWebTokenAuthHandler(this, updatedScopes, delimiter); + } + + /** + * Return a new handler with the specified required scopes. + * + * @param scopes the list of required scopes + * @return a new handler instance + */ + public CompactWebTokenAuthHandler withScopes(List scopes) { + Objects.requireNonNull(scopes, "scopes cannot be null"); + return new CompactWebTokenAuthHandler(this, scopes, delimiter); + } + + /** + * Sets the delimiter used to split the scope claim string. + * Default is space " ". + * + * @param delimiter the delimiter string + * @return self + */ + public CompactWebTokenAuthHandler scopeDelimiter(String delimiter) { + Objects.requireNonNull(delimiter, "delimiter cannot be null"); + this.delimiter = delimiter; + return this; + } +} \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java index f085353..5f40eb5 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java @@ -54,8 +54,6 @@ public class KadNode extends BosonVerticle implements Node { public static final int VERSION_NUMBER = 1; public static final int VERSION = Version.build(SHORT_NAME, VERSION_NUMBER); - public static final int MAX_PEER_AGE = 120 * 60 * 1000; // 2 hours in milliseconds - public static final int MAX_VALUE_AGE = 120 * 60 * 1000; // 2 hours in milliseconds public static final int RE_ANNOUNCE_INTERVAL = 5 * 60 * 1000; // 5 minutes in milliseconds public static final int STORAGE_EXPIRE_INTERVAL = 10 * 60 * 1000; // 10 minutes in milliseconds