Before you start
Make sure that you can compile and run core tests and read a basic example Writing your first HTTP server.
Step by step guide
This tutorial shows how to create a custom digest authorization checker. In the tutorial the authorization data is stored in PostgreSQL database, and information of an authorized user (i.e. Authorization header) is passed to the HTTP handler.
Authentication credentials checking logic is set in base class server::handlers::auth::digest::AuthChecker
. This sample simply defines derived class samples::digest_auth::AuthChecker
, that can operate with user data and unnamed nonce pool. Digest authentication logic and hashing logic is out of scope of this tutorial. For reference, read RFC2617.
PostgreSQL Table
Let's make a table to store users data:
DROP SCHEMA IF EXISTS auth_schema CASCADE;
CREATE SCHEMA IF NOT EXISTS auth_schema;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS auth_schema.users (
username TEXT NOT NULL,
nonce TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
nonce_count integer NOT NULL DEFAULT 0,
ha1 TEXT NOT NULL,
PRIMARY KEY(username)
);
CREATE TABLE IF NOT EXISTS auth_schema.unnamed_nonce (
id uuid NOT NULL,
nonce TEXT NOT NULL,
creation_time TIMESTAMPTZ NOT NULL,
PRIMARY KEY(id),
UNIQUE(nonce)
);
Authorization Checker
To implement an authorization checker derive from server::handlers::auth::digest::AuthChecker
and override the virtual functions:
#include "auth_digest.hpp"
#include "user_info.hpp"
#include "sql/queries.hpp"
#include <algorithm>
#include <optional>
#include <string_view>
namespace samples::digest_auth {
using TimePoint = std::chrono::time_point<std::chrono::system_clock>;
public:
AuthChecker(
const AuthDigestSettings& digest_settings,
std::string realm,
const ::components::ComponentContext& context,
const SecdistConfig& secdist_config
)
: server::handlers::auth::digest::AuthCheckerBase(digest_settings,
std::move(realm), secdist_config),
pg_cluster_(context.FindComponent<
components::Postgres>(
"auth-database").GetCluster()),
nonce_ttl_(digest_settings.nonce_ttl) {}
std::optional<UserData> FetchUserData(const std::string& username) const override;
void SetUserData(
const std::string& username,
const std::string& nonce,
std::int64_t nonce_count,
TimePoint nonce_creation_time
) const override;
void PushUnnamedNonce(std::string nonce) const override;
std::optional<TimePoint> GetUnnamedNonceCreationTime(const std::string& nonce) const override;
private:
const std::chrono::milliseconds nonce_ttl_;
};
The authorization in base class calls the following functions:
- Returns user data from database:
std::optional<UserData> AuthChecker::FetchUserData(const std::string& username) const {
if (res.IsEmpty()) return std::nullopt;
auto user_db_info = res.
AsSingleRow<UserDbInfo>(storages::postgres::kRowTag);
return UserData{
HA1{user_db_info.ha1}, user_db_info.nonce, user_db_info.timestamp.GetUnderlying(), user_db_info.nonce_count};
}
- Inserts user data into database:
void AuthChecker::SetUserData(
const std::string& username,
const std::string& nonce,
std::int64_t nonce_count,
TimePoint nonce_creation_time
) const {
pg_cluster_->Execute(
uservice_dynconf::sql::kUpdateUser,
nonce,
nonce_count,
username
);
}
- Pushes unnamed nonce into database using kInsertUnnameNonce query:
void AuthChecker::PushUnnamedNonce(std::string nonce) const {
auto res = pg_cluster_->Execute(
uservice_dynconf::sql::kInsertUnnamedNonce,
nonce,
);
}
- Pops unnamed nonce from database and gets it's creation time using kSelectUnnamedNonce query:
std::optional<TimePoint> AuthChecker::GetUnnamedNonceCreationTime(const std::string& nonce) const {
auto res = pg_cluster_->Execute(
);
if (res.IsEmpty()) return std::nullopt;
}
- kInsertUnnameNonce query looks like this:
"WITH expired AS( "
" SELECT id FROM auth_schema.unnamed_nonce WHERE creation_time <= $1 "
"LIMIT 1 "
"), "
"free_id AS ( "
"SELECT COALESCE((SELECT id FROM expired LIMIT 1), "
"uuid_generate_v4()) AS id "
") "
"INSERT INTO auth_schema.unnamed_nonce (id, nonce, creation_time) "
"SELECT "
" free_id.id, "
" $2, "
" $3 "
"FROM free_id "
"ON CONFLICT (id) DO UPDATE SET "
" nonce=$2, "
" creation_time=$3 "
" WHERE auth_schema.unnamed_nonce.id=(SELECT free_id.id FROM free_id "
"LIMIT 1) ",
Authorization Factory
Authorization checkers are produced by authorization factories derived from server::handlers::auth::AuthCheckerFactoryBase
:
namespace samples::digest_auth {
public:
server::handlers::auth::AuthCheckerBasePtr
const override;
};
public:
server::handlers::auth::AuthCheckerBasePtr
const override;
};
}
Factories work with component system and parse handler-specific authorization configs:
server::handlers::auth::AuthCheckerBasePtr CheckerFactory::
const {
const auto& digest_auth_settings =
return std::make_shared<AuthChecker>(
digest_auth_settings,
auth_config["realm"].As<std::string>({}),
context,
);
}
Each factory should be registered at the beginning of the main()
function via server::handlers::auth::RegisterAuthCheckerFactory
function call:
int main(int argc, const char* const argv[]) {
"digest", std::make_unique<samples::digest_auth::CheckerFactory>()
);
"digest-proxy", std::make_unique<samples::digest_auth::CheckerProxyFactory>()
);
Static config
That factory is invoked on each HTTP handler with the matching authorization type:
handler-hello:
path: /v1/hello
task_processor: main-task-processor
method: GET
auth: # Authorization config for this handler
types:
- digest # Authorization type that was specified in main()
realm: registred@userver.com
handler-hello-proxy:
path: /v1/hello-proxy
task_processor: main-task-processor
method: GET
auth: # Authorization config for this handler
types:
- digest-proxy
realm: registred@userver.com
Digest settings are set using server::handlers::auth::digest::AuthCheckerSettingsComponent
and universal for each handler, which uses digest authentication:
auth-digest-checker-settings:
algorithm: MD5
qops:
- auth
is-proxy: false
is-session: false
domains:
- /v1/hello
nonce-ttl: 1000s
auth-digest-checker-settings-proxy:
algorithm: MD5
qops:
- auth
is-proxy: true
is-session: false
domains:
- /v1/hello
nonce-ttl: 1000s
You also need to provide a secret as a value to the http_server_digest_auth_secret
key using components::Secdist
.
default-secdist-provider: # Component that loads configuration of hosts and passwords
config: '/etc/digest_auth/secdist.json' # Values are supposed to be stored in this file
missing-ok: true # ... but if the file is missing it is still ok
environment-secrets-key: SERVER_DIGEST_AUTH_SECRET # ... values will be loaded from this environment value
Secret is used to generate the value for the derivatives.
int main()
Aside from calling server::handlers::auth::RegisterAuthCheckerFactory
for authorization factory registration, the main()
function should also construct the component list and start the daemon:
const auto component_list =
.Append<samples::digest_auth::Hello>()
.Append<samples::digest_auth::Hello>("handler-hello-proxy")
.Append<components::TestsuiteSupport>()
.Append<server::handlers::TestsControl>()
.Append<components::Secdist>()
.Append<server::handlers::auth::digest::AuthCheckerSettingsComponent>("auth-digest-checker-settings-proxy")
Functional testing
Functional tests for the service could be implemented using the testsuite. To do that you have to:
- Provide PostgreSQL schema to start the database:
DROP SCHEMA IF EXISTS auth_schema CASCADE;
CREATE SCHEMA IF NOT EXISTS auth_schema;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS auth_schema.users (
username TEXT NOT NULL,
nonce TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
nonce_count integer NOT NULL DEFAULT 0,
ha1 TEXT NOT NULL,
PRIMARY KEY(username)
);
CREATE TABLE IF NOT EXISTS auth_schema.unnamed_nonce (
id uuid NOT NULL,
nonce TEXT NOT NULL,
creation_time TIMESTAMPTZ NOT NULL,
PRIMARY KEY(id),
UNIQUE(nonce)
);
- Tell the testsuite to start the PostgreSQL database by adjusting the samples/digest_auth_service/tests/conftest.py
- Prepare the DB test data samples/digest_auth_service/postgresql/data/test_data.sql
- Write the test:
import auth_utils
import pytest
@pytest.mark.pgsql('auth', files=['test_data.sql'])
async def test_authenticate_base(service_client):
response = await service_client.get('/v1/hello')
assert response.status == 401
authentication_header = response.headers['WWW-Authenticate']
auth_directives = auth_utils.parse_directives(authentication_header)
auth_utils.auth_directives_assert(auth_directives)
challenge = auth_utils.construct_challenge(auth_directives)
auth_header = auth_utils.construct_header('username', 'pswd', challenge)
response = await service_client.get(
'/v1/hello', headers={'Authorization': auth_header},
)
assert response.status == 200
assert 'Authentication-Info' in response.headers
Full sources
See the full example: