Authentication & sessions
Local authentication
Section titled “Local authentication”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.
Password policy
Section titled “Password policy”Enforced server-side via AUTH_PASSWORD_VALIDATORS
(config/settings/base.py) regardless of anything shown client-side:
- 14–128 characters (
MinimumLengthValidator/ a customMaximumLengthValidator). - 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 byPWNED_PASSWORD_CHECK_ENABLED(defaulttrue). Set it tofalsefor 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 (Google / Microsoft)
Section titled “OAuth (Google / Microsoft)”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 |
|---|---|---|
| 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.
TOTP MFA
Section titled “TOTP MFA”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_mfafrom Role Management. - The
mfa_requiredinstance-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.
MFA verification throttling
Section titled “MFA verification throttling”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.
Login lockout & rate limiting
Section titled “Login lockout & rate limiting”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.
Session lifecycle
Section titled “Session lifecycle”- Fixed cap:
SESSION_COOKIE_AGEis hard-coded to 4 hours and isn’t separately configurable via environment variable. - Idle timeout:
SESSION_IDLE_TIMEOUT_SECONDS(default1800, or 30 minutes, HIPAA’s automatic-logoff figure).IdleTimeoutMiddlewaretracks alast_activity_tsvalue 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_sessionsdeletes every other DjangoSessionrow (and the app’s ownUserSessionindex 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.
Password reset & account setup links
Section titled “Password reset & account setup links”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(default3600, 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.