Improved permissions and permission validation

This commit is contained in:
Mia Rose Winter 2024-03-21 10:09:13 +01:00
parent d825ebdc4f
commit 3b64009ada
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
5 changed files with 215 additions and 119 deletions

View file

@ -3,9 +3,9 @@
@using Wave.Data @using Wave.Data
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using System.Security.Claims
@rendermode InteractiveServer @rendermode @(new InteractiveServerRenderMode(false))
@attribute [Authorize(Policy = "ArticleEditOrReviewPermissions")]
@inject UserManager<ApplicationUser> UserManager @inject UserManager<ApplicationUser> UserManager
@inject IStringLocalizer<ArticleEditor> Localizer @inject IStringLocalizer<ArticleEditor> Localizer
@ -23,7 +23,7 @@
<ChildContent> <ChildContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
<Wave.Components.Pages.Partials.ArticleEditorPartial Id="@Id" User="@User" /> <Wave.Components.Pages.Partials.ArticleEditorPartial Id="@Id" User="@User" ClaimsUser="@ClaimsUser" />
</ChildContent> </ChildContent>
<ErrorContent> <ErrorContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6">Not found</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6">Not found</h1>
@ -41,10 +41,12 @@
[Parameter] [Parameter]
public Guid? Id { get; set; } public Guid? Id { get; set; }
private ApplicationUser? User { get; set; } private ApplicationUser? User { get; set; }
private ClaimsPrincipal? ClaimsUser { get; set; }
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
if (AuthenticationState is null) throw new ApplicationException("???"); if (AuthenticationState is null) throw new ApplicationException("???");
var state = await AuthenticationState; var state = await AuthenticationState;
ClaimsUser = state.User;
var user = await UserManager.GetUserAsync(state.User); var user = await UserManager.GetUserAsync(state.User);
User = user ?? throw new ApplicationException("???2"); User = user ?? throw new ApplicationException("???2");
} }

View file

@ -3,9 +3,7 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Wave.Data @using Wave.Data
@using System.Security.Claims @using System.Security.Claims
@using System.Diagnostics.CodeAnalysis
@using System.Globalization @using System.Globalization
@using System.Net
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using Wave.Services @using Wave.Services
@ -54,45 +52,41 @@
<ErrorBoundary> <ErrorBoundary>
<ChildContent> <ChildContent>
<AuthorizeView Policy="ArticleEditOrReviewPermissions"> @if (GetArticle(HttpContext.User) is {} article) {
<Authorized> <ArticleComponent Article="@article"/>
<ArticleComponent Article="@GetArticleProtected(context.User)" />
<div class="flex gap-2 mt-3 flex-wrap"> <div class="flex gap-2 mt-3 flex-wrap">
@if (CanEdit) { @if (article.AllowedToEdit(HttpContext.User)) {
<a class="btn btn-info w-full sm:btn-wide" href="article/@Article!.Id/edit" <a class="btn btn-info w-full sm:btn-wide" href="article/@Article!.Id/edit"
data-enhance-nav="false">@Localizer["Edit"]</a> data-enhance-nav="false">@Localizer["Edit"]</a>
} }
@if (Article.Status is ArticleStatus.Draft) { @if (article.AllowedToSubmitForReview(HttpContext.User)) {
<form @formname="submit-for-review" method="post" @onsubmit="SubmitForReview" class="max-sm:w-full"> <form @formname="submit-for-review" method="post" @onsubmit="SubmitForReview" class="max-sm:w-full">
<AntiforgeryToken /> <AntiforgeryToken/>
<button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Review_Submit"]</button> <button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Review_Submit"]</button>
</form> </form>
} }
@if (Article.Status is ArticleStatus.InReview || (Article.Status is ArticleStatus.Draft && context.User.IsInRole("Admin"))) { @if (article.AllowedToPublish(HttpContext.User)) {
<form @formname="submit-for-publish" method="post" @onsubmit="SubmitForPublish" class="max-sm:w-full"> <form @formname="submit-for-publish" method="post" @onsubmit="SubmitForPublish" class="max-sm:w-full">
<AntiforgeryToken /> <AntiforgeryToken/>
<button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Publish_Submit"]</button> <button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Publish_Submit"]</button>
@if (Features.Value.EmailSubscriptions) { @if (Features.Value.EmailSubscriptions && HttpContext.User.IsInRole("Admin")) {
<div class="form-control"> <div class="form-control">
<label class="label cursor-pointer"> <label class="label cursor-pointer">
<span class="label-text">@Localizer["Publish_Silent_Label"]</span> <span class="label-text">@Localizer["Publish_Silent_Label"]</span>
<InputCheckbox @bind-Value="PublishSilently" class="checkbox" /> <InputCheckbox @bind-Value="PublishSilently" class="checkbox"/>
</label> </label>
</div> </div>
} }
</form> </form>
} }
<AuthorizeView Policy="ArticleDeletePermissions" Context="_"> @if (article.AllowedToDelete(HttpContext.User)) {
<a class="btn btn-error w-full sm:btn-wide" href="/article/@Article.Id/delete"> <a class="btn btn-error w-full sm:btn-wide" href="/article/@article.Id/delete">
@Localizer["Delete_Submit"] @Localizer["Delete_Submit"]
</a> </a>
</AuthorizeView> }
</div> </div>
</Authorized> }
<NotAuthorized>
<ArticleComponent Article="@GetArticlePublic()" />
</NotAuthorized>
</AuthorizeView>
</ChildContent> </ChildContent>
<ErrorContent> <ErrorContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["NotFound_Title"]</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["NotFound_Title"]</h1>
@ -136,51 +130,10 @@
[CascadingParameter] [CascadingParameter]
public HttpContext HttpContext { get; set; } = default!; public HttpContext HttpContext { get; set; } = default!;
private bool CanEdit { get; set; } private Article GetArticle(ClaimsPrincipal principal) {
if (Article.AllowedToRead(principal)) return Article!;
private Article GetArticlePublic() { throw new ApplicationException("Article not found or missing permissions.");
if (Article is null) throw new ApplicationException("Article not found.");
if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) {
return Article;
}
throw new ApplicationException("Article is not public.");
}
[SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")]
private Article GetArticleProtected(ClaimsPrincipal principal) {
if (Article is null) throw new ApplicationException("Article not found.");
// The Article is publicly available
if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) {
if (principal.IsInRole("Admin"))
CanEdit = true;
return Article;
}
// Admins always get access
if (principal.IsInRole("Admin")) {
CanEdit = true;
return Article;
}
// You can only access your own drafts
if (Article.Status is ArticleStatus.Draft) {
if (Article.Author.Id == principal.FindFirst("Id")!.Value) {
CanEdit = true;
return Article;
}
throw new ApplicationException("Cannot access draft article without being author or admin.");
}
// InReview Articles can only be accessed by reviewers
if (Article.Status is ArticleStatus.InReview) {
if (principal.IsInRole("Reviewer")) {
CanEdit = true;
return Article;
}
throw new ApplicationException("Cannot access in-review article without being a reviewer or admin.");
}
throw new ApplicationException("User does not have access to this article.");
} }
protected override void OnInitialized() { protected override void OnInitialized() {
@ -207,10 +160,10 @@
} }
private async Task SubmitForReview() { private async Task SubmitForReview() {
if (Article is null) return; if (Article.AllowedToSubmitForReview(HttpContext.User) is false) return;
await using var context = await ContextFactory.CreateDbContextAsync(); await using var context = await ContextFactory.CreateDbContextAsync();
Article.Status = ArticleStatus.InReview; Article!.Status = ArticleStatus.InReview;
context.Update(Article); context.Update(Article);
await context.SaveChangesAsync(); await context.SaveChangesAsync();
Message.ShowSuccess(Localizer["Submit_Review_Success"]); Message.ShowSuccess(Localizer["Submit_Review_Success"]);
@ -224,6 +177,8 @@
await EmailService.ConnectAsync(CancellationToken.None); await EmailService.ConnectAsync(CancellationToken.None);
foreach (var reviewer in reviewers) { foreach (var reviewer in reviewers) {
if (reviewer.Id == HttpContext.User.FindFirst("Id")!.Value) continue;
var email = await Email.CreateDefaultEmail( var email = await Email.CreateDefaultEmail(
reviewer.Email!, reviewer.Email!,
reviewer.Name, reviewer.Name,
@ -245,6 +200,8 @@
} }
private async Task SubmitForPublish() { private async Task SubmitForPublish() {
if (Article.AllowedToPublish(HttpContext.User) is false) return;
await using var context = await ContextFactory.CreateDbContextAsync(); await using var context = await ContextFactory.CreateDbContextAsync();
Article!.Status = ArticleStatus.Published; Article!.Status = ArticleStatus.Published;
string userId = HttpContext.User.FindFirst("Id")!.Value; string userId = HttpContext.User.FindFirst("Id")!.Value;
@ -269,9 +226,11 @@
} }
try { try {
await EmailService.ConnectAsync(CancellationToken.None);
var author = Article.Author; var author = Article.Author;
if (author.Id != HttpContext.User.FindFirst("Id")!.Value) {
await EmailService.ConnectAsync(CancellationToken.None);
string publishMessage = string publishMessage =
(Article.PublishDate < DateTimeOffset.Now) ? (Article.PublishDate < DateTimeOffset.Now) ?
"Is is now publicly available." : "Is is now publicly available." :
@ -290,6 +249,7 @@
await EmailService.SendEmailAsync(email); await EmailService.SendEmailAsync(email);
await EmailService.DisconnectAsync(CancellationToken.None); await EmailService.DisconnectAsync(CancellationToken.None);
}
} catch (Exception ex) { } catch (Exception ex) {
Logger.LogError(ex, "Failed to send mail to author about article '{title}' being published.", Article.Title); Logger.LogError(ex, "Failed to send mail to author about article '{title}' being published.", Article.Title);
} }

View file

@ -2,6 +2,7 @@
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using System.Diagnostics.CodeAnalysis @using System.Diagnostics.CodeAnalysis
@using System.Net @using System.Net
@using System.Security.Claims
@using Microsoft.AspNetCore.Identity @using Microsoft.AspNetCore.Identity
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Wave.Services @using Wave.Services
@ -231,6 +232,9 @@
public Guid? Id { get; set; } public Guid? Id { get; set; }
[Parameter] [Parameter]
public required ApplicationUser User { get; set; } public required ApplicationUser User { get; set; }
[Parameter]
public required ClaimsPrincipal ClaimsUser { get; set; }
[SupplyParameterFromForm] [SupplyParameterFromForm]
private InputModel Model { get; set; } = new(); private InputModel Model { get; set; } = new();
@ -261,11 +265,12 @@
.Include(a => a.Images) .Include(a => a.Images)
.FirstAsync(a => a.Id == Id); .FirstAsync(a => a.Id == Id);
if (article is null) throw new ApplicationException("Article not found."); if (article is null) throw new ApplicationException("Article not found.");
await HandleRoles(article, User);
} }
if (article is not null) { if (article is not null) {
if (!article.AllowedToEdit(ClaimsUser))
throw new ApplicationException("You are not allowed to edit this article");
Model.Id ??= article.Id; Model.Id ??= article.Id;
Model.Title ??= article.Title; Model.Title ??= article.Title;
Model.Slug ??= article.Slug; Model.Slug ??= article.Slug;
@ -278,6 +283,10 @@
} }
private async Task OnValidSubmit() { private async Task OnValidSubmit() {
if (Article.AllowedToEdit(ClaimsUser) is false) {
Message.ShowError("Permission denied.");
return;
}
try { try {
Saving = true; Saving = true;

View file

@ -3,9 +3,14 @@
<Found Context="routeData"> <Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>
<RedirectToLogin /> <RedirectToLogin/>
</NotAuthorized> </NotAuthorized>
<Authorizing>
<div class="flex place-content-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
</Authorizing>
</AuthorizeRouteView> </AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found> </Found>
</Router> </Router>

View file

@ -0,0 +1,120 @@
using System.Security.Claims;
using Wave.Data;
namespace Wave.Utilities;
/// <summary>
/// Central location for assessing if a user has access to an article, or may modify them in specific ways
/// </summary>
public static class Permissions {
public static bool AllowedToRead(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
// The Article is publicly available
if (article.Status >= ArticleStatus.Published && article.PublishDate <= DateTimeOffset.UtcNow) {
return true;
}
// Admins always get access
if (principal.IsInRole("Admin")) {
return true;
}
// You can only access your own drafts
if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) {
return true;
}
// Reviewers can see in-review articles
if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer")) {
return true;
}
return false;
}
public static bool AllowedToEdit(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
// Admins always can edit articles
if (principal.IsInRole("Admin")) {
return true;
}
// You can edit your own draft articles
if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) {
return true;
}
// Reviewers can edit in-review articles
if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer")) {
return true;
}
// Moderators can edit published/-ing articles
if (article.Status is ArticleStatus.Published && principal.IsInRole("Moderator")) {
return true;
}
return false;
}
public static bool AllowedToSubmitForReview(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
// Draft articles can be submitted by their authors (admins can publish them anyway, no need to submit)
if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) {
return true;
}
return false;
}
public static bool AllowedToPublish(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
// Admins can skip review and directly publish draft articles
if (article.Status is ArticleStatus.Draft && principal.IsInRole("Admin")) {
return true;
}
// Admins may always review articles
if (article.Status is ArticleStatus.InReview && principal.IsInRole("Admin")) {
return true;
}
// Reviewers can review in-review articles, as long as they are not their own
if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer") &&
article.Author.Id != principal.FindFirst("Id")!.Value) {
return true;
}
return false;
}
public static bool AllowedToDelete(this Article? article, ClaimsPrincipal principal) {
if (article is null || article.IsDeleted) return false;
// Admins can delete articles whenever
if (principal.IsInRole("Admin")) {
return true;
}
// You can delete your drafts
if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) {
return true;
}
// Reviewers can reject/delete in-review articles
if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer")) {
return true;
}
// Moderators can take down articles
if (article.Status is ArticleStatus.Published && principal.IsInRole("Moderator")) {
return true;
}
return false;
}
}