Add support for API key security, where X-API-KEY header must match apiKey from settings

apiKey in settings is null by default at this point, for backwards compatibility.
In the future, the Windows installer could generate a UUID for apiKey.
apiKey in settings needs to be at least 8 characters.

API calls in the documentation engine are now marked with an open/closed padlock
to show where API key might be required.
Add support for API key security, where X-API-KEY header must match apiKey from settings

apiKey in settings is null by default at this point, for backwards compatibility.
In the future, the Windows installer could generate a UUID for apiKey.
apiKey in settings needs to be at least 8 characters.

API calls in the documentation engine are now marked with an open/closed padlock
to show where API key might be required.
This commit is contained in:
catbref 2020-11-04 15:35:20 +00:00
parent ad5050f92e
commit 41f178bf59
8 changed files with 72 additions and 2 deletions

View File

@ -5,10 +5,20 @@ import java.net.UnknownHostException;
import javax.servlet.http.HttpServletRequest;
public class Security {
import org.qortal.settings.Settings;
public abstract class Security {
public static final String API_KEY_HEADER = "X-API-KEY";
// TODO: replace with proper authentication
public static void checkApiCallAllowed(HttpServletRequest request) {
String expectedApiKey = Settings.getInstance().getApiKey();
String passedApiKey = request.getHeader(API_KEY_HEADER);
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());
@ -19,4 +29,5 @@ public class Security {
if (!remoteAddr.isLoopbackAddress())
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
}
}

View File

@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.math.BigDecimal;
@ -473,6 +474,7 @@ public class AddressesResource {
}
)
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String computePublicize(String rawBytes58) {
Security.checkApiCallAllowed(request);

View File

@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.io.IOException;
@ -133,6 +134,7 @@ public class AdminResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public NodeStatus status() {
Security.checkApiCallAllowed(request);
@ -153,6 +155,7 @@ public class AdminResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public String shutdown() {
Security.checkApiCallAllowed(request);
@ -181,6 +184,7 @@ public class AdminResource {
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public ActivitySummary summary() {
Security.checkApiCallAllowed(request);
@ -226,6 +230,7 @@ public class AdminResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public Controller.StatsSnapshot getEngineStats() {
Security.checkApiCallAllowed(request);
@ -244,6 +249,7 @@ public class AdminResource {
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public List<MintingAccountData> getMintingAccounts() {
Security.checkApiCallAllowed(request);
@ -290,6 +296,7 @@ public class AdminResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE, ApiError.CANNOT_MINT})
@SecurityRequirement(name = "apiKey")
public String addMintingAccount(String seed58) {
Security.checkApiCallAllowed(request);
@ -342,6 +349,7 @@ public class AdminResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String deleteMintingAccount(String key58) {
Security.checkApiCallAllowed(request);
@ -441,6 +449,7 @@ public class AdminResource {
}
)
@ApiErrors({ApiError.INVALID_HEIGHT, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String orphan(String targetHeightString) {
Security.checkApiCallAllowed(request);
@ -482,6 +491,7 @@ public class AdminResource {
}
)
@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String forceSync(String targetPeerAddress) {
Security.checkApiCallAllowed(request);
@ -527,6 +537,7 @@ public class AdminResource {
description = "Requires enough free space to rebuild repository. This will pause your node for a while."
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public void performRepositoryMaintenance() {
Security.checkApiCallAllowed(request);

View File

@ -1,11 +1,17 @@
package org.qortal.api.resource;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.security.SecuritySchemes;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.qortal.api.Security;
@OpenAPIDefinition(
info = @Info( title = "Qortal API", description = "NOTE: byte-arrays are encoded in Base58" ),
tags = {
@ -30,5 +36,9 @@ import io.swagger.v3.oas.annotations.tags.Tag;
})
}
)
@SecuritySchemes({
@SecurityScheme(name = "basicAuth", type = SecuritySchemeType.HTTP, scheme = "basic"),
@SecurityScheme(name = "apiKey", type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.HEADER, paramName = Security.API_KEY_HEADER)
})
public class ApiDefinition {
}

View File

@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
@ -156,6 +157,7 @@ public class ChatResource {
}
)
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String buildChat(ChatTransactionData transactionData) {
Security.checkApiCallAllowed(request);
@ -203,6 +205,7 @@ public class ChatResource {
}
)
@ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.INVALID_DATA, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String buildChat(String rawBytes58) {
Security.checkApiCallAllowed(request);

View File

@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.math.BigDecimal;
@ -155,6 +156,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String buildTrade(CrossChainBuildRequest tradeRequest) {
Security.checkApiCallAllowed(request);
@ -250,6 +252,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String buildTradeMessage(CrossChainTradeRequest tradeRequest) {
Security.checkApiCallAllowed(request);
@ -333,6 +336,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String buildRedeemMessage(CrossChainSecretRequest secretRequest) {
Security.checkApiCallAllowed(request);
@ -404,6 +408,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String buildCancelMessage(CrossChainCancelRequest cancelRequest) {
Security.checkApiCallAllowed(request);
@ -459,6 +464,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) {
Security.checkApiCallAllowed(request);
@ -485,6 +491,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) {
Security.checkApiCallAllowed(request);
@ -542,6 +549,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) {
Security.checkApiCallAllowed(request);
@ -568,6 +576,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) {
Security.checkApiCallAllowed(request);
@ -656,6 +665,7 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) {
Security.checkApiCallAllowed(request);
@ -683,6 +693,7 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) {
Security.checkApiCallAllowed(request);
@ -793,6 +804,7 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) {
Security.checkApiCallAllowed(request);
@ -821,6 +833,7 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) {
Security.checkApiCallAllowed(request);
@ -935,6 +948,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY})
@SecurityRequirement(name = "apiKey")
public String getBitcoinWalletBalance(String xprv58) {
Security.checkApiCallAllowed(request);
@ -969,6 +983,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE})
@SecurityRequirement(name = "apiKey")
public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) {
Security.checkApiCallAllowed(request);
@ -1019,6 +1034,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public List<TradeBotData> getTradeBotStates() {
Security.checkApiCallAllowed(request);
@ -1049,6 +1065,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) {
Security.checkApiCallAllowed(request);
@ -1104,6 +1121,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) {
Security.checkApiCallAllowed(request);
@ -1168,6 +1186,7 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public String tradeBotDelete(String tradePrivateKey58) {
Security.checkApiCallAllowed(request);

View File

@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.net.InetSocketAddress;
@ -131,6 +132,7 @@ public class PeersResource {
)
}
)
@SecurityRequirement(name = "apiKey")
public ExecuteProduceConsume.StatsSnapshot getEngineStats() {
Security.checkApiCallAllowed(request);
@ -168,6 +170,7 @@ public class PeersResource {
@ApiErrors({
ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE
})
@SecurityRequirement(name = "apiKey")
public String addPeer(String address) {
Security.checkApiCallAllowed(request);
@ -222,6 +225,7 @@ public class PeersResource {
@ApiErrors({
ApiError.INVALID_NETWORK_ADDRESS, ApiError.REPOSITORY_ISSUE
})
@SecurityRequirement(name = "apiKey")
public String removePeer(String address) {
Security.checkApiCallAllowed(request);
@ -257,6 +261,7 @@ public class PeersResource {
@ApiErrors({
ApiError.REPOSITORY_ISSUE
})
@SecurityRequirement(name = "apiKey")
public String removeKnownPeers(String address) {
Security.checkApiCallAllowed(request);
@ -296,6 +301,7 @@ public class PeersResource {
}
)
@ApiErrors({ApiError.INVALID_DATA, ApiError.REPOSITORY_ISSUE})
@SecurityRequirement(name = "apiKey")
public List<BlockSummaryData> commonBlock(String targetPeerAddress) {
Security.checkApiCallAllowed(request);

View File

@ -65,6 +65,7 @@ public class Settings {
"::1", "127.0.0.1"
};
private Boolean apiRestricted;
private String apiKey = null;
private boolean apiLoggingEnabled = false;
private boolean apiDocumentationEnabled = false;
// Both of these need to be set for API to use SSL
@ -275,6 +276,9 @@ public class Settings {
// Validation goes here
if (this.minBlockchainPeers < 1)
throwValidationError("minBlockchainPeers must be at least 1");
if (this.apiKey != null && this.apiKey.trim().length() < 8)
throwValidationError("apiKey must be at least 8 characters");
}
// Getters / setters
@ -323,6 +327,10 @@ public class Settings {
return !BlockChain.getInstance().isTestChain();
}
public String getApiKey() {
return this.apiKey;
}
public boolean isApiLoggingEnabled() {
return this.apiLoggingEnabled;
}