Аутентификация и авторизация в Blazor: практическое руководство
Если вы работали с ASP.NET MVC или Razor Pages, у вас, вероятно, есть мысленная модель того, как работает аутентификация: промежуточное программное обеспечение перехватывает запрос, проверяет файл cookie или токен, заполняет HttpContext.User, и вы готовы к гонкам. Blazor меняет эту ментальную модель тонкими, но важными способами — и если вы не поймете эти различия на ранней стадии, вам придется отлаживать проблемы аутентификации, которые кажутся невозможными.
В этом посте я хочу рассказать, как на самом деле работают аутентификация и авторизация в Blazor, охватывая модели хостинга Server и WebAssembly. Мы пройдем от основ до пользовательских поставщиков, внешнего OAuth и ловушек, которые, как я видел, сбивают с толку даже опытных разработчиков .NET.
Почему аутентификация в Blazor отличается
В традиционном ASP.NET каждое взаимодействие с пользователем представляет собой HTTP-запрос. Сервер проверяет учетные данные, устанавливает файл cookie, и каждый последующий запрос передает этот файл cookie. Конвейер аутентификации линеен и предсказуем.
Blazor Server работает через постоянное соединение SignalR. После того как первоначальный HTTP-запрос загружает страницу, все последующие взаимодействия происходят через WebSockets. Для каждого нажатия кнопки не создается новый HTTP-запрос, поэтому промежуточное программное обеспечение не выполняется повторно при каждом взаимодействии. HttpContext доступен во время первоначального соединения, но полагаться на него на протяжении всего срока службы схемы — верный путь к ошибкам.
Blazor WebAssembly полностью работает в браузере. Серверной части HttpContext вообще нет. Состояние аутентификации должно быть получено из API, сохранено на стороне клиента и управляемо с помощью токенов — обычно JWT. Сервер доверяет клиенту только в рамках проверки токена.
Это означает, что Blazor нужна собственная абстракция для состояния аутентификации, которая работает независимо от модели хостинга. Эта абстракция — AuthenticationStateProvider.
Состояние аутентификации: Фонд
В основе системы аутентификации Blazor лежит AuthenticationStateProvider. Это абстрактный класс, предоставляющий единственный критический метод:
public abstract Task<AuthenticationState> GetAuthenticationStateAsync();
Объект AuthenticationState оборачивает ClaimsPrincipal — ту же модель идентификации, которая используется во всем .NET. Компоненты не взаимодействуют напрямую с файлами cookie или токенами; они запрашивают AuthenticationStateProvider о текущем состоянии.
Чтобы сделать это состояние доступным для всего вашего дерева компонентов, Blazor предоставляет CascadingAuthenticationState. Обычно вы оборачиваете свой маршрутизатор в App.razor или в свой макет:
[[[ТОК_11]]]
AuthorizeRouteView здесь выполняет двойную функцию: он проверяет, прошел ли пользователь аутентификацию и авторизацию, прежде чем отображать соответствующий компонент страницы, и предоставляет запасной пользовательский интерфейс, если это не так.
В .NET 8 и более поздних версиях с унифицированной моделью Blazor вы настроите это в своем App.razor и платформа автоматически обрабатывает параметр каскадирования, когда вы используете AddCascadingAuthenticationState() при регистрации службы.
Интеграция удостоверений ASP.NET
Для большинства проектов вам не нужно создавать аутентификацию с нуля. ASP.NET Identity обеспечивает управление пользователями, хеширование паролей, двухфакторную аутентификацию и подтверждение учетной записи «из коробки».Настройка с помощью Blazor начинается в Program.cs:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDefaultIdentity<IdentityUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});
Благодаря шаблону веб-приложения Blazor в .NET 8+ шаблонный пользовательский интерфейс Identity напрямую использует компоненты Razor. Вы получаете страницы входа, регистрации и управления учетной записью, которые естественным образом интегрируются с остальной частью вашего приложения Blazor — больше не нужно неудобного сочетания страниц Razor и компонентов Blazor.
ApplicationDbContext наследуется от IdentityDbContext и вам потребуется выполнить миграцию для создания таблиц идентификации:
public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
}
dotnet ef migrations add InitialIdentity
dotnet ef database update
Компонент AuthorizeView
Как только состояние аутентификации проходит через ваше дерево компонентов, AuthorizeView позволяет вам условно отображать пользовательский интерфейс:
<AuthorizeView>
<Authorized>
<p>Welcome, @context.User.Identity?.Name!</p>
<a href="/account/manage">Manage Account</a>
<form method="post" action="/account/logout">
<button type="submit">Log Out</button>
</form>
</Authorized>
<NotAuthorized>
<a href="/account/login">Log In</a>
<a href="/account/register">Register</a>
</NotAuthorized>
</AuthorizeView>
Параметр context внутри <Authorized> предоставляет вам доступ к AuthenticationState, поэтому вы можете проверять утверждения, роли и личность пользователя непосредственно в вашей разметке.
Вы также можете использовать AuthorizeView с ролями и политиками:
<AuthorizeView Roles="Admin,Moderator">
<Authorized>
<button @onclick="DeletePost">Delete Post</button>
</Authorized>
</AuthorizeView>
<AuthorizeView Policy="CanEditArticles">
<Authorized>
<button @onclick="EditArticle">Edit</button>
</Authorized>
</AuthorizeView>
Следует иметь в виду одну вещь: AuthorizeView — это проблема пользовательского интерфейса. Он скрывает или показывает элементы, но не защищает основную логику. Если кто-то может вызвать конечную точку вашего API или вызвать ваш метод напрямую, он полностью обходит AuthorizeView . Всегда применяйте авторизацию и на стороне сервера.
Атрибут [Авторизация]
Чтобы защитить всю страницу, примените атрибут [Authorize]:
@page "/admin/dashboard"
@attribute [Authorize(Roles = "Admin")]
<h1>Admin Dashboard</h1>
<p>Only administrators can see this page.</p>
Когда неаутентифицированный пользователь переходит на эту страницу, срабатывает AuthorizeRouteView и отображает шаблон <NotAuthorized>, который вы определили ранее. Вместо этого вы можете перенаправить на страницу входа, обработав случай NotAuthorized с помощью навигации:
<NotAuthorized>
@if (!context.User.Identity?.IsAuthenticated ?? true)
{
<RedirectToLogin />
}
else
{
<p>You don't have permission to access this page.</p>
}
</NotAuthorized>
Простой компонент RedirectToLogin может выглядеть так:
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(Navigation.Uri);
Navigation.NavigateTo($"/account/login?returnUrl={returnUrl}", forceLoad: true);
}
}
Здесь важен forceLoad: true — вам нужна настоящая HTTP-навигация, чтобы промежуточное программное обеспечение аутентификации на стороне сервера могло правильно обрабатывать процесс входа в систему.
Авторизация на основе ролей и политик
Роли — это самая простая модель: назначьте пользователей в группы, например «Администратор» или «Редактор», а затем проверьте членство. Но политика дает вам гораздо больше гибкости.
Зарегистрируйте политики в Program.cs:
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("CanPublish", policy =>
policy.RequireClaim("Permission", "Publish"));
options.AddPolicy("MinimumAge", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(18)));
options.AddPolicy("PremiumUser", policy =>
policy.RequireRole("Premium")
.RequireClaim("Subscription", "Active"));
});
Пользовательским требованиям нужен обработчик:
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int minimumAge) => MinimumAge = minimumAge;
}
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthClaim = context.User.FindFirst("DateOfBirth");
if (dateOfBirthClaim is null)
return Task.CompletedTask;
var dateOfBirth = DateOnly.Parse(dateOfBirthClaim.Value);
var age = DateOnly.FromDateTime(DateTime.Today).Year - dateOfBirth.Year;
if (age >= requirement.MinimumAge)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
Зарегистрируйте обработчик:
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
В компонентах также можно проверить авторизацию программно, когда вам нужна динамическая логика:
@inject IAuthorizationService AuthorizationService
@inject AuthenticationStateProvider AuthStateProvider
@code {
private bool canPublish;
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var result = await AuthorizationService.AuthorizeAsync(
authState.User, "CanPublish");
canPublish = result.Succeeded;
}
}
Внешние поставщики OAuth
Поддержка «Войти с помощью Google» или «Войти с помощью GitHub» проста с помощью промежуточного программного обеспечения аутентификации ASP.NET. Они настраиваются на стороне сервера, поскольку поток OAuth требует перенаправления HTTP.
builder.Services.AddAuthentication()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Auth:Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Google:ClientSecret"]!;
options.Scope.Add("profile");
})
.AddMicrosoftAccount(options =>
{
options.ClientId = builder.Configuration["Auth:Microsoft:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:Microsoft:ClientSecret"]!;
})
.AddGitHub(options =>
{
options.ClientId = builder.Configuration["Auth:GitHub:ClientId"]!;
options.ClientSecret = builder.Configuration["Auth:GitHub:ClientSecret"]!;
});
Для GitHub вам понадобится пакет NuGet AspNet.Security.OAuth.GitHub NuGet, поскольку он не включен в библиотеки ASP.NET по умолчанию.
Пользовательский интерфейс входа затем предоставляет ссылки, которые запускают внешний вызов:
@page "/account/external-login"
<h2>Sign in with an external provider</h2>
<form method="post" action="/api/auth/external-login">
<button type="submit" name="provider" value="Google">
Sign in with Google
</button>
<button type="submit" name="provider" value="Microsoft">
Sign in with Microsoft
</button>
<button type="submit" name="provider" value="GitHub">
Sign in with GitHub
</button>
</form>
Конечная точка API запускает вызов и обрабатывает обратный вызов:
app.MapPost("/api/auth/external-login", (string provider, HttpContext context) =>
{
var properties = new AuthenticationProperties
{
RedirectUri = "/api/auth/external-callback"
};
return Results.Challenge(properties, [provider]);
});
Внешняя проверка подлинности в Blazor всегда требует полностраничной навигации — вы не можете выполнить перенаправление OAuth внутри схемы SignalR или приложения WebAssembly, минуя сервер.
Аутентификация на основе токенов в Blazor WebAssemblyBlazor WebAssembly запускается на клиенте, поэтому проверка подлинности на основе файлов cookie не применяется таким же образом. Вместо этого вы обычно используете JWT, хранящиеся в памяти и прикрепленные к исходящим HTTP-запросам.
Платформа предоставляет AuthorizationMessageHandler для автоматического прикрепления токенов:
builder.Services.AddHttpClient("API",
client => client.BaseAddress = new Uri("https://api.example.com"))
.AddHttpMessageHandler<AuthorizationMessageHandler>();
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IHttpClientFactory>().CreateClient("API"));
Для автономных приложений Blazor WASM, которые проходят аутентификацию по собственному API, вы реализуете собственный AuthenticationStateProvider для анализа JWT:
public class JwtAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ILocalStorageService _localStorage;
private readonly HttpClient _httpClient;
public JwtAuthenticationStateProvider(
ILocalStorageService localStorage,
HttpClient httpClient)
{
_localStorage = localStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var token = await _localStorage.GetItemAsync<string>("authToken");
if (string.IsNullOrWhiteSpace(token))
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var claims = ParseClaimsFromJwt(token);
var identity = new ClaimsIdentity(claims, "jwt");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void NotifyAuthStateChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
{
var payload = jwt.Split('.')[1];
var padded = payload.Length % 4 switch
{
2 => payload + "==",
3 => payload + "=",
_ => payload
};
var bytes = Convert.FromBase64String(padded);
var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(bytes);
return json?.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))
?? Enumerable.Empty<Claim>();
}
}
После успешного входа в систему вы сохраняете токен и уведомляете состояние аутентификации:
public class AuthService
{
private readonly HttpClient _httpClient;
private readonly ILocalStorageService _localStorage;
private readonly JwtAuthenticationStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
ILocalStorageService localStorage,
AuthenticationStateProvider authStateProvider)
{
_httpClient = httpClient;
_localStorage = localStorage;
_authStateProvider = (JwtAuthenticationStateProvider)authStateProvider;
}
public async Task<bool> LoginAsync(string email, string password)
{
var response = await _httpClient.PostAsJsonAsync("/api/auth/login",
new { Email = email, Password = password });
if (!response.IsSuccessStatusCode)
return false;
var result = await response.Content
.ReadFromJsonAsync<LoginResponse>();
await _localStorage.SetItemAsync("authToken", result!.Token);
_authStateProvider.NotifyAuthStateChanged();
return true;
}
public async Task LogoutAsync()
{
await _localStorage.RemoveItemAsync("authToken");
_authStateProvider.NotifyAuthStateChanged();
}
}
Предупреждение: хранение JWT в localStorage подвергает их XSS-атакам. Для приложений с более высоким уровнем безопасности рассмотрите возможность хранения токенов только в памяти и использования токенов обновления или принятия шаблона Backend-for-Frontend (BFF), где сервер управляет токенами, а клиент использует файлы cookie только для HTTP.
Blazor Server против WebAssembly: соображения безопасности
Модель хостинга фундаментально меняет вашу безопасность.
Blazor Server хранит всю логику ваших компонентов на сервере. Клиент видит только отображаемые различия HTML через SignalR. Это означает:
- Чувствительная логика никогда не покидает сервер
- Вы можете получить доступ к базам данных и внутренним сервисам непосредственно из компонентов.
- Состояние аутентификации поступает от
HttpContextсервера при первоначальном подключении. - Схема может пережить файл cookie аутентификации — если срок действия файла cookie пользователя истечет, схема останется активной до тех пор, пока не будет отключена.
- Вы должны корректно обрабатывать отключение канала и повторно проверять состояние аутентификации при повторном подключении.
Blazor WebAssembly полностью работает в браузере. Это означает:
- Весь код вашего компонента можно загрузить и проверить.
- Никогда не помещайте секреты, строки подключения или конфиденциальную бизнес-логику в компоненты WASM. — Аутентификация применяется только на клиенте для UX; реальное соблюдение требований должно происходить на уровне вашего API.
- Управление токенами — ваша ответственность. – Рассмотрите возможность использования размещенной модели, в которой серверный проект обрабатывает аутентификацию и обслуживает приложение WASM.
Шаблон, который я рекомендую для приложений WebAssembly, заключается в том, чтобы рассматривать каждый компонент так, как будто это «ненадежный пользовательский интерфейс», а каждую конечную точку API — так, как если бы он вызывался неизвестным клиентом. Проверяйте все на стороне сервера независимо от того, что проверяет клиент.
Создание пользовательского поставщика AuthenticationStateProvider
Иногда встроенные поставщики не соответствуют вашей архитектуре. Возможно, вы интегрируетесь с устаревшей системой аутентификации или вам нужно опросить изменения состояния аутентификации. Вот более полный пользовательский поставщик для Blazor Server:
public class CustomAuthStateProvider : AuthenticationStateProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IUserService _userService;
public CustomAuthStateProvider(
IHttpContextAccessor httpContextAccessor,
IUserService userService)
{
_httpContextAccessor = httpContextAccessor;
_userService = userService;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext?.User?.Identity?.IsAuthenticated != true)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var user = await _userService.GetUserWithClaimsAsync(userId);
if (user is null)
return new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity()));
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, user.DisplayName),
new(ClaimTypes.Email, user.Email)
};
claims.AddRange(user.Roles.Select(r => new Claim(ClaimTypes.Role, r)));
claims.AddRange(user.Permissions.Select(p => new Claim("Permission", p)));
var identity = new ClaimsIdentity(claims, "Custom");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
public void MarkUserAsAuthenticated(string userId)
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public void MarkUserAsLoggedOut()
{
var anonymous = new ClaimsPrincipal(new ClaimsIdentity());
var authState = Task.FromResult(new AuthenticationState(anonymous));
NotifyAuthenticationStateChanged(authState);
}
}
Зарегистрируйте его в Program.cs:
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<CustomAuthStateProvider>();
Ключевым моментом здесь является NotifyAuthenticationStateChanged — его вызов запускает обновление каскадного параметра, который повторно оценивает каждый AuthorizeView и AuthorizeRouteView в вашем дереве компонентов. Вот как вы заставляете пользовательский интерфейс реагировать на события входа/выхода без полного обновления страницы.
Распространенные ошибки и решения
После работы с авторизацией Blazor во многих проектах я чаще всего вижу следующие проблемы:
1. Использование HttpContext в серверных компонентах BlazorHttpContext доступен во время первоначального HTTP-запроса, но он null или устарел во время взаимодействия SignalR. Не вводите IHttpContextAccessor в компоненты, которые запускаются после первоначального рендеринга.
Решение: Во время инициализации захватите все, что вам нужно, из HttpContext и сохраните его в сервисе с ограниченной областью действия:
public class UserContext
{
public string? UserId { get; set; }
public string? AccessToken { get; set; }
}
// In a component that renders during the initial HTTP request:
@inject IHttpContextAccessor HttpContextAccessor
@inject UserContext UserContext
@code {
protected override void OnInitialized()
{
var context = HttpContextAccessor.HttpContext;
UserContext.UserId = context?.User.FindFirstValue(ClaimTypes.NameIdentifier);
UserContext.AccessToken = context?.Request.Headers.Authorization
.ToString().Replace("Bearer ", "");
}
}
2. Состояние аутентификации не обновляется после входа в систему
Вы вызываете свой API входа в систему, это удается, но пользовательский интерфейс по-прежнему показывает «Войти».
Решение: Вы должны вызвать NotifyAuthenticationStateChanged на своем AuthenticationStateProvider после изменения состояния аутентификации. Платформа не может волшебным образом обнаружить, что токен был сохранен или был установлен файл cookie.
3. Атрибут авторизации не работает с компонентами
Вы добавляете [Authorize] в компонент, но он не блокирует неаутентифицированных пользователей.
Решение. Убедитесь, что вы используете AuthorizeRouteView вместо обычного RouteView в своем App.razor. Стандарт RouteView полностью игнорирует атрибуты авторизации.
4. Предварительный рендеринг нарушает состояние аутентификации
Во время предварительной отрисовки на стороне сервера в Blazor WebAssembly токен аутентификации недоступен. Компоненты отображаются как не прошедшие проверку подлинности, а затем после загрузки WASM переходят в состояние проверки подлинности.
Решение. Либо отключите предварительную отрисовку для страниц, чувствительных к аутентификации, с помощью @rendermode InteractiveWebAssembly (без предварительной отрисовки), либо корректно обработайте состояние загрузки:
<AuthorizeView>
<Authorized>
<p>Welcome back, @context.User.Identity?.Name</p>
</Authorized>
<Authorizing>
<p>Loading...</p>
</Authorizing>
<NotAuthorized>
<a href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
5. Срок действия токена в долгоработающих схемах
Цепи Blazor Server могут оставаться активными часами. Если срок действия вашего токена или сеанса истекает, пользователь остается «аутентифицированным» в пользовательском интерфейсе, но вызовы API начинают завершаться сбоем.
Решение. Внедрите периодическую проверку или используйте RevalidatingServerAuthenticationStateProvider:
public class RevalidatingAuthStateProvider
: RevalidatingServerAuthenticationStateProvider
{
private readonly IServiceScopeFactory _scopeFactory;
public RevalidatingAuthStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory)
: base(loggerFactory)
{
_scopeFactory = scopeFactory;
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider
.GetRequiredService<UserManager<IdentityUser>>();
var user = await userManager.GetUserAsync(authenticationState.User);
return user is not null;
}
}
Это подтверждает, что пользователь все еще существует (и его отметка безопасности не изменилась) каждые 30 минут.
Заключение
Аутентификация и авторизация в Blazor требуют отхода от традиционного ASP.NET типа «запрос-ответ». Абстракция AuthenticationStateProvider — это ключ к пониманию того, как все это сочетается друг с другом. Как только вы это усвоите, все остальное вытекает само собой.
Для большинства приложений начните с ASP.NET Identity и встроенных шаблонов. Они берут на себя тяжелую работу по управлению пользователями, хешированию паролей и генерации токенов. Добавляйте политики и авторизацию на основе утверждений по мере роста ваших требований. Добавляйте внешних поставщиков OAuth, когда ваши пользователи этого ожидают.
Модель хостинга имеет значение: Blazor Server предлагает более традиционную схему безопасности, при которой код остается на сервере, а WebAssembly подталкивает вас к мышлению, ориентированному на приоритет API, где клиент не заслуживает доверия по своей конструкции. Ни один из них по своей сути не является более безопасным — просто у них разные модели угроз.
Какой бы подход вы ни выбрали, помните золотое правило: авторизация в пользовательском интерфейсе предназначена для удобства пользователя, авторизация на сервере — для обеспечения безопасности. Всегда применяйте и то, и другое.