Add example of custom auth policy, and documentation of new auth types (#2050)

This commit is contained in:
Eddy Ashton 2021-01-07 12:40:13 +00:00 коммит произвёл GitHub
Родитель 6fbd8202b6
Коммит 429258d7ef
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
4 изменённых файлов: 186 добавлений и 0 удалений

Просмотреть файл

@ -119,3 +119,36 @@ Instead of taking and returning `nlohmann::json` objects directly, the endpoint
:dedent: 6
This produces validation error messages with a low performance overhead, and ensures the schema and parsing logic stay in sync, but is only suitable for simple schema - an object with some required and some optional fields, each of a supported type.
Authentication
~~~~~~~~~~~~~~
Each endpoint must provide a list of associated authentication policies in the call to ``make_endpoint``. Each request to this endpoint will first be checked by these policies in the order they are specified, and the handler will only be invoked if at least one of these policies accepts the request. Inside the handler, the caller identity that was constructed by the accepting policy check can be retrieved with ``get_caller`` or ``try_get_caller`` - the latter should be used when multiple policies are present, to detect which policy accepted the request. This caller identity can then be used to make authorization decisions during execution of the endpoint.
For example in the ``/log/private`` endpoint above there is a single policy stating that requests must come from a known user cert, over mutually authenticated TLS. This is one of several built-in policies provided by CCF. These built-in policies will check that the caller's TLS cert is a known user or member identity, or that the request is HTTP signed by a known user or member identity, or that the request contains a JWT signed by a known issuer. Additionally, there is an empty policy which accepts all requests, which should be used as the final policy to declare that the endpoint is optionally authenticated (either an earlier-listed policy passes providing a real caller identity, or the empty policy passes and the endpoint is invoked with no caller identity). To declare that an endpoint has no authentication requirements and should be accessible by any caller, use the special value ``no_auth_required``.
Applications can extend this system by writing their own authentication policies. There is an example of this in the C++ logging app. First it defines a type describing the identity details it aims to find in an acceptable request:
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: custom_identity
:end-before: SNIPPET_END: custom_identity
:dedent: 2
Next it defines the policy itself. The core functionality is the implementation of the ``authenticate()`` method, which looks at each request and returns either a valid new identity if it accepts the request, or ``nullptr`` if it doesn't. In this demo case it is looking for a pair of headers and doing some validation of their values:
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: custom_auth_policy
:end-before: SNIPPET_END: custom_auth_policy
:dedent: 2
Note that ``authenticate()`` is also passed a ``ReadOnlyTx`` object, so more complex authentication decisions can depend on the current state of the KV. For instance the built-in TLS cert auth policies are looking up the currently known user/member certs stored in the KV, which will change over the life of the service.
The final piece is the definition of the endpoint itself, which uses an instance of this new policy when it is constructed and then retrieves the custom identity inside the handler:
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: custom_auth_endpoint
:end-before: SNIPPET_END: custom_auth_endpoint
:dedent: 6

Просмотреть файл

@ -435,6 +435,22 @@
}
}
},
"/custom_auth": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/json"
}
}
},
"description": "Default response description"
}
}
}
},
"/endpoint_metrics": {
"get": {
"responses": {

Просмотреть файл

@ -6,6 +6,7 @@
#include "node/quote.h"
#include "node/rpc/user_frontend.h"
#include <charconv>
#define FMT_HEADER_ONLY
#include <fmt/format.h>
@ -17,6 +18,84 @@ namespace loggingapp
// SNIPPET: table_definition
using Table = kv::Map<size_t, string>;
// SNIPPET_START: custom_identity
struct CustomIdentity : public ccf::AuthnIdentity
{
std::string name;
size_t age;
};
// SNIPPET_END: custom_identity
// SNIPPET_START: custom_auth_policy
class CustomAuthPolicy : public ccf::AuthnPolicy
{
public:
std::unique_ptr<ccf::AuthnIdentity> authenticate(
kv::ReadOnlyTx&,
const std::shared_ptr<enclave::RpcContext>& ctx,
std::string& error_reason) override
{
const auto& headers = ctx->get_request_headers();
constexpr auto name_header_key = "x-custom-auth-name";
const auto name_header_it = headers.find(name_header_key);
if (name_header_it == headers.end())
{
error_reason =
fmt::format("Missing required header {}", name_header_key);
return nullptr;
}
const auto& name = name_header_it->second;
if (name.empty())
{
error_reason = "Name must not be empty";
return nullptr;
}
constexpr auto age_header_key = "x-custom-auth-age";
const auto age_header_it = headers.find(age_header_key);
if (name_header_it == headers.end())
{
error_reason =
fmt::format("Missing required header {}", age_header_key);
return nullptr;
}
const auto& age_s = age_header_it->second;
size_t age;
const auto [p, ec] =
std::from_chars(age_s.data(), age_s.data() + age_s.size(), age);
if (ec != std::errc())
{
error_reason =
fmt::format("Unable to parse age header as a number: {}", age_s);
return nullptr;
}
constexpr auto min_age = 16;
if (age < min_age)
{
error_reason = fmt::format("Caller age must be at least {}", min_age);
return nullptr;
}
auto ident = std::make_unique<CustomIdentity>();
ident->name = name;
ident->age = age;
return ident;
}
std::optional<ccf::OpenAPISecuritySchema> get_openapi_security_schema()
const override
{
// There is no OpenAPI-compliant way to describe this auth scheme, so we
// return nullopt
return std::nullopt;
}
};
// SNIPPET_END: custom_auth_policy
// SNIPPET: inherit_frontend
class LoggerHandlers : public ccf::UserEndpointRegistry
{
@ -375,6 +454,25 @@ namespace loggingapp
.set_auto_schema<void, std::string>()
.install();
// SNIPPET_START: custom_auth_endpoint
auto custom_auth = [](auto& ctx) {
const auto& caller_identity = ctx.template get_caller<CustomIdentity>();
nlohmann::json response;
response["name"] = caller_identity.name;
response["age"] = caller_identity.age;
response["description"] = fmt::format(
"Your name is {} and you are {}",
caller_identity.name,
caller_identity.age);
ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
ctx.rpc_ctx->set_response_body(response.dump(2));
};
auto custom_policy = std::make_shared<CustomAuthPolicy>();
make_endpoint("custom_auth", HTTP_GET, custom_auth, {custom_policy})
.set_auto_schema<void, nlohmann::json>()
.install();
// SNIPPET_END: custom_auth_endpoint
// SNIPPET_START: log_record_text
auto log_record_text = [this](auto& args) {
const auto expected = http::headervalues::contenttype::TEXT;

Просмотреть файл

@ -286,6 +286,44 @@ def test_multi_auth(network, args):
return network
@reqs.description("Call an endpoint with a custom auth policy")
@reqs.supports_methods("custom_auth")
def test_custom_auth(network, args):
if args.package == "liblogging":
primary, _ = network.find_primary()
with primary.client("user0") as c:
LOG.info("Request without custom headers is refused")
r = c.get("/app/custom_auth")
assert r.status_code == http.HTTPStatus.UNAUTHORIZED.value, r.status_code
name_header = "x-custom-auth-name"
age_header = "x-custom-auth-age"
LOG.info("Requests with partial headers are refused")
r = c.get("/app/custom_auth", headers={name_header: "Bob"})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED.value, r.status_code
r = c.get("/app/custom_auth", headers={age_header: 42})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED.value, r.status_code
LOG.info("Requests with unacceptable header contents are refused")
r = c.get("/app/custom_auth", headers={name_header: "", age_header: 42})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED.value, r.status_code
r = c.get("/app/custom_auth", headers={name_header: "Bob", age_header: 12})
assert r.status_code == http.HTTPStatus.UNAUTHORIZED.value, r.status_code
LOG.info("Request which meets all requirements is accepted")
r = c.get(
"/app/custom_auth", headers={name_header: "Alice", age_header: 42}
)
assert r.status_code == http.HTTPStatus.OK.value, r.status_code
response = r.body.json()
assert response["name"] == "Alice", response
assert response["age"] == 42, response
return network
@reqs.description("Write non-JSON body")
@reqs.supports_methods("log/private/raw_text/{id}", "log/private")
def test_raw_text(network, args):
@ -652,6 +690,7 @@ def run(args):
network = test_cert_prefix(network, args)
network = test_anonymous_caller(network, args)
network = test_multi_auth(network, args)
network = test_custom_auth(network, args)
network = test_raw_text(network, args)
network = test_historical_query(network, args)
network = test_view_history(network, args)