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 CacheContainer = std::unordered_map<
UserDbInfo,
std::hash<server::auth::UserAuthInfo::Ticket>,
};
}
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 {
public:
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 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 {
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 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 {
public:
server::handlers::auth::AuthCheckerBasePtr
const override;
};
}
Factories work with component system and parse handler-specific authorization configs:
server::handlers::auth::AuthCheckerBasePtr CheckerFactory::
const {
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[]) {
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:
public:
static constexpr std::string_view kName = "handler-hello";
using HttpHandlerBase::HttpHandlerBase;
request.GetHttpResponse().
SetContentType(http::content_type::kTextPlain);
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:
.
Append<samples::pg::AuthCache>()
.Append<components::Postgres>("auth-database")
.Append<components::TestsuiteSupport>()
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/2.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/2.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: