Public Access
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update.
125 lines
4.8 KiB
C#
125 lines
4.8 KiB
C#
using DodoSSH.Contracts;
|
|
using DodoSSH.Infrastructure;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Authorization.Policy;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DodoSSH.Api.Authorization;
|
|
|
|
/// <summary>
|
|
/// Requires that the caller has published an identity key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not a confidentiality boundary — vault access is decided by ownership and grants, and an
|
|
/// unenrolled user holds neither. What this prevents is a client with no key material writing
|
|
/// ciphertext nobody can ever decrypt, and reading vault contents it has no way to open. Both
|
|
/// present to a user as data corruption, so the server refuses early and says why.
|
|
/// </remarks>
|
|
internal sealed class EnrolledRequirement : IAuthorizationRequirement;
|
|
|
|
/// <summary>Checks enrollment state against the database.</summary>
|
|
/// <remarks>
|
|
/// Scoped, not singleton, because it uses the request's <see cref="DodoDbContext"/>. Registering it
|
|
/// with the default singleton lifetime would capture one context for the process.
|
|
/// </remarks>
|
|
internal sealed class EnrolledHandler(
|
|
ICurrentUserContext currentUser,
|
|
DodoDbContext database,
|
|
IHttpContextAccessor accessor)
|
|
: AuthorizationHandler<EnrolledRequirement>
|
|
{
|
|
/// <inheritdoc />
|
|
protected override async Task HandleRequirementAsync(
|
|
AuthorizationHandlerContext context,
|
|
EnrolledRequirement requirement)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
|
|
// Anonymous callers are already refused by the policy's authentication requirement, which
|
|
// produces a 401. Leaving this one unmet as well would turn that into a 403.
|
|
if (context.User.Identity?.IsAuthenticated != true)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
|
|
|
|
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var enrolled = await database.UserKeys
|
|
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (enrolled)
|
|
{
|
|
context.Succeed(requirement);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns an unmet <see cref="EnrolledRequirement"/> into a ProblemDetails response carrying
|
|
/// <see cref="ProblemCodes.EnrollmentRequired"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A bare 403 has an empty body, so a client cannot tell "you have not enrolled" — which it can
|
|
/// fix on its own — from "you may not touch this vault", which it cannot. Every other failure falls
|
|
/// through to the framework's default handling unchanged.
|
|
/// </remarks>
|
|
internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
|
|
{
|
|
private readonly AuthorizationMiddlewareResultHandler defaultHandler = new();
|
|
|
|
/// <inheritdoc />
|
|
public async Task HandleAsync(
|
|
RequestDelegate next,
|
|
HttpContext context,
|
|
AuthorizationPolicy policy,
|
|
PolicyAuthorizationResult authorizeResult)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
ArgumentNullException.ThrowIfNull(authorizeResult);
|
|
|
|
var accountNotActive = authorizeResult.Forbidden
|
|
&& authorizeResult.AuthorizationFailure is { } refusal
|
|
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
|
|
|
|
if (accountNotActive)
|
|
{
|
|
await TypedResults.Problem(
|
|
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
|
|
statusCode: StatusCodes.Status403Forbidden,
|
|
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
|
|
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["code"] = ProblemCodes.AccountNotActive,
|
|
})
|
|
.ExecuteAsync(context)
|
|
.ConfigureAwait(false);
|
|
|
|
return;
|
|
}
|
|
|
|
var enrollmentMissing = authorizeResult.Forbidden
|
|
&& authorizeResult.AuthorizationFailure is { } failure
|
|
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
|
|
|
if (!enrollmentMissing)
|
|
{
|
|
await defaultHandler.HandleAsync(next, context, policy, authorizeResult).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
await TypedResults.Problem(
|
|
detail: "Publish an identity key at POST /api/v1/me/enrollment before using vaults.",
|
|
statusCode: StatusCodes.Status403Forbidden,
|
|
type: ProblemCodes.TypeBaseUri + ProblemCodes.EnrollmentRequired,
|
|
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
|
{
|
|
["code"] = ProblemCodes.EnrollmentRequired,
|
|
})
|
|
.ExecuteAsync(context)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|