userver: Digest Authorization/Authentication via PostgreSQL
Loading...
Searching...
No Matches
Digest Authorization/Authentication via PostgreSQL

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 SecdistConfig = storages::secdist::SecdistConfig;
using TimePoint = std::chrono::time_point<std::chrono::system_clock>;
class AuthChecker final : public server::handlers::auth::digest::AuthCheckerBase {
public:
using AuthCheckResult = server::handlers::auth::AuthCheckResult;
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 {
    pg_cluster_->Execute(storages::postgres::ClusterHostType::kSlave, uservice_dynconf::sql::kSelectUser, username);
    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,
    storages::postgres::TimePointTz{nonce_creation_time},
    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(
    storages::postgres::ClusterHostType::kSlave, uservice_dynconf::sql::kSelectUnnamedNonce, nonce
    );
    if (res.IsEmpty()) return std::nullopt;
    return res.AsSingleRow<storages::postgres::TimePointTz>().GetUnderlying();
    }
  • kInsertUnnameNonce query looks like this:
    const storages::postgres::Query kInsertUnnamedNonce{
    "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) ",
    storages::postgres::Query::Name{"insert_unnamed_nonce"}};

Authorization Factory

Authorization checkers are produced by authorization factories derived from server::handlers::auth::AuthCheckerFactoryBase:

namespace samples::digest_auth {
class CheckerFactory final : public server::handlers::auth::AuthCheckerFactoryBase {
public:
server::handlers::auth::AuthCheckerBasePtr
operator()(const ::components::ComponentContext& context, const server::handlers::auth::HandlerAuthConfig& auth_config, const server::handlers::auth::AuthCheckerSettings&)
const override;
};
class CheckerProxyFactory final : public server::handlers::auth::AuthCheckerFactoryBase {
public:
server::handlers::auth::AuthCheckerBasePtr
operator()(const ::components::ComponentContext& context, const server::handlers::auth::HandlerAuthConfig& auth_config, const server::handlers::auth::AuthCheckerSettings&)
const override;
};
} // namespace samples::digest_auth

Factories work with component system and parse handler-specific authorization configs:

server::handlers::auth::AuthCheckerBasePtr CheckerFactory::
operator()(const ::components::ComponentContext& context, const server::handlers::auth::HandlerAuthConfig& auth_config, const server::handlers::auth::AuthCheckerSettings&)
const {
const auto& digest_auth_settings =
context.FindComponent<server::handlers::auth::digest::AuthCheckerSettingsComponent>().GetSettings();
return std::make_shared<AuthChecker>(
digest_auth_settings,
auth_config["realm"].As<std::string>({}),
context,
context.FindComponent<components::Secdist>().Get()
);
}

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<components::Postgres>("auth-database")
.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")
return utils::DaemonMain(argc, argv, component_list);

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: