Login, área administrativa, upload de capas e notificações em tempo real

Login, área administrativa, upload de capas e notificações em tempo real

O site da Livraria ganha login por OpenID Connect com Keycloak e PKCE, tokens guardados e renovados no servidor sem corrida entre abas, uma área administrativa em Blazor Server, upload de capas validado byte a byte e recodificado em WebP, e um painel em SignalR que avisa cada pedido.
Linguagem C#

• • 25 min de leitura

O site da Livraria ainda não sabe quem o está usando. Neste artigo ele ganha login por OpenID Connect, uma área administrativa em Blazor Server para editar o catálogo, upload seguro de capas e um painel que avisa os administradores de cada pedido no momento em que ele acontece.

Quem autentica quem

Em "Autenticação e autorização com JWT" a API aprendeu a validar tokens emitidos por um provedor de identidade. Faltava alguém obter esses tokens em nome de uma pessoa. No site, isso acontece com o fluxo Authorization Code do OpenID Connect (OIDC), a camada de identidade construída sobre o OAuth 2.0:

1. Visitante clica "Entrar" no site
2. Site redireciona o navegador ao provedor de identidade (com um desafio PKCE)
3. Visitante digita a senha NO PROVEDOR, nunca no site
4. Provedor redireciona de volta ao site com um código de uso único
5. Site troca o código por tokens, servidor a servidor, com seu segredo de cliente
6. Site cria um cookie de sessão; os tokens ficam guardados no servidor

O site nunca vê a senha. Bloqueio contra força bruta, recuperação de conta, segundo fator e login social ficam no provedor, onde "Autenticação e autorização com JWT" disse que deveriam ficar. O PKCE (Proof Key for Code Exchange) garante que um código interceptado no redirecionamento não sirva a quem o roubou. O RFC 9700, as boas práticas atuais de segurança do OAuth publicadas em 2025, o recomenda para todos os clientes, e o middleware do ASP.NET Core o usa por padrão.

Para desenvolvimento usamos o Keycloak, provedor de identidade de código aberto mantido por uma comunidade que começou na Red Hat e hoje é projeto incubado da CNCF:

docker run -d --name keycloak -p 8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.0 start-dev

No console do Keycloak, em http://localhost:8081, criamos:

  • Um realm livraria, o espaço de usuários da loja.
  • Um cliente livraria-site, confidencial, com a URL de retorno https://localhost:5001/signin-oidc.
  • O papel de realm admin, atribuído à usuária Ana.
  • Dois mappers no escopo do cliente. O primeiro põe a audiência livraria-api no access token, para que a API o aceite. O segundo publica os papéis numa claim plana chamada role. Por padrão o Keycloak aninha os papéis em realm_access.roles, um JSON que o RequireRole do ASP.NET Core não sabe ler.

A API troca o dotnet user-jwts de desenvolvimento pela autoridade real, como o artigo sobre JWT antecipou:

"Authentication": {
  "Schemes": {
    "Bearer": {
      "Authority": "http://localhost:8081/realms/livraria",
      "ValidAudiences": [ "livraria-api" ],
      "RequireHttpsMetadata": false
    }
  }
}

RequireHttpsMetadata: false vale só para o Keycloak local em HTTP. Em produção, a autoridade é sempre HTTPS.

Cookie e OpenID Connect no site

dotnet add src/Livraria.Site package Microsoft.AspNetCore.Authentication.OpenIdConnect
builder.Services.AddAuthentication(o =>
    {
        o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(o =>
    {
        o.Cookie.Name = "livraria.sessao";
        o.Cookie.HttpOnly = true;
        o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        o.Cookie.SameSite = SameSiteMode.Lax;
        o.ExpireTimeSpan = TimeSpan.FromHours(8);
        o.SlidingExpiration = true;
    })
    .AddOpenIdConnect(o =>
    {
        builder.Configuration.GetSection("Oidc").Bind(o);   // Authority, ClientId, ClientSecret
        o.ResponseType = OpenIdConnectResponseType.Code;
        o.Scope.Add("offline_access");                      // pede refresh token
        o.MapInboundClaims = false;
        o.TokenValidationParameters.NameClaimType = "name";
        o.TokenValidationParameters.RoleClaimType = "role";
        o.SaveTokens = false;                               // veja a próxima seção
        o.Events.OnTokenValidated = async ctx =>
        {
            var armazem = ctx.HttpContext.RequestServices.GetRequiredService<ArmazemDeTokens>();
            await armazem.GuardarAsync(ctx.Principal!, ctx.TokenEndpointResponse!, ctx.HttpContext.RequestAborted);
        };
    });

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("GerenciarCatalogo", p => p.RequireRole("admin"));

Dois esquemas trabalham juntos. O OIDC conduz o login no provedor. O cookie guarda a sessão depois, e é ele que autentica cada requisição seguinte. DefaultChallengeScheme = OpenIdConnect faz o site, ao encontrar uma página que exige login, redirecionar ao Keycloak em vez de simplesmente responder 401. O MapInboundClaims = false e os tipos de nome e papel são a lição do exercício 3 de "Autenticação e autorização com JWT", aplicada desde o início.

Entrar e sair são dois endpoints:

app.MapGet("/entrar", (string? voltarPara, HttpContext ctx) =>
{
    var destino = !string.IsNullOrEmpty(voltarPara) && Uri.IsWellFormedUriString(voltarPara, UriKind.Relative)
                  && voltarPara.StartsWith('/') && !voltarPara.StartsWith("//")
        ? voltarPara : "/";
    return Results.Challenge(new AuthenticationProperties { RedirectUri = destino });
});

app.MapPost("/sair", async (HttpContext ctx, ArmazemDeTokens armazem) =>
{
    await armazem.RemoverAsync(ctx.User, ctx.RequestAborted);
    return Results.SignOut(new AuthenticationProperties { RedirectUri = "/" },
        [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]);
});

A validação de voltarPara impede um redirecionamento aberto. Sem ela, um link de phishing como /entrar?voltarPara=https://livraria-falsa.exemplo usaria a página de login legítima para mandar a vítima, recém-autenticada e confiante, a um site falso. Só caminhos locais são aceitos, e // é recusado porque os navegadores o interpretam como "outro host".

Sair é POST, com antiforgery, pelo mesmo raciocínio de "Formulários e validação". Um logout por GET poderia ser disparado por uma simples tag <img src="/sair"> em qualquer site. O SignOut com os dois esquemas encerra o cookie local e redireciona ao provedor para encerrar a sessão lá. Sem isso, o próximo "Entrar" logaria de novo sem pedir senha.

Tokens no servidor, não no cookie

Com SaveTokens = true, o middleware guardaria access token, refresh token e id token dentro do cookie criptografado. Funciona, mas tem três custos. Os tokens do Keycloak somam facilmente 3 a 4 KB, e o cookie é dividido em pedaços e enviado em toda requisição, inclusive de imagens e CSS. Renovar o token exige reescrever o cookie, o que não é possível no meio de um circuito Blazor Server, que não tem resposta HTTP. E revogar uma sessão no servidor não tem efeito sobre um cookie que continua válido.

Por isso os tokens ficam num armazém do lado do servidor, indexado pela sessão:

public sealed record TokensDoUsuario(string AccessToken, string RefreshToken, DateTimeOffset ExpiraEm);

public sealed class ArmazemDeTokens(
    IDistributedCache cache,
    IOptionsMonitor<OpenIdConnectOptions> oidc,
    IHttpClientFactory http,
    ILogger<ArmazemDeTokens> log)
{
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Travas = new();

    private static string Chave(ClaimsPrincipal u) =>
        $"site:tokens:{u.FindFirstValue("sub")}:{u.FindFirstValue("sid")}";

    public Task GuardarAsync(ClaimsPrincipal usuario, OpenIdConnectMessage resposta, CancellationToken ct) =>
        SalvarAsync(Chave(usuario), new TokensDoUsuario(
            resposta.AccessToken, resposta.RefreshToken,
            DateTimeOffset.UtcNow.AddSeconds(int.Parse(resposta.ExpiresIn, CultureInfo.InvariantCulture))), ct);

    public async Task<string?> ObterAccessTokenAsync(ClaimsPrincipal usuario, CancellationToken ct)
    {
        if (usuario.Identity?.IsAuthenticated != true) return null;
        var chave = Chave(usuario);

        var atuais = await LerAsync(chave, ct);
        if (atuais is null) return null;
        if (atuais.ExpiraEm > DateTimeOffset.UtcNow.AddMinutes(1)) return atuais.AccessToken;

        var trava = Travas.GetOrAdd(chave, _ => new SemaphoreSlim(1, 1));
        await trava.WaitAsync(ct);
        try
        {
            atuais = await LerAsync(chave, ct);                  // outra requisição pode ter renovado
            if (atuais is null) return null;
            if (atuais.ExpiraEm > DateTimeOffset.UtcNow.AddMinutes(1)) return atuais.AccessToken;

            var novos = await RenovarAsync(atuais.RefreshToken, ct);
            if (novos is null)
            {
                await cache.RemoveAsync(chave, ct);
                return null;                                     // força novo login
            }
            await SalvarAsync(chave, novos, ct);
            return novos.AccessToken;
        }
        finally { trava.Release(); }
    }

    private async Task<TokensDoUsuario?> RenovarAsync(string refreshToken, CancellationToken ct)
    {
        var opcoes = oidc.Get(OpenIdConnectDefaults.AuthenticationScheme);
        var config = await opcoes.ConfigurationManager!.GetConfigurationAsync(ct);

        using var resposta = await http.CreateClient().PostAsync(config.TokenEndpoint,
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "refresh_token",
                ["refresh_token"] = refreshToken,
                ["client_id"] = opcoes.ClientId!,
                ["client_secret"] = opcoes.ClientSecret!
            }), ct);

        if (!resposta.IsSuccessStatusCode)
        {
            log.LogWarning("Renovação de token recusada: {Status}", (int)resposta.StatusCode);
            return null;
        }

        var json = await resposta.Content.ReadFromJsonAsync<JsonElement>(ct);
        return new TokensDoUsuario(
            json.GetProperty("access_token").GetString()!,
            json.GetProperty("refresh_token").GetString()!,
            DateTimeOffset.UtcNow.AddSeconds(json.GetProperty("expires_in").GetInt32()));
    }

    // LerAsync, SalvarAsync (JSON no IDistributedCache, expiração de 8 h) e RemoverAsync omitidos
}

A chave combina sub (quem) e sid (qual sessão). Ana logada no notebook e no celular tem duas entradas independentes, e sair em um aparelho não derruba o outro. A trava por sessão resolve um problema concreto. Com refresh tokens rotativos, os do exercício 5 do artigo sobre JWT, duas requisições simultâneas que encontram o token expirado tentariam renovar com o mesmo refresh token. A segunda seria vista pelo provedor como reutilização de um token já usado, o sinal de roubo, e toda a família de tokens seria revogada. A usuária seria deslogada por ter aberto duas abas. A trava faz a segunda esperar e reler o token que a primeira acabou de renovar. Com várias réplicas do site, a trava em memória não basta, e ela passa a ser uma trava distribuída no Redis, com SET NX e expiração curta, a mesma técnica da chave de idempotência de "Cache com Redis, HybridCache e output caching". Outra saída é configurar no provedor um curto período de tolerância para reuso.

O transform do YARP em "O site consumindo a API" passa a usar o armazém, e o cookie de sessão fica pequeno, só com as claims:

contexto.AddRequestTransform(async t =>
{
    var armazem = t.HttpContext.RequestServices.GetRequiredService<ArmazemDeTokens>();
    var token = await armazem.ObterAccessTokenAsync(t.HttpContext.User, t.HttpContext.RequestAborted);
    if (token is not null)
        t.ProxyRequest.Headers.Authorization = new("Bearer", token);
});

A área administrativa em Blazor Server

"Blazor: modos de renderização, WebAssembly e estado" já indicou: a administração roda em Interactive Server. São poucos usuários, então o custo por circuito é irrelevante. O código fica no servidor, e as telas de edição, os fluxos de aprovação e as integrações nunca vão para o navegador. E os componentes podem usar serviços do servidor diretamente.

builder.Services.AddCascadingAuthenticationState();
@* Routes.razor *@
<Router AppAssembly="typeof(Program).Assembly"
        AdditionalAssemblies="[typeof(Livraria.Site.Client._Imports).Assembly]">
    <Found Context="rota">
        <AuthorizeRouteView RouteData="rota" DefaultLayout="typeof(Layout.MainLayout)">
            <NotAuthorized>
                @if (context.User.Identity?.IsAuthenticated != true)
                {
                    <RedirecionarParaLogin />
                }
                else
                {
                    <p role="alert">Você não tem permissão para acessar esta página.</p>
                }
            </NotAuthorized>
        </AuthorizeRouteView>
    </Found>
</Router>
@* Componentes/Admin/Produtos.razor *@
@page "/admin/produtos"
@attribute [Authorize(Policy = "GerenciarCatalogo")]
@rendermode InteractiveServer
@inject ApiDoAdmin Api

<PageTitle>Catálogo · Administração</PageTitle>
<h1>Catálogo</h1>

<AuthorizeView>
    <p>Conectada como @context.User.Identity!.Name</p>
</AuthorizeView>

@* grade paginada, com links para /admin/produtos/{id} *@

[Authorize] numa página roteável é verificado no servidor antes de qualquer renderização. AuthorizeView só esconde ou mostra partes da interface. Esconder um botão nunca é proteção: a proteção real é a política na página e, principalmente, na API, que recusa o PUT de quem não é admin independentemente do que o site mostre. São camadas de defesa, e a última é a que decide.

AddCascadingAuthenticationState disponibiliza o usuário a todos os componentes. Em Interactive Server, esse usuário é capturado da requisição que abriu o circuito e continua disponível depois, via AuthenticationStateProvider, mesmo sem HttpContext. É o caminho correto para saber quem está do outro lado de um circuito.

Chamando a API de dentro do circuito

public sealed class ApiDoAdmin(HttpClient http, AuthenticationStateProvider autenticacao, ArmazemDeTokens tokens,
    ILogger<ApiDoAdmin> log)
{
    private async Task<HttpRequestMessage> MontarAsync(HttpMethod metodo, string url, CancellationToken ct)
    {
        var usuario = (await autenticacao.GetAuthenticationStateAsync()).User;
        var token = await tokens.ObterAccessTokenAsync(usuario, ct)
            ?? throw new SessaoExpiradaException();

        var requisicao = new HttpRequestMessage(metodo, url);
        requisicao.Headers.Authorization = new("Bearer", token);
        return requisicao;
    }

    public async Task<Resultado<ProdutoResposta>> AlterarAsync(
        int id, AlteracaoProduto dados, string etag, CancellationToken ct)
    {
        using var requisicao = await MontarAsync(HttpMethod.Put, $"v1/produtos/{id}", ct);
        requisicao.Headers.IfMatch.Add(EntityTagHeaderValue.Parse(etag));
        requisicao.Content = JsonContent.Create(dados);
        using var resposta = await http.SendAsync(requisicao, ct);
        return await resposta.LerAsync<ProdutoResposta>(log, ct);
    }
}

builder.Services.AddHttpClient<ApiDoAdmin>(c => c.BaseAddress = new Uri(builder.Configuration["Servicos:Api"]!))
    .AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods());

Por que o token é anexado no próprio cliente tipado e não num DelegatingHandler, como o cache condicional do artigo anterior? Porque a IHttpClientFactory cria os handlers num escopo de DI próprio, separado do escopo do circuito. Um handler que pedisse AuthenticationStateProvider receberia uma instância de outro escopo, sem usuário nenhum. O cliente tipado, por outro lado, é resolvido no escopo do componente que o injeta, e ali o provedor de autenticação é o do circuito certo. Esse detalhe consome horas de depuração de quem o encontra pela primeira vez.

A edição de preço usa If-Match com o ETag lido ao abrir a tela, a concorrência otimista de "A API da Livraria". Se outro administrador salvou antes, o Resultado.Conflito vira uma mensagem clara com a opção de recarregar, em vez de sobrescrever em silêncio. É o mesmo cenário de Ana e Bruno, agora com uma interface de verdade.

Upload de capas

Receber arquivos de usuários é uma das operações mais arriscadas de uma aplicação web. Um arquivo pode ser enorme, pode não ser o que diz ser, pode conter código, pode ter um nome como ../../appsettings.json, e pode carregar metadados que ninguém pretendia publicar. Cada risco tem uma defesa.

No componente

<InputFile OnChange="SelecionarAsync" accept="image/jpeg,image/png,image/webp" />
@if (erro is not null) { <p role="alert">@erro</p> }

@code {
    private const long TamanhoMaximo = 5 * 1024 * 1024;   // 5 MB

    private async Task SelecionarAsync(InputFileChangeEventArgs e)
    {
        erro = null;
        if (e.File.Size > TamanhoMaximo) { erro = "A imagem deve ter no máximo 5 MB."; return; }

        await using var fluxo = e.File.OpenReadStream(TamanhoMaximo);
        var resultado = await Api.EnviarCapaAsync(ProdutoId, fluxo, e.File.ContentType, cancelamento.Token);
        // trata Resultado como nas outras operações
    }
}

accept só filtra o seletor de arquivos do sistema operacional e não é segurança nenhuma. OpenReadStream(maxAllowedSize) lança exceção se o arquivo passar do limite. O padrão é só 500 KB, justamente para que ninguém esqueça de decidir. Em Interactive Server os bytes viajam pelo circuito SignalR até o servidor do site, que os repassa em streaming à API, sem carregar o arquivo inteiro na memória.

Na API

produtos.MapPost("/{id:int}/capa", EnviarCapa)
    .RequireAuthorization("GerenciarCatalogo")
    .DisableAntiforgery()                      // API autenticada por Bearer, não por cookie
    .WithMetadata(new RequestSizeLimitAttribute(6 * 1024 * 1024));

static async Task<Results<Ok<ProdutoResposta>, NotFound, ProblemHttpResult>> EnviarCapa(
    int id, IFormFile arquivo, IRepositorioProdutos repositorio,
    IArmazemDeArquivos armazem, EfeitosDeAlteracao efeitos, CancellationToken ct)
{
    var produto = await repositorio.ObterAsync(id, ct);
    if (produto is null) return TypedResults.NotFound();

    await using var entrada = arquivo.OpenReadStream();
    if (!await AssinaturaDeImagem.EhImagemPermitidaAsync(entrada, ct))
        return TypedResults.Problem(statusCode: 415, title: "Formato não suportado",
            detail: "Envie uma imagem JPEG, PNG ou WebP.");

    entrada.Position = 0;
    using var imagem = await Image.LoadAsync(entrada, ct);
    imagem.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(800, 1200), Mode = ResizeMode.Max }));
    imagem.Metadata.ExifProfile = null;
    imagem.Metadata.XmpProfile = null;

    using var saida = new MemoryStream();
    await imagem.SaveAsWebpAsync(saida, ct);
    saida.Position = 0;

    var nome = $"capas/{Convert.ToHexStringLower(SHA256.HashData(saida.ToArray()))[..32]}.webp";
    saida.Position = 0;
    var url = await armazem.GuardarAsync(nome, saida, "image/webp", ct);

    produto.DefinirCapa(url);
    await repositorio.AtualizarAsync(produto, ct);
    await efeitos.ProdutosAlteradosAsync([id]);
    return TypedResults.Ok(produto.ParaResposta());
}

Cada linha responde a um risco:

  • DisableAntiforgery: desde o .NET 8, endpoints mínimos que recebem IFormFile exigem token antiforgery quando o middleware está ativo. Faz sentido para formulários com cookie, mas esta API se autentica por Authorization: Bearer, que o navegador nunca envia sozinho. É o raciocínio de "Formulários e validação" sobre por que a API não precisa de antiforgery.
  • Limite de tamanho no servidor: o limite do componente é conveniência. Este é o que vale, e rejeita o corpo antes de lê-lo inteiro.
  • Assinatura em bytes, não em nome ou Content-Type: o nome do arquivo e o tipo informados vêm do cliente e são mentira quando alguém quer. Os primeiros bytes de uma imagem real têm assinaturas conhecidas: FF D8 FF no JPEG, 89 50 4E 47 0D 0A 1A 0A no PNG, RIFF....WEBP no WebP. AssinaturaDeImagem lê os primeiros 12 bytes e compara.
  • Decodificar e recodificar: carregar a imagem com uma biblioteca de verdade prova que ela é uma imagem válida, e salvá-la de novo produz um arquivo nosso, limpo. Arquivos poliglotas, válidos como imagem e como HTML ou script ao mesmo tempo, não sobrevivem à recodificação. Aproveitamos para limitar as dimensões e converter tudo para WebP, que é menor.
  • Remover EXIF e XMP: fotos de celular carregam a localização GPS de onde foram tiradas. Uma capa fotografada pelo administrador em casa publicaria o endereço dele. Recodificar sem metadados elimina o risco.
  • O nome é gerado, nunca aproveitado: o nome do arquivo do usuário não chega nem perto do armazenamento, então não há path traversal possível. O nome é o hash do conteúdo, e isso tem duas consequências boas: a mesma capa enviada duas vezes vira o mesmo arquivo, e a URL é imutável. Uma capa nova tem outra URL, e a antiga pode ser guardada em cache por um ano, como no exercício 1 de "Cache com Redis, HybridCache e output caching".

A biblioteca usada é a ImageSharp, da Six Labors, escrita em C# gerenciado. A licença importa: desde a versão 3 ela é gratuita para projetos de código aberto e empresas com faturamento anual abaixo de um milhão de dólares, e comercial acima disso. A SkiaSharp, com licença MIT, é a alternativa mais usada, com uma API mais próxima da biblioteca Skia em C++.

Onde as capas moram

Não no banco, porque blobs grandes incham os backups e competem com as consultas pela memória do banco. Não em wwwroot, porque some a cada deploy de contêiner, não é compartilhado entre réplicas, e mistura conteúdo de usuários com o código da aplicação. O lugar certo é um armazenamento de objetos, como Amazon S3, Azure Blob Storage ou, localmente, o MinIO, compatível com a API do S3:

public interface IArmazemDeArquivos
{
    Task<string> GuardarAsync(string nome, Stream conteudo, string tipo, CancellationToken ct);
}

A implementação de desenvolvimento grava num diretório local servido por um endpoint com Cache-Control: public, max-age=31536000, immutable. A de produção usa o SDK do provedor e devolve a URL pública do objeto, de preferência atrás de uma CDN. Produto ganha a propriedade CapaUrl, com uma migration por provedor relacional e um campo novo no documento do Mongo, e ProdutoResposta passa a expô-la. Como acrescentar um campo opcional na saída é compatível, a v1 não quebra.

Notificações em tempo real para administradores

O hub de "Tempo real com SignalR" ganha um grupo para administradores, preenchido automaticamente na conexão:

public sealed class HubDaLivraria : Hub<IClienteDaLivraria>
{
    public const string GrupoAdmins = "admins";

    public override async Task OnConnectedAsync()
    {
        if (Context.User?.IsInRole("admin") == true)
            await Groups.AddToGroupAsync(Context.ConnectionId, GrupoAdmins);
        await base.OnConnectedAsync();
    }

    // AcompanharProduto, DeixarDeAcompanhar...
}

public interface IClienteDaLivraria
{
    Task ProdutoAlterado(int produtoId);
    Task PedidoRegistrado(int pedidoId, decimal total);
    Task PedidoRecebido(int pedidoId, decimal total, int quantidadeDeItens);   // para admins
    Task EstoqueBaixo(int produtoId, string titulo, int estoque);              // para admins
}

A entrada no grupo é decidida pelo servidor, a partir das claims do token validado. Um método público EntrarComoAdmin() que o cliente pudesse chamar seria uma escalada de privilégio. EfeitosDeAlteracao.PedidoRegistradoAsync acrescenta Clients.Group(GrupoAdmins).PedidoRecebido(...). O aviso de estoque baixo nasce no consumidor de eventos da própria API, a fila livraria.api.efeitos de "Worker Services e mensageria com RabbitMQ". Lá ela só recebia produto.#; agora ganha também um binding para pedido.registrado, e o aviso sai quando um pedido deixa algum item abaixo do mínimo configurado.

O painel administrativo roda em Interactive Server, então quem se conecta ao hub é o servidor do site, dentro do circuito, com o token da administradora:

@page "/admin"
@attribute [Authorize(Policy = "GerenciarCatalogo")]
@rendermode InteractiveServer
@implements IAsyncDisposable
@inject AuthenticationStateProvider Autenticacao
@inject ArmazemDeTokens Tokens
@inject IConfiguration Configuracao

<h1>Painel</h1>
<ul class="eventos" aria-live="polite">
    @foreach (var evento in eventos) { <li>@evento</li> }
</ul>

@code {
    private readonly List<string> eventos = [];
    private HubConnection? conexao;

    protected override async Task OnInitializedAsync()
    {
        var usuario = (await Autenticacao.GetAuthenticationStateAsync()).User;

        conexao = new HubConnectionBuilder()
            .WithUrl(new Uri(new Uri(Configuracao["Servicos:Api"]!), "hubs/livraria"),
                     o => o.AccessTokenProvider = () => Tokens.ObterAccessTokenAsync(usuario, default))
            .WithAutomaticReconnect()
            .Build();

        conexao.On<int, decimal, int>("PedidoRecebido", (id, total, itens) =>
            Registrar($"{DateTime.Now:HH:mm} · Pedido {id}: {itens} itens, {total:C}"));
        conexao.On<int, string, int>("EstoqueBaixo", (id, titulo, estoque) =>
            Registrar($"{DateTime.Now:HH:mm} · Estoque baixo: {titulo} ({estoque})"));

        await conexao.StartAsync();
    }

    private Task Registrar(string texto) => InvokeAsync(() =>
    {
        eventos.Insert(0, texto);
        if (eventos.Count > 50) eventos.RemoveAt(eventos.Count - 1);
        StateHasChanged();
    });

    public async ValueTask DisposeAsync()
    {
        if (conexao is not null) await conexao.DisposeAsync();
    }
}

O artigo sobre os modos de renderização do Blazor alertou que uma conexão do servidor com a API por circuito não escala para milhares de visitantes. Aqui ela é a escolha certa: são três ou quatro administradores, cada um com sua identidade, e o hub precisa saber quem é cada um para colocá-lo no grupo. Os mesmos padrões, com os números diferentes, levam a decisões diferentes. O AccessTokenProvider é chamado a cada reconexão e passa pelo armazém, que renova o token se necessário. Uma conexão de dias continua funcionando. Os handlers do SignalR rodam em threads do cliente SignalR, e daí o InvokeAsync antes de mexer no estado do componente, como naquele artigo.

Auditoria

Com vários administradores alterando preços, "quem mudou o preço de Dom Casmurro para R$ 3,99?" vai ser perguntado um dia. Na API, cada alteração administrativa grava um evento estruturado com o sub de quem a fez, o recurso, os valores antes e depois e o traceId:

[LoggerMessage(EventId = 4001, Level = LogLevel.Information,
    Message = "Produto {ProdutoId} alterado por {UsuarioId}: preço {PrecoAnterior} → {PrecoNovo}")]
public static partial void ProdutoAlterado(this ILogger logger, int produtoId, string usuarioId,
    decimal precoAnterior, decimal precoNovo);

Para exigências legais ou fiscais, o registro vai também para uma tabela de auditoria só de inserção, gravada na mesma transação da alteração, com a mesma técnica do outbox. Log é para operação, tabela de auditoria é para prestação de contas, e as duas coisas têm retenções e controles de acesso diferentes.

A Livraria tem agora pessoas de verdade do lado de dentro. Visitantes entram pelo provedor de identidade sem que o site toque na senha. Sessões são cookies pequenos, com tokens guardados e renovados no servidor sem corrida entre abas. Administradores editam o catálogo numa área em Blazor Server protegida em três camadas, enviam capas que são validadas byte a byte, limpas e guardadas com URLs imutáveis, e acompanham os pedidos no momento em que acontecem. Tudo isso ainda roda no computador de quem desenvolve, com meia dúzia de dotnet run e contêineres soltos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um desenvolvedor simplificou o login para app.MapGet("/entrar", (string voltarPara) => Results.Challenge(new() { RedirectUri = voltarPara })). Monte o ataque que isso permite e explique por que a vítima provavelmente não percebe nada.

Ver resposta

✓ Resposta: O atacante envia por e-mail o link https://loja.livraria.exemplo/entrar?voltarPara=https://loja-livraria.exemplo/confirme-seus-dados. A vítima vê o domínio verdadeiro no link, clica, é levada ao Keycloak legítimo, faz login com sucesso e é redirecionada ao site falso, que imita a Livraria e pede "confirme seu cartão para concluir". Tudo o que a vítima viu até ali era real: domínio, cadeado, tela de login. O redirecionamento final é o único passo falso, e acontece logo depois de uma autenticação bem-sucedida, quando a confiança está no ponto mais alto. A correção é aceitar só caminhos locais: começam com /, não com // nem /\, e não são URIs absolutas. O ASP.NET Core oferece Url.IsLocalUrl e Results.LocalRedirect, que já fazem essas checagens.

Exercício 2

Por que o ArmazemDeTokens relê o armazenamento depois de adquirir a trava, se já tinha lido antes? Descreva a sequência que daria errado sem essa segunda leitura.

Ver resposta

✓ Resposta: É o padrão de verificação dupla. Sequência sem a segunda leitura: as requisições A e B leem o token e veem que expirou. A adquire a trava, renova com o refresh token R1, recebe R2, grava e libera. B adquire a trava e, usando o que leu antes, tenta renovar com R1. O provedor, com rotação de refresh tokens, vê R1 reutilizado depois de já ter sido trocado, interpreta como roubo e revoga a família inteira, R2 incluído. A usuária é deslogada sem motivo. Com a segunda leitura, B encontra o token novo gravado por A, válido, e o usa sem renovar. A trava serializa as renovações, e a releitura garante que quem esperou aproveita o trabalho de quem chegou primeiro.

Exercício 3

Um administrador envia um arquivo capa.jpg que na verdade é um SVG com <script> dentro, com Content-Type: image/jpeg. Percorra as defesas da API e diga qual o barra, e o que aconteceria se só o Content-Type fosse verificado e o arquivo fosse servido como veio.

Ver resposta

✓ Resposta: O limite de tamanho passa, porque o arquivo é pequeno. A verificação de assinatura barra o arquivo: um SVG começa com <?xml ou <svg, não com FF D8 FF, e a API responde 415. Mesmo que passasse, a decodificação pela ImageSharp falharia, já que ela não abre SVG como bitmap. Se a verificação fosse só pelo Content-Type informado e o arquivo fosse guardado e servido como veio, com extensão .svg ou com o servidor inferindo image/svg+xml pelo conteúdo, qualquer pessoa que abrisse a URL da "capa" diretamente executaria o script no domínio onde o arquivo é servido. Se esse domínio for o do site, é um XSS armazenado, capaz de ler a página e agir em nome de quem está logado. Por isso a defesa em profundidade: verificar os bytes, recodificar para um formato nosso, gerar o nome, e servir arquivos de usuários de um domínio separado (a CDN ou o bucket), com Content-Type fixo e X-Content-Type-Options: nosniff.

Exercício 4

A área administrativa usa ApiDoAdmin com o token anexado dentro do cliente tipado. Um colega o refatora para um DelegatingHandler que injeta AuthenticationStateProvider, "para ficar mais limpo". Tudo funciona nos testes de unidade, mas em produção a API responde 401 a todas as chamadas do painel. Explique.

Ver resposta

✓ Resposta: A IHttpClientFactory cria e reaproveita os handlers num escopo de DI próprio, independente de quem pede o cliente. O AuthenticationStateProvider injetado no handler é uma instância desse escopo, que nunca recebeu o estado de autenticação de circuito nenhum. Ele devolve um usuário anônimo, ObterAccessTokenAsync devolve nulo, a requisição sai sem Authorization, e a API responde 401. Nos testes de unidade o handler provavelmente recebia um provedor falso já preenchido, e o problema de escopo nunca aparecia. As correções: manter a anexação no cliente tipado, que é resolvido no escopo do circuito, ou usar o padrão documentado de um acessor de serviços do circuito, um CircuitHandler que guarda o IServiceProvider do circuito num AsyncLocal para que o handler o consulte. Um teste de integração com um circuito real teria revelado o problema.

Exercício 5

O grupo admins é preenchido em OnConnectedAsync a partir do papel no token. Ana perde o papel de admin às 14h, mas o painel dela, aberto desde as 9h com a conexão SignalR ativa, continua recebendo pedidos às 15h. Por quê, e como corrigir?

Ver resposta

✓ Resposta: O papel é lido uma vez, na abertura da conexão. A conexão WebSocket continua aberta por horas, e o grupo não é reavaliado. O access token renovado às 14h05 já vem sem o papel, mas é usado só em reconexões, e enquanto a conexão não cai ninguém o consulta. Há três correções, que podem ser combinadas: (1) habilitar CloseOnAuthenticationExpiration no mapeamento do hub, o que encerra a conexão quando o token que a autenticou expira. Com tokens de 5 a 15 minutos, a reconexão acontece logo, com o token novo, e OnConnectedAsync não coloca mais Ana no grupo; (2) na revogação do papel, disparar um evento que o hub trata removendo as conexões daquele usuário do grupo admins; (3) além disso, nenhuma ação deve depender do grupo. Qualquer comando do painel vai à API com o token atual e é autorizado de novo. O grupo só controla o que é exibido, e o dano de um atraso ali é limitado a ver avisos que não deveria.

Comentários

Mais em Linguagem C#

Testes Automatizados com xUnit: Mudar Sem Medo
Testes Automatizados com xUnit: Mudar Sem Medo

Como montar um projeto de testes com xUnit para a Livraria: Fact e Theory…

SQL Essencial com SQLite: Perguntas a um Banco de Dados
SQL Essencial com SQLite: Perguntas a um Banco de Dados

O modelo relacional e a linguagem SQL na prática, com SQLite: tabelas STRICT…

Cache com Redis, HybridCache e output caching: responder sem ir ao banco
Cache com Redis, HybridCache e output caching: responder sem ir ao banco

Onde guardar respostas prontas da Livraria e quando jogá-las fora: cache HTTP…