Skip to content
bitzorcas
中EN

Concept

Identity login, MFA, tokens, and sessions

Follow LoginFlow through password encryption, risk, captcha, resolution, timing defense, lockout, MFA, JWT, sessions, audit queries, and compensation.

Last updated

LoginFlow is the deep authentication module. It is not three lines that fetch a user, compare a password, and issue JWT. It is a state machine with several business outcomes, and clients and alternative authentication entries must handle them explicitly.

1. State-machine overview

BlockFirst Captcha requestSecond Captcha requestMFA / StepUpNoneengine failureFailureSuccessYesNo

LoginRequest

Risk assessment

RiskBlocked

Generate captcha

Consume and verify answer

Mark risk MFA

Resolve user

RiskAssessmentUnavailable

Account-state guard

Password verification

Failure count / lockout

User 2FA or risk MFA?

Issue 5-minute MFA challenge

Issue access and refresh tokens

Store refresh token

Write UserSession

Audit login success

Login.Command only combines request, tenant, platform, IP, and User-Agent into LoginInput and delegates to ILoginFlow. Architecture tests pin that boundary so the handler cannot become a second login implementation.

2. HTTP input and client platform

POST /api/auth/login is anonymous and uses authPolicy plus the standard command timeout. Effective tenant comes from ICurrentTenant; IP and User-Agent come from the HTTP context.

Password client encryption (required)

  1. Call GET /api/auth/cipher-key for SPKI public key, keyId, serverTimestamp, and maxClockSkewSeconds.
  2. Browser builds nonce|timestamp|password and encrypts with RSA-OAEP-SHA256 (Web Crypto).
  3. Login body sends only encryptedPassword + cipherKeyId — never plaintext password.
  4. Server resolves the private key for that keyId, decrypts, checks clock skew and one-time Nonce, then runs BCrypt.

Server keys:

ItemBehavior
GenerationDistributed mode creates a shared first key when the cache is absent; only memory fallback creates one at each API process start
keyId{yyyyMMddHHmmss}-{6hex}
RotationDefault RotationHours=24; effective range 1–720 hours (cap = 30 days) — values outside are silently clamped, process still starts. Hosted service sleeps then rotates. Previous-key grace defaults to 10 minutes (at least 1)
RingAt most current + previous can decrypt
Multi-instanceWith ICacheStore plus the private-key material protector, current + previous are shared; instances must share a Data Protection key ring. Missing either dependency falls back in-process with a Warning
NonceICacheStore area identity:cipher:nonce; in-process fallback + Warning without Redis
ErrorsKeyExpired / DecryptFailed / ClockSkewExceeded / ReplayDetected / EncryptedPasswordRequired / PayloadInvalid

Platform SDK encrypts login, change/reset password, activation, create-user, and other password-bearing paths automatically and retries once on Identity.Cipher.KeyExpired.

Complete LoginRequest (encrypted)
{
"userName": "alice@example.com",
"encryptedPassword": "Base64(RSA-OAEP(nonce|serverTs|password))",
"cipherKeyId": "20260811120000-a1b2c3",
"rememberMe": false,
"deviceId": "alice-browser-01",
"captchaChallengeId": null,
"captchaAnswer": null
}

Server config Identity:Password:Cipher

Bound by PasswordCipherOptions. Defaults apply if the section is omitted. Full lifecycle, validation vs clamp, and ops notes: Identity configuration §2.1.

Key (under Identity:Password:Cipher)DefaultRange / out-of-rangeMeaning
KeySize20482048–4096; startup validation fails processRSA bits
RotationHours241–720 (cap 720h = 30 days); no startup validation — Math.Clamp onlyRotation interval (hours). Values above 720 still start the host but rotate every 30 days
GracePeriodMinutes10Runtime min 1; no startup upper boundPrevious-key decrypt grace (minutes); recommend 5–30 and much less than rotation
MaxClockSkewSeconds12030–600; startup validationTimestamp skew cap (seconds); also returned on cipher-key
NonceTtlSeconds12030–600; startup validation + claim-time clampNonce cache TTL (seconds)
appsettings fragment (copy-paste ready)
{
"Identity": {
"Password": {
"Cipher": {
"KeySize": 2048,
"RotationHours": 24,
"GracePeriodMinutes": 10,
"MaxClockSkewSeconds": 120,
"NonceTtlSeconds": 120
}
}
}
}
  • Distributed mode stores a Data Protection-protected PKCS#8 snapshot under identity:cipher:keyring:v1, immediately zeroing plaintext key bytes; instances must share the Data Protection key ring. Only memory fallback rotates on restart.
  • Nonce uses ICacheStore; without distributed cache the process falls back in-memory (weaker multi-instance replay protection — use Redis in production).
  • Not every field fails startup: KeySize / MaxClockSkewSeconds / NonceTtlSeconds do via ValidateOnStart; RotationHours is only clamped to 1–720; GracePeriodMinutes only enforces ≥1.
  • Plain HTTP / IP access: when crypto.subtle is unavailable the SDK uses a pure-JS RSA-OAEP-SHA256 fallback so password encryption still works; the server auto-sets refresh/trusted-device cookies to Secure=false and downgrades SameSite=None to Lax on non-HTTPS requests (otherwise cookies never stick and refresh returns 401). Prefer HTTPS in production.

X-Client-Platform selects client platform and defaults to Web. The value enters JWT platform, refresh-token slot, and session record. Refresh cannot relabel the original token as another platform.

3. Risk runs before password

IRiskAssessmentEngine receives tenant, user name, IP, User-Agent, and DeviceId. It returns four challenge classes:

ChallengeLoginFlow behaviorPassword verified?
NoneContinueYes
BlockAudit failure and return RiskBlockedNo
CaptchaGenerate first; consume answer on second requestOnly after captcha passes
MFA / StepUpMark second-factor requirement and continueYes

Risk Block rejects before BCrypt to save high-risk brute-force cost. That branch does not execute dummy BCrypt, but it does write a login failure for future risk evaluation.

4. Captcha is a two-stage protocol

If risk requires captcha and the request has no captchaChallengeId, the service returns a challenge:

Captcha challenge response
{
"accessToken": "",
"refreshToken": "",
"expiresIn": 0,
"requiresMfa": false,
"mfaToken": null,
"requiresPasswordChange": false,
"requiresCaptcha": true,
"captchaChallengeId": "captcha-ticket-id",
"captchaRenderData": "svg-or-base64-render-data",
"isHost": false
}

After rendering it, the client repeats the login with captchaChallengeId and captchaAnswer. Verification consumes the ticket and prevents replay.

Second login request with captcha answer
# Stage two must preserve the identity, device, and tenant context from stage one.
# ChallengeId is one-time state and must not be replayed after consumption.
curl --fail-with-body \
--request POST 'https://localhost:5001/api/auth/login' \
--header 'Content-Type: application/json' \
--data '{
"userName":"alice@example.com",
"password":"Correct-Horse-9!",
"rememberMe":false,
"deviceId":"alice-browser-01",
"captchaChallengeId":"captcha-ticket-id",
"captchaAnswer":"8241"
}'

A ticket with no answer, failed verification, or provider failure returns Identity.Login.CaptchaFailed and writes a sanitized failure reason.

5. User resolution and tenant trust

ResolveUserAsync evaluates in this order:

  1. Find a Host user by UserName in the global IsHost = true namespace.
  2. If UserName contains @, perform cross-tenant email lookup and use the persisted user’s TenantId.
  3. Otherwise, find UserName inside the tenant provided by trusted resolution.

A client cannot invent a TenantId to log in across tenants. Host TenantId = "0" is a platform semantic and cannot masquerade as a normal tenant user.

Host users live in legacy-compatible tenant tables (TenantId = "0"), where the dual-ORM unified tenant-visibility filter intercepts cross-tenant reads. Host login was previously mis-blocked by that filter. The fix does not disable the filter; instead IIdentityLoginPersistenceScope installs a registered RootCrossTenantOperation capability ticket (operation code identity.login.resolve-and-establish-session) to perform a bounded, audited Host exact-key lookup. Once Host identity is confirmed, CurrentUser, CurrentTenant, and the ORM execution context sync so the host-admin role and its permissions enter the session — but only within the confirmed IsHost login scope, which restores via LIFO on exit.

Lookup failure or absence executes dummy BCrypt on the submitted password before returning. This reduces response-time differences that could reveal valid user names.

6. Account state and lockout

The defaults are five failures and a 15-minute lockout. On password failure, VerifyPasswordFailed changes the aggregate and the repository saves it. If failure-count persistence fails, login returns that store error and issues no token.

An active lockout executes dummy BCrypt and rejects. An elapsed lockout calls Unlock() before the normal state guard. Only UserStatus.Active proceeds; Disabled uses Identity.Login.AccountDisabled, and other non-active states use Identity.Login.AccountNotActive.

Essential login-state guard semantics
// An active lockout runs the timing defense before returning a stable locked result.
if (user.Status == UserStatus.Locked && user.LockoutEnd > clock.UtcNow)
{
passwordHasher.VerifyPassword(input.Password, DummyPasswordHash);
return LoginResult.Locked(accountLockedError);
}
// A correct password cannot bypass PendingActivation, Disabled, or Expired.
if (user.Status != UserStatus.Active)
{
passwordHasher.VerifyPassword(input.Password, DummyPasswordHash);
return LoginResult.Locked(accountNotActiveError);
}

This excerpt focuses on the state decision and leaves login-audit calls to LoginFlow.cs, together with the exact constants, errors, and logging.

7. Correct password may not finish login

After BCrypt succeeds, the aggregate calls VerifyPasswordSucceeded and saves login state. The flow then unions user.TwoFactorEnabled with risk MFA/StepUp.

When a tenant-mandated MFA policy requires a second factor but the user has not enrolled any MFA credential, login returns an MfaEnrollmentRequired branch: a restricted token (mfaEnrollmentRequired: true) is issued that only permits the MFA enrollment flow, not other resources. This ensures a mandatory policy cannot be bypassed by a user simply having no MFA registered.

When MFA is required, IMfaService generates a one-time challenge and stores it in UserToken:

  • Provider: MFA
  • Name: Challenge
  • Lifetime: five minutes

This branch returns requiresMfa = true and creates no final access token, refresh token, or session. The MFA completion use case performs final issuance.

8. JWT claim contract

The final access token contains framework claims plus:

ClaimSourcePurpose
user_idUserAggregate.IdStable identity for HttpContextCurrentUser
platformLogin client platformClient isolation and risk context
caller_typeHost or UserDistinguish platform operator from tenant user
RolesIUserQueryStore.GetUserRolesAsyncDownstream authorization context
auth_sourceOptional external or mini-program entryDistinguish issuance channel

Access tokens last 15 minutes by default. If role lookup fails, the current implementation issues with an empty role set. A product that requires role availability to gate login needs a fail-closed change and tests.

9. Refresh-token families and platform isolation

Refresh tokens last seven days by default and use storage name RefreshToken:{platform}. With IRefreshTokenStore, the service also stores SHA-256-hashed RefreshTokenRecord nodes in Active, Rotated, or Revoked state.

refreshmark

Web token A
Active

Web token B
Active

Web token A
Rotated

App token X
Active

App slot unchanged

Refreshing Web must not overwrite App. A rotated record inherits the original platform. Reusing a Rotated token can be detected and the family handled.

Without IRefreshTokenStore, JwtTokenService uses its legacy single-token path and lacks full family tracking. Production acceptance must inspect actual DI, not merely the interface declaration.

10. Session write and compensation

After creating access and refresh tokens, login does this:

  1. Store platform-isolated refresh token.
  2. Compute SHA-256 hash of access token.
  3. Create a seven-day UserSession with DeviceId, IP, User-Agent, and platform.
  4. Save the session.
  5. Audit login success.

Refresh storage failure returns no token. If session persistence fails, LoginFlow revokes the freshly stored refresh token and records issuance failure. LoginFlowTests pin this compensation.

11. Login-log query

Login audit produced by the flow is exposed through two read-only QUERY endpoints:

  • QUERY /api/login-logs requires authorization (resource identity/loginlog, action View) and supports UserId/UserName plus PageIndex/PageSize; its POST fallback is /api/login-logs/_query.
  • QUERY /api/login-logs/me requires authentication and fixes TenantId/UserId from the current identity; its POST fallback is /api/login-logs/me/_query.

GetLoginLogsQueryRule validates the administrative request before the handler: PageIndex must be at least 1, PageSize must be in 1..1000, and trimmed UserId/UserName filters must be at most 64 characters with no control characters. Whitespace filters become null. Rejection uses Identity.LoginLog.InvalidQuery and does not call the store. The self-service endpoint does not currently have the same request rule; the underlying service normalizes its page through PagingLimits. This boundary still needs convergence.

Hot and archived records are merged as (CreateTime descending, LogId descending) with a maximum combined window of 100000. Pages beyond that window are not a complete long-term browsing contract; use a cursor or a shared hot/cold Query Shape if deeper history is required.

The returned LoginLogDto fields: LogId, UserId, UserName, Result (LoginResult), FailureReason, IpAddress, UserAgent, DeviceInfo, LoginProvider, CreateTime. Result and LoginProvider are enums so the client can classify by success/failure and login channel. Login audit is written by LoginLogService; archival follows a separate retention policy (see Operations backup and archive — LoginLog is one of the two fixed archive policies).

12. Actual password-change and revocation boundaries

OperationCurrent implementationDo not assume
LogoutUses the presented refresh token to revoke its authoritative family; no access token is requiredIt does not remove every device
RevokeSessionRevokes one sessionIt is not password change
RevokeAllSessionsRevokes all user sessionsIt must be invoked explicitly
ChangePasswordChecks old password, policy, history; updates hash and stampIt currently does not call RevokeAllSessions

An expired access token must not trap a still-active refresh cookie. The logout endpoint therefore admits the request without Bearer authentication, but it authenticates the action by possession of the high-entropy refresh token and resolves user and tenant only from the authoritative token record. Cookie-backed logout still requires a same-origin or explicitly allowed Origin; native clients present the refresh token in the body.

A security-stamp change invalidates old access tokens immediately only if the authentication pipeline validates the stamp. JWT normally remains usable until expiry, so high-risk products should compose session and refresh revocation and prove their access-token rejection policy.

Browser login validates the refresh-cookie origin before checking credentials. If the API returns:

"errorCode": "Authentication.Cookie.OriginRejected"

the browser Origin does not match the request origin seen by the API. This is not an invalid-password failure.

CheckRequirement
Address barMust match Frontend__BaseUrl and Cors__AllowedOrigins__0 exactly (scheme + port)
OpenResty → GatewaySet Host and X-Forwarded-Host from $http_host; send X-Forwarded-Proto and X-Forwarded-Port $server_port
API ForwardedHeadersTrust Gateway via KnownProxies; ForwardLimit covers OpenResty + Gateway; place Forwarded Port middleware immediately afterward
Cookie Origin implementationCompares browser Origin to trusted middleware-corrected Scheme://Host (plus explicit allowlist); unverified raw forwarded headers are not an Origin
HTTP previewDevelopment may set Auth__WebRefreshCookie__Secure=false and SameSite=Lax; production HTTPS keeps Secure

After reverse proxy hops, Scheme://Host on the API must become the public entry, or you must list that entry in the origin allowlist. See 1Panel Development Preview and OpenAPI and Scalar documentation surface.

14. Client implementation rules

  • Do not treat HTTP success alone as completed login; inspect requiresCaptcha, requiresMfa, and token fields.
  • Repeat the same identity, credential, device, and tenant context for captcha stage two.
  • Do not store an empty access token before MFA completes.
  • Keep refresh tokens in platform-specific secure storage; never copy between Web and App.
  • Do not reveal user existence for credential, status, or lockout outcomes.
  • When requiresPasswordChange is true, enter controlled password change and explicitly apply the product’s session-revocation policy.
  • On preview deployments, rule out OriginRejected before investigating InvalidCredentials (Demo seed and passwords).

15. Tests and source navigation

Terminal window
# Application-level login state-machine branches.
dotnet test tests/BitzOrcas.Application.Tests/BitzOrcas.Application.Tests.csproj \
--configuration Release \
--filter 'FullyQualifiedName~Identity.LoginFlowTests|FullyQualifiedName~Identity.LoginRiskIntegrationTests|FullyQualifiedName~Identity.VerifyMfaLoginCompletionTests'
# Claims and client-platform isolation.
dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \
--configuration Release \
--filter 'FullyQualifiedName~Identity.TokenServiceClaimsTests|FullyQualifiedName~Identity.PlatformTokenIsolationTests'
# Confirm the deep login module remains the single orchestrator.
rg -n "ILoginFlow|new LoginFlow|GenerateAccessToken" \
src/Platform/Identity src/Hosts/BitzOrcas.Api -g '*.cs'

A new authentication entry should reuse ILoginFlow or document why it owns a different state machine. It must not copy password lockout, MFA, and token issuance code.

Identity overview · Configuration and integrations · Testing and operations

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%