July 4 - Implement TOTP 2FA
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user