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 retornohttps://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-apino access token, para que a API o aceite. O segundo publica os papéis numa claim plana chamadarole. Por padrão o Keycloak aninha os papéis emrealm_access.roles, um JSON que oRequireRoledo 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 recebemIFormFileexigem token antiforgery quando o middleware está ativo. Faz sentido para formulários com cookie, mas esta API se autentica porAuthorization: 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 FFno JPEG,89 50 4E 47 0D 0A 1A 0Ano PNG,RIFF....WEBPno WebP.AssinaturaDeImagemlê 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
- Microsoft Learn — Configure OpenID Connect Web (UI) authentication in ASP.NET Core: https://learn.microsoft.com/aspnet/core/security/authentication/configure-oidc-web-authentication
- Microsoft Learn — Secure an ASP.NET Core Blazor Web App with OpenID Connect (OIDC): https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc
- RFC 9700 — Best Current Practice for OAuth 2.0 Security: https://www.rfc-editor.org/rfc/rfc9700
- Microsoft Learn — Upload files in ASP.NET Core e ASP.NET Core Blazor file uploads: https://learn.microsoft.com/aspnet/core/mvc/models/file-uploads
- OWASP — File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- Keycloak — Server Administration Guide: https://www.keycloak.org/documentation
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.