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

View file

@ -3,9 +3,7 @@
@using Microsoft.EntityFrameworkCore
@using Wave.Data
@using System.Security.Claims
@using System.Diagnostics.CodeAnalysis
@using System.Globalization
@using System.Net
@using Microsoft.AspNetCore.Identity
@using Microsoft.Extensions.Options
@using Wave.Services
@ -54,25 +52,25 @@
<ErrorBoundary>
<ChildContent>
<AuthorizeView Policy="ArticleEditOrReviewPermissions">
<Authorized>
<ArticleComponent Article="@GetArticleProtected(context.User)" />
@if (GetArticle(HttpContext.User) is {} article) {
<ArticleComponent Article="@article"/>
<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"
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">
<AntiforgeryToken/>
<button type="submit" class="btn btn-primary w-full sm:btn-wide">@Localizer["Review_Submit"]</button>
</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">
<AntiforgeryToken/>
<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">
<label class="label cursor-pointer">
<span class="label-text">@Localizer["Publish_Silent_Label"]</span>
@ -82,17 +80,13 @@
}
</form>
}
<AuthorizeView Policy="ArticleDeletePermissions" Context="_">
<a class="btn btn-error w-full sm:btn-wide" href="/article/@Article.Id/delete">
@if (article.AllowedToDelete(HttpContext.User)) {
<a class="btn btn-error w-full sm:btn-wide" href="/article/@article.Id/delete">
@Localizer["Delete_Submit"]
</a>
</AuthorizeView>
}
</div>
</Authorized>
<NotAuthorized>
<ArticleComponent Article="@GetArticlePublic()" />
</NotAuthorized>
</AuthorizeView>
}
</ChildContent>
<ErrorContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["NotFound_Title"]</h1>
@ -136,51 +130,10 @@
[CascadingParameter]
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() {
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.");
throw new ApplicationException("Article not found or missing permissions.");
}
protected override void OnInitialized() {
@ -207,10 +160,10 @@
}
private async Task SubmitForReview() {
if (Article is null) return;
if (Article.AllowedToSubmitForReview(HttpContext.User) is false) return;
await using var context = await ContextFactory.CreateDbContextAsync();
Article.Status = ArticleStatus.InReview;
Article!.Status = ArticleStatus.InReview;
context.Update(Article);
await context.SaveChangesAsync();
Message.ShowSuccess(Localizer["Submit_Review_Success"]);
@ -224,6 +177,8 @@
await EmailService.ConnectAsync(CancellationToken.None);
foreach (var reviewer in reviewers) {
if (reviewer.Id == HttpContext.User.FindFirst("Id")!.Value) continue;
var email = await Email.CreateDefaultEmail(
reviewer.Email!,
reviewer.Name,
@ -245,6 +200,8 @@
}
private async Task SubmitForPublish() {
if (Article.AllowedToPublish(HttpContext.User) is false) return;
await using var context = await ContextFactory.CreateDbContextAsync();
Article!.Status = ArticleStatus.Published;
string userId = HttpContext.User.FindFirst("Id")!.Value;
@ -269,9 +226,11 @@
}
try {
await EmailService.ConnectAsync(CancellationToken.None);
var author = Article.Author;
if (author.Id != HttpContext.User.FindFirst("Id")!.Value) {
await EmailService.ConnectAsync(CancellationToken.None);
string publishMessage =
(Article.PublishDate < DateTimeOffset.Now) ?
"Is is now publicly available." :
@ -290,6 +249,7 @@
await EmailService.SendEmailAsync(email);
await EmailService.DisconnectAsync(CancellationToken.None);
}
} catch (Exception ex) {
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.Diagnostics.CodeAnalysis
@using System.Net
@using System.Security.Claims
@using Microsoft.AspNetCore.Identity
@using Microsoft.EntityFrameworkCore
@using Wave.Services
@ -231,6 +232,9 @@
public Guid? Id { get; set; }
[Parameter]
public required ApplicationUser User { get; set; }
[Parameter]
public required ClaimsPrincipal ClaimsUser { get; set; }
[SupplyParameterFromForm]
private InputModel Model { get; set; } = new();
@ -261,11 +265,12 @@
.Include(a => a.Images)
.FirstAsync(a => a.Id == Id);
if (article is null) throw new ApplicationException("Article not found.");
await HandleRoles(article, User);
}
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.Title ??= article.Title;
Model.Slug ??= article.Slug;
@ -278,6 +283,10 @@
}
private async Task OnValidSubmit() {
if (Article.AllowedToEdit(ClaimsUser) is false) {
Message.ShowError("Permission denied.");
return;
}
try {
Saving = true;

View file

@ -5,6 +5,11 @@
<NotAuthorized>
<RedirectToLogin/>
</NotAuthorized>
<Authorizing>
<div class="flex place-content-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
</Authorizing>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>

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;
}
}