< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Controllers.AuthController
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Controllers/AuthController.cs
Line coverage
95%
Covered lines: 101
Uncovered lines: 5
Coverable lines: 106
Total lines: 660
Line coverage: 95.2%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
95%
Covered methods: 21
Fully covered methods: 21
Total methods: 22
Method coverage: 95.4%
Full method coverage: 95.4%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Login()100%11100%
PasswordRecovery()100%11100%
Register()100%11100%
ResetPassword()100%11100%
VerifyEmail()100%11100%
ResendVerification()100%11100%
GoogleSignIn()100%11100%
GoogleSignOut()100%11100%
EnableTwoFactorAuth()100%11100%
ConfirmTwoFactorAuth()100%11100%
VerifyTwoFactorAuth()100%11100%
ResendTwoFactorChallengeCode()100%11100%
DisableTwoFactorAuth()100%11100%
RegenerateRecoveryCodes()100%11100%
ExportMyData()100%11100%
RequestErasure()100%11100%
RestrictProcessing()100%11100%
ReapplyErasures()100%210%
LiftProcessingRestriction()100%11100%
ListErasureRequests()100%11100%
GetTwoFactorStatus()100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Controllers/AuthController.cs

#LineLine coverage
 1using ArturRios.Heimdall.Command.Input;
 2using ArturRios.Heimdall.Command.Output;
 3using ArturRios.Heimdall.Query.Input;
 4using ArturRios.Heimdall.Query.Output;
 5using ArturRios.Heimdall.Domain.Enums;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Mediator.Command;
 8using ArturRios.Mediator.Query;
 9using ArturRios.Output;
 10using ArturRios.Heimdall.WebApi.Security;
 11using ArturRios.Util.WebApi.AspNetCore;
 12using ArturRios.Util.WebApi.Security.Attributes;
 13using ArturRios.Util.WebApi.Security.Extensions;
 14using Microsoft.AspNetCore.Mvc;
 15using Microsoft.AspNetCore.RateLimiting;
 16
 17namespace ArturRios.Heimdall.WebApi.Controllers;
 18
 19[Route("api/auth")]
 59220public class AuthController(CommandMediator commandMediator, QueryMediator queryMediator) : Controller
 21{
 22    /// <summary>
 23    ///     Authenticates a person by email and password and returns a token (UC-11, FR-AU-01…07). A
 24    ///     <c>User</c> also sends the <c>PublicId</c> of their scope; a <c>ScopeAdmin</c> or
 25    ///     <c>SystemAdmin</c> sends credentials only. Open to anonymous callers — this is where a
 26    ///     caller gets the token every other endpoint requires. Every rejection (AF-11a…AF-11e)
 27    ///     answers 401 alike, so the endpoint cannot be used to enumerate accounts.
 28    /// </summary>
 29    [HttpPost("login")]
 30    [AllowAnonymous]
 31    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 32    public async Task<ActionResult<DataOutput<LoginCommandOutput?>>> Login(
 33        [FromBody] LoginCommand command)
 34    {
 10535        var result = await commandMediator
 10536            .ExecuteCommandAsync<LoginCommand, LoginCommandOutput>(command);
 37
 10338        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 10339    }
 40
 41    /// <summary>
 42    ///     Requests a password reset link (UC-12, FR-PR-01/02). A <c>User</c> also sends the
 43    ///     <c>PublicId</c> of their scope; a <c>ScopeAdmin</c> or <c>SystemAdmin</c> sends the email
 44    ///     alone. Open to anonymous callers — someone who has lost their password cannot hold a
 45    ///     token.
 46    /// </summary>
 47    /// <remarks>
 48    ///     Answers 200 with the same message whether or not the address belongs to anyone (AF-12a),
 49    ///     so the endpoint cannot be used to enumerate accounts. The only rejection is a malformed
 50    ///     request (400, NFR-10), which says nothing about who is registered.
 51    /// </remarks>
 52    [HttpPost("password-recovery")]
 53    [AllowAnonymous]
 54    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 55    public async Task<ActionResult<DataOutput<PasswordRecoveryCommandOutput?>>> PasswordRecovery(
 56        [FromBody] PasswordRecoveryCommand command)
 57    {
 2958        var result = await commandMediator
 2959            .ExecuteCommandAsync<PasswordRecoveryCommand, PasswordRecoveryCommandOutput>(command);
 60
 2961        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 2962    }
 63
 64    /// <summary>
 65    ///     Registers a password <c>User</c> in a scope that has opened self-registration (UC-47,
 66    ///     FR-SR-03…FR-SR-09). Open to anonymous callers — someone without an account cannot hold a
 67    ///     token.
 68    /// </summary>
 69    /// <remarks>
 70    ///     Answers 202 with the same message whether the address was free or taken (AF-47b), and in the
 71    ///     same time: a pending registration is written and its link mailed, or the holder told of the
 72    ///     attempt, after the response, by <c>RegistrationDispatcher</c>. No <c>User</c> exists until
 73    ///     that link is followed (UC-14, FR-SR-07). A missing, deleted or closed scope answers 403
 74    ///     (AF-47a); a malformed request 400 (AF-47c). Neither says anything about an address.
 75    /// </remarks>
 76    [HttpPost("register")]
 77    [AllowAnonymous]
 78    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 79    public async Task<ActionResult<DataOutput<RegisterCommandOutput?>>> Register(
 80        [FromBody] RegisterCommand command)
 81    {
 2482        var result = await commandMediator
 2483            .ExecuteCommandAsync<RegisterCommand, RegisterCommandOutput>(command);
 84
 2485        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 2486    }
 87
 88    /// <summary>
 89    ///     Sets a new password from the reset token mailed by UC-12 (UC-13, FR-PR-03/04). Open to
 90    ///     anonymous callers for the same reason: the token is the only credential someone who has
 91    ///     lost their password can present.
 92    /// </summary>
 93    /// <remarks>
 94    ///     Unlike the two endpoints above, each rejection is named — unknown (AF-13c), expired
 95    ///     (AF-13a), and spent (AF-13b) tokens all answer 400 with their own message, as does a
 96    ///     malformed request (AF-13d). Nothing is disclosed by the distinction: the token identifies
 97    ///     no account to a caller who does not already hold it. The account is checked again when the
 98    ///     link is followed (AF-13e, FR-PR-08): a person restricted, deleted or anonymised since it was
 99    ///     mailed, or who can no longer sign in through their scope, gets exactly the unknown-token
 100    ///     answer, 400 "Invalid token.", and nothing is changed. A link mailed before the person's
 101    ///     address changed has been retired (FR-PE-17) and answers 400 "Token already used.".
 102    /// </remarks>
 103    [HttpPost("password-reset")]
 104    [AllowAnonymous]
 105    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 106    public async Task<ActionResult<DataOutput<ResetPasswordCommandOutput?>>> ResetPassword(
 107        [FromBody] ResetPasswordCommand command)
 108    {
 29109        var result = await commandMediator
 29110            .ExecuteCommandAsync<ResetPasswordCommand, ResetPasswordCommandOutput>(command);
 111
 29112        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 29113    }
 114
 115    /// <summary>
 116    ///     Confirms an email address from the verification token mailed at person creation (UC-14,
 117    ///     FR-EV-03). Open to anonymous callers: the person reaches this from a link in their mail
 118    ///     client, where they hold no bearer token — and the point of the link is that they have not
 119    ///     proved anything yet.
 120    /// </summary>
 121    /// <remarks>
 122    ///     Each rejection is named, as UC-13's are — unknown (AF-14c), expired (AF-14a), and spent
 123    ///     (AF-14b) tokens all answer 400 with their own message, as does a request carrying no token
 124    ///     at all. An address that was already verified answers 200: UC-14 defines no alternative flow
 125    ///     for it, and the link did what it promised. As in UC-13, the account is checked when the link
 126    ///     is followed (AF-14d, FR-EV-08): a person restricted, deleted or anonymised since it was
 127    ///     mailed, or who can no longer sign in through their scope, gets 400 "Invalid token.", and
 128    ///     nothing is changed. A link mailed before the person's address changed has been retired
 129    ///     (FR-PE-17) and answers 400 "Token already used.".
 130    ///     <para>
 131    ///         A token that names a UC-47 pending registration also needs <c>password</c>, the one chosen
 132    ///         at sign-up: without it the answer is 422, and with a wrong one 401, and either leaves the
 133    ///         registration in place — except that the 10th wrong password discards it, after which the
 134    ///         token answers 400 "Invalid token" (FR-SR-09). <c>password</c> is read only for pending
 135    ///         registrations and ignored for an ordinary verification token.
 136    ///     </para>
 137    /// </remarks>
 138    [HttpPost("verify-email")]
 139    [AllowAnonymous]
 140    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 141    public async Task<ActionResult<DataOutput<VerifyEmailCommandOutput?>>> VerifyEmail(
 142        [FromBody] VerifyEmailCommand command)
 143    {
 114144        var result = await commandMediator
 114145            .ExecuteCommandAsync<VerifyEmailCommand, VerifyEmailCommandOutput>(command);
 146
 114147        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 114148    }
 149
 150    /// <summary>
 151    ///     Retires the caller's outstanding verification links and mails a fresh one (UC-15,
 152    ///     FR-EV-04), for someone whose first link expired, was lost, or never arrived. The one
 153    ///     authenticated endpoint on this controller — and the reason it takes no request body: the
 154    ///     person is read from the bearer token, so a caller can only ever ask for their own link.
 155    /// </summary>
 156    /// <remarks>
 157    ///     No <c>RoleRequirement</c>: the authorization matrix grants email verification to all three
 158    ///     roles and withholds it from anonymous callers, which is exactly what authentication alone
 159    ///     enforces. An address that is already verified answers 400 (AF-15a) — unlike UC-14's
 160    ///     idempotent success, since a link mailed to a verified address could do nothing when clicked.
 161    ///     A token naming a person who no longer exists answers 404: it was validated, but there is no
 162    ///     address left to send to. The send is charged to the person's account-email budget, shared
 163    ///     with a two-factor setup code and an own email change: one per
 164    ///     <c>HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS</c> (five minutes by default) and five per
 165    ///     day-long window from the first. Inside the cooldown it answers 429 "An email of this kind
 166    ///     was sent to you recently. Try again in a few minutes." (AF-15b); once the window's five are
 167    ///     spent, 429 "You have reached the limit for account emails. Try again later."
 168    ///     (AF-15c). Both carry <c>Retry-After</c>, the seconds until a send would be accepted: the
 169    ///     cooldown's end, or for the limit the later of the window's end and the cooldown's.
 170    ///     Nothing is sent either way. The
 171    ///     endpoint is also under the credential endpoints' rate limit, whose 429 is "Too many
 172    ///     requests." with a <c>Retry-After</c> header.
 173    /// </remarks>
 174    [HttpPost("resend-verification")]
 175    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 176    public async Task<ActionResult<DataOutput<ResendVerificationEmailCommandOutput?>>> ResendVerification()
 177    {
 32178        var command = new ResendVerificationEmailCommand();
 32179        HttpContext.ApplyActor(command);
 180
 32181        var result = await commandMediator
 32182            .ExecuteCommandAsync<ResendVerificationEmailCommand, ResendVerificationEmailCommandOutput>(
 32183                command);
 184
 32185        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 32186    }
 187
 188    /// <summary>
 189    ///     Signs a Google account up or in against a scope and returns a token (UC-25,
 190    ///     FR-GO-03…FR-GO-13). The caller sends the ID token they obtained from Google and the
 191    ///     <c>PublicId</c> of the scope they are entering; the first call for a given Google account
 192    ///     in a given scope creates the Google User, every later one authenticates it. Open to
 193    ///     anonymous callers for the same reason <c>login</c> is — this is where a Google User gets
 194    ///     the token every other endpoint requires. <c>nonce</c> is optional: the value the client
 195    ///     sent Google when it asked for the ID token. When it is present, the token's <c>nonce</c>
 196    ///     claim must equal it exactly (AF-25g, FR-GO-20), a client-side check that the token it
 197    ///     presents was issued for its own sign-in request; when it is absent or empty, nothing is
 198    ///     compared. Whether a token is accepted rests on its signature, issuer, audience and expiry.
 199    /// </summary>
 200    /// <remarks>
 201    ///     The 401 flows answer alike (AF-25a, AF-25d, AF-25g) with "Google authentication failed.",
 202    ///     as UC-11's do, so an anonymous caller cannot use the endpoint to learn which Google accounts
 203    ///     a scope has registered or which were deleted. AF-25b answers 403 for a missing, deleted, and
 204    ///     disabled scope alike — a scope is not enumerable through here either. A first sign-in whose
 205    ///     token does not carry <c>email_verified: true</c> creates nothing and answers 403 "Google
 206    ///     sign-up requires a verified email address." (AF-25f, FR-GO-07), before the address is
 207    ///     compared with the scope's; a Google User that already exists signs in whatever the claim
 208    ///     says. AF-25c (409) is named, and it discloses nothing: the caller has proved the address is
 209    ///     theirs, and Google vouches for it.
 210    /// </remarks>
 211    [HttpPost("google")]
 212    [AllowAnonymous]
 213    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 214    public async Task<ActionResult<DataOutput<GoogleSignInCommandOutput?>>> GoogleSignIn(
 215        [FromBody] GoogleSignInCommand command)
 216    {
 33217        var result = await commandMediator
 33218            .ExecuteCommandAsync<GoogleSignInCommand, GoogleSignInCommandOutput>(command);
 219
 33220        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 33221    }
 222
 223    /// <summary>
 224    ///     Ends the caller's Google-authenticated session (UC-26, FR-GO-18). Takes no request body for
 225    ///     the reason <c>resend-verification</c> does not: the Google User is read from the bearer
 226    ///     token, so a caller can only ever sign themselves out.
 227    /// </summary>
 228    /// <remarks>
 229    ///     No <c>RoleRequirement</c>, deliberately. The authorization matrix grants Google sign-out to
 230    ///     a Google User acting on themselves and marks it not-applicable for both administrator roles
 231    ///     — who can never be Google Users (FR-GO-04) — so the rule is "the caller is a live Google
 232    ///     User", which is data the attribute cannot see and the handler checks. It also keeps the
 233    ///     endpoint to the two answers UC-26 defines: 200, or 401 for every rejection. A missing or
 234    ///     malformed token is the other half of AF-26a and never reaches here — authentication answers
 235    ///     it with the same 401.
 236    /// </remarks>
 237    [HttpPost("google/sign-out")]
 238    public async Task<ActionResult<DataOutput<GoogleSignOutCommandOutput?>>> GoogleSignOut()
 239    {
 6240        var command = new GoogleSignOutCommand();
 6241        HttpContext.ApplyActor(command);
 242
 6243        var result = await commandMediator
 6244            .ExecuteCommandAsync<GoogleSignOutCommand, GoogleSignOutCommandOutput>(command);
 245
 6246        return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes);
 6247    }
 248
 249    /// <summary>
 250    ///     Begins opting the caller into two-factor authentication (UC-36, FR-2F-01…FR-2F-03),
 251    ///     selecting an authenticator-app method, an email method, or both. Setup stays inactive until
 252    ///     confirmed by UC-37. The person acted on is always the caller themselves — read from the
 253    ///     bearer token, the same as <see cref="ResendVerification" />.
 254    /// </summary>
 255    /// <remarks>
 256    ///     No <c>RoleRequirement</c>: the authorization matrix grants two-factor setup to all three
 257    ///     person roles (<c>User</c>, <c>ScopeAdmin</c>, <c>SystemAdmin</c>) and withholds it from
 258    ///     anonymous callers, which is exactly what authentication alone enforces. AF-36b (Google User,
 259    ///     403) is not a role the attribute can see — a Google-issued token names a
 260    ///     <c>GoogleUser</c>, not a <c>Person</c> — so the handler enforces it by resolving the caller
 261    ///     against the <c>Person</c> table itself. The request carries the caller's
 262    ///     <c>currentPassword</c> (FR-2F-18), so the action sits under the credential endpoints' rate
 263    ///     limit, as <c>2fa/confirm</c> and <c>2fa/disable</c> do. Selecting <c>Email</c> mails a code,
 264    ///     charged to the account-email budget the verification resend uses: 429 "An email of this
 265    ///     kind was sent to you recently. Try again in a few minutes." inside the cooldown (AF-36f),
 266    ///     429 "You have reached the limit for account emails. Try again later." once the
 267    ///     window's five are spent (AF-36g), both with <c>Retry-After</c>; it is checked before the
 268    ///     password and charged only after it. Selecting
 269    ///     <c>App</c> alone is never held back by it. A missing
 270    ///     or wrong password answers 401 "The password or code is incorrect.", and is charged to the
 271    ///     account's re-authentication budget (FR-AU-13): five failures within fifteen minutes pause
 272    ///     the check for fifteen minutes, with the same answer.
 273    /// </remarks>
 274    [HttpPost("2fa/enable")]
 275    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 276    public async Task<ActionResult<DataOutput<EnableTwoFactorAuthCommandOutput?>>> EnableTwoFactorAuth(
 277        [FromBody] EnableTwoFactorAuthCommand command)
 278    {
 26279        HttpContext.ApplyActor(command);
 280
 26281        var result = await commandMediator
 26282            .ExecuteCommandAsync<EnableTwoFactorAuthCommand, EnableTwoFactorAuthCommandOutput>(command);
 283
 26284        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 26285    }
 286
 287    /// <summary>
 288    ///     Confirms the caller's pending two-factor authentication setup (UC-37, FR-2F-04/05), proving
 289    ///     control of every method selected in UC-36 — an <c>appCode</c> if <c>AppEnabled</c>, an
 290    ///     <c>emailCode</c> if <c>EmailEnabled</c>, both if both. On success, activates the
 291    ///     configuration and returns ten recovery codes in plaintext, exactly once: sixteen characters
 292    ///     of Crockford's base32 (the digits and the uppercase letters but I, L, O and U), shown as
 293    ///     <c>XXXX-XXXX-XXXX-XXXX</c> (FR-2F-20). The person acted on
 294    ///     is always the caller themselves — read from the bearer token, the same as
 295    ///     <see cref="EnableTwoFactorAuth" />.
 296    /// </summary>
 297    /// <remarks>
 298    ///     No <c>RoleRequirement</c>, for the same reason <see cref="EnableTwoFactorAuth" /> has none:
 299    ///     the authorization matrix grants confirmation to all three person roles and withholds it from
 300    ///     anonymous callers, which authentication alone already enforces. AF-37a's 404 covers both "no
 301    ///     setup was ever initiated" and "the caller is a Google User" alike — a Google-issued token
 302    ///     names a <c>GoogleUser</c>, never a row this lookup could find. A missing or wrong code for
 303    ///     either method (AF-37b, AF-37c) answers 401 "The password or code is incorrect.", whichever
 304    ///     it was, and is charged to the account's re-authentication budget (FR-AU-13), whose pause
 305    ///     answers the same.
 306    /// </remarks>
 307    [HttpPost("2fa/confirm")]
 308    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 309    public async Task<ActionResult<DataOutput<ConfirmTwoFactorAuthCommandOutput?>>> ConfirmTwoFactorAuth(
 310        [FromBody] ConfirmTwoFactorAuthCommand command)
 311    {
 29312        HttpContext.ApplyActor(command);
 313
 29314        var result = await commandMediator
 29315            .ExecuteCommandAsync<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput>(command);
 316
 29317        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 29318    }
 319
 320    /// <summary>
 321    ///     Completes a 2FA-gated login (UC-38, FR-2F-09) by redeeming the challenge token AF-11g
 322    ///     issued at login, together with an app/email code or a recovery code, for the full
 323    ///     authentication token. Open to anonymous callers — the caller holds no bearer token yet,
 324    ///     only the challenge token, which is submitted here as a plain request body field, exactly
 325    ///     like <see cref="ResetPassword" />'s token, never as an <c>Authorization</c> header.
 326    /// </summary>
 327    /// <remarks>
 328    ///     AF-38a (invalid/expired challenge token) and AF-38b/AF-38c (wrong code, or a reused
 329    ///     recovery code) all answer the same 401 with the same message, so this endpoint cannot be
 330    ///     used to distinguish a forged challenge from an expired one, or a wrong code from a
 331    ///     recovery code that was already spent. A recovery code is accepted with or without its
 332    ///     hyphens, with spaces, and in any case, and an O, I or L in a sixteen-character code reads as
 333    ///     0, 1 and 1; codes of the shorter <c>XXXX-XXXX</c> shape issued before keep working until used
 334    ///     or regenerated (FR-2F-20). <see cref="MfaPendingGuardFilter" /> is what keeps a
 335    ///     challenge token from working anywhere else (FR-2F-10) — it never applies here, since this
 336    ///     action never reads the challenge token as a bearer credential in the first place.
 337    /// </remarks>
 338    [HttpPost("2fa/verify")]
 339    [AllowAnonymous]
 340    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 341    public async Task<ActionResult<DataOutput<VerifyTwoFactorAuthCommandOutput?>>> VerifyTwoFactorAuth(
 342        [FromBody] VerifyTwoFactorAuthCommand command)
 343    {
 62344        var result = await commandMediator
 62345            .ExecuteCommandAsync<VerifyTwoFactorAuthCommand, VerifyTwoFactorAuthCommandOutput>(command);
 346
 62347        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 62348    }
 349
 350    /// <summary>
 351    ///     Reissues the email code for a two-factor challenge that is still outstanding (UC-46,
 352    ///     FR-2F-16), so someone who never received the code UC-11 mailed can ask for another
 353    ///     without starting sign-in over. Open to anonymous callers and authorized by the challenge
 354    ///     token alone, submitted as a request body field exactly as
 355    ///     <see cref="VerifyTwoFactorAuth" /> takes it — never as an <c>Authorization</c> header
 356    ///     (FR-2F-10).
 357    /// </summary>
 358    /// <remarks>
 359    ///     <para>
 360    ///         Answers <c>200 OK</c> with the same message and no data on every path — a valid
 361    ///         challenge, an unknown one, a forged one, an expired one, one naming a person with no
 362    ///         email method, and one whose reissues are spent (AF-46a…AF-46e). A response that varied
 363    ///         would tell an anonymous caller whether an address is registered and has email
 364    ///         two-factor enabled, which is the question UC-11 and UC-38 collapse their own answers
 365    ///         to refuse.
 366    ///     </para>
 367    ///     <para>
 368    ///         The reissue lands inside the existing challenge's window and never lengthens it: a
 369    ///         new challenge token could only be returned for a challenge that was real, which would
 370    ///         be exactly that oracle.
 371    ///     </para>
 372    ///     <para>
 373    ///         Rate-limited like every other anonymous auth route. The address is never the caller's
 374    ///         to choose — it is read from the person the challenge token names — and FR-2F-13's cap
 375    ///         already bounds how many codes one challenge can produce, so the limiter is not what
 376    ///         stops this being a mail cannon. What it bounds is the unauthenticated request volume
 377    ///         itself, which is the same thing it bounds on <see cref="Login" /> and
 378    ///         <see cref="PasswordRecovery" />.
 379    ///     </para>
 380    /// </remarks>
 381    [HttpPost("2fa/challenge/resend")]
 382    [AllowAnonymous]
 383    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 384    public async Task<ActionResult<DataOutput<ResendTwoFactorChallengeCodeCommandOutput?>>>
 385        ResendTwoFactorChallengeCode([FromBody] ResendTwoFactorChallengeCodeCommand command)
 386    {
 28387        var result = await commandMediator
 28388            .ExecuteCommandAsync<ResendTwoFactorChallengeCodeCommand,
 28389                ResendTwoFactorChallengeCodeCommandOutput>(command);
 390
 28391        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 28392    }
 393
 394    /// <summary>
 395    ///     Turns off the caller's own two-factor authentication (UC-39, FR-2F-11), requiring both the
 396    ///     caller's current password and a valid second factor — an app/email code or a recovery
 397    ///     code — exactly as hard to satisfy as a login. On success, permanently removes the
 398    ///     <c>TWO_FACTOR_AUTH</c> row and its recovery codes. The person acted on is always the
 399    ///     caller themselves — read from the bearer token, the same as <see cref="EnableTwoFactorAuth" />.
 400    /// </summary>
 401    /// <remarks>
 402    ///     No <c>RoleRequirement</c>, for the same reason <see cref="EnableTwoFactorAuth" /> has none:
 403    ///     the authorization matrix grants disabling two-factor authentication to all three person
 404    ///     roles and withholds it from anonymous callers, which authentication alone already enforces.
 405    ///     AF-39a answers 404 (not active). AF-39b and AF-39c — a wrong password and a wrong second
 406    ///     factor — answer the same 401, "The password or code is incorrect.", so the answer never
 407    ///     says which was wrong; a paused re-authentication budget (FR-AU-13) answers it too.
 408    ///     <c>recoveryCode</c> is matched as <c>2fa/verify</c> matches it: <c>XXXX-XXXX-XXXX-XXXX</c>,
 409    ///     with or without hyphens, with spaces, in any case, an O, I or L read as 0, 1 and 1, and
 410    ///     the shorter <c>XXXX-XXXX</c> codes issued before still accepted (FR-2F-20).
 411    /// </remarks>
 412    [HttpPost("2fa/disable")]
 413    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 414    public async Task<ActionResult<DataOutput<DisableTwoFactorAuthCommandOutput?>>> DisableTwoFactorAuth(
 415        [FromBody] DisableTwoFactorAuthCommand command)
 416    {
 19417        HttpContext.ApplyActor(command);
 418
 19419        var result = await commandMediator
 19420            .ExecuteCommandAsync<DisableTwoFactorAuthCommand, DisableTwoFactorAuthCommandOutput>(command);
 421
 19422        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 19423    }
 424
 425    /// <summary>
 426    ///     Invalidates the caller's current recovery codes and issues a fresh set of ten (UC-40,
 427    ///     FR-2F-12), requiring a valid second factor — an app/email code or one of the caller's
 428    ///     remaining recovery codes — verified exactly as <see cref="VerifyTwoFactorAuth" /> verifies
 429    ///     one. On success, every existing <c>TWO_FACTOR_RECOVERY_CODE</c> row for the caller is
 430    ///     removed, including any still unused, and replaced with ten new ones of the current
 431    ///     <c>XXXX-XXXX-XXXX-XXXX</c> shape (FR-2F-20) — which is also how a person holding the shorter
 432    ///     <c>XXXX-XXXX</c> codes issued before gets the stronger ones. The person acted on is
 433    ///     always the caller themselves — read from the bearer token, the same as
 434    ///     <see cref="EnableTwoFactorAuth" />.
 435    /// </summary>
 436    /// <remarks>
 437    ///     No <c>RoleRequirement</c>, for the same reason <see cref="EnableTwoFactorAuth" /> has none:
 438    ///     the authorization matrix grants regenerating recovery codes to all three person roles and
 439    ///     withholds it from anonymous callers, which authentication alone already enforces. AF-40a
 440    ///     (404, not active) reuses UC-39's "not active" message; AF-40b (401, second factor invalid,
 441    ///     or a paused re-authentication budget, FR-AU-13) answers "The password or code is
 442    ///     incorrect.", as every re-authentication does.
 443    /// </remarks>
 444    [HttpPost("2fa/recovery-codes/regenerate")]
 445    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 446    public async Task<ActionResult<DataOutput<RegenerateRecoveryCodesCommandOutput?>>> RegenerateRecoveryCodes(
 447        [FromBody] RegenerateRecoveryCodesCommand command)
 448    {
 17449        HttpContext.ApplyActor(command);
 450
 17451        var result = await commandMediator
 17452            .ExecuteCommandAsync<RegenerateRecoveryCodesCommand, RegenerateRecoveryCodesCommandOutput>(command);
 453
 17454        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 17455    }
 456
 457    /// <summary>
 458    ///     Returns a copy of everything held about the caller (UC-41, GDPR Art. 15 and 20, LGPD Art.
 459    ///     18 II and V).
 460    /// </summary>
 461    /// <remarks>
 462    ///     <para>
 463    ///         No <c>RoleRequirement</c>: access and portability are rights of the data subject
 464    ///         rather than privileges of a role, and a Google User has them as much as a person.
 465    ///     </para>
 466    ///     <para>
 467    ///         <b><c>POST</c>, for a read.</b> Two reasons. A <c>GET</c> response is cacheable, and
 468    ///         this one is a document containing everything about a person — exactly what must never
 469    ///         sit in a proxy or browser cache. And the export is the highest-value action a stolen
 470    ///         token can take, so it has to reach the audit trail, which in this codebase means
 471    ///         being a command.
 472    ///     </para>
 473    ///     <para>
 474    ///         The subject is the token's. There is no path parameter and no body field naming an
 475    ///         identity, so one caller cannot export another's data.
 476    ///     </para>
 477    ///     <para>
 478    ///         Reachable by a subject whose processing is restricted, or who is suspended pending
 479    ///         erasure (NFR-24): the right to a copy does not depend on the account's standing.
 480    ///     </para>
 481    /// </remarks>
 482    [HttpPost("data-export")]
 483    [DataSubjectRight]
 484    public async Task<ActionResult<DataOutput<DataExportCommandOutput?>>> ExportMyData()
 485    {
 12486        var command = new ExportMyDataCommand();
 12487        HttpContext.ApplyActor(command);
 488
 12489        var result = await commandMediator
 12490            .ExecuteCommandAsync<ExportMyDataCommand, DataExportCommandOutput>(command);
 491
 492        // A copy of everything about one person must not be cached anywhere on its way back.
 12493        Response.Headers.CacheControl = "no-store";
 494
 12495        return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes);
 12496    }
 497
 498    /// <summary>
 499    ///     Requests erasure of the caller's own identity (UC-42, GDPR Art. 17, LGPD Art. 18 VI).
 500    /// </summary>
 501    /// <remarks>
 502    ///     <para>
 503    ///         No <c>RoleRequirement</c>: every role, and a Google User, may ask to be erased — it is
 504    ///         a right of the data subject rather than a privilege of a role. Authentication alone
 505    ///         enforces what matters, which is that somebody is asking for themselves.
 506    ///     </para>
 507    ///     <para>
 508    ///         The subject is never named in the request. There is no path parameter and no body
 509    ///         field for it, so the endpoint has no shape in which one identity could ask for
 510    ///         another's erasure.
 511    ///     </para>
 512    ///     <para>
 513    ///         The body carries a credential, and which one depends on the caller: a person presents
 514    ///         their password, a Google User a freshly issued Google ID token. A bearer token alone
 515    ///         is not enough for an irreversible operation. Because it verifies a password, this
 516    ///         endpoint is governed by NFR-18 rather than NFR-05.
 517    ///     </para>
 518    ///     <para>
 519    ///         Reachable by a subject whose processing is restricted (NFR-24), and a successful request
 520    ///         lifts that restriction — without the Art. 18(3) notice, since the subject is lifting
 521    ///         their own — so the erasure is not held behind it; <c>restrictionLifted</c> in the
 522    ///         response says whether it did. A suspended identity
 523    ///         reaches the handler too, and is answered by AF-42c (409) if it asked already, AF-42b
 524    ///         (403) otherwise.
 525    ///     </para>
 526    /// </remarks>
 527    [HttpPost("erasure-request")]
 528    [DataSubjectRight]
 529    [EnableRateLimiting(Startup.AuthEndpointRateLimitPolicy)]
 530    public async Task<ActionResult<DataOutput<RequestErasureCommandOutput?>>> RequestErasure(
 531        [FromBody] RequestErasureCommand command)
 532    {
 11533        HttpContext.ApplyActor(command);
 534
 11535        var result = await commandMediator
 11536            .ExecuteCommandAsync<RequestErasureCommand, RequestErasureCommandOutput>(command);
 537
 11538        return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes);
 11539    }
 540
 541    /// <summary>
 542    ///     Restricts processing of the caller's own identity (UC-44, GDPR Art. 18, LGPD Art. 18
 543    ///     III–IV) — suspended without being deleted, while something about it is disputed.
 544    /// </summary>
 545    /// <remarks>
 546    ///     No credential is required, unlike UC-42's erasure request. A restriction destroys
 547    ///     nothing and is liftable, and somebody asking for one may be doing so precisely because
 548    ///     they believe the account is compromised — demanding the password of a person in that
 549    ///     position would be the wrong way round. Reachable by a suspended subject (UC-44), and by a
 550    ///     restricted one, who is answered by AF-44a (409).
 551    /// </remarks>
 552    [HttpPost("processing-restriction")]
 553    [DataSubjectRight]
 554    public async Task<ActionResult<DataOutput<RestrictProcessingCommandOutput?>>> RestrictProcessing(
 555        [FromBody] RestrictProcessingCommand command)
 556    {
 4557        HttpContext.ApplyActor(command);
 558
 4559        var result = await commandMediator
 4560            .ExecuteCommandAsync<RestrictProcessingCommand, RestrictProcessingCommandOutput>(command);
 561
 4562        return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes);
 4563    }
 564
 565    /// <summary>
 566    ///     Re-applies erasure to identities a database restore brought back (NFR-25). System Admin
 567    ///     only.
 568    /// </summary>
 569    /// <remarks>
 570    ///     A step in the restore runbook rather than an everyday operation. The identifiers come
 571    ///     from the erasure ledger the anonymisation pass writes to the logs, which is the only copy
 572    ///     a restore cannot roll back.
 573    /// </remarks>
 574    [HttpPost("erasure-reconciliation")]
 575    [RoleRequirement((int)Roles.SystemAdmin)]
 576    public async Task<ActionResult<DataOutput<ReapplyErasuresCommandOutput?>>> ReapplyErasures(
 577        [FromBody] ReapplyErasuresCommand command)
 578    {
 0579        HttpContext.ApplyActor(command);
 580
 0581        var result = await commandMediator
 0582            .ExecuteCommandAsync<ReapplyErasuresCommand, ReapplyErasuresCommandOutput>(command);
 583
 0584        return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes);
 0585    }
 586
 587    /// <summary>
 588    ///     Lifts a restriction on processing (UC-45, GDPR Art. 18(3)).
 589    /// </summary>
 590    /// <remarks>
 591    ///     No <c>RoleRequirement</c>, because the rule is data-dependent: a subject of any role may
 592    ///     lift their own, and only a System Admin may lift somebody else's. The handler decides,
 593    ///     and it informs the subject first in the second case — Art. 18(3) makes that a
 594    ///     precondition of the act rather than a courtesy afterwards, so a failed notification
 595    ///     refuses the lift. The subject can reach this with a restricted identity — it is how they
 596    ///     lift their own (NFR-24) — but a System Admin who is restricted or suspended may not lift
 597    ///     anyone else's (AF-45c, 403).
 598    /// </remarks>
 599    [HttpPost("processing-restriction/lift")]
 600    [DataSubjectRight]
 601    public async Task<ActionResult<DataOutput<LiftProcessingRestrictionCommandOutput?>>>
 602        LiftProcessingRestriction([FromBody] LiftProcessingRestrictionCommand command)
 603    {
 6604        HttpContext.ApplyActor(command);
 6605        command.ActingSubjectRightsOnly = HttpContext.GetUser<IdentityUser>()!.SubjectRightsOnly;
 606
 6607        var result = await commandMediator
 6608            .ExecuteCommandAsync<LiftProcessingRestrictionCommand, LiftProcessingRestrictionCommandOutput>(command);
 609
 6610        return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes);
 6611    }
 612
 613    /// <summary>
 614    ///     Lists the erasure requests that have not yet been carried out (UC-43). System Admin only.
 615    /// </summary>
 616    /// <remarks>
 617    ///     A Scope Admin is deliberately excluded, though they administer their scope's users: which
 618    ///     of them has asked to be erased is not something the request entitles anyone to know, and
 619    ///     most requests need no human at all. This queue exists for the ones NFR-12 blocks, which
 620    ///     need an owner transferred before they can proceed.
 621    /// </remarks>
 622    [HttpGet("erasure-requests")]
 623    [RoleRequirement((int)Roles.SystemAdmin)]
 624    public async Task<ActionResult<PaginatedOutput<ErasureRequestOutput>>> ListErasureRequests(
 625        [FromQuery] ListErasureRequestsQuery query)
 626    {
 1627        HttpContext.ApplyActor(query);
 628
 1629        var result = await queryMediator
 1630            .ExecutePaginatedQueryAsync<ListErasureRequestsQuery, ErasureRequestOutput>(query);
 631
 1632        return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes);
 1633    }
 634
 635    /// <summary>
 636    ///     Reports the caller's own two-factor authentication status (FR-2F-15): whether it is
 637    ///     active, which methods are configured, and how many recovery codes remain unused. The
 638    ///     person read is always the caller themselves — taken from the bearer token, the same as
 639    ///     <see cref="EnableTwoFactorAuth" /> — so a configuration is never addressed by an
 640    ///     identifier in a path.
 641    /// </summary>
 642    /// <remarks>
 643    ///     No <c>RoleRequirement</c>, for the same reason its <c>POST</c> siblings have none: the
 644    ///     authorization matrix grants two-factor management to all three person roles and withholds
 645    ///     it from anonymous callers, which authentication alone enforces. A caller with no
 646    ///     configuration is answered 200 with every flag false; a Google User is answered 403, since
 647    ///     FR-2F-01 makes them permanently ineligible.
 648    /// </remarks>
 649    [HttpGet("2fa")]
 650    public async Task<ActionResult<DataOutput<TwoFactorStatusOutput?>>> GetTwoFactorStatus()
 651    {
 4652        var query = new GetTwoFactorStatusQuery();
 4653        HttpContext.ApplyActor(query);
 654
 4655        var result = await queryMediator
 4656            .ExecuteQueryAsync<GetTwoFactorStatusQuery, TwoFactorStatusOutput>(query);
 657
 4658        return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes);
 4659    }
 660}