DefaultAuthenticationService.java
/*
* Copyright Hyperledger Besu Contributors.
*
* Licensed 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.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.ethereum.api.jsonrpc.authentication;
import org.hyperledger.besu.ethereum.api.jsonrpc.JsonRpcConfiguration;
import org.hyperledger.besu.ethereum.api.jsonrpc.internal.methods.JsonRpcMethod;
import org.hyperledger.besu.ethereum.api.jsonrpc.websocket.WebSocketConfiguration;
import java.io.File;
import java.util.Collection;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.Nullable;
import com.google.common.annotations.VisibleForTesting;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.JWTOptions;
import io.vertx.ext.auth.User;
import io.vertx.ext.auth.authentication.AuthenticationProvider;
import io.vertx.ext.auth.authentication.Credentials;
import io.vertx.ext.auth.authentication.TokenCredentials;
import io.vertx.ext.auth.authentication.UsernamePasswordCredentials;
import io.vertx.ext.auth.jwt.JWTAuth;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.ext.web.RoutingContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Provides authentication handlers for use in the http and websocket services */
public class DefaultAuthenticationService implements AuthenticationService {
public static final String USERNAME = "username";
public static final String PASSWORD = "password";
private final JWTAuth jwtAuthProvider;
@VisibleForTesting public final JWTAuthOptions jwtAuthOptions;
private final Optional<AuthenticationProvider> credentialAuthProvider;
private static final JWTAuthOptionsFactory jwtAuthOptionsFactory = new JWTAuthOptionsFactory();
private static final Logger LOG = LoggerFactory.getLogger(DefaultAuthenticationService.class);
public DefaultAuthenticationService(
final JWTAuth jwtAuthProvider,
final JWTAuthOptions jwtAuthOptions,
final Optional<AuthenticationProvider> credentialAuthProvider) {
this.jwtAuthProvider = jwtAuthProvider;
this.jwtAuthOptions = jwtAuthOptions;
this.credentialAuthProvider = credentialAuthProvider;
}
/**
* Creates a ready for use set of authentication providers if authentication is enabled
*
* @param vertx The vertx instance that will be providing requests that this set of authentication
* providers will be handling
* @param config The {{@link JsonRpcConfiguration}} that describes this rpc setup
* @return Optionally an authentication service. If empty then authentication isn't to be enabled
* on this service
*/
public static Optional<AuthenticationService> create(
final Vertx vertx, final JsonRpcConfiguration config) {
return create(
vertx,
config.isAuthenticationEnabled(),
config.getAuthenticationCredentialsFile(),
config.getAuthenticationPublicKeyFile(),
config.getAuthenticationAlgorithm());
}
/**
* Creates a ready for use set of authentication providers if authentication is enabled
*
* @param vertx The vertx instance that will be providing requests that this set of authentication
* providers will be handling
* @param config The {{@link WebSocketConfiguration}} that describes this rpc setup
* @return Optionally an authentication service. If empty then authentication isn't to be enabled
* on this service
*/
public static Optional<AuthenticationService> create(
final Vertx vertx, final WebSocketConfiguration config) {
return create(
vertx,
config.isAuthenticationEnabled(),
config.getAuthenticationCredentialsFile(),
config.getAuthenticationPublicKeyFile(),
config.getAuthenticationAlgorithm());
}
private static Optional<AuthenticationService> create(
final Vertx vertx,
final boolean authenticationEnabled,
final String authenticationCredentialsFile,
final File authenticationPublicKeyFile,
final JwtAlgorithm authenticationAlgorithm) {
if (!authenticationEnabled) {
return Optional.empty();
}
final JWTAuthOptions jwtAuthOptions;
if (authenticationPublicKeyFile == null) {
jwtAuthOptions = jwtAuthOptionsFactory.createWithGeneratedKeyPair();
} else {
jwtAuthOptions =
authenticationAlgorithm == null
? jwtAuthOptionsFactory.createForExternalPublicKey(authenticationPublicKeyFile)
: jwtAuthOptionsFactory.createForExternalPublicKeyWithAlgorithm(
authenticationPublicKeyFile, authenticationAlgorithm);
}
final Optional<AuthenticationProvider> credentialAuthProvider =
makeCredentialAuthProvider(vertx, authenticationEnabled, authenticationCredentialsFile);
return Optional.of(
new DefaultAuthenticationService(
JWTAuth.create(vertx, jwtAuthOptions), jwtAuthOptions, credentialAuthProvider));
}
private static Optional<AuthenticationProvider> makeCredentialAuthProvider(
final Vertx vertx,
final boolean authenticationEnabled,
@Nullable final String authenticationCredentialsFile) {
if (authenticationEnabled && authenticationCredentialsFile != null) {
return Optional.of(
new TomlAuthOptions().setTomlPath(authenticationCredentialsFile).createProvider(vertx));
} else {
return Optional.empty();
}
}
/**
* Static route for terminating login requests when Authentication is disabled
*
* @param routingContext The vertx routing context for this request
*/
public static void handleDisabledLogin(final RoutingContext routingContext) {
routingContext
.response()
.setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
.setStatusMessage("Authentication not enabled")
.end();
}
/**
* Handles a login request and checks the provided credentials against our credential auth
* provider
*
* @param routingContext Routing context associated with this request
*/
@Override
public void handleLogin(final RoutingContext routingContext) {
if (credentialAuthProvider.isPresent()) {
login(routingContext, credentialAuthProvider.get());
} else {
handleDisabledLogin(routingContext);
}
}
private void login(
final RoutingContext routingContext, final AuthenticationProvider credentialAuthProvider) {
final JsonObject requestBody = routingContext.body().asJsonObject();
if (requestBody == null
|| requestBody.getValue(USERNAME) == null
|| requestBody.getValue(PASSWORD) == null) {
routingContext
.response()
.setStatusCode(HttpResponseStatus.BAD_REQUEST.code())
.setStatusMessage(HttpResponseStatus.BAD_REQUEST.reasonPhrase())
.end("Authentication failed: username and password are required.");
return;
}
// Check user
final JsonObject authParams = new JsonObject();
authParams.put(USERNAME, requestBody.getValue(USERNAME));
authParams.put(PASSWORD, requestBody.getValue(PASSWORD));
final Credentials credentials = new UsernamePasswordCredentials(authParams);
credentialAuthProvider.authenticate(
credentials,
r -> {
if (r.failed()) {
routingContext
.response()
.setStatusCode(HttpResponseStatus.UNAUTHORIZED.code())
.setStatusMessage(HttpResponseStatus.UNAUTHORIZED.reasonPhrase())
.end("Authentication failed: the username or password is incorrect.");
} else {
final User user = r.result();
final JWTOptions options =
new JWTOptions().setExpiresInMinutes(5).setAlgorithm("RS256");
final JsonObject jwtContents =
new JsonObject()
.put("permissions", user.principal().getValue("permissions"))
.put(USERNAME, user.principal().getValue(USERNAME));
final String privacyPublicKey = user.principal().getString("privacyPublicKey");
if (privacyPublicKey != null) {
jwtContents.put("privacyPublicKey", privacyPublicKey);
}
final String token = jwtAuthProvider.generateToken(jwtContents, options);
final JsonObject responseBody = new JsonObject().put("token", token);
final HttpServerResponse response = routingContext.response();
if (!response.closed()) {
response.setStatusCode(200);
response.putHeader("Content-Type", "application/json");
response.end(responseBody.encode());
}
}
});
}
@Override
public JWTAuth getJwtAuthProvider() {
return jwtAuthProvider;
}
@Override
public void authenticate(final String token, final Handler<Optional<User>> handler) {
try {
getJwtAuthProvider()
.authenticate(
new TokenCredentials(new JsonObject().put("token", token)),
r -> {
if (r.succeeded()) {
final Optional<User> user = Optional.ofNullable(r.result());
validateExpiryExists(user);
handler.handle(user);
} else {
LOG.debug("Invalid JWT token {}", r.cause().toString());
handler.handle(Optional.empty());
}
});
} catch (Exception e) {
LOG.debug("exception validating JWT ", e);
handler.handle(Optional.empty());
}
}
@Override
public boolean isPermitted(
final Optional<User> optionalUser,
final JsonRpcMethod jsonRpcMethod,
final Collection<String> noAuthMethods) {
AtomicBoolean foundMatchingPermission = new AtomicBoolean();
// if the method is configured as a no auth method we skip permission check
if (noAuthMethods.stream().anyMatch(m -> m.equals(jsonRpcMethod.getName()))) {
return true;
}
if (optionalUser.isPresent()) {
User user = optionalUser.get();
for (String perm : jsonRpcMethod.getPermissions()) {
user.isAuthorized(
perm,
(authed) -> {
if (authed.result()) {
LOG.trace(
"user {} authorized : {} via permission {}",
user,
jsonRpcMethod.getName(),
perm);
foundMatchingPermission.set(true);
}
});
// exit if a matching permission was found, no need to keep checking
if (foundMatchingPermission.get()) {
return foundMatchingPermission.get();
}
}
}
if (!foundMatchingPermission.get()) {
LOG.trace("user NOT authorized : {}", jsonRpcMethod.getName());
}
return foundMatchingPermission.get();
}
private void validateExpiryExists(final Optional<User> user) {
if (!user.map(User::attributes).map(a -> a.containsKey("exp")).orElse(false)) {
throw new IllegalStateException("Invalid JWT doesn't have expiry");
}
}
}