July 4 - Implement TOTP 2FA

This commit is contained in:
2026-07-04 13:40:03 -04:00
parent 07226b4878
commit d87c889ca2
23 changed files with 1336 additions and 10 deletions
+124
View File
@@ -0,0 +1,124 @@
"""
tests/test_mfa.py
-----------------
End-to-end tests for phase35 two-factor authentication (main app).
Drives the real login → challenge flow through the test client and asserts:
* an MFA-enabled account is NOT authenticated until the code step passes
* a correct TOTP completes login; a wrong code does not
* a recovery code works once and is then consumed
* a non-MFA account logs in directly (no challenge, no regression)
* the mfa utility verifies/rejects codes correctly
"""
import pyotp
import pytest
@pytest.fixture
def client(app):
"""Fresh schema + client, rate limiter disabled for deterministic runs."""
from app import db, limiter
limiter.enabled = False
with app.app_context():
db.drop_all()
db.create_all()
yield app.test_client()
db.session.remove()
limiter.enabled = True
def _make_user(username='admin1', role='admin', mfa=False):
from app import db
from app.models.user import User
from app.utils import mfa as mfa_util
u = User(username=username, full_name='Ada Admin',
email=f'{username}@example.com', role=role, active=True,
password_set=True)
u.set_password('pw-correct')
secret = None
if mfa:
secret = mfa_util.new_secret()
u.mfa_secret = secret
u.mfa_enabled = True
# store two known recovery codes (hashed)
u.mfa_recovery_codes = [
__import__('werkzeug.security', fromlist=['generate_password_hash'])
.generate_password_hash(c) for c in ('aaaa-bbbb', 'cccc-dddd')
]
db.session.add(u)
db.session.commit()
return u.id, secret
def _is_authenticated(client):
"""Profile page is login-gated: 200 == authenticated, 302 == not."""
return client.get('/auth/profile').status_code == 200
def test_mfa_user_must_pass_second_factor(client):
uid, secret = _make_user(mfa=True)
# Correct password → redirected to the challenge, NOT yet authenticated.
resp = client.post('/auth/login',
data={'username': 'admin1', 'password': 'pw-correct'})
assert resp.status_code == 302
assert '/auth/mfa' in resp.headers['Location']
assert not _is_authenticated(client)
# Wrong code keeps us out.
bad = client.post('/auth/mfa', data={'code': '000000'})
assert not _is_authenticated(client)
# Correct TOTP completes login.
code = pyotp.TOTP(secret).now()
ok = client.post('/auth/mfa', data={'code': code})
assert ok.status_code == 302
assert _is_authenticated(client)
def test_recovery_code_logs_in_and_is_consumed(client):
uid, secret = _make_user(mfa=True)
client.post('/auth/login', data={'username': 'admin1', 'password': 'pw-correct'})
# Use a recovery code.
r = client.post('/auth/mfa', data={'code': 'aaaa-bbbb', 'recovery': '1'})
assert r.status_code == 302
assert _is_authenticated(client)
# The consumed code no longer works on a fresh login.
client.get('/auth/logout')
client.post('/auth/login', data={'username': 'admin1', 'password': 'pw-correct'})
reuse = client.post('/auth/mfa', data={'code': 'aaaa-bbbb', 'recovery': '1'})
assert not _is_authenticated(client)
# The other, unused code still works.
good = client.post('/auth/mfa', data={'code': 'cccc-dddd', 'recovery': '1'})
assert _is_authenticated(client)
def test_non_mfa_user_logs_in_directly(client):
_make_user(username='pm1', role='project_manager', mfa=False)
resp = client.post('/auth/login',
data={'username': 'pm1', 'password': 'pw-correct'})
assert resp.status_code == 302
assert '/auth/mfa' not in resp.headers['Location']
assert _is_authenticated(client)
def test_wrong_password_never_reaches_challenge(client):
_make_user(mfa=True)
client.post('/auth/login', data={'username': 'admin1', 'password': 'WRONG'})
# No pending challenge, not authenticated.
assert client.get('/auth/mfa').status_code == 302 # bounced back to login
assert not _is_authenticated(client)
def test_mfa_util_verifies_and_rejects():
from app.utils import mfa
s = mfa.new_secret()
assert mfa.verify_totp(s, pyotp.TOTP(s).now()) is True
assert mfa.verify_totp(s, '000000') is False
assert mfa.verify_totp(s, 'not-a-code') is False
assert mfa.verify_totp('', '123456') is False
+55
View File
@@ -0,0 +1,55 @@
"""
tests/test_panel_mfa.py
-----------------------
Behaviour tests for the superadmin-panel two-factor helper (control/mfa.py).
The panel routes are thin wrappers over this module (and mirror app/utils/mfa.py,
which is covered by tests/test_mfa.py). These tests lock the panel helper's
crypto behaviour independently so the control-plane mirror can't silently drift.
Pure logic — no control DB, no MySQL, no Flask app required.
"""
import pyotp
def test_totp_round_trip_and_rejection():
from control import mfa
s = mfa.new_secret()
assert mfa.verify_totp(s, pyotp.TOTP(s).now()) is True
assert mfa.verify_totp(s, '000000') is False
assert mfa.verify_totp(s, 'abc') is False
assert mfa.verify_totp('', '123456') is False
assert mfa.verify_totp(s, None) is False
def test_recovery_codes_are_hashed_single_use():
from control import mfa
plaintext, hashed = mfa.generate_recovery_codes()
assert len(plaintext) == len(hashed) == 10
# Never stored in plaintext.
assert all(p not in hashed for p in plaintext)
matched, remaining = mfa.check_and_consume_recovery(hashed, plaintext[3])
assert matched is True
assert len(remaining) == 9
# Consumed code cannot be reused.
reused, _ = mfa.check_and_consume_recovery(remaining, plaintext[3])
assert reused is False
# A different, unused code still works.
ok, remaining2 = mfa.check_and_consume_recovery(remaining, plaintext[0])
assert ok is True
assert len(remaining2) == 8
def test_provisioning_uri_and_qr_are_well_formed():
from control import mfa
s = mfa.new_secret()
uri = mfa.provisioning_uri(s, 'admin@example.com')
assert uri.startswith('otpauth://totp/')
assert 'JQC%20Admin' in uri or 'JQC Admin' in uri
svg = mfa.qr_svg(uri)
assert svg.lstrip().startswith('<?xml')
assert '<svg' in svg
+133
View File
@@ -0,0 +1,133 @@
"""
tests/test_tenant_isolation.py
------------------------------
Cross-tenant data-isolation guard (MT-1 core mechanism).
The entire database-per-tenant isolation guarantee rests on ONE thing:
`RoutingSession.get_bind()` binding every query to `g.tenant_engine` for the
current request. If a future refactor breaks that — returns a stale engine, or
falls back to the default when a tenant is active — tenant A would silently read
or write tenant B's database. That is the single worst, hardest-to-detect
failure mode for the SaaS, so it gets a dedicated test.
These tests construct two independent in-memory SQLite databases (standing in
for two tenant DBs), seed each with distinct data, then flip `g.tenant_engine`
and assert `db.session` reads and writes land in — and only in — the selected
tenant's database. No MySQL required; the routing logic is engine-agnostic.
"""
import pytest
from flask import g
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from sqlalchemy.orm import Session as SASession
def _make_tenant_engine():
"""A standalone in-memory SQLite engine that persists across connections.
StaticPool keeps a single underlying connection so the in-memory schema and
rows survive between checkouts (a plain sqlite:// memory DB is per-connection
and would appear empty on the next query).
"""
return create_engine(
'sqlite://',
connect_args={'check_same_thread': False},
poolclass=StaticPool,
future=True,
)
@pytest.fixture
def two_tenants(app):
"""Two isolated tenant DBs: A has facility 'ACME-HQ', B has 'Globex-Plant'."""
from app import db
from app.models.facility import Facility
eng_a = _make_tenant_engine()
eng_b = _make_tenant_engine()
with app.app_context():
db.metadata.create_all(eng_a)
db.metadata.create_all(eng_b)
sa = SASession(eng_a)
sa.add(Facility(name='ACME-HQ', active=True))
sa.commit(); sa.close()
sb = SASession(eng_b)
sb.add(Facility(name='Globex-Plant', active=True))
sb.commit(); sb.close()
yield eng_a, eng_b
eng_a.dispose()
eng_b.dispose()
def _facility_names():
from app import db
from app.models.facility import Facility
return {f.name for f in db.session.query(Facility).all()}
def test_reads_are_routed_to_the_active_tenant(app, two_tenants):
"""db.session reads only ever see the tenant bound via g.tenant_engine."""
from app import db
eng_a, eng_b = two_tenants
with app.app_context():
# Bind tenant A.
g.tenant_engine = eng_a
db.session.remove() # force a fresh bind on next query
names = _facility_names()
assert names == {'ACME-HQ'}
assert 'Globex-Plant' not in names # B's data must never leak into A
# Switch to tenant B — same session machinery, different engine.
g.tenant_engine = eng_b
db.session.remove()
names = _facility_names()
assert names == {'Globex-Plant'}
assert 'ACME-HQ' not in names # A's data must never leak into B
def test_writes_do_not_leak_across_tenants(app, two_tenants):
"""A write performed while tenant B is active must not touch tenant A."""
from app import db
from app.models.facility import Facility
eng_a, eng_b = two_tenants
with app.app_context():
# Insert into B.
g.tenant_engine = eng_b
db.session.remove()
db.session.add(Facility(name='Globex-NewSite', active=True))
db.session.commit()
# A must be completely unaffected by B's write.
g.tenant_engine = eng_a
db.session.remove()
names_a = _facility_names()
assert names_a == {'ACME-HQ'}
assert 'Globex-NewSite' not in names_a
# And B genuinely received the new row.
g.tenant_engine = eng_b
db.session.remove()
names_b = _facility_names()
assert 'Globex-NewSite' in names_b
def test_routing_reads_g_dynamically_per_request(app, two_tenants):
"""Re-binding g.tenant_engine within the app context re-routes subsequent
queries — proving get_bind() reads g live, not a cached value."""
from app import db
eng_a, eng_b = two_tenants
with app.app_context():
for engine, expected in ((eng_a, 'ACME-HQ'), (eng_b, 'Globex-Plant'),
(eng_a, 'ACME-HQ')):
g.tenant_engine = engine
db.session.remove()
assert _facility_names() == {expected}