diff --git a/.gitignore b/.gitignore index 225b48bf..382ccb88 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ /tmp /data* /src/test/resources/arbitrary/*/.qortal/cache +apikey diff --git a/src/main/java/org/qortal/api/ApiKey.java b/src/main/java/org/qortal/api/ApiKey.java new file mode 100644 index 00000000..5ab455fa --- /dev/null +++ b/src/main/java/org/qortal/api/ApiKey.java @@ -0,0 +1,93 @@ +package org.qortal.api; + +import org.qortal.settings.Settings; +import org.qortal.utils.Base58; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.SecureRandom; + +public class ApiKey { + + private String apiKey; + + public ApiKey() throws IOException { + this.load(); + } + + public void generate() throws IOException { + byte[] apiKey = new byte[16]; + new SecureRandom().nextBytes(apiKey); + this.apiKey = Base58.encode(apiKey); + + this.save(); + } + + + /* Filesystem */ + + private Path getFilePath() { + return Paths.get(Settings.getInstance().getApiKeyPath(), "apikey"); + } + + private boolean load() throws IOException { + Path path = this.getFilePath(); + File apiKeyFile = new File(path.toString()); + if (!apiKeyFile.exists()) { + // Try settings - to allow legacy API keys to be supported + return this.loadLegacyApiKey(); + } + + try { + this.apiKey = new String(Files.readAllBytes(path)); + + } catch (IOException e) { + throw new IOException(String.format("Couldn't read contents from file %s", path.toString())); + } + + return true; + } + + private boolean loadLegacyApiKey() { + String legacyApiKey = Settings.getInstance().getApiKey(); + if (legacyApiKey != null && !legacyApiKey.isEmpty()) { + this.apiKey = Settings.getInstance().getApiKey(); + + try { + // Save it to the apikey file + this.save(); + } catch (IOException e) { + // Ignore failures as it will be reloaded from settings next time + } + return true; + } + return false; + } + + public void save() throws IOException { + if (this.apiKey == null || this.apiKey.isEmpty()) { + throw new IllegalStateException("Unable to save a blank API key"); + } + + Path filePath = this.getFilePath(); + + BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString())); + writer.write(this.apiKey); + writer.close(); + } + + + public boolean generated() { + return (this.apiKey != null); + } + + public String getApiKey() { + return this.apiKey; + } + +} diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 5baf2c5d..697543c7 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -14,6 +14,7 @@ import java.security.SecureRandom; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; +import org.checkerframework.checker.units.qual.A; import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.rewrite.handler.RedirectPatternRule; import org.eclipse.jetty.rewrite.handler.RewriteHandler; @@ -54,6 +55,7 @@ public class ApiService { private final ResourceConfig config; private Server server; + private ApiKey apiKey; private ApiService() { this.config = new ResourceConfig(); @@ -74,6 +76,15 @@ public class ApiService { return this.config.getClasses(); } + public void setApiKey(ApiKey apiKey) { + this.apiKey = apiKey; + } + + public ApiKey getApiKey() { + return this.apiKey; + } + + public void start() { try { // Create API server diff --git a/src/main/java/org/qortal/api/Security.java b/src/main/java/org/qortal/api/Security.java index 448f951a..75e2facc 100644 --- a/src/main/java/org/qortal/api/Security.java +++ b/src/main/java/org/qortal/api/Security.java @@ -1,33 +1,45 @@ package org.qortal.api; -import java.net.InetAddress; -import java.net.UnknownHostException; +import java.io.IOException; import javax.servlet.http.HttpServletRequest; -import org.qortal.settings.Settings; - public abstract class Security { public static final String API_KEY_HEADER = "X-API-KEY"; public static void checkApiCallAllowed(HttpServletRequest request) { - String expectedApiKey = Settings.getInstance().getApiKey(); - String passedApiKey = request.getHeader(API_KEY_HEADER); + ApiKey apiKey = Security.getApiKey(request); - if ((expectedApiKey != null && !expectedApiKey.equals(passedApiKey)) || - (passedApiKey != null && !passedApiKey.equals(expectedApiKey))) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); - - InetAddress remoteAddr; - try { - remoteAddr = InetAddress.getByName(request.getRemoteAddr()); - } catch (UnknownHostException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); + if (!apiKey.generated()) { + // Not generated an API key yet, so disallow sensitive API calls + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "API key not generated"); } - if (!remoteAddr.isLoopbackAddress()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); + String passedApiKey = request.getHeader(API_KEY_HEADER); + if (passedApiKey == null) { + // We require an API key to be passed + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Missing 'X-API-KEY' header"); + } + + if (!apiKey.equals(passedApiKey)) { + // The API keys must match + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "API key invalid"); + } + } + + public static ApiKey getApiKey(HttpServletRequest request) { + ApiKey apiKey = ApiService.getInstance().getApiKey(); + if (apiKey == null) { + try { + apiKey = new ApiKey(); + } catch (IOException e) { + // Couldn't load API key - so we need to treat it as not generated, and therefore unauthorized + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED); + } + ApiService.getInstance().setApiKey(apiKey); + } + return apiKey; } } diff --git a/src/main/java/org/qortal/api/resource/AdminResource.java b/src/main/java/org/qortal/api/resource/AdminResource.java index 2fdacf9d..5a755288 100644 --- a/src/main/java/org/qortal/api/resource/AdminResource.java +++ b/src/main/java/org/qortal/api/resource/AdminResource.java @@ -39,12 +39,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.LoggerContext; import org.apache.logging.log4j.core.appender.RollingFileAppender; +import org.checkerframework.checker.units.qual.A; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; -import org.qortal.api.ApiError; -import org.qortal.api.ApiErrors; -import org.qortal.api.ApiExceptionFactory; -import org.qortal.api.Security; +import org.qortal.api.*; import org.qortal.api.model.ActivitySummary; import org.qortal.api.model.NodeInfo; import org.qortal.api.model.NodeStatus; @@ -717,4 +715,40 @@ public class AdminResource { } } + + @POST + @Path("/apikey/generate") + @Operation( + summary = "Generate an API key", + description = "This request is unauthenticated if no API key has been generated yet. " + + "If an API key already exists, it needs to be passed as a header and this endpoint " + + "will then generate a new key which replaces the existing one.", + responses = { + @ApiResponse( + description = "API key string", + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @SecurityRequirement(name = "apiKey") + public String generateApiKey() { + ApiKey apiKey = Security.getApiKey(request); + + // If the API key is already generated, we need to authenticate this request + if (apiKey.generated()) { + Security.checkApiCallAllowed(request); + } + + // Not generated yet - so we are safe to generate one + // FUTURE: we may want to restrict this to local/loopback only? + + try { + apiKey.generate(); + } catch (IOException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Unable to generate API key"); + } + + return apiKey.getApiKey(); + } + } diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index f64a9c8b..a3835ad7 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -72,8 +72,13 @@ public class Settings { private String[] apiWhitelist = new String[] { "::1", "127.0.0.1" }; - private Boolean apiRestricted; + + /** Legacy API key (deprecated Nov 2021). Use /admin/apikey/generate API endpoint instead */ private String apiKey = null; + /** Storage location for API key generated by API (Nov 2021 onwards) */ + private String apiKeyPath = ""; + + private Boolean apiRestricted; private boolean apiLoggingEnabled = false; private boolean apiDocumentationEnabled = false; // Both of these need to be set for API to use SSL @@ -479,6 +484,10 @@ public class Settings { return this.apiKey; } + public String getApiKeyPath() { + return this.apiKeyPath; + } + public boolean isApiLoggingEnabled() { return this.apiLoggingEnabled; }