150cbcdc29
Critical fixes (review findings #1, #2, #3): - Create IEbookRepository abstraction in Application layer - Remove illegal EF Core dependency from IngestEbookCommandHandler - Create EbookRepository implementation in Infrastructure/Persistence - Create ISyncBroadcaster in Application/Abstractions/Messaging - Create SignalRSyncBroadcaster in Infrastructure/RealTime - Move UpdateReadingProgressCommandHandler from Infrastructure → Application - Add EbookId to GetReaderPageQuery and IEpubReader signature - Rewrite EpubReaderService: DB-resolved file path, remove auto-provisioning - Split EpubService.cs into EpubReaderService.cs + EpubMetadataExtractor.cs - Add CurrentEbookId to IReaderNavigationService and ReaderNavigationService - Update WasmEpubReader and /api/epub endpoint for new signature High severity fixes (#4, #6, #7, #8, #16): - Change BookStorageService registration from Singleton → Scoped - Fix empty catch{} in ReaderCanvas JS interop init — now logs warnings - Replace all Console.WriteLine with ILogger in KnowledgeService + ReaderCanvas - Cache JsonSerializerOptions as static field in KnowledgeService - Wrap SyncService Task.Run body in comprehensive try/catch with ILogger Medium/Low fixes (#11, #13, #14, #15, #18, #20): - BookIngestionModal.DisposeAsync now nullifies _epubBytes (50MB array) - KnowledgeCoordinator.OnGraphUpdated: Action<T> → Func<T, Task> - BookStorageService: Path.Combine → forward-slash string interpolation - SignalR CancellationToken passed as named parameter (not payload arg)
534 lines
12 KiB
Markdown
534 lines
12 KiB
Markdown
# Blazor Authentication & Authorization
|
|
|
|
## Authentication Setup
|
|
|
|
### Blazor Server Setup
|
|
|
|
```csharp
|
|
// Program.cs
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Add authentication
|
|
builder.Services
|
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/login";
|
|
options.LogoutPath = "/logout";
|
|
options.AccessDeniedPath = "/unauthorized";
|
|
});
|
|
|
|
builder.Services.AddAuthorizationCore();
|
|
|
|
// Add Blazor Server
|
|
builder.Services.AddRazorPages();
|
|
builder.Services.AddServerSideBlazor();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Add authentication middleware BEFORE MapRazorPages
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapRazorPages();
|
|
app.MapBlazorHub();
|
|
```
|
|
|
|
### Blazor WebAssembly Setup
|
|
|
|
```csharp
|
|
// Program.cs
|
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
|
|
|
builder.RootComponents.Add<App>("#app");
|
|
|
|
// Add authentication
|
|
builder.Services.AddAuthorizationCore();
|
|
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
|
|
builder.Services.AddScoped<HttpClient>(sp =>
|
|
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
|
|
|
await builder.Build().RunAsync();
|
|
|
|
// CustomAuthStateProvider
|
|
public class CustomAuthStateProvider : AuthenticationStateProvider
|
|
{
|
|
private readonly HttpClient httpClient;
|
|
|
|
public CustomAuthStateProvider(HttpClient httpClient)
|
|
{
|
|
this.httpClient = httpClient;
|
|
}
|
|
|
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
{
|
|
try
|
|
{
|
|
var user = await httpClient.GetJsonAsync<UserInfo>("/api/user");
|
|
|
|
var claims = new[]
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
|
new Claim(ClaimTypes.Name, user.Name),
|
|
new Claim(ClaimTypes.Email, user.Email)
|
|
};
|
|
|
|
var identity = new ClaimsIdentity(claims, "Custom");
|
|
return new AuthenticationState(new ClaimsPrincipal(identity));
|
|
}
|
|
catch
|
|
{
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## AuthorizeView Component
|
|
|
|
AuthorizeView displays content conditionally based on authorization status.
|
|
|
|
### Basic Authorization Check
|
|
|
|
```html
|
|
<AuthorizeView>
|
|
<Authorized>
|
|
<p>Hello, @context.User.Identity?.Name!</p>
|
|
</Authorized>
|
|
<NotAuthorized>
|
|
<p>Please log in.</p>
|
|
</NotAuthorized>
|
|
</AuthorizeView>
|
|
```
|
|
|
|
### Authorize by Role
|
|
|
|
```html
|
|
<AuthorizeView Roles="Admin">
|
|
<p>This content is only for Admins</p>
|
|
</AuthorizeView>
|
|
|
|
<AuthorizeView Roles="User, Moderator">
|
|
<p>User or Moderator content</p>
|
|
</AuthorizeView>
|
|
```
|
|
|
|
### Authorize by Policy
|
|
|
|
```html
|
|
<AuthorizeView Policy="ContentEditor">
|
|
<p>Only content editors can see this</p>
|
|
</AuthorizeView>
|
|
```
|
|
|
|
### Multiple AuthorizeView States
|
|
|
|
```html
|
|
<AuthorizeView>
|
|
<Authorized>
|
|
@if (context.User.IsInRole("Admin"))
|
|
{
|
|
<p>Admin dashboard</p>
|
|
}
|
|
else if (context.User.IsInRole("Editor"))
|
|
{
|
|
<p>Editor dashboard</p>
|
|
}
|
|
else
|
|
{
|
|
<p>User dashboard</p>
|
|
}
|
|
</Authorized>
|
|
<Authorizing>
|
|
<p>Checking authorization...</p>
|
|
</Authorizing>
|
|
<NotAuthorized>
|
|
<p>Not authorized</p>
|
|
</NotAuthorized>
|
|
</AuthorizeView>
|
|
```
|
|
|
|
### Authorize Multiple Resources
|
|
|
|
```html
|
|
<AuthorizeView Context="Auth">
|
|
<Authorized>
|
|
<div>
|
|
<h2>@Auth.User.Identity?.Name</h2>
|
|
|
|
@if (Auth.User.IsInRole("Admin"))
|
|
{
|
|
<a href="/admin">Admin Panel</a>
|
|
}
|
|
|
|
@if (Auth.User.HasClaim("department", "engineering"))
|
|
{
|
|
<a href="/engineering">Engineering</a>
|
|
}
|
|
</div>
|
|
</Authorized>
|
|
</AuthorizeView>
|
|
```
|
|
|
|
## Authorize Attribute
|
|
|
|
Apply `[Authorize]` to pages to require authentication.
|
|
|
|
### Basic Page Authorization
|
|
|
|
```csharp
|
|
@page "/admin"
|
|
@attribute [Authorize]
|
|
|
|
<h2>Admin Page</h2>
|
|
<p>Only authenticated users can see this.</p>
|
|
```
|
|
|
|
### Role-Based Authorization
|
|
|
|
```csharp
|
|
@page "/admin"
|
|
@attribute [Authorize(Roles = "Admin")]
|
|
|
|
<h2>Admin Panel</h2>
|
|
<p>Only admins can access this page.</p>
|
|
```
|
|
|
|
### Policy-Based Authorization
|
|
|
|
```csharp
|
|
@page "/dashboard"
|
|
@attribute [Authorize(Policy = "RequireAdminRole")]
|
|
|
|
<h2>Dashboard</h2>
|
|
```
|
|
|
|
### Multiple Requirements
|
|
|
|
```csharp
|
|
@page "/admin"
|
|
@attribute [Authorize(Roles = "Admin, Manager")]
|
|
@attribute [Authorize(Policy = "ActiveSubscription")]
|
|
|
|
<h2>Admin Dashboard</h2>
|
|
```
|
|
|
|
## Authorization Policies
|
|
|
|
Define fine-grained authorization policies.
|
|
|
|
### Setup Policies
|
|
|
|
```csharp
|
|
// Program.cs
|
|
builder.Services.AddAuthorizationCore(options =>
|
|
{
|
|
options.AddPolicy("RequireAdminRole", policy =>
|
|
policy.RequireRole("Admin"));
|
|
|
|
options.AddPolicy("ActiveSubscription", policy =>
|
|
policy.Requirements.Add(new ActiveSubscriptionRequirement()));
|
|
|
|
options.AddPolicy("ContentEditor", policy =>
|
|
policy.RequireClaim("department", "engineering", "content"));
|
|
|
|
options.AddPolicy("AdultUser", policy =>
|
|
policy.Requirements.Add(new MinimumAgeRequirement(18)));
|
|
});
|
|
|
|
// Add custom policy handler
|
|
builder.Services.AddSingleton<IAuthorizationHandler, ActiveSubscriptionHandler>();
|
|
```
|
|
|
|
### Custom Policy Handlers
|
|
|
|
```csharp
|
|
public class ActiveSubscriptionRequirement : IAuthorizationRequirement { }
|
|
|
|
public class ActiveSubscriptionHandler : AuthorizationHandler<ActiveSubscriptionRequirement>
|
|
{
|
|
private readonly IUserService userService;
|
|
|
|
public ActiveSubscriptionHandler(IUserService userService)
|
|
{
|
|
this.userService = userService;
|
|
}
|
|
|
|
protected override async Task HandleRequirementAsync(
|
|
AuthorizationHandlerContext context,
|
|
ActiveSubscriptionRequirement requirement)
|
|
{
|
|
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
context.Fail();
|
|
return;
|
|
}
|
|
|
|
var user = await userService.GetUserAsync(userId);
|
|
|
|
if (user?.SubscriptionActive == true)
|
|
{
|
|
context.Succeed(requirement);
|
|
}
|
|
else
|
|
{
|
|
context.Fail();
|
|
}
|
|
}
|
|
}
|
|
|
|
public class MinimumAgeRequirement : IAuthorizationRequirement
|
|
{
|
|
public int MinimumAge { get; set; }
|
|
|
|
public MinimumAgeRequirement(int minimumAge)
|
|
{
|
|
MinimumAge = minimumAge;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Accessing Authentication State
|
|
|
|
### In Components
|
|
|
|
```csharp
|
|
@page "/user-profile"
|
|
|
|
@if (authState == null)
|
|
{
|
|
<p>Loading...</p>
|
|
}
|
|
else if (authState.User.Identity?.IsAuthenticated == true)
|
|
{
|
|
<h2>Welcome, @authState.User.Identity?.Name</h2>
|
|
}
|
|
else
|
|
{
|
|
<p>Not authenticated</p>
|
|
}
|
|
|
|
@code {
|
|
[CascadingParameter]
|
|
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
|
|
|
private AuthenticationState? authState;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
authState = await AuthStateTask!;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Check Claims and Roles
|
|
|
|
```csharp
|
|
@code {
|
|
private async Task CheckUserAsync()
|
|
{
|
|
var authState = await AuthStateTask!;
|
|
var user = authState.User;
|
|
|
|
if (user.Identity?.IsAuthenticated == true)
|
|
{
|
|
var name = user.Identity.Name;
|
|
var email = user.FindFirst(ClaimTypes.Email)?.Value;
|
|
var isAdmin = user.IsInRole("Admin");
|
|
var department = user.FindFirst("department")?.Value;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Login/Logout Implementation
|
|
|
|
### Login Page
|
|
|
|
```csharp
|
|
@page "/login"
|
|
@layout BlankLayout
|
|
|
|
<div class="login-form">
|
|
<h2>Login</h2>
|
|
|
|
@if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
<div class="alert alert-danger">@errorMessage</div>
|
|
}
|
|
|
|
<EditForm Model="@model" OnValidSubmit="@HandleLoginAsync">
|
|
<DataAnnotationsValidator />
|
|
|
|
<div class="form-group">
|
|
<label>Email:</label>
|
|
<InputText @bind-Value="model.Email" class="form-control" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Password:</label>
|
|
<InputText @bind-Value="model.Password" type="password" class="form-control" />
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary">Login</button>
|
|
</EditForm>
|
|
</div>
|
|
|
|
@code {
|
|
[SupplyParameterFromQuery]
|
|
public string? ReturnUrl { get; set; }
|
|
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
@inject NavigationManager Navigation
|
|
|
|
private LoginModel model = new();
|
|
private string? errorMessage;
|
|
|
|
private async Task HandleLoginAsync()
|
|
{
|
|
try
|
|
{
|
|
var result = await AuthService.LoginAsync(model.Email, model.Password);
|
|
|
|
// Update authentication state
|
|
if (AuthStateProvider is CustomAuthStateProvider customAuth)
|
|
{
|
|
await customAuth.SetUserAsync(result.User);
|
|
}
|
|
|
|
// Redirect to return URL or home
|
|
var url = !string.IsNullOrEmpty(ReturnUrl) ? ReturnUrl : "/";
|
|
Navigation.NavigateTo(url, forceLoad: true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errorMessage = ex.Message;
|
|
}
|
|
}
|
|
}
|
|
|
|
public class LoginModel
|
|
{
|
|
[Required]
|
|
[EmailAddress]
|
|
public string Email { get; set; } = "";
|
|
|
|
[Required]
|
|
public string Password { get; set; } = "";
|
|
}
|
|
```
|
|
|
|
### Logout Endpoint
|
|
|
|
```csharp
|
|
// Pages/Logout.cshtml (in Blazor Server)
|
|
@page "/logout"
|
|
@using Microsoft.AspNetCore.Identity
|
|
@inject SignInManager<IdentityUser> SignInManager
|
|
@inject NavigationManager Navigation
|
|
|
|
@code {
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await SignInManager.SignOutAsync();
|
|
Navigation.NavigateTo("/");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Claims-Based Authorization
|
|
|
|
Working with claims for fine-grained authorization.
|
|
|
|
### Add Claims to User
|
|
|
|
```csharp
|
|
var claims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
|
new Claim(ClaimTypes.Name, user.Name),
|
|
new Claim(ClaimTypes.Email, user.Email),
|
|
new Claim("department", "engineering"),
|
|
new Claim("level", "senior")
|
|
};
|
|
|
|
var identity = new ClaimsIdentity(claims, "Custom");
|
|
var principal = new ClaimsPrincipal(identity);
|
|
```
|
|
|
|
### Check Claims in Component
|
|
|
|
```csharp
|
|
@code {
|
|
private async Task CheckDepartmentAsync()
|
|
{
|
|
var authState = await AuthStateTask!;
|
|
var user = authState.User;
|
|
|
|
var department = user.FindFirst("department")?.Value;
|
|
var level = user.FindFirst("level")?.Value;
|
|
|
|
if (department == "engineering")
|
|
{
|
|
// Show engineering-specific UI
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Use Cascading AuthenticationState
|
|
|
|
```csharp
|
|
// App.razor - already cascades AuthenticationState by default
|
|
<CascadingAuthenticationState>
|
|
<Router ... />
|
|
</CascadingAuthenticationState>
|
|
```
|
|
|
|
### Always Check firstRender in OnAfterRender
|
|
|
|
```csharp
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (firstRender)
|
|
{
|
|
// Initialize only once
|
|
authState = await AuthStateTask!;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Use forceLoad for Logout
|
|
|
|
```csharp
|
|
private async Task LogoutAsync()
|
|
{
|
|
await AuthService.LogoutAsync();
|
|
// forceLoad clears client-side state
|
|
Navigation.NavigateTo("/", forceLoad: true);
|
|
}
|
|
```
|
|
|
|
### Validate on Server
|
|
|
|
- Never trust client-side authorization
|
|
- Always validate authorization on backend API
|
|
- Check claims/roles on server methods
|
|
|
|
### Use ReturnUrl After Login
|
|
|
|
```csharp
|
|
// Redirect back to originally-requested page
|
|
Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
|
|
```
|
|
|
|
---
|
|
|
|
**Related Resources:** See [routing-navigation.md](routing-navigation.md) for route-based authorization. See [components-lifecycle.md](components-lifecycle.md) for parameter security.
|