| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 4 | | using ArturRios.Output; |
| | | 5 | | using ArturRios.Util.Http; |
| | | 6 | | using ArturRios.Util.WebApi.Security.Extensions; |
| | | 7 | | using Microsoft.AspNetCore.Mvc; |
| | | 8 | | using Microsoft.AspNetCore.Mvc.Filters; |
| | | 9 | | using Microsoft.EntityFrameworkCore; |
| | | 10 | | |
| | | 11 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Global authorization filter rejecting a request whose bearer token names an identity that no |
| | | 15 | | /// longer exists or has been logically deleted (FR-AU-05, FR-GO-12), whose role claim no longer |
| | | 16 | | /// matches that identity's role (Threat Model TH-08), or — for a person — whose security stamp is |
| | | 17 | | /// missing or no longer the person's (FR-AU-11, TH-36), with <c>401 Unauthorized</c>. |
| | | 18 | | /// </summary> |
| | | 19 | | /// <remarks> |
| | | 20 | | /// <para> |
| | | 21 | | /// Authentication runs in <c>ClaimsOnly</c> mode (<c>Startup.ConfigureSecurity</c>): the |
| | | 22 | | /// caller is rebuilt from the token's claims and no data store is consulted, which is what |
| | | 23 | | /// keeps an ordinary request free of a per-request lookup. The cost is that a token outlives |
| | | 24 | | /// the state it describes — both the identity it names and the role it claims, for the whole |
| | | 25 | | /// of its lifetime, an hour by default — and nothing in the pipeline noticed. |
| | | 26 | | /// </para> |
| | | 27 | | /// <para> |
| | | 28 | | /// Several handlers already compensated, and that is precisely why this filter exists: |
| | | 29 | | /// <c>ScopeOwnershipChecker</c> and <c>GetPersonByIdQueryHandler</c> excluded a deleted actor |
| | | 30 | | /// explicitly, while the checks that grant on the role claim alone — every System Admin |
| | | 31 | | /// bypass, and every "the actor is acting on themselves" branch — did not. The protection was |
| | | 32 | | /// therefore present for a Scope Admin and absent for a System Admin, who can do strictly |
| | | 33 | | /// more. Enforcing it once here makes the rule uniform instead of a per-handler decision that |
| | | 34 | | /// each new endpoint has to remember, and the handlers' own guards stay as defence in depth. |
| | | 35 | | /// </para> |
| | | 36 | | /// <para> |
| | | 37 | | /// Both identity tables are consulted because both mint tokens through the same claims: a |
| | | 38 | | /// password identity is a <see cref="Person" /> (UC-11), a Google-authenticated one is a |
| | | 39 | | /// <see cref="GoogleUser" /> (UC-25 step 8), and the token itself does not say which. The |
| | | 40 | | /// lookups run against unique indexes on <c>public_id</c>, and the second only when the first |
| | | 41 | | /// misses — so an authenticated request costs one indexed read, and a Google User's two. |
| | | 42 | | /// </para> |
| | | 43 | | /// <para> |
| | | 44 | | /// One exception: an action marked <see cref="DataSubjectRightAttribute" /> — the endpoints |
| | | 45 | | /// through which a subject exports, erases, restricts, or lifts their own restriction — also |
| | | 46 | | /// admits an identity that is restricted, or logically deleted but not yet anonymised, and |
| | | 47 | | /// leaves the use case's own eligibility flows to answer. See that attribute for why. |
| | | 48 | | /// </para> |
| | | 49 | | /// <para> |
| | | 50 | | /// A request carrying no bearer token resolves to <c>null</c> and is left alone, exactly as |
| | | 51 | | /// <see cref="MfaPendingGuardFilter" /> leaves it: this filter narrows an identity the |
| | | 52 | | /// pipeline already attached and never requires authentication of its own, so |
| | | 53 | | /// <c>[AllowAnonymous]</c> endpoints are unaffected. |
| | | 54 | | /// </para> |
| | | 55 | | /// </remarks> |
| | 1411 | 56 | | public class ActorLivenessFilter( |
| | 1411 | 57 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 1411 | 58 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader) : IAsyncAuthorizationFilter |
| | | 59 | | { |
| | | 60 | | /// <summary> |
| | | 61 | | /// The single refusal, deliberately identical for "no such identity" and "deleted": a caller |
| | | 62 | | /// holding a token the API will not honour learns only that, never which of the two it was. |
| | | 63 | | /// </summary> |
| | | 64 | | public const string ActorNotLive = "The identity this token names no longer exists or has been deleted."; |
| | | 65 | | |
| | | 66 | | /// <summary> |
| | | 67 | | /// The refusal for a token whose role claim no longer matches the identity's role. Separate |
| | | 68 | | /// from <see cref="ActorNotLive" /> on purpose: that message is deliberately vague because |
| | | 69 | | /// the two cases it covers are both questions about whether an account exists, which a |
| | | 70 | | /// caller has no business learning. A role change is not an existence question — it is the |
| | | 71 | | /// caller's own account, they can establish it by signing in again, and telling them to is |
| | | 72 | | /// more useful than telling them nothing. |
| | | 73 | | /// </summary> |
| | | 74 | | public const string ActorRoleChanged = "This token's role is out of date. Sign in again."; |
| | | 75 | | |
| | | 76 | | /// <summary> |
| | | 77 | | /// The refusal for a person token whose security stamp is missing or is no longer the |
| | | 78 | | /// person's (FR-AU-11): a password reset, an email change or a second-factor change since it |
| | | 79 | | /// was issued revoked it, or it predates the stamp. Worded like <see cref="ActorRoleChanged" /> |
| | | 80 | | /// and for the same reason: the token is the caller's own, and signing in again is the remedy. |
| | | 81 | | /// </summary> |
| | | 82 | | public const string ActorTokenRevoked = "This token has been revoked. Sign in again."; |
| | | 83 | | |
| | | 84 | | public async Task OnAuthorizationAsync(AuthorizationFilterContext context) |
| | | 85 | | { |
| | 1342 | 86 | | var user = context.HttpContext.GetUser<IdentityUser>(); |
| | | 87 | | |
| | 1342 | 88 | | if (user is null) |
| | | 89 | | { |
| | 483 | 90 | | return; |
| | | 91 | | } |
| | | 92 | | |
| | 859 | 93 | | var subjectRight = context.ActionDescriptor.EndpointMetadata.OfType<DataSubjectRightAttribute>().Any(); |
| | | 94 | | |
| | 859 | 95 | | var error = await RefusalReasonAsync(user, subjectRight); |
| | | 96 | | |
| | 859 | 97 | | if (error is null) |
| | | 98 | | { |
| | 819 | 99 | | return; |
| | | 100 | | } |
| | | 101 | | |
| | 40 | 102 | | var output = ProcessOutput.New.WithError(error); |
| | | 103 | | |
| | 40 | 104 | | context.Result = new ObjectResult(output) { StatusCode = HttpStatusCodes.Unauthorized }; |
| | 1342 | 105 | | } |
| | | 106 | | |
| | | 107 | | /// <summary> |
| | | 108 | | /// Why this actor may not act, or <c>null</c> when it may. |
| | | 109 | | /// </summary> |
| | | 110 | | /// <remarks> |
| | | 111 | | /// <para> |
| | | 112 | | /// The role is compared as well as the identity, and the reason is TH-08 in the Threat |
| | | 113 | | /// Model: without this, a role change took effect only when the token expired — an hour |
| | | 114 | | /// by default — because the role travels in a claim and nothing re-read it. Deletion was |
| | | 115 | | /// enforced within one request and demotion was not enforced at all. |
| | | 116 | | /// </para> |
| | | 117 | | /// <para> |
| | | 118 | | /// That gap was worse than a stale privilege, because the window was long enough to make |
| | | 119 | | /// itself permanent: a role change is authorised from the acting role claim, and the one |
| | | 120 | | /// transition <c>UpdatePersonCommandHandler</c> supports is a promotion to System Admin, |
| | | 121 | | /// so a demoted System Admin could promote themselves back with a write that outlives |
| | | 122 | | /// every token involved. |
| | | 123 | | /// </para> |
| | | 124 | | /// <para> |
| | | 125 | | /// It costs nothing: the row was already being read to check liveness, so this reads the |
| | | 126 | | /// role from that same row instead of asking whether it exists. |
| | | 127 | | /// </para> |
| | | 128 | | /// <para> |
| | | 129 | | /// The security stamp is read from the same row for the same reason, and checked first |
| | | 130 | | /// (FR-AU-11): a token a credential change has revoked is refused whatever else it claims. |
| | | 131 | | /// A credential change is when a person's earlier sessions should end, and the stamp is |
| | | 132 | | /// what makes them end then rather than when each token expires (TH-36). A token with |
| | | 133 | | /// no stamp is refused too: every token issued before the stamp existed lacks one, and no |
| | | 134 | | /// person token issued since does. A Google User's token carries none and is not asked |
| | | 135 | | /// for one, since nothing here can change its credential. |
| | | 136 | | /// </para> |
| | | 137 | | /// </remarks> |
| | | 138 | | private async Task<string?> RefusalReasonAsync(IdentityUser actor, bool subjectRight) |
| | | 139 | | { |
| | 859 | 140 | | var actorPublicId = actor.Id; |
| | 859 | 141 | | var actorRole = actor.RoleId; |
| | | 142 | | |
| | | 143 | | // On a subject-rights action a restricted or suspended identity still counts, an anonymised |
| | | 144 | | // one never does: once NFR-20 has run there is no subject left to serve. |
| | 859 | 145 | | var stored = await personReader.Query() |
| | 859 | 146 | | .Where(person => person.PublicId == actorPublicId && |
| | 859 | 147 | | (subjectRight |
| | 859 | 148 | | ? person.AnonymisedAt == null |
| | 859 | 149 | | : !person.IsDeleted && person.ProcessingRestrictedAt == null)) |
| | 859 | 150 | | .Select(person => new { person.RoleId, person.SecurityStamp }) |
| | 859 | 151 | | .FirstOrDefaultAsync(); |
| | | 152 | | |
| | 859 | 153 | | if (stored is not null) |
| | | 154 | | { |
| | 824 | 155 | | if (actor.SecurityStamp != stored.SecurityStamp) |
| | | 156 | | { |
| | 16 | 157 | | return ActorTokenRevoked; |
| | | 158 | | } |
| | | 159 | | |
| | 808 | 160 | | return stored.RoleId == actorRole ? null : ActorRoleChanged; |
| | | 161 | | } |
| | | 162 | | |
| | 35 | 163 | | var googleUserIsLive = await googleUserReader.Query() |
| | 35 | 164 | | .AnyAsync(googleUser => googleUser.PublicId == actorPublicId && |
| | 35 | 165 | | (subjectRight |
| | 35 | 166 | | ? googleUser.AnonymisedAt == null |
| | 35 | 167 | | : !googleUser.IsDeleted && googleUser.ProcessingRestrictedAt == null)); |
| | | 168 | | |
| | 35 | 169 | | if (!googleUserIsLive) |
| | | 170 | | { |
| | 20 | 171 | | return ActorNotLive; |
| | | 172 | | } |
| | | 173 | | |
| | | 174 | | // A Google User has no stored role — authentication is delegated to Google — and UC-25 mints |
| | | 175 | | // its token with Roles.User every time. So there is no drift to detect here, only the |
| | | 176 | | // assertion that a token naming a Google identity claims nothing more than that role. |
| | 15 | 177 | | return actorRole == (int)Roles.User ? null : ActorRoleChanged; |
| | 859 | 178 | | } |
| | | 179 | | } |