Skip to content

Authentication & sessions

Local accounts (User.AuthType.LOCAL) are username + password, authenticated by apps.accounts.backends.LockoutAwareModelBackend. There is no open self-registration. SingleRoleAccountAdapter.is_open_for_signup always returns False, so every account is created by an administrator or provisioned by first OAuth login (below), then activated via a single-use, 24-hour emailed setup link where the user sets their own password.

Enforced server-side via AUTH_PASSWORD_VALIDATORS (config/settings/base.py) regardless of anything shown client-side:

  • 14–128 characters (MinimumLengthValidator / a custom MaximumLengthValidator).
  • Rejected if too similar to the account’s own username/email (UserAttributeSimilarityValidator).
  • Rejected if it’s a known common password (CommonPasswordValidator) or entirely numeric (NumericPasswordValidator).
  • Checked against the Have I Been Pwned breach corpus via k-anonymity, so only a 5-character SHA-1 hash prefix ever leaves the instance and never the password itself (apps.accounts.password_validators.PwnedPasswordValidator). Controlled by PWNED_PASSWORD_CHECK_ENABLED (default true). Set it to false for air-gapped deployments with no outbound internet access.

A client-side password-strength meter (zxcvbn-ts, see Frontend asset builds) gives advisory feedback only. The validators above are what’s actually enforced.

OAUTH_PROVIDER ("google", "microsoft", or empty) enables OAuth sign-in alongside local auth. It’s additive, never a replacement, so local accounts keep working either way.

RedScribe is single-tenant per instance, and this is enforced at the adapter level, not just documentation: OAUTH_ALLOWED_DOMAIN locks sign-in to exactly one email domain. SingleProviderSocialAdapter.pre_social_login (apps/accounts/adapters.py) checks every incoming social login’s email domain against it and raises an immediate redirect back to /login/ with an error message for anything outside it. This check happens before a local account is ever created for that identity, so an out-of-domain Google or Microsoft account can never provision itself an account by mistake.

The first time someone from the allowed domain signs in via OAuth, SingleProviderSocialAdapter.save_user auto-provisions a local User row with auth_type = OAUTH and the Consultant role, the lowest-privilege built-in role. An admin can change their role afterward the same way as any other account. OAuth accounts never set a local password and don’t go through the local password validators above.

Per-provider setup:

Provider Console Redirect URI
Google console.cloud.google.com/apis/credentials, an OAuth 2.0 Client ID of type “Web application” https://<your-domain>/accounts/social/google/login/callback/
Microsoft entra.microsoft.com → App registrations https://<your-domain>/accounts/social/microsoft/login/callback/

MICROSOFT_OAUTH_TENANT_ID defaults to organizations; set it to a specific Entra tenant ID for a stricter, belt-and-braces restriction alongside (not instead of) OAUTH_ALLOWED_DOMAIN.

Every local account must enroll in TOTP MFA, a QR code plus confirmation code on first login, with no way to skip it. Whether MFA is additionally required to be satisfied before using the app is computed per-request by apps.accounts.middleware.mfa_required_for:

mfa_required_for(user) = (
user.auth_type == LOCAL
and (feature_flags.mfa_required OR user.role.requires_mfa)
)
  • The Superadmin role hard-codes requires_mfa = True, so it’s always enforced regardless of the instance-wide toggle.
  • Any other role can independently be marked requires_mfa from Role Management.
  • The mfa_required instance-wide feature flag (default off) applies it to every local account at once when turned on. See the Alpha status note: turning this on is the right call before any real deployment.
  • OAuth accounts are exempt from this check entirely, since MFA there is the identity provider’s own responsibility.

MFAEnforcementMiddleware redirects any request from a user this applies to straight to enrollment (/mfa/enroll/) if they have no confirmed TOTP device yet, or to verification (/mfa/verify/) if they have one but haven’t passed it this session yet. This applies to every URL except the enrollment/verification/logout/session-ping endpoints themselves and static files.

Separately from login lockout below, repeated wrong TOTP codes are rate-limited per account: MFA_THROTTLE_MAX_ATTEMPTS (default 5) within MFA_THROTTLE_WINDOW_SECONDS (default 30). Both are environment-configurable, though not listed in .env.example since the defaults rarely need changing.

LockoutAwareModelBackend checks recent failed attempts (LoginAttempt rows, scoped per-username) before verifying the password, using a short-lived cache-based lock (login-attempt-lock:<username>, held up to 10 seconds) so two concurrent login attempts against the same username can’t race past the threshold check simultaneously.

Two different thresholds apply depending on the account being authenticated:

Non-Superadmin Superadmin
Env vars LOCKOUT_THRESHOLD (default 5), LOCKOUT_DURATION_SECONDS (default 86400 = 24h) SUPERADMIN_RATE_LIMIT_MAX_ATTEMPTS (default 10), SUPERADMIN_RATE_LIMIT_WINDOW_SECONDS (default 60)
Effect once hit Account locked for the full duration Rate-limited for the (much shorter) window, a lighter touch, since a Superadmin account being unreachable is itself an operational risk

The failure count only looks at attempts since the last successful login for that username, so a successful login effectively resets the counter.

The moment a lockout or rate-limit actually triggers (not on every subsequent blocked attempt), every active Superadmin gets an email alert naming the affected username, how many failed attempts, and the source IP. It’s an active notification, not something you’d only discover by later checking the Audit Log.

  • Fixed cap: SESSION_COOKIE_AGE is hard-coded to 4 hours and isn’t separately configurable via environment variable.
  • Idle timeout: SESSION_IDLE_TIMEOUT_SECONDS (default 1800, or 30 minutes, HIPAA’s automatic-logoff figure). IdleTimeoutMiddleware tracks a last_activity_ts value in the session on every non-exempt request and force-logs-out (with a user-facing message) once that gap is exceeded, independent of the fixed 4-hour cap.
  • Single active session per user: on a new successful login, invalidate_other_sessions deletes every other Django Session row (and the app’s own UserSession index row used to find them without decoding every session in the table) belonging to that user, keeping only the one just created. This is timed to apply after MFA is actually satisfied where required, so a login attempt that’s still mid-MFA-challenge doesn’t prematurely kick out an already-authenticated session on another device.

Both use Django’s PasswordResetTokenGenerator machinery (HMAC-signed, single-use, tied to the user’s current password hash so using the link invalidates it for reuse):

  • Password reset: PASSWORD_RESET_TIMEOUT_SECONDS (default 3600, or 1 hour).
  • New-account setup (staff and client-portal alike): a fixed 24-hour window (AccountSetupTokenGenerator.timeout_seconds), not separately configurable.

Both are single-use in practice: consuming a valid token to set a password changes the password hash the token is bound to, so the same link can’t be replayed afterward.