userver: Custom Authorization/Authentication via PostgreSQL
Loading...
Searching...
No Matches
Custom 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 authorization check by tokens with scopes for HTTP handlers. Required scopes are specified in static configuration file for each HTTP handler. In the tutorial the authorization data is cached in components::PostgreCache, and information of an authorized user (it's name) is passed to the HTTP handler.

Creation of tokens and user registration is out of scope of this tutorial.

Warning
Authorization scheme from this sample is vulnerable to the MITM-attack unless you are using a secure connection (HTTPS).

PostgreSQL Cache

Let's make a table to store users data:

V001__create_db.sql
V002__add_name.sql
DROP SCHEMA IF EXISTS auth_schema CASCADE;
CREATE SCHEMA IF NOT EXISTS auth_schema;
CREATE TABLE IF NOT EXISTS auth_schema.tokens (
token TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL,
scopes TEXT[] NOT NULL,
updated TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE auth_schema.tokens
ADD COLUMN name TEXT NOT NULL;

Authorization data is rarely changed and often queried. Caching it would improve response times:

#include <vector>
#include <userver/server/auth/user_auth_info.hpp>
namespace samples::pg {
struct UserDbInfo {
std::int64_t user_id;
std::vector<std::string> scopes;
std::string name;
};
struct AuthCachePolicy {
static constexpr std::string_view kName = "auth-pg-cache";
using ValueType = UserDbInfo;
static constexpr auto kKeyMember = &UserDbInfo::token;
static constexpr const char* kQuery =
"SELECT token, user_id, scopes, name FROM auth_schema.tokens";
static constexpr const char* kUpdatedField = "updated";
using UpdatedFieldType = storages::postgres::TimePointTz;
// Using crypto::algorithm::StringsEqualConstTimeComparator to avoid timing
// attack at find(token).
using CacheContainer =
std::unordered_map<server::auth::UserAuthInfo::Ticket, UserDbInfo,
std::hash<server::auth::UserAuthInfo::Ticket>,
};
} // namespace samples::pg

Cache configuration is straightforward:

# yaml
auth-database:
dbconnection: 'postgresql://testsuite@localhost:15433/postgres'
blocking_task_processor: fs-task-processor
dns_resolver: async
auth-pg-cache:
pgcomponent: auth-database
update-interval: 10s

Authorization Checker

To implement an authorization checker derive from server::handlers::auth::AuthCheckerBase and override the virtual functions:

#include "auth_bearer.hpp"
#include "user_info_cache.hpp"
#include <algorithm>
namespace samples::pg {
class AuthCheckerBearer final : public server::handlers::auth::AuthCheckerBase {
public:
using AuthCheckResult = server::handlers::auth::AuthCheckResult;
AuthCheckerBearer(const AuthCache& auth_cache,
std::vector<server::auth::UserScope> required_scopes)
: auth_cache_(auth_cache), required_scopes_(std::move(required_scopes)) {}
[[nodiscard]] AuthCheckResult CheckAuth(
const server::http::HttpRequest& request,
server::request::RequestContext& request_context) const override;
[[nodiscard]] bool SupportsUserAuth() const noexcept override { return true; }
private:
const AuthCache& auth_cache_;
const std::vector<server::auth::UserScope> required_scopes_;
};

The authorization should do the following steps:

  • check for "Authorization" header, return 401 if it is missing;
    AuthCheckerBearer::AuthCheckResult AuthCheckerBearer::CheckAuth(
    const server::http::HttpRequest& request,
    server::request::RequestContext& request_context) const {
    const auto& auth_value = request.GetHeader(http::headers::kAuthorization);
    if (auth_value.empty()) {
    return AuthCheckResult{AuthCheckResult::Status::kTokenNotFound,
    {},
    "Empty 'Authorization' header",
    }
  • check for "Authorization" header value to have Bearer some-token format, return 403 if it is not;
    const auto bearer_sep_pos = auth_value.find(' ');
    if (bearer_sep_pos == std::string::npos ||
    std::string_view{auth_value.data(), bearer_sep_pos} != "Bearer") {
    return AuthCheckResult{
    AuthCheckResult::Status::kTokenNotFound,
    {},
    "'Authorization' header should have 'Bearer some-token' format",
    }
  • check that the token is in the cache, return 403 if it is not;
    const server::auth::UserAuthInfo::Ticket token{auth_value.data() +
    bearer_sep_pos + 1};
    const auto cache_snapshot = auth_cache_.Get();
    auto it = cache_snapshot->find(token);
    if (it == cache_snapshot->end()) {
    return AuthCheckResult{AuthCheckResult::Status::kForbidden};
    }
  • check that user has the required authorization scopes, return 403 if not;
    const UserDbInfo& info = it->second;
    for (const auto& scope : required_scopes_) {
    if (std::find(info.scopes.begin(), info.scopes.end(), scope.GetValue()) ==
    info.scopes.end()) {
    return AuthCheckResult{AuthCheckResult::Status::kForbidden,
    {},
    "No '" + scope.GetValue() + "' permission"};
    }
    }
  • if everything is fine, then store user name in request context and proceed to HTTP handler logic.
    request_context.SetData("name", info.name);
    return {};
    }
Warning
CheckAuth functions are invoked concurrently on the same instance of the class. In this sample the AuthCheckerBearer class only reads the class data. synchronization primitives should be used if data is mutated.

Authorization Factory

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

namespace samples::pg {
class CheckerFactory final
public:
server::handlers::auth::AuthCheckerBasePtr operator()(
const ::components::ComponentContext& context,
};
} // namespace samples::pg

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

server::handlers::auth::AuthCheckerBasePtr CheckerFactory::operator()(
const ::components::ComponentContext& context,
auto scopes = auth_config["scopes"].As<server::auth::UserScopes>({});
const auto& auth_cache = context.FindComponent<AuthCache>();
return std::make_shared<AuthCheckerBearer>(auth_cache, std::move(scopes));
}

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[]) {
"bearer", std::make_unique<samples::pg::CheckerFactory>());

That factory is invoked on each HTTP handler with the matching authorization type:

# yaml
handler-hello:
path: /v1/hello
task_processor: main-task-processor
method: GET
auth: # Authorization config for this handler
types:
- bearer # Authorization type that was specified in main()
scopes: # Required user scopes for that handler
- read
- hello

Request Context

Data that was set by authorization checker could be retrieved by handler from server::request::RequestContext:

class Hello final : public server::handlers::HttpHandlerBase {
public:
static constexpr std::string_view kName = "handler-hello";
using HttpHandlerBase::HttpHandlerBase;
std::string HandleRequestThrow(
server::request::RequestContext& ctx) const override {
return "Hello world, " + ctx.GetData<std::string>("name") + "!\n";
}
};

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 = components::MinimalServerComponentList()
.Append<samples::pg::AuthCache>()
.Append<components::Postgres>("auth-database")
.Append<samples::pg::Hello>()
.Append<components::TestsuiteSupport>()
return utils::DaemonMain(argc, argv, component_list);

Build and Run

To build the sample, execute the following build steps at the userver root directory:

mkdir build_release
cd build_release
cmake -DCMAKE_BUILD_TYPE=Release ..
make userver-samples-postgres_auth

The sample could be started by running make start-userver-samples-postgres_auth. The command would invoke testsuite start target that sets proper paths in the configuration files, prepares and starts the DB, and starts the service.

To start the service manually start the DB server and run ./samples/postgres_service/userver-samples-postgres_auth -c </path/to/static_config.yaml>.

Now you can send a request to your service from another terminal:

bash
$ curl 'localhost:8080/v1/hello' -i
HTTP/1.1 401 Unauthorized
Date: Wed, 21 Dec 2022 13:04:17 UTC
Content-Type: text/html
X-YaRequestId: dbc9dbaa3fc04ce8a86b27a1aa582cd6
X-YaSpanId: aa573144f2312714
X-YaTraceId: 4dfb9e852e07473c9d57a8eb520e7965
Server: userver/1.0.0 (20221221124812; rv:unknown)
Connection: keep-alive
Content-Length: 28
Empty 'Authorization' header
$ curl -H 'Authorization: Bearer SOME_WRONG_USER_TOKEN' 'localhost:8080/v1/hello' -i
HTTP/1.1 403 Forbidden
Date: Wed, 21 Dec 2022 13:06:06 UTC
Content-Type: text/html
X-YaRequestId: 6e39f3bf27324aa3acb01a30b9653b2d
X-YaTraceId: e5d38ab53b3f495a9b97279a731f5fde
X-YaSpanId: e64b939c37035d88
Server: userver/1.0.0 (20221221124812; rv:unknown)
Connection: keep-alive
Content-Length: 0

Functional testing

Functional tests for the service could be implemented using the testsuite. To do that you have to:

Full sources

See the full example: