RiskControl becomes useful only when a consumer enforces its decision. Login, MFA policy, and an anonymous Website form have different subjects, timing, and failure policy. They must not be described as one interchangeable snippet.
1. LoginFlow evaluates before the password
Login.Command implements IRiskAssessedRequest. The handler evaluates risk, while the outer pipeline persists a successfully captured assessment. The actual order is:
- build a risk context from TenantId, UserName, IP, UserAgent, and DeviceId; UserId is still null;
- log an engine failure and continue with no challenge;
- reject Block before user lookup and BCrypt, then record a failed login;
- issue Captcha on the first request and verify its ticket on the second;
- mark MFA or StepUp as risk-required and continue to password verification;
- combine account 2FA with the risk requirement after the password succeeds;
- issue access and refresh tokens plus
UserSessiononly after every required step passes.
The engine failure branch is fail-open for Login. This is an implemented availability choice, not a general RiskControl guarantee. High-value tenants need explicit risk acceptance or a stricter consumer policy.
2. The two-stage Login Captcha contract
LoginResponse represents the first stage through RequiresCaptcha, ChallengeId, and RenderData. It contains no access or refresh token. The retry sends the same login identity and credentials together with Id and Answer.
var first = await auth.LoginAsync(credentials, cancellationToken);if (first.RequiresCaptcha){ // Interpret RenderData according to the Captcha protocol selected by the server. var answer = await captchaUi.SolveAsync( first.CaptchaChallengeId, first.CaptchaRenderData, cancellationToken);
// Reuse the same tenant and username, then return the one-time ticket and answer. var second = credentials with { CaptchaChallengeId = first.CaptchaChallengeId, CaptchaAnswer = answer, }; return await auth.LoginAsync(second, cancellationToken);}
return first;The current projection does not expose CaptchaType, and Login always calls ResolveDefault(). ImageCode therefore works as the implicit protocol today. Selecting Slider, PointSelect, or Behavior later requires a versioned response contract; clients must not guess from RenderData.
3. Risk-driven MFA is a separate evaluation path
Identity Infrastructure’s RiskDrivenMfaPolicyService maps MfaRiskContext into LoginRiskContext and applies another policy:
| Level | MFA requirement |
|---|---|
| Low | TOTP; a trusted device may skip |
| Medium | TOTP; trusted-device skip disabled |
| High / Critical | TOTP plus FIDO2 |
| Block | Deny |
| Engine missing, exception, or Failure | TOTP; trusted-device skip disabled |
Its fallback still demands TOTP, unlike Login’s no-challenge continuation. Alerts and runbooks must name the consumer.
If LoginFlow and the MFA service both evaluate within one request, both try to capture an assessment in RiskAssessmentExecutionContext. The context is capture-once; the second call throws and is converted into engine Failure. A composed flow should reuse the first assessment instead of evaluating twice.
4. Public Website contact form
GET /contact/captcha uses server-side AnonymousVisitorContext.VisitorId to produce:
captcha:public-website:{visitorId}:{nonce}On submission, the handler checks the ChallengeId prefix before invoking the default provider, then rejects both infrastructure Failure and Passed=false. It validates and stores the PII-bearing lead only after Captcha succeeds.
This consumer is more complete than the general endpoint filter. It still needs visitor/IP rate limits, generation throttling, and contact deduplication. Captcha alone does not stop an attacker from generating unlimited challenges.
5. The general endpoint filter is incomplete
RequireCaptchaAttribute and RequireCaptchaEndpointFilter are Host scaffolding. A source comment says the filter is not attached to any endpoint. The filter reads two JSON fields and invokes the default provider.
var verifyResult = await resolver.ResolveDefault().VerifyAsync( new CaptchaVerifyRequest(challengeId, answer), httpContext.RequestAborted);
// This rejects only an infrastructure-level Result failure.if (verifyResult.IsFailure) return Results.BadRequest();
// A wrong answer returns Success(Passed=false), so the protected handler still runs.return await next(context);A corrected filter must also check Passed, bind the ticket to tenant/session/scenario, bound the body size, validate Content-Type, and map errors without disclosing security detail. It then needs an HTTP test against every real attached endpoint. Merely decorating a source-generated endpoint contract with the attribute does not attach the filter.
6. Assessment query API
GetRiskAssessments.Query generates GET /api/risk/assessments and declares:
- resource module
risk-controland resourceassessment; - action
Viewand catalog permissionrisk-control.assessment.view; - optional UserId, Level, From, To, and pagination;
- Validation for an unknown Level or reversed time interval;
- fallback page 1 / size 20, with valid size capped at 100.
The handler currently takes ICurrentUser.User.TenantId as its tenant baseline; the store adds an explicit tenant predicate. Normal tenant users are isolated, but formal tenant operation needs the effective data plane:
public static class TenantErrors{ public static readonly Error Required = Error.Forbidden("Tenant.Required", "A trusted tenant is required.");}
var effectiveTenantId = currentTenant.Tenant.EffectiveTenantId;if (!TenancyDefaults.IsValid(effectiveTenantId)){ // A risk-history query must never fall back to a user-supplied tenant id. return Result.Failure<RiskAssessmentPage>(TenantErrors.Required);}
// The tenant comes from the trusted multitenancy runtime, not query-string input.var page = await store.SearchAsync( effectiveTenantId, filter, pageRequest, cancellationToken);Host-wide risk analysis also needs a separate permission, operation reason, and audit trail. Switching CurrentTenant alone is not sufficient authority.
7. Factors are a privacy boundary
RiskAssessmentItem returns the full IReadOnlyList<RiskFactor>. Evidence can currently expose:
- the raw IP in
NewIp.ip; - a raw device fingerprint or UserAgent in
NewDevice.deviceFingerprint; - full business-local time in
OffHours.businessNow; - failure counts and history-set sizes.
A commercial API should separate:
- list projection: Id, user alias, level, score, challenge, factor codes, and AssessedAt;
- sensitive detail projection: redacted Evidence behind stronger permission and auditing;
- export: separate approval, declared purpose, and short-lived download.
Retention, deletion, and support-access policy must cover both FactorsJson and downstream logs or exports.
8. Cross-module dependency direction
RiskControl.Contracts exposes narrow ports. Identity Infrastructure supplies login history; Identity Application consumes the engine and Captcha; RiskControl Infrastructure depends on IEntitySet and QueryShape rather than an ORM package.
RiskControl does not read the Identity aggregate, and Identity does not reference RiskControl Infrastructure. That boundary is what allows either side to replace its storage adapter.
9. Integration test contract
- all five Login challenge branches and their password/MFA/token ordering;
- different engine-failure fallback in Login and MFA policy;
- first-stage Captcha does not disclose user existence or issue tokens;
- Login tickets reject cross-tenant and cross-username use;
- Website tickets reject another visitor without consuming the legitimate ticket;
- the filter is attached, rejects a wrong answer, and preserves request-body behavior;
- query permission, Effective Tenant, paging, damaged snapshots, and Evidence redaction;
- repeated risk evaluation in one request neither overwrites nor loses an assessment.
Back: Policy and persistence · Next: Testing and production operations