17f6010a93
Security review fixes: - Constrain OAuth connect returnUrl to a site-relative path so the redeemable token_key can't be redirected off-domain (was a refresh- token leak / connection hijack) - Route all login redirects (entra/dev/local) through ToLocalReturnUrl, also closing a protocol-relative // open redirect in local-login - Neutralize CSV formula prefixes in both audit-log exporters via CsvSanitizer - Force Secure flag on the prod auth cookie (Always, not SameAsRequest) - Gate admin pages with an app_role-claim "Admin" policy instead of a render-time check Findings and rationale recorded in SECURITY-TODO.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
4.6 KiB
Plaintext
103 lines
4.6 KiB
Plaintext
@page "/admin/audit"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Admin")]
|
|
@inject IAuditService AuditService
|
|
@inject IUserContextAccessor UserContext
|
|
@inject NavigationManager Nav
|
|
@inject TranslationSource T
|
|
@rendermode InteractiveServer
|
|
@using SharepointToolbox.Web.Core.Models
|
|
@using SharepointToolbox.Web.Services.Audit
|
|
@using SharepointToolbox.Web.Services.Session
|
|
|
|
<h1 class="page-title">@T["adminaudit.title"]</h1>
|
|
<p class="page-subtitle">@T["adminaudit.subtitle"]</p>
|
|
|
|
@if (!UserContext.IsAuthenticated || UserContext.Role != UserRole.Admin)
|
|
{
|
|
<div class="alert alert-error">@T["adminaudit.accessdenied"]</div>
|
|
return;
|
|
}
|
|
|
|
<div class="flex-row" style="margin-bottom:16px;flex-wrap:wrap;gap:8px">
|
|
<input class="form-input" style="width:200px" placeholder="@T["adminaudit.filter.user"]" @bind="_filterUser" @bind:event="oninput" />
|
|
<input class="form-input" style="width:200px" placeholder="@T["adminaudit.filter.client"]" @bind="_filterClient" @bind:event="oninput" />
|
|
<input class="form-input" style="width:200px" placeholder="@T["adminaudit.filter.action"]" @bind="_filterAction" @bind:event="oninput" />
|
|
<a href="/audit/export" class="btn btn-secondary" target="_blank">@T["audit.btn.exportCsv"]</a>
|
|
</div>
|
|
|
|
@if (_loading)
|
|
{
|
|
<div class="alert alert-info">@T["adminaudit.loading"]</div>
|
|
}
|
|
else if (_filtered.Count == 0)
|
|
{
|
|
<div class="alert alert-info">@T["adminaudit.noentries"]</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="card" style="overflow-x:auto">
|
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
<thead>
|
|
<tr style="border-bottom:2px solid var(--border)">
|
|
<th style="text-align:left;padding:6px">@T["report.col.timestamp"]</th>
|
|
<th style="text-align:left;padding:6px">@T["report.col.user"]</th>
|
|
<th style="text-align:left;padding:6px">@T["adminaudit.col.role"]</th>
|
|
<th style="text-align:left;padding:6px">@T["adminaudit.col.action"]</th>
|
|
<th style="text-align:left;padding:6px">@T["adminaudit.col.client"]</th>
|
|
<th style="text-align:left;padding:6px">@T["report.col.sites"]</th>
|
|
<th style="text-align:left;padding:6px">@T["adminaudit.col.details"]</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var e in _filtered)
|
|
{
|
|
<tr style="border-bottom:1px solid var(--border)">
|
|
<td style="padding:6px;white-space:nowrap">@e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")</td>
|
|
<td style="padding:6px">@e.UserDisplay<br /><span class="text-muted" style="font-size:11px">@e.UserEmail</span></td>
|
|
<td style="padding:6px"><span class="chip @RoleChipClass(e.UserRole)">@e.UserRole</span></td>
|
|
<td style="padding:6px;font-weight:600">@e.Action</td>
|
|
<td style="padding:6px">@e.ClientName</td>
|
|
<td style="padding:6px">@string.Join(", ", e.Sites)</td>
|
|
<td style="padding:6px;color:var(--text-muted)">@e.Details</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-muted" style="margin-top:8px;font-size:12px">@string.Format(T["adminaudit.showing"], _filtered.Count, _entries.Count)</p>
|
|
}
|
|
|
|
@code {
|
|
private List<AuditEntry> _entries = new();
|
|
private List<AuditEntry> _filtered = new();
|
|
private bool _loading = true;
|
|
private string _filterUser = string.Empty;
|
|
private string _filterClient = string.Empty;
|
|
private string _filterAction = string.Empty;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
_entries = (await AuditService.GetAllAsync())
|
|
.OrderByDescending(e => e.Timestamp)
|
|
.ToList();
|
|
_loading = false;
|
|
ApplyFilters();
|
|
}
|
|
|
|
private void ApplyFilters()
|
|
{
|
|
_filtered = _entries.Where(e =>
|
|
(string.IsNullOrEmpty(_filterUser) || e.UserEmail.Contains(_filterUser, StringComparison.OrdinalIgnoreCase) || e.UserDisplay.Contains(_filterUser, StringComparison.OrdinalIgnoreCase)) &&
|
|
(string.IsNullOrEmpty(_filterClient) || e.ClientName.Contains(_filterClient, StringComparison.OrdinalIgnoreCase)) &&
|
|
(string.IsNullOrEmpty(_filterAction) || e.Action.Contains(_filterAction, StringComparison.OrdinalIgnoreCase))
|
|
).ToList();
|
|
}
|
|
|
|
private static string RoleChipClass(UserRole role) => role switch
|
|
{
|
|
UserRole.Admin => "chip-red",
|
|
UserRole.TechN1 => "chip-green",
|
|
_ => "chip-blue"
|
|
};
|
|
}
|