O primeiro artigo apresentou o TsgcHTTPRESTServer e o seu tratamento de requisições. Este aqui cobre os três componentes complementares que transformam um endpoint funcional em algo que você pode de fato colocar diante dos clientes: um repositório de usuários, multitenancy e as métricas que a equipe de operações vai pedir logo no primeiro dia.
Os três são componentes separados. Você cria os que precisa e os atribui ao servidor, e tudo o que deixar sem atribuir custa um único teste de ponteiro por requisição.
Um repositório de usuários no lugar de uma TStringList
O TsgcHTTPServer_Users guarda contas com hashes de senha com salt e iterações. A parte importante é o quão pouca ligação é necessária: atribua-o às opções de autenticação do servidor e o controle HTTP Basic resolve as credenciais contra ele sozinho.
uses
sgcHTTP_REST_Server, sgcHTTP_REST_Server_Users;
FUsers := TsgcHTTPServer_Users.Create(self);
FServer := TsgcHTTPRESTServer.Create(self);
FServer.Authentication.Users := FUsers;
É só isso. Você não precisa de um manipulador OnAuthentication para que a autenticação Basic funcione, o controle faz a busca e a comparação do hash.
As contas são adicionadas com AddUser, que retorna o Id de usuário gerado, ou uma string vazia quando o nome de usuário está em branco ou já foi usado:
FUsers.AddUser('alice', 'secret123', 'admin,reader');
FUsers.AddUser('bob', 'secret456', 'reader');
O restante da superfície é o que você esperaria, e toda busca é thread safe:
if FUsers.ValidateCredentials('alice', 'secret123') then
...
FUsers.SetPassword('bob', 'newsecret');
FUsers.EnableUser('bob', False);
FUsers.DeleteUser('bob');
Papéis
Os papéis são uma lista separada por vírgulas na conta, com auxiliares para lê-los e testá-los. Um controle de papel em um manipulador é uma única chamada:
FUsers.AddRole('bob', 'admin');
if not FUsers.UserHasRole(vUser, 'admin') then
begin
AResponseInfo.ResponseNo := 403;
AResponseInfo.ContentType := 'application/json';
AResponseInfo.ContentText := '{"error":"forbidden"}';
Exit;
end;
Hashing
As senhas recebem hash com SHA-512 em 10000 iterações por padrão, e cada conta carrega o seu próprio salt aleatório. É esse salt por conta que já faz duas senhas idênticas terem hashes diferentes, então raramente você precisa mexer nisso:
FUsers.Hashing.Algorithm := uhaSHA512;
FUsers.Hashing.Iterations := 10000;
A propriedade Salt é algo diferente e vale ler com atenção. Ela é um pepper opcional misturado a cada hash em cima do salt por conta, e está vazia por padrão de propósito: um valor inventado no momento da construção seria diferente na execução seguinte, e toda senha correta passaria a ser rejeitada sem nenhum erro que explicasse o motivo. Defina-a apenas quando ela puder vir de algum lugar que não seja o próprio repositório de usuários, uma variável de ambiente ou um cofre de chaves, por exemplo. Um pepper guardado ao lado dos hashes que protege não acrescenta nada. Alterá-lo invalida todas as senhas já armazenadas.
Persistência
O repositório fica em memória por padrão. Aponte-o para um arquivo e ele sobrevive a uma reinicialização:
FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'sgcRESTUsers.dat';
FUsers.Storage.EncryptAtRest := True;
FUsers.Storage.EncryptionKey := GetKeyFromEnvironment;
FUsers.Storage.AutoSaveSeconds := 30;
FUsers.LoadUsers;
Com AutoSaveSeconds definido, o repositório se descarrega periodicamente; caso contrário, chame SaveUsers ou SaveToFile quando lhe convier. Para um repositório que já vive no seu próprio banco de dados, defina StorageType como ustCustom e responda aos eventos, onde OnValidateCredentials passa a ser a autoridade:
procedure TForm1.UsersValidateCredentials(Sender: TObject;
const aUsername, aPassword: string; var Valid: Boolean);
begin
Valid := MyDatabase.CheckLogin(aUsername, aPassword);
end;
Um detalhe que importa se você montar uma tela de administração: GetUserByIndex apaga PasswordHash e Salt antes de devolver o registro, então uma rota de enumeração não consegue vazar uma credencial nem por acidente. O FindUser não faz isso. Ele devolve o registro exatamente como está armazenado, incluindo os campos de credencial, porque é a consulta que o próprio controle de autenticação utiliza. Leia dele apenas os campos de que você precisa, e nunca serialize o registro inteiro no corpo de uma resposta.
Multitenancy
O TsgcHTTPServer_Tenancy responde a uma pergunta por requisição: para qual cliente é isso? Ele resolve uma string de tenant antes que o seu manipulador execute, e o servidor a expõe na propriedade somente leitura Tenant.
FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;
FTenancy.HeaderName := 'X-Tenant-Id';
FTenancy.DefaultTenant := 'public';
FServer.Tenancy := FTenancy;
Existem cinco modos de resolução:
| Resolution | Origem | Configurado com |
|---|---|---|
trNone | desligado, Tenant fica sempre vazio | — |
trHost | o nome do host | HostSuffix |
trPath | um segmento do caminho da requisição | PathSegmentIndex |
trHeader | um cabeçalho da requisição | HeaderName |
trJWTClaim | uma claim do token bearer | ClaimName |
Com trHost e HostSuffix definido como .example.com, uma requisição para acme.example.com resolve para acme. Com trPath e PathSegmentIndex igual a 0, /acme/api/orders resolve da mesma forma. Quando a origem configurada não produz nada, é usado o DefaultTenant.
Lê-lo em um manipulador é apenas uma propriedade:
procedure TForm1.ServerCommandGet(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
begin
AResponseInfo.ResponseNo := 200;
AResponseInfo.ContentType := 'application/json';
AResponseInfo.ContentText := '{"tenant":"' + FServer.Tenant + '"}';
end;
Tenant é válido dentro de OnBeforeCommand, OnCommandGet e OnCommandOther, e é local à thread, então um servidor movimentado atendendo muitos tenants ao mesmo tempo nunca os mistura.
Sobre o trJWTClaim há uma coisa que precisa ficar clara. A claim é lida do payload do token sem validar a assinatura, porque o tenant é apenas uma dica de roteamento. A assinatura continua sendo verificada pela autenticação JWT quando você a habilita. Não trate o tenant como prova de identidade por si só.
Se nenhum dos cinco modos servir, OnResolveTenant entrega todas as origens de uma vez e deixa você decidir:
procedure TForm1.TenancyResolveTenant(Sender: TObject;
const aHost, aPath, aHeaderValue, aJWTPayload: string; var aTenant: string);
begin
aTenant := LookupTenantForHost(aHost);
end;
Métricas e saúde
O TsgcHTTPServerStats conta o que o servidor fez e pode publicar isso em dois endpoints. Ambos ficam desligados até que você os habilite individualmente, então nada se torna acessível só porque você soltou o componente no formulário.
FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Metrics.Enabled := True;
FStats.Endpoints.Health.Enabled := True;
FServer.ServerStats := FStats;
Os contadores também podem ser lidos por código, o que é prático para uma tela interna de status:
lblRequests.Caption := IntToStr(FStats.TotalRequests);
lblErrors.Caption := IntToStr(FStats.Status5xx);
lblLatency.Caption := IntToStr(FStats.LatencyAvgMs) + ' ms';
lblUptime.Caption := IntToStr(FStats.UptimeSeconds) + ' s';
/metrics responde diretamente no formato de exposição de texto do Prometheus, sem exportador no meio. Contadores por endpoint estão incluídos, com a cardinalidade limitada a 256 caminhos distintos, para que uma rota com um id dentro dela não possa explodir a contagem de séries. Tudo o que passar do limite é agrupado em other.
# HELP sgc_server_endpoint_requests_total Requests per endpoint
# TYPE sgc_server_endpoint_requests_total counter
sgc_server_endpoint_requests_total{endpoint="/api/orders"} 3120
sgc_server_endpoint_requests_total{endpoint="/api/users"} 845
Se os componentes de firewall, limitador de taxa, circuit breaker ou gerenciador de chaves de API estiverem anexados, as métricas deles são acrescentadas à mesma saída:
FStats.RateLimiter := FRateLimiter;
FStats.Firewall := FFirewall;
/health responde com um documento JSON compacto. O seu status indica ok, ou degraded quando um circuit breaker está anexado e tem breakers abertos, o que o torna diretamente utilizável como sonda de balanceador de carga.
Por fim, OnStats dispara com o objeto de estatísticas inteiro caso você prefira enviar os números para algum lugar por conta própria:
procedure TForm1.StatsEvent(Sender: TObject;
const aStats: TsgcHTTPServerStats);
begin
MyTelemetry.Send(aStats.TotalRequests, aStats.LatencyAvgMs);
end;
Juntando tudo
Um servidor com os três anexados tem cerca de uma dúzia de linhas, e cada componente permanece independente dos outros:
FStats := TsgcHTTPServerStats.Create(self);
FStats.Endpoints.Health.Enabled := True;
FStats.Endpoints.Metrics.Enabled := True;
FTenancy := TsgcHTTPServer_Tenancy.Create(self);
FTenancy.Resolution := trHeader;
FUsers := TsgcHTTPServer_Users.Create(self);
FUsers.Storage.StorageType := ustFile;
FUsers.Storage.FileName := 'users.dat';
FUsers.LoadUsers;
FServer := TsgcHTTPRESTServer.Create(self);
FServer.ServerStats := FStats;
FServer.Tenancy := FTenancy;
FServer.Authentication.Users := FUsers;
FServer.Authentication.Enabled := True;
FServer.Port := 5876;
FServer.Active := True;
O próximo artigo coloca um contrato OpenAPI à frente desse servidor, de modo que roteamento, validação e documentação venham todos da especificação.
Baixe a versão mais recente na página de download do sgcWebSockets.
