feat(auth): TOTP hash algorithm SHA-1, SHA-256 or SHA-512, default SHA-256
Settings -> Security gets a "TOTP hash algorithm" selector (setting totp_algorithm). It applies to new enrolments only, self-service and forced alike. Each account keeps the algorithm it was issued with in users.totp_algorithm (migration 068, NULL = SHA-1), so switching the setting never breaks an enrolled authenticator and SHA-1 and SHA-2 tokens coexist. Secret length follows RFC 6238 (160/256/512 bit); the otpauth URI carries algorithm= for SHA-2. The enrolment screens warn that Microsoft Authenticator and some built-in phone authenticators only do SHA-1. Disable and admin MFA reset clear the stored algorithm.
This commit is contained in:
+20
-2
@@ -376,7 +376,7 @@ by the upstream IdP and offers no buttons.
|
||||
### Admin reset (lost device)
|
||||
|
||||
Settings → User Management → orange **Reset MFA** button next to the user clears
|
||||
`totp_enabled` + `totp_secret`. The user can then log in with password alone and
|
||||
`totp_enabled`, `totp_secret` and `totp_algorithm`. The user can then log in with password alone and
|
||||
re-enrol via `/settings`. Logged as `MFA_DISABLED` audit event with the admin
|
||||
as actor.
|
||||
|
||||
@@ -384,7 +384,7 @@ as actor.
|
||||
|
||||
| Endpoint | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `POST /auth/mfa/setup` | self | Verify current password → return `secret` + `provisioning_uri` (otpauth://) |
|
||||
| `POST /auth/mfa/setup` | self | Verify current password → return `secret` + `provisioning_uri` (otpauth://) + `algorithm` |
|
||||
| `POST /auth/mfa/activate` | self | Confirm enrolment with a fresh code, flip `totp_enabled` |
|
||||
| `POST /auth/mfa/disable` | self | Disable, requires password |
|
||||
| `POST /auth/mfa/verify` | login flow | Second-factor step after `/auth/login` returns `mfa_required: true` |
|
||||
@@ -393,6 +393,24 @@ as actor.
|
||||
Secrets are stored Fernet-encrypted in `users.totp_secret`. Key in env:
|
||||
`AUTH_PROVIDER_CRYPTO_KEY` — losing it invalidates every stored TOTP enrolment.
|
||||
|
||||
### Hash algorithm (SHA-1 / SHA-256 / SHA-512)
|
||||
|
||||
Settings → Security → **TOTP hash algorithm** (setting `totp_algorithm`,
|
||||
default `SHA256`) picks the algorithm for **new** enrolments only. Each account
|
||||
keeps the one it was issued with in `users.totp_algorithm` (migration 068;
|
||||
NULL = SHA1, everyone enrolled before the choice existed), so switching the
|
||||
setting never breaks an authenticator and SHA-1 and SHA-2 tokens coexist. To
|
||||
move a user to SHA-2: Reset MFA, they re-enrol under the new setting.
|
||||
|
||||
- Secret length follows RFC 6238: 160 bit (SHA-1), 256 bit (SHA-256), 512 bit
|
||||
(SHA-512). The `otpauth://` URI carries `algorithm=` for SHA-2 only.
|
||||
- Microsoft Authenticator and some built-in phone authenticators do SHA-1 only:
|
||||
they import a SHA-2 QR code and show codes that never match. Enrolment
|
||||
catches it (activation needs a valid first code) and the setup screen warns.
|
||||
For such a user, set SHA-1 while they enrol and switch back afterwards.
|
||||
- SHA-1 in HMAC (RFC 4226) has no known practical weakness; SHA-2 is what
|
||||
auditors ask for.
|
||||
|
||||
### Frontend proxy note
|
||||
|
||||
Next.js needs an explicit `route.ts` for every backend path it forwards.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add users.totp_algorithm
|
||||
|
||||
Revision ID: 068
|
||||
Revises: 067
|
||||
Create Date: 2026-11-04 10:00:00.000000
|
||||
|
||||
Hash algorithm a TOTP enrolment was issued with (app/auth/totp.py). Existing
|
||||
rows stay NULL, which means SHA1 — the only algorithm before this column, so
|
||||
every enrolled authenticator keeps working. Idempotent, same shape as 067.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "068"
|
||||
down_revision = "067"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_algorithm VARCHAR(10);")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS totp_algorithm;")
|
||||
@@ -13,6 +13,9 @@ Settings (KV in `settings` table):
|
||||
(SAML/OIDC/LDAP users normally rely on
|
||||
the IdP/AD-side MFA; flip on if you want
|
||||
an additional TOTP layer.)
|
||||
- `totp_algorithm` SHA1 | SHA256 | SHA512 default: SHA256
|
||||
Hash for NEW enrolments only; enrolled
|
||||
accounts keep theirs (app/auth/totp.py).
|
||||
|
||||
If `mfa_enforced=false` the function is a no-op — existing deploys
|
||||
stay unchanged until an admin flips the toggle.
|
||||
@@ -25,6 +28,7 @@ from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.totp import ALGORITHMS
|
||||
from app.models.setting import Setting
|
||||
from app.models.user import AuthProvider, User, UserRole
|
||||
|
||||
@@ -33,6 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
_KEY_ENFORCED = "mfa_enforced"
|
||||
_KEY_ROLES = "mfa_enforced_roles"
|
||||
_KEY_ENFORCE_SSO = "mfa_enforce_sso_users"
|
||||
_KEY_TOTP_ALGORITHM = "totp_algorithm"
|
||||
|
||||
_DEFAULT_ROLES = [UserRole.ADMIN.value, UserRole.EDITOR.value]
|
||||
|
||||
@@ -74,6 +79,12 @@ def enforce_sso_users(db: Session) -> bool:
|
||||
return _bool(_read_setting(db, _KEY_ENFORCE_SSO))
|
||||
|
||||
|
||||
def totp_algorithm(db: Session) -> str:
|
||||
"""Hash algorithm for new TOTP enrolments. Unknown values → SHA256."""
|
||||
value = (_read_setting(db, _KEY_TOTP_ALGORITHM) or "").strip().upper().replace("-", "")
|
||||
return value if value in ALGORITHMS else "SHA256"
|
||||
|
||||
|
||||
def requires_setup(db: Session, user: User) -> bool:
|
||||
"""
|
||||
True when this user MUST enrol TOTP right now before getting a JWT.
|
||||
|
||||
+34
-10
@@ -5,6 +5,12 @@ Used as optional MFA for local-auth users. SSO users get MFA from their IdP
|
||||
and should not enable TOTP here (UI hides the setup button when
|
||||
auth_provider != local).
|
||||
|
||||
Hash algorithm: SHA1, SHA256 or SHA512 (RFC 6238). New enrolments use the
|
||||
`totp_algorithm` setting (app/auth/mfa_policy.py); each account keeps the one
|
||||
it enrolled with in `users.totp_algorithm` (NULL = SHA1, enrolled before the
|
||||
choice existed). Switching the setting therefore never breaks an existing
|
||||
authenticator — SHA-1 and SHA-2 tokens coexist.
|
||||
|
||||
Storage: `users.totp_secret` holds the base32 secret encrypted with Fernet.
|
||||
Key resolution order:
|
||||
1. `AUTH_PROVIDER_CRYPTO_KEY` env var (recommended for production — key
|
||||
@@ -16,6 +22,7 @@ We never log the secret. We never log the key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
@@ -32,6 +39,16 @@ TOTP_DIGITS = 6
|
||||
TOTP_INTERVAL = 30
|
||||
TOTP_VALID_WINDOW = 1 # accept current ± 1 step to tolerate clock skew
|
||||
|
||||
# algorithm -> (digest, base32 secret length). RFC 6238 keys match the hash
|
||||
# output: 160 bit for SHA-1, 256 for SHA-256, 512 for SHA-512 (rounded up to
|
||||
# whole base32 blocks of 8 chars).
|
||||
ALGORITHMS = {
|
||||
"SHA1": (hashlib.sha1, 32),
|
||||
"SHA256": (hashlib.sha256, 56),
|
||||
"SHA512": (hashlib.sha512, 104),
|
||||
}
|
||||
LEGACY_ALGORITHM = "SHA1"
|
||||
|
||||
SETTINGS_KEY_CRYPTO = "auth_provider_crypto_key"
|
||||
|
||||
# Cache the resolved key for the process lifetime. Re-resolved on each restart.
|
||||
@@ -99,9 +116,13 @@ def _fernet() -> Fernet:
|
||||
return Fernet(_resolve_key())
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Generate a new base32 TOTP secret (160-bit)."""
|
||||
return pyotp.random_base32()
|
||||
def _digest(algorithm: Optional[str]):
|
||||
return ALGORITHMS[algorithm or LEGACY_ALGORITHM][0]
|
||||
|
||||
|
||||
def generate_secret(algorithm: str = LEGACY_ALGORITHM) -> str:
|
||||
"""Generate a new base32 TOTP secret sized for the hash algorithm."""
|
||||
return pyotp.random_base32(ALGORITHMS[algorithm][1])
|
||||
|
||||
|
||||
def encrypt_secret(plain_secret: str) -> str:
|
||||
@@ -157,21 +178,24 @@ def decrypt_secret(stored: str) -> str:
|
||||
raise
|
||||
|
||||
|
||||
def provisioning_uri(username: str, plain_secret: str) -> str:
|
||||
"""Generate the otpauth:// URI for QR-code rendering in the UI."""
|
||||
return pyotp.totp.TOTP(plain_secret).provisioning_uri(
|
||||
def provisioning_uri(username: str, plain_secret: str, algorithm: str = LEGACY_ALGORITHM) -> str:
|
||||
"""Generate the otpauth:// URI for QR-code rendering in the UI.
|
||||
pyotp adds `algorithm=` only for non-SHA1 digests, as apps expect."""
|
||||
return pyotp.totp.TOTP(plain_secret, digest=_digest(algorithm)).provisioning_uri(
|
||||
name=username, issuer_name=ISSUER
|
||||
)
|
||||
|
||||
|
||||
def verify(plain_secret: str, code: str) -> bool:
|
||||
"""Verify a 6-digit OTP code against the secret with skew tolerance."""
|
||||
def verify(plain_secret: str, code: str, algorithm: Optional[str] = None) -> bool:
|
||||
"""Verify a 6-digit OTP code against the secret with skew tolerance.
|
||||
`algorithm` None means SHA1 (accounts enrolled before the choice)."""
|
||||
if not code or not plain_secret:
|
||||
return False
|
||||
code = code.strip().replace(" ", "")
|
||||
if len(code) != TOTP_DIGITS or not code.isdigit():
|
||||
return False
|
||||
totp = pyotp.TOTP(plain_secret, digits=TOTP_DIGITS, interval=TOTP_INTERVAL)
|
||||
totp = pyotp.TOTP(plain_secret, digits=TOTP_DIGITS, interval=TOTP_INTERVAL,
|
||||
digest=_digest(algorithm))
|
||||
return totp.verify(code, valid_window=TOTP_VALID_WINDOW)
|
||||
|
||||
|
||||
@@ -199,7 +223,7 @@ def verify_for_user(user, code: str, db=None) -> bool:
|
||||
logger.warning("TOTP replay rejected for user_id=%s", user.id)
|
||||
return False
|
||||
|
||||
if not verify(secret, cleaned):
|
||||
if not verify(secret, cleaned, user.totp_algorithm):
|
||||
return False
|
||||
|
||||
_replay_record(user.id, cleaned)
|
||||
|
||||
@@ -79,6 +79,7 @@ class User(Base, TimestampMixin):
|
||||
# SSO users get MFA from their IdP.
|
||||
totp_secret = Column(String(255), nullable=True) # base32, encrypted at rest
|
||||
totp_enabled = Column(Boolean, default=False, nullable=False)
|
||||
totp_algorithm = Column(String(10), nullable=True) # SHA1|SHA256|SHA512, NULL = SHA1 (app/auth/totp.py)
|
||||
|
||||
# Account Status
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
+26
-19
@@ -34,6 +34,7 @@ from app.services.audit_trail import record_change
|
||||
from app.auth.orchestrator import AuthOrchestrator
|
||||
from app.auth.strategies.base import AuthError
|
||||
from app.auth import totp as totp_helper
|
||||
from app.auth import mfa_policy
|
||||
from app.auth.passkey import enabled as passkeys_enabled, suggested as passkey_suggested
|
||||
from app.auth.token_revocation import revoke as revoke_token
|
||||
from app.models.user import AuthProvider
|
||||
@@ -83,6 +84,7 @@ class MfaSetupRequest(BaseModel):
|
||||
class MfaSetupResponse(BaseModel):
|
||||
secret: str # base32 (one-time, only shown during setup)
|
||||
provisioning_uri: str # otpauth:// for QR rendering
|
||||
algorithm: str # SHA1 | SHA256 | SHA512 — the UI warns about app support
|
||||
|
||||
|
||||
class MfaActivateRequest(BaseModel):
|
||||
@@ -441,6 +443,22 @@ def list_auth_providers():
|
||||
}
|
||||
|
||||
|
||||
def _start_totp_enrolment(db: Session, user: User) -> MfaSetupResponse:
|
||||
"""New secret under the current `totp_algorithm` setting, stored encrypted
|
||||
with its algorithm. totp_enabled stays False until a first code is verified."""
|
||||
algorithm = mfa_policy.totp_algorithm(db)
|
||||
secret = totp_helper.generate_secret(algorithm)
|
||||
user.totp_secret = totp_helper.encrypt_secret(secret)
|
||||
user.totp_algorithm = algorithm
|
||||
user.totp_enabled = False
|
||||
db.commit()
|
||||
return MfaSetupResponse(
|
||||
secret=secret,
|
||||
provisioning_uri=totp_helper.provisioning_uri(user.username, secret, algorithm),
|
||||
algorithm=algorithm,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mfa/setup", response_model=MfaSetupResponse)
|
||||
@limiter.limit("5/minute")
|
||||
def mfa_setup(
|
||||
@@ -473,16 +491,8 @@ def mfa_setup(
|
||||
user_id=current_user.id, request=request)
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
secret = totp_helper.generate_secret()
|
||||
uri = totp_helper.provisioning_uri(current_user.username, secret)
|
||||
|
||||
# Stash encrypted secret pending activation. totp_enabled stays False
|
||||
# until /auth/mfa/activate succeeds.
|
||||
current_user.totp_secret = totp_helper.encrypt_secret(secret)
|
||||
current_user.totp_enabled = False
|
||||
db.commit()
|
||||
|
||||
return MfaSetupResponse(secret=secret, provisioning_uri=uri)
|
||||
# Pending until /auth/mfa/activate succeeds.
|
||||
return _start_totp_enrolment(db, current_user)
|
||||
|
||||
|
||||
@router.post("/mfa/activate")
|
||||
@@ -509,7 +519,7 @@ def mfa_activate(
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="MFA secret unreadable. Restart enrolment.")
|
||||
|
||||
if not totp_helper.verify(secret, payload.code):
|
||||
if not totp_helper.verify(secret, payload.code, current_user.totp_algorithm):
|
||||
log_audit_event(db, AuditEventType.MFA_FAILED, "MFA activation: bad code",
|
||||
user_id=current_user.id, request=request)
|
||||
raise HTTPException(status_code=401, detail="Invalid MFA code")
|
||||
@@ -542,6 +552,7 @@ def mfa_disable(
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
current_user.totp_enabled = False
|
||||
current_user.totp_secret = None
|
||||
current_user.totp_algorithm = None
|
||||
db.commit()
|
||||
log_audit_event(db, AuditEventType.MFA_DISABLED, "MFA disabled",
|
||||
user_id=current_user.id, request=request)
|
||||
@@ -588,12 +599,7 @@ def mfa_forced_setup_start(
|
||||
# is sent back through normal login.
|
||||
raise HTTPException(status_code=400, detail="MFA already enabled — please log in again")
|
||||
|
||||
secret = totp_helper.generate_secret()
|
||||
uri = totp_helper.provisioning_uri(user.username, secret)
|
||||
|
||||
user.totp_secret = totp_helper.encrypt_secret(secret)
|
||||
user.totp_enabled = False
|
||||
db.commit()
|
||||
enrolment = _start_totp_enrolment(db, user)
|
||||
|
||||
log_audit_event(
|
||||
db, AuditEventType.MFA_FAILED, # MFA_PENDING would be better — re-use FAILED until enum migrated
|
||||
@@ -601,7 +607,7 @@ def mfa_forced_setup_start(
|
||||
user_id=user.id, request=request,
|
||||
)
|
||||
|
||||
return MfaSetupResponse(secret=secret, provisioning_uri=uri)
|
||||
return enrolment
|
||||
|
||||
|
||||
@router.post("/mfa/forced-setup/activate", response_model=LoginResponse)
|
||||
@@ -625,7 +631,7 @@ def mfa_forced_setup_activate(
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="MFA secret unreadable. Restart enrolment.")
|
||||
|
||||
if not totp_helper.verify(secret, payload.code):
|
||||
if not totp_helper.verify(secret, payload.code, user.totp_algorithm):
|
||||
log_audit_event(
|
||||
db, AuditEventType.MFA_FAILED,
|
||||
f"Forced MFA activation: bad code for {user.username}",
|
||||
@@ -993,6 +999,7 @@ def admin_reset_mfa(
|
||||
had_mfa = bool(user.totp_enabled or user.totp_secret)
|
||||
user.totp_enabled = False
|
||||
user.totp_secret = None
|
||||
user.totp_algorithm = None
|
||||
passkeys_removed = db.query(Passkey).filter(Passkey.user_id == user.id).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import TotpAlgorithmNote from '@/components/auth/TotpAlgorithmNote';
|
||||
import api from '../../lib/api';
|
||||
|
||||
type Phase = 'starting' | 'verify' | 'done' | 'error';
|
||||
@@ -28,6 +29,7 @@ export default function ForcedMfaSetupPage() {
|
||||
const [phase, setPhase] = useState<Phase>('starting');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [provisioningUri, setProvisioningUri] = useState('');
|
||||
const [algorithm, setAlgorithm] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -50,6 +52,7 @@ export default function ForcedMfaSetupPage() {
|
||||
.then((res) => {
|
||||
setSecret(res.data.secret);
|
||||
setProvisioningUri(res.data.provisioning_uri);
|
||||
setAlgorithm(res.data.algorithm);
|
||||
setPhase('verify');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
@@ -135,6 +138,8 @@ export default function ForcedMfaSetupPage() {
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="mb-4"><TotpAlgorithmNote algorithm={algorithm} /></div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleActivate}>
|
||||
<div>
|
||||
<label htmlFor="code" className="block text-sm font-medium text-gray-900 font-mono">
|
||||
|
||||
@@ -1814,6 +1814,8 @@ export default function SettingsPage() {
|
||||
const [mfaEnforcedRoles, setMfaEnforcedRoles] = useState<string[]>(['admin', 'editor']);
|
||||
const [mfaEnforceSso, setMfaEnforceSso] = useState(false);
|
||||
const [mfaSavingStatus, setMfaSavingStatus] = useState({ message: '', type: '' });
|
||||
// Default mirrors app/auth/mfa_policy.py.
|
||||
const [totpAlgorithm, setTotpAlgorithm] = useState('SHA256');
|
||||
const [smtpConfigured, setSmtpConfigured] = useState(false);
|
||||
const [isSmtpModalOpen, setIsSmtpModalOpen] = useState(false);
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
|
||||
@@ -2162,12 +2164,23 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSecuritySetting = async (key: string, value: string) => {
|
||||
setMfaSavingStatus({ message: 'Saving…', type: 'loading' });
|
||||
try {
|
||||
await api.put(`/api/v1/settings/${key}`, { value });
|
||||
setMfaSavingStatus({ message: 'Saved.', type: 'success' });
|
||||
setTimeout(() => setMfaSavingStatus({ message: '', type: '' }), 2000);
|
||||
} catch (e: any) {
|
||||
setMfaSavingStatus({ message: e?.response?.data?.detail || 'Save failed', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Load the digest template + delivery-schedule/rate-limit settings
|
||||
// (separate from the big loader to keep that destructure stable).
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [dig, sched, hour, delay, cap, lockPerm, lifecycle, mail] = await Promise.all([
|
||||
const [dig, sched, hour, delay, cap, lockPerm, lifecycle, mail, totpAlg] = await Promise.all([
|
||||
api.get('/api/v1/settings/email_template_new_vuln_digest').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/notification_schedule').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/notification_nightly_hour').catch(() => ({ data: { value: null } })),
|
||||
@@ -2176,6 +2189,7 @@ export default function SettingsPage() {
|
||||
api.get('/api/v1/settings/auth_lockout_permanent').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/notification_lifecycle_mode').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/mail_enabled').catch(() => ({ data: { value: null } })),
|
||||
api.get('/api/v1/settings/totp_algorithm').catch(() => ({ data: { value: null } })),
|
||||
]);
|
||||
if (dig.data?.value) { try { const p = JSON.parse(dig.data.value); setDigestTemplate({ subject: p.subject || '', body: p.body || '' }); } catch { } }
|
||||
if (sched.data?.value) setNotificationSchedule(String(sched.data.value).toLowerCase() === 'nightly' ? 'nightly' : 'per_sync');
|
||||
@@ -2185,6 +2199,7 @@ export default function SettingsPage() {
|
||||
if (lockPerm.data?.value != null) setAuthLockoutPermanent(String(lockPerm.data.value).toLowerCase() === 'true');
|
||||
if (lifecycle.data?.value) setNotificationLifecycleMode(String(lifecycle.data.value).toLowerCase() === 'include' ? 'include' : 'exclude');
|
||||
if (mail.data?.value != null) setMailEnabled(String(mail.data.value).toLowerCase() !== 'false');
|
||||
if (totpAlg.data?.value) setTotpAlgorithm(String(totpAlg.data.value).toUpperCase());
|
||||
} catch { /* non-admin 403s → keep defaults */ }
|
||||
})();
|
||||
}, []);
|
||||
@@ -2818,6 +2833,24 @@ export default function SettingsPage() {
|
||||
<span className="ml-2 text-sm font-mono">{mfaEnforceSso ? 'On' : 'Off'}</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
<li className="flex items-center justify-between gap-x-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-6 text-gray-900 font-mono">TOTP hash algorithm</p>
|
||||
<div className="mt-1 text-xs leading-5 text-gray-500">
|
||||
Used for new enrolments only. Enrolled accounts keep the algorithm they were issued with, so changing this never breaks an existing authenticator and SHA-1 and SHA-2 tokens work side by side. Microsoft Authenticator and some built-in phone authenticators support SHA-1 only.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={totpAlgorithm}
|
||||
onChange={(e) => { setTotpAlgorithm(e.target.value); handleSaveSecuritySetting('totp_algorithm', e.target.value); }}
|
||||
className="rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-truevuln-blue sm:text-sm font-mono"
|
||||
>
|
||||
<option value="SHA1">SHA-1</option>
|
||||
<option value="SHA256">SHA-256</option>
|
||||
<option value="SHA512">SHA-512</option>
|
||||
</select>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import api from "../../lib/api";
|
||||
import TotpAlgorithmNote from "./TotpAlgorithmNote";
|
||||
|
||||
type AuthMeResponse = {
|
||||
id: number;
|
||||
@@ -16,6 +17,7 @@ type AuthMeResponse = {
|
||||
type SetupResponse = {
|
||||
secret: string;
|
||||
provisioning_uri: string;
|
||||
algorithm: string;
|
||||
};
|
||||
|
||||
type Stage = "idle" | "setup" | "activate" | "disable";
|
||||
@@ -33,6 +35,7 @@ export default function MfaCard() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [uri, setUri] = useState("");
|
||||
const [algorithm, setAlgorithm] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -53,6 +56,7 @@ export default function MfaCard() {
|
||||
setPassword("");
|
||||
setSecret("");
|
||||
setUri("");
|
||||
setAlgorithm("");
|
||||
setCode("");
|
||||
setErr("");
|
||||
};
|
||||
@@ -65,6 +69,7 @@ export default function MfaCard() {
|
||||
const res = await api.post<SetupResponse>("/auth/mfa/setup", { password });
|
||||
setSecret(res.data.secret);
|
||||
setUri(res.data.provisioning_uri);
|
||||
setAlgorithm(res.data.algorithm);
|
||||
setStage("activate");
|
||||
setPassword("");
|
||||
} catch (e: any) {
|
||||
@@ -221,6 +226,8 @@ export default function MfaCard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TotpAlgorithmNote algorithm={algorithm} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-mono text-gray-700 mb-1">
|
||||
Step 2 — enter the 6-digit code your app shows:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Shown during TOTP enrolment when the token is SHA-256/512 (setting
|
||||
* `totp_algorithm`, app/auth/totp.py). Apps that only do SHA-1 import the QR
|
||||
* without complaint and then show codes that never match.
|
||||
*/
|
||||
export default function TotpAlgorithmNote({ algorithm }: { algorithm?: string }) {
|
||||
if (!algorithm || algorithm === 'SHA1') return null;
|
||||
const label = algorithm.replace('SHA', 'SHA-');
|
||||
return (
|
||||
<div className="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 font-mono">
|
||||
This token uses <strong>{label}</strong>. Microsoft Authenticator and some
|
||||
built-in phone authenticators only support SHA-1 — they import the QR code but show codes that
|
||||
never match. If the first code is rejected, use an app that supports {label}
|
||||
{' '}or ask an admin.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
TOTP hash algorithm: SHA-1, SHA-256 or SHA-512, chosen globally for NEW
|
||||
enrolments (setting `totp_algorithm`, default SHA256). Each account keeps the
|
||||
algorithm it enrolled with, so switching the setting never breaks an existing
|
||||
authenticator — SHA-1 and SHA-2 tokens coexist.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "0" * 64)
|
||||
|
||||
import pyotp # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
from cryptography.fernet import Fernet # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||
|
||||
import app.models # noqa: F401,E402
|
||||
from app.auth import totp # noqa: E402
|
||||
from app.auth.jwt_handler import hash_password # noqa: E402
|
||||
from app.database import get_db # noqa: E402
|
||||
from app.models.base import Base # noqa: E402
|
||||
from app.models.setting import Setting # noqa: E402
|
||||
from app.models.user import User, UserRole # noqa: E402
|
||||
from app.rate_limiter import limiter # noqa: E402
|
||||
from app.routers import auth # noqa: E402
|
||||
|
||||
PASSWORD = "Str0ng!Passw0rd"
|
||||
DIGESTS = {"SHA1": hashlib.sha1, "SHA256": hashlib.sha256, "SHA512": hashlib.sha512}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_PROVIDER_CRYPTO_KEY", Fernet.generate_key().decode())
|
||||
monkeypatch.setattr(totp, "_cached_key", None)
|
||||
totp._replay_cache.clear()
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
db = sessionmaker(bind=engine)()
|
||||
db.add(User(username="alice", email="a@example.com", role=UserRole.EDITOR,
|
||||
is_active=True, password_hash=hash_password(PASSWORD)))
|
||||
db.commit()
|
||||
app = FastAPI()
|
||||
app.state.limiter = limiter
|
||||
limiter.reset()
|
||||
app.include_router(auth.router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
return TestClient(app), db
|
||||
|
||||
|
||||
def _set(db, value):
|
||||
db.add(Setting(key="totp_algorithm", value=value))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _bearer(client) -> dict:
|
||||
token = client.post("/auth/login", json={"username": "alice", "password": PASSWORD}).json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _enrol(client) -> dict:
|
||||
headers = _bearer(client)
|
||||
res = client.post("/auth/mfa/setup", json={"password": PASSWORD}, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
code = pyotp.TOTP(body["secret"], digest=DIGESTS[body["algorithm"]]).now()
|
||||
assert client.post("/auth/mfa/activate", json={"code": code}, headers=headers).status_code == 200
|
||||
return body
|
||||
|
||||
|
||||
def _mfa_login(client, code):
|
||||
mfa_token = client.post("/auth/login", json={"username": "alice", "password": PASSWORD}).json()["mfa_token"]
|
||||
return client.post("/auth/mfa/verify", json={"mfa_token": mfa_token, "code": code})
|
||||
|
||||
|
||||
def test_new_enrolment_defaults_to_sha256(env):
|
||||
client, db = env
|
||||
body = _enrol(client)
|
||||
assert body["algorithm"] == "SHA256"
|
||||
assert "algorithm=SHA256" in body["provisioning_uri"]
|
||||
assert len(body["secret"]) == 56 # 256-bit key per RFC 6238 for SHA-256
|
||||
assert db.query(User).one().totp_algorithm == "SHA256"
|
||||
|
||||
secret = body["secret"]
|
||||
assert _mfa_login(client, pyotp.TOTP(secret).now()).status_code == 401 # SHA-1 code of same key
|
||||
totp._replay_cache.clear()
|
||||
assert _mfa_login(client, pyotp.TOTP(secret, digest=hashlib.sha256).now()).status_code == 200
|
||||
|
||||
|
||||
def test_sha512_enrolment_fits_the_secret_column(env):
|
||||
client, db = env
|
||||
_set(db, "SHA512")
|
||||
body = _enrol(client)
|
||||
assert body["algorithm"] == "SHA512"
|
||||
assert len(body["secret"]) == 104
|
||||
assert len(db.query(User).one().totp_secret) <= 255
|
||||
|
||||
|
||||
def test_sha1_is_still_selectable_and_omits_the_uri_parameter(env):
|
||||
client, db = env
|
||||
_set(db, "sha1")
|
||||
body = _enrol(client)
|
||||
assert body["algorithm"] == "SHA1"
|
||||
assert "algorithm=" not in body["provisioning_uri"]
|
||||
|
||||
|
||||
def test_existing_sha1_token_keeps_working_after_switch(env):
|
||||
"""Enrolled before this change: totp_algorithm is NULL, meaning SHA-1."""
|
||||
client, db = env
|
||||
secret = pyotp.random_base32()
|
||||
user = db.query(User).one()
|
||||
user.totp_secret, user.totp_enabled, user.totp_algorithm = totp.encrypt_secret(secret), True, None
|
||||
db.commit()
|
||||
_set(db, "SHA512")
|
||||
assert _mfa_login(client, pyotp.TOTP(secret).now()).status_code == 200
|
||||
|
||||
|
||||
def test_unknown_setting_falls_back_to_default(env):
|
||||
client, db = env
|
||||
_set(db, "MD5")
|
||||
assert _enrol(client)["algorithm"] == "SHA256"
|
||||
|
||||
|
||||
def test_disable_clears_the_algorithm(env):
|
||||
client, db = env
|
||||
_enrol(client)
|
||||
res = client.post("/auth/mfa/disable", json={"password": PASSWORD}, headers=_bearer_after_mfa(client))
|
||||
assert res.status_code == 200
|
||||
user = db.query(User).one()
|
||||
assert (user.totp_enabled, user.totp_secret, user.totp_algorithm) == (False, None, None)
|
||||
|
||||
|
||||
def _bearer_after_mfa(client) -> dict:
|
||||
# Once MFA is on, login needs the second factor — sign the token directly.
|
||||
from app.auth.jwt_handler import create_access_token
|
||||
return {"Authorization": f"Bearer {create_access_token({'sub': 'alice', 'role': 'editor'})}"}
|
||||
Reference in New Issue
Block a user