Improved permissions and permission validation
This commit is contained in:
parent
d825ebdc4f
commit
3b64009ada
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
120
Wave/Utilities/Permissions.cs
Normal file
120
Wave/Utilities/Permissions.cs
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue