| | | 1 | | using ArturRios.Heimdall.Command.Input; |
| | | 2 | | using ArturRios.Heimdall.Command.Output; |
| | | 3 | | using ArturRios.Heimdall.Query.Input; |
| | | 4 | | using ArturRios.Heimdall.Query.Output; |
| | | 5 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 7 | | using ArturRios.Mediator.Command; |
| | | 8 | | using ArturRios.Mediator.Query; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using ArturRios.Heimdall.WebApi.Security; |
| | | 11 | | using ArturRios.Util.WebApi.AspNetCore; |
| | | 12 | | using ArturRios.Util.WebApi.Security.Attributes; |
| | | 13 | | using ArturRios.Util.WebApi.Security.Extensions; |
| | | 14 | | using Microsoft.AspNetCore.Mvc; |
| | | 15 | | using Microsoft.AspNetCore.RateLimiting; |
| | | 16 | | |
| | | 17 | | namespace ArturRios.Heimdall.WebApi.Controllers; |
| | | 18 | | |
| | | 19 | | [Route("api/auth")] |
| | 592 | 20 | | public 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 | | { |
| | 105 | 35 | | var result = await commandMediator |
| | 105 | 36 | | .ExecuteCommandAsync<LoginCommand, LoginCommandOutput>(command); |
| | | 37 | | |
| | 103 | 38 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 103 | 39 | | } |
| | | 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 | | { |
| | 29 | 58 | | var result = await commandMediator |
| | 29 | 59 | | .ExecuteCommandAsync<PasswordRecoveryCommand, PasswordRecoveryCommandOutput>(command); |
| | | 60 | | |
| | 29 | 61 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 29 | 62 | | } |
| | | 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 | | { |
| | 24 | 82 | | var result = await commandMediator |
| | 24 | 83 | | .ExecuteCommandAsync<RegisterCommand, RegisterCommandOutput>(command); |
| | | 84 | | |
| | 24 | 85 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 24 | 86 | | } |
| | | 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 | | { |
| | 29 | 109 | | var result = await commandMediator |
| | 29 | 110 | | .ExecuteCommandAsync<ResetPasswordCommand, ResetPasswordCommandOutput>(command); |
| | | 111 | | |
| | 29 | 112 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 29 | 113 | | } |
| | | 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 | | { |
| | 114 | 144 | | var result = await commandMediator |
| | 114 | 145 | | .ExecuteCommandAsync<VerifyEmailCommand, VerifyEmailCommandOutput>(command); |
| | | 146 | | |
| | 114 | 147 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 114 | 148 | | } |
| | | 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 | | { |
| | 32 | 178 | | var command = new ResendVerificationEmailCommand(); |
| | 32 | 179 | | HttpContext.ApplyActor(command); |
| | | 180 | | |
| | 32 | 181 | | var result = await commandMediator |
| | 32 | 182 | | .ExecuteCommandAsync<ResendVerificationEmailCommand, ResendVerificationEmailCommandOutput>( |
| | 32 | 183 | | command); |
| | | 184 | | |
| | 32 | 185 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 32 | 186 | | } |
| | | 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 | | { |
| | 33 | 217 | | var result = await commandMediator |
| | 33 | 218 | | .ExecuteCommandAsync<GoogleSignInCommand, GoogleSignInCommandOutput>(command); |
| | | 219 | | |
| | 33 | 220 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 33 | 221 | | } |
| | | 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 | | { |
| | 6 | 240 | | var command = new GoogleSignOutCommand(); |
| | 6 | 241 | | HttpContext.ApplyActor(command); |
| | | 242 | | |
| | 6 | 243 | | var result = await commandMediator |
| | 6 | 244 | | .ExecuteCommandAsync<GoogleSignOutCommand, GoogleSignOutCommandOutput>(command); |
| | | 245 | | |
| | 6 | 246 | | return result.ToActionResult(statusMap: AuthMessageMap.StatusCodes); |
| | 6 | 247 | | } |
| | | 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 | | { |
| | 26 | 279 | | HttpContext.ApplyActor(command); |
| | | 280 | | |
| | 26 | 281 | | var result = await commandMediator |
| | 26 | 282 | | .ExecuteCommandAsync<EnableTwoFactorAuthCommand, EnableTwoFactorAuthCommandOutput>(command); |
| | | 283 | | |
| | 26 | 284 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 26 | 285 | | } |
| | | 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 | | { |
| | 29 | 312 | | HttpContext.ApplyActor(command); |
| | | 313 | | |
| | 29 | 314 | | var result = await commandMediator |
| | 29 | 315 | | .ExecuteCommandAsync<ConfirmTwoFactorAuthCommand, ConfirmTwoFactorAuthCommandOutput>(command); |
| | | 316 | | |
| | 29 | 317 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 29 | 318 | | } |
| | | 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 | | { |
| | 62 | 344 | | var result = await commandMediator |
| | 62 | 345 | | .ExecuteCommandAsync<VerifyTwoFactorAuthCommand, VerifyTwoFactorAuthCommandOutput>(command); |
| | | 346 | | |
| | 62 | 347 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 62 | 348 | | } |
| | | 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 | | { |
| | 28 | 387 | | var result = await commandMediator |
| | 28 | 388 | | .ExecuteCommandAsync<ResendTwoFactorChallengeCodeCommand, |
| | 28 | 389 | | ResendTwoFactorChallengeCodeCommandOutput>(command); |
| | | 390 | | |
| | 28 | 391 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 28 | 392 | | } |
| | | 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 | | { |
| | 19 | 417 | | HttpContext.ApplyActor(command); |
| | | 418 | | |
| | 19 | 419 | | var result = await commandMediator |
| | 19 | 420 | | .ExecuteCommandAsync<DisableTwoFactorAuthCommand, DisableTwoFactorAuthCommandOutput>(command); |
| | | 421 | | |
| | 19 | 422 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 19 | 423 | | } |
| | | 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 | | { |
| | 17 | 449 | | HttpContext.ApplyActor(command); |
| | | 450 | | |
| | 17 | 451 | | var result = await commandMediator |
| | 17 | 452 | | .ExecuteCommandAsync<RegenerateRecoveryCodesCommand, RegenerateRecoveryCodesCommandOutput>(command); |
| | | 453 | | |
| | 17 | 454 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 17 | 455 | | } |
| | | 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 | | { |
| | 12 | 486 | | var command = new ExportMyDataCommand(); |
| | 12 | 487 | | HttpContext.ApplyActor(command); |
| | | 488 | | |
| | 12 | 489 | | var result = await commandMediator |
| | 12 | 490 | | .ExecuteCommandAsync<ExportMyDataCommand, DataExportCommandOutput>(command); |
| | | 491 | | |
| | | 492 | | // A copy of everything about one person must not be cached anywhere on its way back. |
| | 12 | 493 | | Response.Headers.CacheControl = "no-store"; |
| | | 494 | | |
| | 12 | 495 | | return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes); |
| | 12 | 496 | | } |
| | | 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 | | { |
| | 11 | 533 | | HttpContext.ApplyActor(command); |
| | | 534 | | |
| | 11 | 535 | | var result = await commandMediator |
| | 11 | 536 | | .ExecuteCommandAsync<RequestErasureCommand, RequestErasureCommandOutput>(command); |
| | | 537 | | |
| | 11 | 538 | | return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes); |
| | 11 | 539 | | } |
| | | 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 | | { |
| | 4 | 557 | | HttpContext.ApplyActor(command); |
| | | 558 | | |
| | 4 | 559 | | var result = await commandMediator |
| | 4 | 560 | | .ExecuteCommandAsync<RestrictProcessingCommand, RestrictProcessingCommandOutput>(command); |
| | | 561 | | |
| | 4 | 562 | | return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes); |
| | 4 | 563 | | } |
| | | 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 | | { |
| | 0 | 579 | | HttpContext.ApplyActor(command); |
| | | 580 | | |
| | 0 | 581 | | var result = await commandMediator |
| | 0 | 582 | | .ExecuteCommandAsync<ReapplyErasuresCommand, ReapplyErasuresCommandOutput>(command); |
| | | 583 | | |
| | 0 | 584 | | return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes); |
| | 0 | 585 | | } |
| | | 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 | | { |
| | 6 | 604 | | HttpContext.ApplyActor(command); |
| | 6 | 605 | | command.ActingSubjectRightsOnly = HttpContext.GetUser<IdentityUser>()!.SubjectRightsOnly; |
| | | 606 | | |
| | 6 | 607 | | var result = await commandMediator |
| | 6 | 608 | | .ExecuteCommandAsync<LiftProcessingRestrictionCommand, LiftProcessingRestrictionCommandOutput>(command); |
| | | 609 | | |
| | 6 | 610 | | return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes); |
| | 6 | 611 | | } |
| | | 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 | | { |
| | 1 | 627 | | HttpContext.ApplyActor(query); |
| | | 628 | | |
| | 1 | 629 | | var result = await queryMediator |
| | 1 | 630 | | .ExecutePaginatedQueryAsync<ListErasureRequestsQuery, ErasureRequestOutput>(query); |
| | | 631 | | |
| | 1 | 632 | | return result.ToActionResult(statusMap: ErasureMessageMap.StatusCodes); |
| | 1 | 633 | | } |
| | | 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 | | { |
| | 4 | 652 | | var query = new GetTwoFactorStatusQuery(); |
| | 4 | 653 | | HttpContext.ApplyActor(query); |
| | | 654 | | |
| | 4 | 655 | | var result = await queryMediator |
| | 4 | 656 | | .ExecuteQueryAsync<GetTwoFactorStatusQuery, TwoFactorStatusOutput>(query); |
| | | 657 | | |
| | 4 | 658 | | return result.ToActionResult(statusMap: TwoFactorMessageMap.StatusCodes); |
| | 4 | 659 | | } |
| | | 660 | | } |