EngineAuthService.java

/*
 * Copyright Hyperledger Besu.
 *
 * 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.internal.methods.JsonRpcMethod;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Optional;

import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.JWTOptions;
import io.vertx.ext.auth.PubSecKeyOptions;
import io.vertx.ext.auth.User;
import io.vertx.ext.auth.impl.Codec;
import io.vertx.ext.auth.jwt.JWTAuth;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.ext.web.RoutingContext;
import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class EngineAuthService implements AuthenticationService {

  private static final Logger LOG = LoggerFactory.getLogger(EngineAuthService.class);
  private static final int JWT_EXPIRATION_TIME_IN_SECONDS = 60;

  public static final String EPHEMERAL_JWT_FILE = "jwt.hex";

  private final JWTAuth jwtAuthProvider;

  public EngineAuthService(final Vertx vertx, final Optional<File> signingKey, final Path datadir) {
    final JWTAuthOptions jwtAuthOptions =
        engineApiJWTOptions(JwtAlgorithm.HS256, signingKey, datadir);
    this.jwtAuthProvider = JWTAuth.create(vertx, jwtAuthOptions);
  }

  public String createToken() {
    JsonObject claims = new JsonObject();
    claims.put("iat", System.currentTimeMillis() / 1000);
    return this.jwtAuthProvider.generateToken(claims);
  }

  private JWTAuthOptions engineApiJWTOptions(
      final JwtAlgorithm jwtAlgorithm, final Optional<File> keyFile, final Path datadir) {
    byte[] signingKey = null;
    if (!keyFile.isPresent()) {
      final File jwtFile = new File(datadir.toFile(), EPHEMERAL_JWT_FILE);
      jwtFile.deleteOnExit();
      final byte[] ephemeralKey = Bytes32.random().toArray();
      try {
        Files.writeString(jwtFile.toPath(), Codec.base16Encode(ephemeralKey));
      } catch (IOException ioe) {
        LOG.warn("Unable to write ephemeral jwt key file to {}", jwtFile.toPath().toString());
        LOG.info("JWT KEY: {}", Codec.base16Encode(ephemeralKey));
      }
      signingKey = ephemeralKey;
    } else { // user configured option to use a specified file
      if (keyFile.get().exists()) {
        try {
          final String keyHex = Files.readAllLines(keyFile.get().toPath()).get(0);
          if (keyHex.length() >= 64) {
            signingKey = Bytes.fromHexString(keyHex).toArray();
          } else {
            UnsecurableEngineApiException e =
                new UnsecurableEngineApiException("signing key too short, 256 bits required");
            e.fillInStackTrace();
            throw e;
          }
        } catch (IOException ioe) {
          UnsecurableEngineApiException e =
              new UnsecurableEngineApiException(
                  "Could not read key from " + keyFile.get().toString());
          e.fillInStackTrace();
          e.initCause(ioe);
          throw e;
        }
      } else {
        UnsecurableEngineApiException e =
            new UnsecurableEngineApiException(
                "Could not read key from " + keyFile.get().toString());
        e.fillInStackTrace();
        throw e;
      }
    }
    if (signingKey == null || signingKey.length < 32) {
      UnsecurableEngineApiException e =
          new UnsecurableEngineApiException(
              "Could not read at least 256 bits of key from "
                  + (keyFile.isPresent() ? keyFile.get().toString() : "undefined"));
      e.fillInStackTrace();
      throw e;
    }

    return new JWTAuthOptions()
        .setJWTOptions(new JWTOptions().setIgnoreExpiration(true).setLeeway(5))
        .addPubSecKey(
            new PubSecKeyOptions()
                .setAlgorithm(jwtAlgorithm.toString())
                .setBuffer(Buffer.buffer(signingKey)));
  }

  @Override
  public void handleLogin(final RoutingContext routingContext) {
    LOG.warn("Engine Auth does not support logins, no login handled");
  }

  @Override
  public JWTAuth getJwtAuthProvider() {
    return this.jwtAuthProvider;
  }

  @Override
  public void authenticate(final String token, final Handler<Optional<User>> handler) {
    try {
      JsonObject jwt = new JsonObject().put("token", token);
      getJwtAuthProvider()
          .authenticate(
              jwt,
              r -> {
                if (r.succeeded()) {
                  if (issuedRecently(r.result().attributes().getLong("iat"))) {
                    final Optional<User> user = Optional.ofNullable(r.result());
                    handler.handle(user);
                  } else {
                    LOG.warn("Client sent stale token: {}", r.result().attributes());
                    handler.handle(Optional.empty());
                  }

                } else {
                  LOG.debug("Authentication failed: {}", 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) {
    return noAuthMethods.contains(jsonRpcMethod.getName()) || optionalUser.isPresent();
  }

  private boolean issuedRecently(final long iat) {
    long iatSecondsSinceEpoch = iat;
    long nowSecondsSinceEpoch = System.currentTimeMillis() / 1000;
    return (Math.abs((nowSecondsSinceEpoch - iatSecondsSinceEpoch))
        <= JWT_EXPIRATION_TIME_IN_SECONDS);
  }
}