test_security.py 117 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865
  1. """Security tests for the 8 coverage gaps identified in the maintainer review.
  2. Gap 1: encryption.py has zero tests
  3. Gap 2: JWT revocation (revoke_jti, is_jti_revoked, _is_token_fresh) untested
  4. Gap 3: OIDC exchange token replay untested
  5. Gap 4: OIDC email_verified claim handling untested
  6. Gap 5: Email OTP max-attempts invalidation untested
  7. Gap 6: OIDC callback error redirects (SSRF protection) undertested
  8. Gap 7: Login rate limiting untested
  9. Gap 8: challenge_id cookie binding untested
  10. """
  11. from __future__ import annotations
  12. import base64
  13. import secrets
  14. import time
  15. from datetime import datetime, timedelta, timezone
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import jwt as pyjwt
  18. import pytest
  19. from cryptography.hazmat.primitives import serialization
  20. from cryptography.hazmat.primitives.asymmetric import rsa
  21. from httpx import AsyncClient
  22. from sqlalchemy.ext.asyncio import AsyncSession
  23. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  24. from backend.app.models.user import User
  25. AUTH_SETUP_URL = "/api/v1/auth/setup"
  26. LOGIN_URL = "/api/v1/auth/login"
  27. LOGOUT_URL = "/api/v1/auth/logout"
  28. ME_URL = "/api/v1/auth/me"
  29. def _auth_header(token: str) -> dict[str, str]:
  30. return {"Authorization": f"Bearer {token}"}
  31. def _norm_pw(password: str) -> str:
  32. """Ensure password meets complexity requirements (I4: SetupRequest now validates)."""
  33. if not any(c.isupper() for c in password):
  34. password = password[0].upper() + password[1:]
  35. if not any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" for c in password):
  36. password = password + "!"
  37. return password
  38. async def _setup_and_login(client: AsyncClient, username: str, password: str) -> str:
  39. password = _norm_pw(password)
  40. await client.post(
  41. AUTH_SETUP_URL,
  42. json={"auth_enabled": True, "admin_username": username, "admin_password": password},
  43. )
  44. resp = await client.post(LOGIN_URL, json={"username": username, "password": password})
  45. assert resp.status_code == 200
  46. return resp.json()["access_token"]
  47. def _make_test_rsa_key():
  48. def _b64url(n: int, length: int) -> str:
  49. return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
  50. private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  51. private_pem = private_key.private_bytes(
  52. serialization.Encoding.PEM,
  53. serialization.PrivateFormat.TraditionalOpenSSL,
  54. serialization.NoEncryption(),
  55. )
  56. pub_numbers = private_key.public_key().public_numbers()
  57. jwks = {
  58. "keys": [
  59. {
  60. "kty": "RSA",
  61. "use": "sig",
  62. "alg": "RS256",
  63. "kid": "test-kid-1",
  64. "n": _b64url(pub_numbers.n, 256),
  65. "e": _b64url(pub_numbers.e, 3),
  66. }
  67. ]
  68. }
  69. return private_pem, jwks
  70. # ===========================================================================
  71. # Gap 1: encryption.py unit tests
  72. # ===========================================================================
  73. class TestEncryption:
  74. """encrypt/decrypt round-trips, plaintext passthrough, RuntimeError on missing key.
  75. The ``mfa_encryption_isolation`` autouse fixture (conftest.py) resets the
  76. ``encryption`` module's globals before/after each test and points
  77. ``DATA_DIR`` at a tmp path, so individual tests only need to set
  78. ``MFA_ENCRYPTION_KEY`` when they want a specific key in scope.
  79. """
  80. def test_encrypt_decrypt_roundtrip_with_key(self, monkeypatch):
  81. from cryptography.fernet import Fernet
  82. import backend.app.core.encryption as enc_mod
  83. test_key = Fernet.generate_key().decode()
  84. monkeypatch.setenv("MFA_ENCRYPTION_KEY", test_key)
  85. # Force re-initialisation now that the env var is set.
  86. enc_mod._fernet_instance = None
  87. ciphertext = enc_mod.mfa_encrypt("my-totp-secret")
  88. assert ciphertext.startswith("fernet:")
  89. assert enc_mod.mfa_decrypt(ciphertext) == "my-totp-secret"
  90. def test_plaintext_passthrough_without_key(self, monkeypatch):
  91. # Force the auto-bootstrap into the legacy "no key available" branch
  92. # by patching _load_or_generate_key directly. This is more robust than
  93. # chmod tricks (which root bypasses) when verifying the plaintext path.
  94. import backend.app.core.encryption as enc_mod
  95. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  96. enc_mod._fernet_instance = None
  97. result = enc_mod.mfa_encrypt("plaintext-secret")
  98. assert result == "plaintext-secret"
  99. assert enc_mod.mfa_decrypt("plaintext-secret") == "plaintext-secret"
  100. def test_decrypt_raises_runtime_error_without_key_for_encrypted_value(self, monkeypatch):
  101. import backend.app.core.encryption as enc_mod
  102. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  103. enc_mod._fernet_instance = None
  104. with pytest.raises(RuntimeError, match="MFA_ENCRYPTION_KEY must be set"):
  105. enc_mod.mfa_decrypt("fernet:gAAAAA-fake-ciphertext")
  106. # ------------------------------------------------------------------
  107. # Auto-bootstrap tests for _load_or_generate_key
  108. # ------------------------------------------------------------------
  109. def test_load_or_generate_key_uses_env_when_set(self, monkeypatch, tmp_path):
  110. """Valid env var → key_source == 'env', no file written."""
  111. from cryptography.fernet import Fernet
  112. import backend.app.core.encryption as enc_mod
  113. valid_key = Fernet.generate_key().decode()
  114. monkeypatch.setenv("MFA_ENCRYPTION_KEY", valid_key)
  115. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  116. enc_mod._fernet_instance = None
  117. key, source = enc_mod._load_or_generate_key()
  118. assert key == valid_key
  119. assert source == "env"
  120. assert not (tmp_path / ".mfa_encryption_key").exists()
  121. def test_invalid_env_key_falls_through_to_file(self, monkeypatch, tmp_path, caplog):
  122. """Invalid env var → logger.error + file fallback (auto-generated)."""
  123. import logging
  124. import backend.app.core.encryption as enc_mod
  125. monkeypatch.setenv("MFA_ENCRYPTION_KEY", "not-a-valid-fernet-key")
  126. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  127. enc_mod._fernet_instance = None
  128. with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"):
  129. key, source = enc_mod._load_or_generate_key()
  130. assert source == "generated"
  131. assert key is not None
  132. assert (tmp_path / ".mfa_encryption_key").exists()
  133. assert any("not a valid Fernet key" in rec.message for rec in caplog.records)
  134. def test_load_or_generate_key_reads_existing_file(self, monkeypatch, tmp_path):
  135. """File present in DATA_DIR + no env var → key_source == 'file'."""
  136. from cryptography.fernet import Fernet
  137. import backend.app.core.encryption as enc_mod
  138. existing_key = Fernet.generate_key().decode()
  139. key_file = tmp_path / ".mfa_encryption_key"
  140. key_file.write_text(existing_key)
  141. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  142. enc_mod._fernet_instance = None
  143. key, source = enc_mod._load_or_generate_key()
  144. assert key == existing_key
  145. assert source == "file"
  146. def test_load_or_generate_key_creates_file_with_0600(self, monkeypatch, tmp_path):
  147. """Neither env nor file → new key generated, file mode is 0o600."""
  148. import backend.app.core.encryption as enc_mod
  149. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  150. enc_mod._fernet_instance = None
  151. key, source = enc_mod._load_or_generate_key()
  152. assert source == "generated"
  153. assert enc_mod._validate_fernet_key(key)
  154. key_file = tmp_path / ".mfa_encryption_key"
  155. assert key_file.exists()
  156. # Mode bits LSB are 0o600 — owner read+write only.
  157. assert (key_file.stat().st_mode & 0o777) == 0o600
  158. def test_load_or_generate_key_returns_none_on_write_oserror(self, monkeypatch, tmp_path, caplog):
  159. """When DATA_DIR can't be written to (auto-generate path), return (None, 'none_write_failed').
  160. S1: write now uses os.open(O_EXCL|O_CREAT, 0o600) instead of write_text — patch
  161. os.write to simulate the OS-level failure. S8: source distinguishes write-failed
  162. from corrupted to drive accurate operator messaging.
  163. """
  164. import logging
  165. import os
  166. import backend.app.core.encryption as enc_mod
  167. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  168. enc_mod._fernet_instance = None
  169. original_write = os.write
  170. def _raising_write(fd, data):
  171. # Best-effort: trigger OSError specifically for the key write.
  172. raise OSError("simulated read-only filesystem")
  173. monkeypatch.setattr(os, "write", _raising_write)
  174. with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"):
  175. key, source = enc_mod._load_or_generate_key()
  176. # Restore os.write so the rest of the test suite is unaffected.
  177. monkeypatch.setattr(os, "write", original_write)
  178. assert key is None
  179. assert source == "none_write_failed"
  180. assert any("Could not save MFA encryption key" in rec.message for rec in caplog.records)
  181. def test_load_or_generate_key_returns_none_on_read_oserror(self, monkeypatch, tmp_path, caplog):
  182. """B4: existing key file but read fails (e.g. permission denied) → (None, 'none_corrupted').
  183. Critical: must NOT regenerate a new key, which would destroy access to
  184. every row already encrypted under the existing key. S8: 'none_corrupted'
  185. marks the cause so operators see the right diagnostic.
  186. """
  187. import logging
  188. from pathlib import Path
  189. import backend.app.core.encryption as enc_mod
  190. # Pre-create a key file so we hit the existing-file branch.
  191. key_file = tmp_path / ".mfa_encryption_key"
  192. key_file.write_text("placeholder-content")
  193. original_size = key_file.stat().st_size
  194. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  195. enc_mod._fernet_instance = None
  196. original_read_text = Path.read_text
  197. def _raising_read_text(self, *args, **kwargs):
  198. if self.name == ".mfa_encryption_key":
  199. raise OSError("simulated permission denied")
  200. return original_read_text(self, *args, **kwargs)
  201. monkeypatch.setattr(Path, "read_text", _raising_read_text)
  202. with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"):
  203. key, source = enc_mod._load_or_generate_key()
  204. assert key is None
  205. assert source == "none_corrupted"
  206. # Critical: file must not have been overwritten with a new key.
  207. assert key_file.exists()
  208. assert key_file.stat().st_size == original_size
  209. assert any("Failed to read existing MFA key file" in rec.message for rec in caplog.records)
  210. assert any("Refusing to regenerate" in rec.message for rec in caplog.records)
  211. def test_get_key_source_reflects_active_source(self, monkeypatch, tmp_path):
  212. """get_key_source() returns the source detected on the most recent _get_fernet() call."""
  213. from cryptography.fernet import Fernet
  214. import backend.app.core.encryption as enc_mod
  215. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  216. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  217. enc_mod._fernet_instance = None
  218. enc_mod._key_source = None
  219. # Trigger lazy initialisation
  220. enc_mod.mfa_encrypt("anything")
  221. assert enc_mod.get_key_source() == "env"
  222. def test_corrupted_key_file_returns_none_without_overwrite(self, monkeypatch, tmp_path, caplog):
  223. """A1: invalid key file content → (None, 'none_corrupted'), file not overwritten.
  224. S8: 'none_corrupted' (vs 'none_write_failed') so operators get the right
  225. diagnostic and don't see a misleading 'DATA_DIR not writable' warning.
  226. """
  227. import logging
  228. import backend.app.core.encryption as enc_mod
  229. key_file = tmp_path / ".mfa_encryption_key"
  230. key_file.write_text("invalid_content")
  231. original_mtime = key_file.stat().st_mtime
  232. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  233. enc_mod._fernet_instance = None
  234. with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"):
  235. key, source = enc_mod._load_or_generate_key()
  236. assert key is None
  237. assert source == "none_corrupted"
  238. assert key_file.exists(), "file must not be deleted"
  239. assert key_file.stat().st_mtime == original_mtime, "file must not be overwritten"
  240. assert any("not a valid Fernet key" in rec.message for rec in caplog.records)
  241. assert any("Refusing to overwrite" in rec.message for rec in caplog.records)
  242. def test_auto_generate_fileexistserror_returns_none_corrupted(self, monkeypatch, tmp_path, caplog):
  243. """S1: O_EXCL race — file appears between exists() check and open() →
  244. return (None, 'none_corrupted') without overwriting."""
  245. import logging
  246. import os
  247. import backend.app.core.encryption as enc_mod
  248. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  249. enc_mod._fernet_instance = None
  250. original_open = os.open
  251. def _excl_raise(path, flags, mode=0o777):
  252. if str(path).endswith(".mfa_encryption_key") and (flags & os.O_EXCL):
  253. raise FileExistsError(17, "File exists", str(path))
  254. return original_open(path, flags, mode)
  255. monkeypatch.setattr(os, "open", _excl_raise)
  256. with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"):
  257. key, source = enc_mod._load_or_generate_key()
  258. assert key is None
  259. assert source == "none_corrupted"
  260. assert any("Race detected" in rec.message for rec in caplog.records)
  261. # ===========================================================================
  262. # Gap 2: JWT revocation — revoke_jti, is_jti_revoked, _is_token_fresh, /me
  263. # ===========================================================================
  264. class TestJWTRevocation:
  265. """JWT revocation and token freshness checks."""
  266. @pytest.mark.asyncio
  267. @pytest.mark.integration
  268. async def test_revoke_jti_and_is_jti_revoked(self, async_client: AsyncClient, db_session: AsyncSession):
  269. """revoke_jti stores the JTI; is_jti_revoked returns True afterwards."""
  270. from backend.app.core.auth import is_jti_revoked, revoke_jti
  271. test_jti = secrets.token_urlsafe(16)
  272. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  273. assert not await is_jti_revoked(test_jti)
  274. await revoke_jti(test_jti, expires, username="testuser")
  275. assert await is_jti_revoked(test_jti)
  276. @pytest.mark.asyncio
  277. @pytest.mark.integration
  278. async def test_revoke_jti_idempotent(self, async_client: AsyncClient):
  279. """Double-revocation of the same JTI should not raise."""
  280. from backend.app.core.auth import is_jti_revoked, revoke_jti
  281. jti = secrets.token_urlsafe(16)
  282. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  283. await revoke_jti(jti, expires)
  284. await revoke_jti(jti, expires) # must not raise
  285. assert await is_jti_revoked(jti)
  286. def test_is_token_fresh_rejects_none_iat(self):
  287. """_is_token_fresh returns False when iat is None (I1 hard cutoff)."""
  288. from backend.app.core.auth import _is_token_fresh
  289. user = MagicMock()
  290. user.password_changed_at = None
  291. assert _is_token_fresh(None, user) is False
  292. def test_is_token_fresh_rejects_token_before_password_change(self):
  293. """_is_token_fresh returns False when iat predates password_changed_at."""
  294. from backend.app.core.auth import _is_token_fresh
  295. now = datetime.now(timezone.utc)
  296. user = MagicMock()
  297. user.password_changed_at = now
  298. old_iat = (now - timedelta(hours=1)).timestamp()
  299. assert _is_token_fresh(old_iat, user) is False
  300. def test_is_token_fresh_accepts_token_after_password_change(self):
  301. """_is_token_fresh returns True when iat is after password_changed_at."""
  302. from backend.app.core.auth import _is_token_fresh
  303. now = datetime.now(timezone.utc)
  304. user = MagicMock()
  305. user.password_changed_at = now - timedelta(hours=1)
  306. recent_iat = now.timestamp()
  307. assert _is_token_fresh(recent_iat, user) is True
  308. def test_is_token_fresh_returns_true_when_no_password_change(self):
  309. """_is_token_fresh returns True when password_changed_at is None (I2 migration not yet run)."""
  310. from backend.app.core.auth import _is_token_fresh
  311. user = MagicMock()
  312. user.password_changed_at = None
  313. assert _is_token_fresh(time.time(), user) is True
  314. @pytest.mark.asyncio
  315. @pytest.mark.integration
  316. async def test_me_endpoint_rejects_token_after_logout(self, async_client: AsyncClient):
  317. """After logout, the bearer token must be rejected by /me (B1 + revocation)."""
  318. token = await _setup_and_login(async_client, "sec_logout_me", "sec_logout_me1")
  319. # Token works before logout
  320. me_resp = await async_client.get(ME_URL, headers=_auth_header(token))
  321. assert me_resp.status_code == 200
  322. # Logout
  323. logout_resp = await async_client.post(LOGOUT_URL, headers=_auth_header(token))
  324. assert logout_resp.status_code == 200
  325. # Token must now be rejected
  326. me_after = await async_client.get(ME_URL, headers=_auth_header(token))
  327. assert me_after.status_code == 401
  328. # ===========================================================================
  329. # Gap 3: OIDC exchange token replay
  330. # ===========================================================================
  331. class TestOIDCExchangeReplay:
  332. """A single-use OIDC exchange token cannot be redeemed twice."""
  333. @pytest.mark.asyncio
  334. @pytest.mark.integration
  335. async def test_exchange_token_is_single_use(self, async_client: AsyncClient, db_session: AsyncSession):
  336. """The second call to /oidc/exchange with the same token returns 401."""
  337. exchange_token = secrets.token_urlsafe(32)
  338. # Seed the user so the exchange can resolve it
  339. from backend.app.core.auth import get_password_hash
  340. from backend.app.core.database import async_session, seed_default_groups
  341. async with async_session() as db:
  342. result = await db.execute(__import__("sqlalchemy").select(User).where(User.username == "oidc_replay_user"))
  343. if result.scalar_one_or_none() is None:
  344. db.add(
  345. User(
  346. username="oidc_replay_user",
  347. password_hash=get_password_hash("pw"),
  348. is_active=True,
  349. )
  350. )
  351. await db.commit()
  352. # Minted after the user exists, as the OIDC callback does (a token from
  353. # before the user's session began is refused).
  354. db_session.add(
  355. AuthEphemeralToken(
  356. token=exchange_token,
  357. token_type="oidc_exchange",
  358. username="oidc_replay_user",
  359. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  360. )
  361. )
  362. await db_session.commit()
  363. first = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": exchange_token})
  364. assert first.status_code == 200
  365. second = await async_client.post("/api/v1/auth/oidc/exchange", json={"oidc_token": exchange_token})
  366. assert second.status_code == 401
  367. # ===========================================================================
  368. # Gap 4: OIDC email_verified claim handling
  369. # ===========================================================================
  370. class TestOIDCEmailVerified:
  371. """email_verified: False/absent must not link OIDC identity to an existing email."""
  372. @pytest.mark.asyncio
  373. @pytest.mark.integration
  374. async def test_unverified_email_does_not_link_to_existing_user(
  375. self, async_client: AsyncClient, db_session: AsyncSession
  376. ):
  377. """If email_verified is False, the OIDC callback must not auto-link by email."""
  378. private_pem, jwks_data = _make_test_rsa_key()
  379. issuer = "https://idp.evtest.example.com"
  380. client_id = "ev-client"
  381. nonce = secrets.token_urlsafe(16)
  382. now = int(time.time())
  383. id_token = pyjwt.encode(
  384. {
  385. "sub": "ev-sub-new",
  386. "iss": issuer,
  387. "aud": client_id,
  388. "nonce": nonce,
  389. "email": "existing@example.com",
  390. "email_verified": False, # <-- must be ignored
  391. "iat": now,
  392. "exp": now + 300,
  393. },
  394. private_pem,
  395. algorithm="RS256",
  396. headers={"kid": "test-kid-1"},
  397. )
  398. admin_token = await _setup_and_login(async_client, "ev_admin", "ev_admin1")
  399. # Create existing user with the same email (use strong password for validator)
  400. create_user_resp = await async_client.post(
  401. "/api/v1/users",
  402. json={"username": "existing_email_user", "password": "Str0ng!Pass", "email": "existing@example.com"},
  403. headers=_auth_header(admin_token),
  404. )
  405. assert create_user_resp.status_code in (200, 201), create_user_resp.json()
  406. # Create OIDC provider
  407. create_resp = await async_client.post(
  408. "/api/v1/auth/oidc/providers",
  409. json={
  410. "name": "EV-IdP",
  411. "issuer_url": issuer,
  412. "client_id": client_id,
  413. "client_secret": "secret",
  414. "scopes": "openid email",
  415. "is_enabled": True,
  416. "auto_create_users": True,
  417. },
  418. headers=_auth_header(admin_token),
  419. )
  420. assert create_resp.status_code == 201
  421. provider_id = create_resp.json()["id"]
  422. state = secrets.token_urlsafe(32)
  423. code_verifier = secrets.token_urlsafe(48)
  424. db_session.add(
  425. AuthEphemeralToken(
  426. token=state,
  427. token_type="oidc_state",
  428. provider_id=provider_id,
  429. nonce=nonce,
  430. code_verifier=code_verifier,
  431. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  432. )
  433. )
  434. await db_session.commit()
  435. discovery_doc = {
  436. "issuer": issuer,
  437. "authorization_endpoint": f"{issuer}/auth",
  438. "token_endpoint": f"{issuer}/token",
  439. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  440. }
  441. class _MockResp:
  442. def __init__(self, data):
  443. self._data = data
  444. self.status_code = 200
  445. self.is_success = True
  446. self.text = str(data)
  447. def json(self):
  448. return self._data
  449. def raise_for_status(self):
  450. pass
  451. class _MockHttpxClientEV:
  452. def __init__(self, *args, **kwargs):
  453. pass
  454. async def __aenter__(self):
  455. return self
  456. async def __aexit__(self, *_):
  457. pass
  458. async def get(self, url, **kwargs):
  459. if "jwks" in url:
  460. return _MockResp(jwks_data)
  461. return _MockResp(discovery_doc)
  462. async def post(self, url, **kwargs):
  463. return _MockResp({"access_token": "mock", "token_type": "Bearer", "id_token": id_token})
  464. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClientEV):
  465. await async_client.get(
  466. f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
  467. follow_redirects=False,
  468. )
  469. # Callback must NOT link to the existing_email_user — a new user is created
  470. # instead (because the email claim was ignored due to email_verified=False).
  471. # Either a new user is provisioned (redirect with oidc_token) or the callback
  472. # fails. In either case, the existing user must not have an OIDC link.
  473. from sqlalchemy import select as sa_select
  474. from backend.app.models.oidc_provider import UserOIDCLink
  475. link_result = await db_session.execute(
  476. sa_select(UserOIDCLink)
  477. .join(User, UserOIDCLink.user_id == User.id)
  478. .where(User.email == "existing@example.com")
  479. )
  480. link = link_result.scalar_one_or_none()
  481. assert link is None, "Existing user must not be auto-linked when email_verified is False"
  482. # ===========================================================================
  483. # Gap 5: Email OTP max-attempts invalidation
  484. # ===========================================================================
  485. class TestEmailOTPMaxAttempts:
  486. """After MAX_ATTEMPTS wrong codes, the OTP is permanently invalidated."""
  487. @pytest.mark.asyncio
  488. @pytest.mark.integration
  489. async def test_email_otp_invalidated_after_max_attempts(self, async_client: AsyncClient, db_session: AsyncSession):
  490. from passlib.context import CryptContext
  491. from sqlalchemy import select as sa_select
  492. from backend.app.models.user_otp_code import UserOTPCode
  493. _pwd_ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
  494. admin_token = await _setup_and_login(async_client, "otp_max_admin", "otp_max_admin1")
  495. # Enable email OTP for admin user
  496. result = await db_session.execute(sa_select(User).where(User.username == "otp_max_admin"))
  497. user = result.scalar_one()
  498. user.email = "otpmax@example.com"
  499. await db_session.commit()
  500. setup_code = "123456"
  501. from backend.app.models.auth_ephemeral import AuthEphemeralToken as AET
  502. setup_token = secrets.token_urlsafe(32)
  503. db_session.add(
  504. AET(
  505. token=setup_token,
  506. token_type="email_otp_setup",
  507. username="otp_max_admin",
  508. nonce=_pwd_ctx.hash(setup_code),
  509. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  510. )
  511. )
  512. await db_session.commit()
  513. await async_client.post(
  514. "/api/v1/auth/2fa/email/enable/confirm",
  515. json={"setup_token": setup_token, "code": setup_code},
  516. headers=_auth_header(admin_token),
  517. )
  518. # Login to get pre_auth_token
  519. login_resp = await async_client.post(
  520. LOGIN_URL, json={"username": "otp_max_admin", "password": "Otp_max_admin1"}
  521. )
  522. pre_auth_token = login_resp.json()["pre_auth_token"]
  523. # Insert an OTP record directly (bypassing SMTP)
  524. real_code = "654321"
  525. otp = UserOTPCode(
  526. user_id=user.id,
  527. code_hash=_pwd_ctx.hash(real_code),
  528. attempts=0,
  529. used=False,
  530. expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
  531. )
  532. db_session.add(otp)
  533. await db_session.commit()
  534. # Submit MAX_ATTEMPTS wrong codes
  535. from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS
  536. for _ in range(MAX_2FA_ATTEMPTS):
  537. r = await async_client.post(
  538. "/api/v1/auth/2fa/verify",
  539. json={"pre_auth_token": pre_auth_token, "code": "000000", "method": "email"},
  540. )
  541. # Each attempt must fail with 401
  542. assert r.status_code == 401
  543. # After max attempts, the correct code is also rejected (either OTP
  544. # invalidated → 401, or rate limit hit → 429). Either means locked out.
  545. final = await async_client.post(
  546. "/api/v1/auth/2fa/verify",
  547. json={"pre_auth_token": pre_auth_token, "code": real_code, "method": "email"},
  548. )
  549. assert final.status_code in (401, 429), f"Expected lockout, got {final.status_code}: {final.json()}"
  550. # ===========================================================================
  551. # Gap 6: OIDC callback SSRF protection — invalid authorization_endpoint scheme
  552. # ===========================================================================
  553. class TestOIDCSSRFProtection:
  554. """authorization_endpoint with non-http(s) scheme must be rejected."""
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_invalid_authorization_endpoint_scheme_rejected(
  558. self, async_client: AsyncClient, db_session: AsyncSession
  559. ):
  560. issuer = "https://idp.ssrf.example.com"
  561. client_id = "ssrf-client"
  562. admin_token = await _setup_and_login(async_client, "ssrf_admin", "ssrf_admin1")
  563. create_resp = await async_client.post(
  564. "/api/v1/auth/oidc/providers",
  565. json={
  566. "name": "SSRF-IdP",
  567. "issuer_url": issuer,
  568. "client_id": client_id,
  569. "client_secret": "secret",
  570. "scopes": "openid",
  571. "is_enabled": True,
  572. "auto_create_users": False,
  573. },
  574. headers=_auth_header(admin_token),
  575. )
  576. assert create_resp.status_code == 201
  577. provider_id = create_resp.json()["id"]
  578. # Discovery doc returns a javascript: authorization_endpoint
  579. malicious_discovery = {
  580. "issuer": issuer,
  581. "authorization_endpoint": "javascript:alert(1)", # <-- malicious
  582. "token_endpoint": f"{issuer}/token",
  583. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  584. }
  585. class _MockResp:
  586. def __init__(self, data):
  587. self._data = data
  588. self.status_code = 200
  589. self.is_success = True
  590. self.text = str(data)
  591. def json(self):
  592. return self._data
  593. def raise_for_status(self):
  594. pass
  595. class _MockHttpxClientSSRF:
  596. def __init__(self, *args, **kwargs):
  597. pass
  598. async def __aenter__(self):
  599. return self
  600. async def __aexit__(self, *_):
  601. pass
  602. async def get(self, url, **kwargs):
  603. return _MockResp(malicious_discovery)
  604. async def post(self, url, **kwargs):
  605. return _MockResp({})
  606. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", _MockHttpxClientSSRF):
  607. # oidc_authorize uses a path parameter, not query param
  608. authorize_resp = await async_client.get(
  609. f"/api/v1/auth/oidc/authorize/{provider_id}",
  610. follow_redirects=False,
  611. )
  612. # Must be rejected with 502 — B2 guard rejects invalid authorization_endpoint scheme
  613. assert authorize_resp.status_code == 502, authorize_resp.json()
  614. detail = authorize_resp.json().get("detail", "").lower()
  615. assert "authorization_endpoint" in detail or "invalid" in detail
  616. # ===========================================================================
  617. # Gap 7: Login rate limiting
  618. # ===========================================================================
  619. class TestLoginRateLimiting:
  620. """10+ failed logins for the same username must return 429."""
  621. @pytest.mark.asyncio
  622. @pytest.mark.integration
  623. async def test_excessive_failed_logins_return_429(self, async_client: AsyncClient):
  624. from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS
  625. # Setup auth but do NOT log in
  626. await async_client.post(
  627. AUTH_SETUP_URL,
  628. json={"auth_enabled": True, "admin_username": "ratelimit_user", "admin_password": "Ratelimit_pw1"},
  629. )
  630. status_codes = []
  631. for _ in range(MAX_LOGIN_ATTEMPTS + 2):
  632. resp = await async_client.post(
  633. LOGIN_URL,
  634. json={"username": "ratelimit_user", "password": "wrong_password"},
  635. )
  636. status_codes.append(resp.status_code)
  637. # The last attempts must be 429 (Too Many Requests)
  638. assert status_codes[-1] == 429, f"Expected 429 after {MAX_LOGIN_ATTEMPTS} failures, got: {status_codes}"
  639. # ===========================================================================
  640. # Gap 8: challenge_id cookie binding
  641. # ===========================================================================
  642. class TestChallengeIdCookieBinding:
  643. """A pre-auth token stolen from session A cannot be used from session B."""
  644. @pytest.mark.asyncio
  645. @pytest.mark.integration
  646. async def test_pre_auth_token_rejected_without_matching_cookie(
  647. self, async_client: AsyncClient, db_session: AsyncSession
  648. ):
  649. import pyotp
  650. from passlib.context import CryptContext
  651. _pwd_ctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
  652. # Set up user with TOTP
  653. await _setup_and_login(async_client, "cookie_bind_user", "cookie_bind_pw1")
  654. secret = pyotp.random_base32()
  655. totp_obj = pyotp.TOTP(secret)
  656. from sqlalchemy import select as sa_select
  657. from backend.app.models.user_totp import UserTOTP
  658. result = await db_session.execute(sa_select(User).where(User.username == "cookie_bind_user"))
  659. user = result.scalar_one()
  660. db_session.add(UserTOTP(user_id=user.id, secret=secret, is_enabled=True))
  661. await db_session.commit()
  662. # Login from "session A" — gets a pre_auth_token and a 2fa_challenge cookie
  663. login_resp = await async_client.post(
  664. LOGIN_URL, json={"username": "cookie_bind_user", "password": "Cookie_bind_pw1"}
  665. )
  666. assert login_resp.status_code == 200
  667. assert login_resp.json()["requires_2fa"] is True
  668. pre_auth_token = login_resp.json()["pre_auth_token"]
  669. # The async_client jar now holds the 2fa_challenge cookie for session A
  670. # Simulate session B by creating a new client WITHOUT the cookie
  671. from httpx import ASGITransport, AsyncClient as FreshClient
  672. from backend.app.main import app
  673. async with FreshClient(transport=ASGITransport(app=app), base_url="http://test") as session_b:
  674. # Attempt to use session A's pre_auth_token from session B (no cookie)
  675. verify_resp = await session_b.post(
  676. "/api/v1/auth/2fa/verify",
  677. json={
  678. "pre_auth_token": pre_auth_token,
  679. "code": totp_obj.now(),
  680. "method": "totp",
  681. },
  682. )
  683. # Must be rejected — pre_auth_token is bound to session A's cookie
  684. assert verify_resp.status_code == 401, (
  685. f"Expected 401 for token replay from cookieless session, got {verify_resp.status_code}: "
  686. f"{verify_resp.json()}"
  687. )
  688. # ===========================================================================
  689. # C2: Security-header middleware
  690. # ===========================================================================
  691. class TestSecurityHeaders:
  692. """Every HTTP response must include standard security headers (C2)."""
  693. @pytest.mark.asyncio
  694. @pytest.mark.integration
  695. async def test_security_headers_present(self, async_client: AsyncClient):
  696. """GET /api/v1/auth/me (unauthenticated → 401) still carries security headers."""
  697. resp = await async_client.get(ME_URL)
  698. assert resp.status_code == 401 # sanity — no auth token
  699. assert resp.headers.get("x-content-type-options") == "nosniff"
  700. assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
  701. assert resp.headers.get("referrer-policy") == "strict-origin-when-cross-origin"
  702. csp = resp.headers.get("content-security-policy", "")
  703. assert "default-src 'self'" in csp
  704. assert "script-src 'self'" in csp
  705. assert "frame-ancestors 'none'" in csp
  706. assert "object-src 'none'" in csp
  707. @pytest.mark.asyncio
  708. @pytest.mark.integration
  709. async def test_hsts_absent_for_http(self, async_client: AsyncClient):
  710. """HSTS must NOT be set over plain HTTP (test transport uses http)."""
  711. resp = await async_client.get(ME_URL)
  712. assert "strict-transport-security" not in resp.headers
  713. # ===========================================================================
  714. # I3: Rate-limit bucket interaction — IP spray vs. username spray
  715. # ===========================================================================
  716. class TestRateLimitBuckets:
  717. """IP-spray and username-spray must each trip the correct independent bucket."""
  718. @pytest.mark.asyncio
  719. @pytest.mark.integration
  720. async def test_ip_spray_trips_ip_bucket(self, async_client: AsyncClient):
  721. """20 failed logins from one IP across 20 different usernames trips the IP bucket.
  722. Each per-username bucket only has 1 failure (well below MAX_LOGIN_ATTEMPTS=10),
  723. so the username bucket is never the reason for the 429.
  724. """
  725. from unittest.mock import patch as _patch
  726. unique_ip = "10.99.1.1"
  727. # Ensure auth is enabled
  728. await async_client.post(
  729. AUTH_SETUP_URL,
  730. json={"auth_enabled": True, "admin_username": "spray_ip_admin", "admin_password": "SprayIp_admin1"},
  731. )
  732. status_codes: list[int] = []
  733. with _patch("backend.app.api.routes.auth._get_client_ip", return_value=unique_ip):
  734. for i in range(22):
  735. resp = await async_client.post(
  736. LOGIN_URL,
  737. json={"username": f"spray_ip_victim_{i}", "password": "wrong"},
  738. )
  739. status_codes.append(resp.status_code)
  740. # The first 20 attempts fail with 401; the 21st+ must be 429 (IP bucket full)
  741. assert status_codes[-1] == 429, f"Expected 429 after 20 IP-spray failures, got: {status_codes}"
  742. # No single username saw more than one attempt → username buckets not tripped
  743. non_429 = [c for c in status_codes[:-2] if c == 429]
  744. assert not non_429, f"Username bucket triggered early: {status_codes}"
  745. @pytest.mark.asyncio
  746. @pytest.mark.integration
  747. async def test_username_spray_trips_username_bucket(self, async_client: AsyncClient):
  748. """One username targeted from 10+ different IPs trips the username bucket.
  749. Each per-IP bucket only sees 1 failure, so no IP bucket is tripped.
  750. The username bucket (max 10) is what fires the 429.
  751. """
  752. from unittest.mock import patch as _patch
  753. from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS
  754. # Ensure auth is enabled
  755. await async_client.post(
  756. AUTH_SETUP_URL,
  757. json={
  758. "auth_enabled": True,
  759. "admin_username": "spray_uname_admin",
  760. "admin_password": "SprayUname_admin1",
  761. },
  762. )
  763. target_username = "spray_uname_victim"
  764. status_codes: list[int] = []
  765. for i in range(MAX_LOGIN_ATTEMPTS + 2):
  766. rotating_ip = f"10.99.2.{i + 1}"
  767. with _patch("backend.app.api.routes.auth._get_client_ip", return_value=rotating_ip):
  768. resp = await async_client.post(
  769. LOGIN_URL,
  770. json={"username": target_username, "password": "wrong"},
  771. )
  772. status_codes.append(resp.status_code)
  773. # After MAX_LOGIN_ATTEMPTS failures for same username the bucket fires
  774. assert status_codes[-1] == 429, (
  775. f"Expected 429 after {MAX_LOGIN_ATTEMPTS} username-spray failures, got: {status_codes}"
  776. )
  777. # ============================================================================
  778. # TestEncryptLegacyMigration
  779. # ============================================================================
  780. class TestEncryptLegacyMigration:
  781. """Re-encryption migration of legacy plaintext OIDC + TOTP rows.
  782. The migration runs against its own ``async_session`` factory (not the
  783. ``db_session`` fixture) so each test patches the module-level factory to
  784. point at the test-engine before invoking the helper. ``db_session`` is
  785. used to seed and to verify state via the same engine.
  786. """
  787. @staticmethod
  788. def _patch_module_session(monkeypatch, db_session):
  789. """Bind ``database.async_session`` to the test engine for one test."""
  790. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  791. from backend.app.core import database as db_mod
  792. test_factory = async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
  793. monkeypatch.setattr(db_mod, "async_session", test_factory)
  794. @staticmethod
  795. def _set_active_key(monkeypatch):
  796. """Configure a valid Fernet key for the migration to use."""
  797. from cryptography.fernet import Fernet
  798. import backend.app.core.encryption as enc_mod
  799. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  800. enc_mod._fernet_instance = None
  801. @pytest.mark.asyncio
  802. @pytest.mark.integration
  803. async def test_migration_encrypts_plaintext_oidc_secret(self, db_session, monkeypatch):
  804. from sqlalchemy import select
  805. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  806. from backend.app.models.oidc_provider import OIDCProvider
  807. self._patch_module_session(monkeypatch, db_session)
  808. self._set_active_key(monkeypatch)
  809. provider = OIDCProvider(
  810. name="LegacyProv",
  811. issuer_url="https://legacy.example.com",
  812. client_id="cid",
  813. _client_secret_enc="legacy-plaintext",
  814. scopes="openid email profile",
  815. is_enabled=True,
  816. )
  817. db_session.add(provider)
  818. await db_session.commit()
  819. await _migrate_encrypt_legacy_secrets()
  820. # Re-fetch on a fresh row state
  821. await db_session.refresh(provider)
  822. assert provider._client_secret_enc.startswith("fernet:")
  823. # Decrypted value matches the original plaintext
  824. assert provider.client_secret == "legacy-plaintext"
  825. # Sanity: a SELECT also sees the encrypted value
  826. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == provider.id))
  827. fetched = result.scalar_one()
  828. assert fetched._client_secret_enc.startswith("fernet:")
  829. @pytest.mark.asyncio
  830. @pytest.mark.integration
  831. async def test_migration_skips_already_encrypted_rows(self, db_session, monkeypatch):
  832. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  833. from backend.app.models.oidc_provider import OIDCProvider
  834. self._patch_module_session(monkeypatch, db_session)
  835. self._set_active_key(monkeypatch)
  836. # Use the property setter so the value is encrypted up front.
  837. provider = OIDCProvider(
  838. name="EncProv",
  839. issuer_url="https://enc.example.com",
  840. client_id="cid",
  841. client_secret="already-encrypted",
  842. scopes="openid email profile",
  843. is_enabled=True,
  844. )
  845. db_session.add(provider)
  846. await db_session.commit()
  847. original_enc = provider._client_secret_enc
  848. await _migrate_encrypt_legacy_secrets()
  849. await _migrate_encrypt_legacy_secrets() # idempotent
  850. await db_session.refresh(provider)
  851. # Value unchanged across two migration runs (still the same ciphertext).
  852. assert provider._client_secret_enc == original_enc
  853. @pytest.mark.asyncio
  854. @pytest.mark.integration
  855. async def test_migration_no_op_when_key_unset(self, db_session, monkeypatch):
  856. import backend.app.core.encryption as enc_mod
  857. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  858. from backend.app.models.oidc_provider import OIDCProvider
  859. self._patch_module_session(monkeypatch, db_session)
  860. # Force "no key" branch
  861. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  862. enc_mod._fernet_instance = None
  863. provider = OIDCProvider(
  864. name="NoKeyProv",
  865. issuer_url="https://nokey.example.com",
  866. client_id="cid",
  867. _client_secret_enc="still-plaintext",
  868. scopes="openid email profile",
  869. is_enabled=True,
  870. )
  871. db_session.add(provider)
  872. await db_session.commit()
  873. await _migrate_encrypt_legacy_secrets()
  874. await db_session.refresh(provider)
  875. # Migration should have early-returned; plaintext untouched.
  876. assert provider._client_secret_enc == "still-plaintext"
  877. @pytest.mark.asyncio
  878. @pytest.mark.integration
  879. async def test_migration_handles_mixed_state(self, db_session, monkeypatch):
  880. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  881. from backend.app.models.oidc_provider import OIDCProvider
  882. self._patch_module_session(monkeypatch, db_session)
  883. self._set_active_key(monkeypatch)
  884. legacy = OIDCProvider(
  885. name="LegacyMix",
  886. issuer_url="https://l.example.com",
  887. client_id="c1",
  888. _client_secret_enc="plain-mix",
  889. scopes="openid email profile",
  890. )
  891. encrypted = OIDCProvider(
  892. name="EncMix",
  893. issuer_url="https://e.example.com",
  894. client_id="c2",
  895. client_secret="encrypted-mix", # uses setter
  896. scopes="openid email profile",
  897. )
  898. db_session.add_all([legacy, encrypted])
  899. await db_session.commit()
  900. original_encrypted = encrypted._client_secret_enc
  901. await _migrate_encrypt_legacy_secrets()
  902. await db_session.refresh(legacy)
  903. await db_session.refresh(encrypted)
  904. assert legacy._client_secret_enc.startswith("fernet:")
  905. assert legacy.client_secret == "plain-mix"
  906. assert encrypted._client_secret_enc == original_encrypted
  907. @pytest.mark.asyncio
  908. @pytest.mark.integration
  909. async def test_migration_encrypts_plaintext_totp_secret(self, db_session, monkeypatch):
  910. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  911. from backend.app.models.user import User
  912. from backend.app.models.user_totp import UserTOTP
  913. self._patch_module_session(monkeypatch, db_session)
  914. self._set_active_key(monkeypatch)
  915. user = User(username="totpuser1219", email="t@example.com", password_hash="x")
  916. db_session.add(user)
  917. await db_session.flush()
  918. totp = UserTOTP(user_id=user.id, _secret_enc="JBSWY3DPEHPK3PXP", is_enabled=True)
  919. db_session.add(totp)
  920. await db_session.commit()
  921. await _migrate_encrypt_legacy_secrets()
  922. await db_session.refresh(totp)
  923. assert totp._secret_enc.startswith("fernet:")
  924. assert totp.secret == "JBSWY3DPEHPK3PXP"
  925. @pytest.mark.asyncio
  926. @pytest.mark.integration
  927. async def test_migration_logs_count_of_rows_re_encrypted(self, db_session, monkeypatch, caplog):
  928. import logging
  929. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  930. from backend.app.models.oidc_provider import OIDCProvider
  931. from backend.app.models.user import User
  932. from backend.app.models.user_totp import UserTOTP
  933. self._patch_module_session(monkeypatch, db_session)
  934. self._set_active_key(monkeypatch)
  935. provider = OIDCProvider(
  936. name="LegacyLog",
  937. issuer_url="https://log.example.com",
  938. client_id="c",
  939. _client_secret_enc="p",
  940. scopes="openid email profile",
  941. )
  942. user = User(username="logger1219", email="l@example.com", password_hash="x")
  943. db_session.add_all([provider, user])
  944. await db_session.flush()
  945. totp = UserTOTP(user_id=user.id, _secret_enc="JBSWY3DPEHPK3PXP", is_enabled=True)
  946. db_session.add(totp)
  947. await db_session.commit()
  948. with caplog.at_level(logging.INFO, logger="backend.app.core.database"):
  949. await _migrate_encrypt_legacy_secrets()
  950. # The migration logs once with both counts.
  951. assert any(
  952. "Re-encrypted legacy plaintext secrets" in rec.message
  953. and "1 OIDC client_secret(s)" in rec.message
  954. and "1 TOTP secret(s)" in rec.message
  955. for rec in caplog.records
  956. )
  957. @pytest.mark.asyncio
  958. @pytest.mark.integration
  959. async def test_migration_continues_on_row_error(self, db_session, monkeypatch, caplog):
  960. """B2: per-row commit semantics — when one row fails to re-encrypt,
  961. OTHER successfully-encrypted rows must remain committed and the
  962. failure surfaces via get_migration_error_count.
  963. Replaces the previous "rollback all" behaviour: a single poison row
  964. used to block every successful re-encryption on every startup forever.
  965. """
  966. import logging
  967. import backend.app.core.encryption as enc_mod # noqa: F401
  968. from backend.app.core.database import (
  969. _migrate_encrypt_legacy_secrets,
  970. get_migration_error_count,
  971. )
  972. from backend.app.models.oidc_provider import OIDCProvider
  973. self._patch_module_session(monkeypatch, db_session)
  974. self._set_active_key(monkeypatch)
  975. good = OIDCProvider(
  976. name="GoodRow",
  977. issuer_url="https://good.example.com",
  978. client_id="c1",
  979. _client_secret_enc="plaintext-good",
  980. scopes="openid email profile",
  981. )
  982. bad = OIDCProvider(
  983. name="BadRow",
  984. issuer_url="https://bad.example.com",
  985. client_id="c2",
  986. _client_secret_enc="plaintext-bad",
  987. scopes="openid email profile",
  988. )
  989. db_session.add_all([good, bad])
  990. await db_session.commit()
  991. original_bad = bad._client_secret_enc
  992. # Force the setter on the SECOND row to raise — patch at the model's
  993. # import location so the property setter picks up the patched function.
  994. import backend.app.models.oidc_provider as oidc_mod
  995. real_encrypt = oidc_mod.mfa_encrypt
  996. call_count = [0]
  997. def _sometimes_raise(value):
  998. call_count[0] += 1
  999. if call_count[0] == 2:
  1000. raise RuntimeError("simulated encrypt failure")
  1001. return real_encrypt(value)
  1002. monkeypatch.setattr(oidc_mod, "mfa_encrypt", _sometimes_raise)
  1003. with caplog.at_level(logging.ERROR, logger="backend.app.core.database"):
  1004. await _migrate_encrypt_legacy_secrets()
  1005. # B2: per-row commit — good IS encrypted, bad is unchanged.
  1006. await db_session.refresh(good)
  1007. await db_session.refresh(bad)
  1008. assert good._client_secret_enc.startswith("fernet:"), (
  1009. "good row must be successfully re-encrypted (per-row commit)"
  1010. )
  1011. assert bad._client_secret_enc == original_bad, "bad row must remain unchanged (savepoint-style isolation)"
  1012. assert get_migration_error_count() == 1, "the skipped row must be exposed via get_migration_error_count"
  1013. assert any("skipping" in rec.message.lower() for rec in caplog.records)
  1014. @pytest.mark.asyncio
  1015. @pytest.mark.integration
  1016. async def test_migration_logs_no_op_when_all_encrypted(self, db_session, monkeypatch, caplog):
  1017. """A2: when all rows are already encrypted, migration logs a debug no-op."""
  1018. import logging
  1019. from backend.app.core.database import _migrate_encrypt_legacy_secrets
  1020. from backend.app.models.oidc_provider import OIDCProvider
  1021. self._patch_module_session(monkeypatch, db_session)
  1022. self._set_active_key(monkeypatch)
  1023. provider = OIDCProvider(
  1024. name="AlreadyEnc",
  1025. issuer_url="https://ae.example.com",
  1026. client_id="cae",
  1027. client_secret="already-encrypted",
  1028. scopes="openid email profile",
  1029. )
  1030. db_session.add(provider)
  1031. await db_session.commit()
  1032. with caplog.at_level(logging.DEBUG, logger="backend.app.core.database"):
  1033. await _migrate_encrypt_legacy_secrets()
  1034. assert any("no rows needed re-encryption" in rec.message for rec in caplog.records)
  1035. @pytest.mark.asyncio
  1036. @pytest.mark.integration
  1037. async def test_init_db_propagates_unexpected_migration_error(self, monkeypatch, tmp_path):
  1038. """B3: an unexpected error from _migrate_encrypt_legacy_secrets must
  1039. surface (re-raise) instead of being silently swallowed.
  1040. Pins the contract introduced for B3: a startup-fatal error like a
  1041. session-creation failure must fail the lifespan / CLI / restore
  1042. handler explicitly, never run the app with half-migrated rows.
  1043. Implementation note: we patch _migrate_encrypt_legacy_secrets itself
  1044. rather than poking the inner read phase, because that is the contract
  1045. boundary the rest of the codebase relies on (init_db -> migration).
  1046. """
  1047. from sqlalchemy import event
  1048. from sqlalchemy.ext.asyncio import create_async_engine
  1049. import backend.app.core.database as db_mod
  1050. from backend.app.core.config import settings
  1051. # init_db() uses the module-level `engine`, which was bound at import
  1052. # time to settings.database_url — that resolves to the real shared
  1053. # bambuddy.db at the project root (or, when DATABASE_URL is set, the
  1054. # configured Postgres). The autouse DATA_DIR fixture runs too late to
  1055. # influence either. Letting this test write to that real DB makes it
  1056. # (a) non-hermetic and (b) flake under `-n 30` with "database is
  1057. # locked" when two workers race on the file. Substitute an isolated
  1058. # per-test SQLite engine — and override settings.database_url for
  1059. # this test so the is_sqlite() / is_postgres() dialect guards inside
  1060. # run_migrations pick the SQLite path against this engine.
  1061. test_db_url = f"sqlite+aiosqlite:///{tmp_path / 'init_db_test.db'}"
  1062. test_engine = create_async_engine(test_db_url, echo=False)
  1063. event.listen(test_engine.sync_engine, "connect", db_mod._set_sqlite_pragmas)
  1064. monkeypatch.setattr(db_mod, "engine", test_engine)
  1065. monkeypatch.setattr(settings, "database_url", test_db_url)
  1066. async def boom():
  1067. raise RuntimeError("simulated startup-fatal failure")
  1068. # Stub out the rest of init_db so we exercise only the migration step.
  1069. # init_db opens the engine.begin() block, runs metadata.create_all,
  1070. # run_migrations, then awaits _migrate_encrypt_legacy_secrets — the
  1071. # only call we want to fail.
  1072. monkeypatch.setattr(db_mod, "_migrate_encrypt_legacy_secrets", boom)
  1073. monkeypatch.setattr(db_mod, "seed_notification_templates", lambda: _noop_async())
  1074. monkeypatch.setattr(db_mod, "seed_default_groups", lambda: _noop_async())
  1075. monkeypatch.setattr(db_mod, "seed_spool_catalog", lambda: _noop_async())
  1076. monkeypatch.setattr(db_mod, "seed_color_catalog", lambda: _noop_async())
  1077. try:
  1078. with pytest.raises(RuntimeError, match="simulated startup-fatal failure"):
  1079. await db_mod.init_db()
  1080. finally:
  1081. await test_engine.dispose()
  1082. async def _noop_async():
  1083. """Helper for tests that need to stub out `seed_*` async coroutines."""
  1084. return None
  1085. # ============================================================================
  1086. # TestEncryptionStatusEndpoint
  1087. # ============================================================================
  1088. class TestEncryptionStatusEndpoint:
  1089. """GET /api/v1/auth/encryption-status: key source, counts, decryption_broken."""
  1090. STATUS_URL = "/api/v1/auth/encryption-status"
  1091. async def _create_admin_and_login(self, async_client: AsyncClient) -> str:
  1092. """Bootstrap auth + return a Bearer token for an admin."""
  1093. await async_client.post(
  1094. "/api/v1/auth/setup",
  1095. json={
  1096. "auth_enabled": True,
  1097. "admin_username": "admin1219",
  1098. "admin_password": "Admin1219!Pass",
  1099. },
  1100. )
  1101. login = await async_client.post(
  1102. "/api/v1/auth/login",
  1103. json={"username": "admin1219", "password": "Admin1219!Pass"},
  1104. )
  1105. assert login.status_code == 200, login.text
  1106. return login.json()["access_token"]
  1107. @pytest.mark.asyncio
  1108. @pytest.mark.integration
  1109. async def test_status_reports_env_source(self, async_client, monkeypatch):
  1110. from cryptography.fernet import Fernet
  1111. import backend.app.core.encryption as enc_mod
  1112. token = await self._create_admin_and_login(async_client)
  1113. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1114. enc_mod._fernet_instance = None
  1115. enc_mod._key_source = None
  1116. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1117. assert resp.status_code == 200
  1118. data = resp.json()
  1119. assert data["key_configured"] is True
  1120. assert data["key_source"] == "env"
  1121. assert data["decryption_broken"] is False
  1122. @pytest.mark.asyncio
  1123. @pytest.mark.integration
  1124. async def test_status_reports_file_source(self, async_client, monkeypatch, tmp_path):
  1125. from cryptography.fernet import Fernet
  1126. import backend.app.core.encryption as enc_mod
  1127. token = await self._create_admin_and_login(async_client)
  1128. # Pre-place a valid key file in DATA_DIR.
  1129. key_file = tmp_path / ".mfa_encryption_key"
  1130. key_file.write_text(Fernet.generate_key().decode())
  1131. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1132. monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False)
  1133. enc_mod._fernet_instance = None
  1134. enc_mod._key_source = None
  1135. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1136. assert resp.status_code == 200
  1137. data = resp.json()
  1138. assert data["key_source"] == "file"
  1139. @pytest.mark.asyncio
  1140. @pytest.mark.integration
  1141. async def test_status_reports_generated_source(self, async_client, monkeypatch, tmp_path):
  1142. import backend.app.core.encryption as enc_mod
  1143. token = await self._create_admin_and_login(async_client)
  1144. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1145. monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False)
  1146. enc_mod._fernet_instance = None
  1147. enc_mod._key_source = None
  1148. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1149. assert resp.status_code == 200
  1150. data = resp.json()
  1151. assert data["key_source"] == "generated"
  1152. assert (tmp_path / ".mfa_encryption_key").exists()
  1153. @pytest.mark.asyncio
  1154. @pytest.mark.integration
  1155. async def test_status_reports_none_source(self, async_client, monkeypatch):
  1156. import backend.app.core.encryption as enc_mod
  1157. token = await self._create_admin_and_login(async_client)
  1158. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  1159. enc_mod._fernet_instance = None
  1160. enc_mod._key_source = None
  1161. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1162. assert resp.status_code == 200
  1163. data = resp.json()
  1164. assert data["key_configured"] is False
  1165. assert data["key_source"] == "none"
  1166. @pytest.mark.asyncio
  1167. @pytest.mark.integration
  1168. async def test_status_counts_legacy_rows(self, async_client, db_session, monkeypatch):
  1169. from backend.app.models.oidc_provider import OIDCProvider
  1170. token = await self._create_admin_and_login(async_client)
  1171. provider = OIDCProvider(
  1172. name="LegacyStatus",
  1173. issuer_url="https://ls.example.com",
  1174. client_id="c",
  1175. _client_secret_enc="plaintext-no-prefix",
  1176. scopes="openid email profile",
  1177. )
  1178. db_session.add(provider)
  1179. await db_session.commit()
  1180. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1181. assert resp.status_code == 200
  1182. data = resp.json()
  1183. assert data["legacy_plaintext_rows"]["oidc_providers"] >= 1
  1184. @pytest.mark.asyncio
  1185. @pytest.mark.integration
  1186. async def test_status_counts_encrypted_rows(self, async_client, db_session, monkeypatch):
  1187. from cryptography.fernet import Fernet
  1188. import backend.app.core.encryption as enc_mod
  1189. from backend.app.models.oidc_provider import OIDCProvider
  1190. token = await self._create_admin_and_login(async_client)
  1191. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1192. enc_mod._fernet_instance = None
  1193. enc_mod._key_source = None
  1194. provider = OIDCProvider(
  1195. name="EncStatus",
  1196. issuer_url="https://es.example.com",
  1197. client_id="c",
  1198. client_secret="real-secret", # via setter → encrypted
  1199. scopes="openid email profile",
  1200. )
  1201. db_session.add(provider)
  1202. await db_session.commit()
  1203. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1204. assert resp.status_code == 200
  1205. data = resp.json()
  1206. assert data["encrypted_rows"]["oidc_providers"] >= 1
  1207. @pytest.mark.asyncio
  1208. @pytest.mark.integration
  1209. async def test_status_warns_on_encrypted_rows_without_key(self, async_client, db_session, monkeypatch):
  1210. """Gap 2: encrypted rows present but no key loadable → decryption_broken=true."""
  1211. import backend.app.core.encryption as enc_mod
  1212. from backend.app.models.oidc_provider import OIDCProvider
  1213. token = await self._create_admin_and_login(async_client)
  1214. # Insert a row whose value is already prefixed (simulates a previously-encrypted row).
  1215. provider = OIDCProvider(
  1216. name="BrokenEnc",
  1217. issuer_url="https://be.example.com",
  1218. client_id="c",
  1219. _client_secret_enc="fernet:gAAAAA-fake-but-prefixed",
  1220. scopes="openid email profile",
  1221. )
  1222. db_session.add(provider)
  1223. await db_session.commit()
  1224. # Now disable key loading so decryption is impossible.
  1225. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  1226. enc_mod._fernet_instance = None
  1227. enc_mod._key_source = None
  1228. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1229. assert resp.status_code == 200
  1230. data = resp.json()
  1231. assert data["key_configured"] is False
  1232. assert data["encrypted_rows"]["oidc_providers"] >= 1
  1233. assert data["decryption_broken"] is True
  1234. @pytest.mark.asyncio
  1235. @pytest.mark.integration
  1236. async def test_status_requires_settings_read_permission(self, async_client, db_session):
  1237. """Non-admin without settings:read permission gets 403."""
  1238. from backend.app.models.user import User
  1239. await self._create_admin_and_login(async_client)
  1240. # Create a low-privilege user (no group → no permissions in default seed).
  1241. from backend.app.core.auth import get_password_hash
  1242. viewer = User(
  1243. username="viewer1219",
  1244. email="viewer1219@example.com",
  1245. password_hash=get_password_hash("Viewer1219!Pass"),
  1246. role="user",
  1247. is_active=True,
  1248. )
  1249. db_session.add(viewer)
  1250. await db_session.commit()
  1251. login = await async_client.post(
  1252. "/api/v1/auth/login",
  1253. json={"username": "viewer1219", "password": "Viewer1219!Pass"},
  1254. )
  1255. assert login.status_code == 200, login.text
  1256. token = login.json().get("access_token")
  1257. assert token is not None, f"Expected access_token in login response, got: {login.json()}"
  1258. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1259. assert resp.status_code == 403
  1260. @pytest.mark.asyncio
  1261. @pytest.mark.integration
  1262. async def test_status_returns_503_on_db_error(self, async_client, monkeypatch):
  1263. """A8: a DB failure during the request must NOT leak internal detail
  1264. and must NOT silently succeed.
  1265. Post-GHSA-6mf4-q26m-47pv: the auth middleware's ``is_auth_enabled``
  1266. probe runs its own DB query before the route is dispatched. Patching
  1267. ``AsyncSession.execute`` to raise now trips the middleware first and
  1268. the request fails closed with 503 — a stronger guarantee than the
  1269. previous route-level 500, because under the old fail-open the
  1270. middleware would have proceeded to dispatch the route unauthenticated.
  1271. Either status would be acceptable; the assertion here pins the
  1272. defense-in-depth posture (request denied, no leak).
  1273. """
  1274. from unittest.mock import AsyncMock
  1275. from sqlalchemy.exc import SQLAlchemyError
  1276. token = await self._create_admin_and_login(async_client)
  1277. async def _raise(*args, **kwargs):
  1278. raise SQLAlchemyError("simulated DB failure")
  1279. monkeypatch.setattr("sqlalchemy.ext.asyncio.AsyncSession.execute", AsyncMock(side_effect=_raise))
  1280. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1281. assert resp.status_code in (500, 503)
  1282. # Either layer's error message must not leak the SQLAlchemy details.
  1283. body = resp.json().get("detail", "").lower()
  1284. assert "simulated" not in body
  1285. assert "sqlalchemy" not in body
  1286. @pytest.mark.asyncio
  1287. @pytest.mark.integration
  1288. async def test_status_returns_403_for_viewer_in_viewers_group(self, async_client, db_session):
  1289. """S2: a user in the Viewers group (has SETTINGS_READ but NOT SETTINGS_UPDATE)
  1290. must get 403 — encryption-status is admin/operator only.
  1291. """
  1292. from sqlalchemy import insert, select
  1293. from backend.app.core.auth import get_password_hash
  1294. from backend.app.models.group import Group, user_groups
  1295. from backend.app.models.user import User
  1296. # Bootstrap auth (creates default groups via setup endpoint).
  1297. await self._create_admin_and_login(async_client)
  1298. # Create a user explicitly in the Viewers group — it has SETTINGS_READ
  1299. # but not SETTINGS_UPDATE, which is the discriminator for S2.
  1300. viewer = User(
  1301. username="viewer_s2",
  1302. email="viewer_s2@example.com",
  1303. password_hash=get_password_hash("ViewerS2!Pass1"),
  1304. role="user",
  1305. is_active=True,
  1306. )
  1307. db_session.add(viewer)
  1308. await db_session.flush()
  1309. viewers_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one_or_none()
  1310. assert viewers_group is not None, "Viewers group must be seeded by setup"
  1311. # Insert the association row directly to avoid touching the lazy
  1312. # `viewer.groups` relationship (which would trigger an implicit
  1313. # IO inside an active async transaction and fail with MissingGreenlet).
  1314. await db_session.execute(insert(user_groups).values(user_id=viewer.id, group_id=viewers_group.id))
  1315. await db_session.commit()
  1316. login = await async_client.post(
  1317. "/api/v1/auth/login",
  1318. json={"username": "viewer_s2", "password": "ViewerS2!Pass1"},
  1319. )
  1320. assert login.status_code == 200, login.text
  1321. token = login.json()["access_token"]
  1322. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1323. assert resp.status_code == 403, "S2: Viewers (SETTINGS_READ only) must NOT be able to read encryption-status"
  1324. @pytest.mark.asyncio
  1325. @pytest.mark.integration
  1326. async def test_status_decryption_broken_when_wrong_key_active(self, async_client, db_session, monkeypatch):
  1327. """B4: key is configured but cannot decrypt existing rows → decryption_broken=True.
  1328. This is the "wrong key" state that the legacy computed_field check
  1329. missed — operator pasted a different valid Fernet key (rotation,
  1330. cross-deployment restore, env override). Status used to show GREEN
  1331. while every encrypted row was unrecoverable.
  1332. """
  1333. from cryptography.fernet import Fernet
  1334. import backend.app.core.encryption as enc_mod
  1335. from backend.app.models.oidc_provider import OIDCProvider
  1336. token = await self._create_admin_and_login(async_client)
  1337. # Insert a row whose value is fernet-prefixed but encrypted under a
  1338. # DIFFERENT key (the prefix matches, but decrypt will throw).
  1339. provider = OIDCProvider(
  1340. name="WrongKeyEnc",
  1341. issuer_url="https://wk.example.com",
  1342. client_id="c",
  1343. _client_secret_enc=("fernet:" + Fernet(Fernet.generate_key()).encrypt(b"original").decode()),
  1344. scopes="openid email profile",
  1345. )
  1346. db_session.add(provider)
  1347. await db_session.commit()
  1348. # Now activate a DIFFERENT key — sample-decrypt must fail.
  1349. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1350. enc_mod._fernet_instance = None
  1351. enc_mod._key_source = None
  1352. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1353. assert resp.status_code == 200, resp.text
  1354. data = resp.json()
  1355. assert data["key_configured"] is True, "different key is still 'configured'"
  1356. assert data["encrypted_rows"]["oidc_providers"] >= 1
  1357. assert data["decryption_broken"] is True, "B4: sample-decrypt must detect wrong-key state"
  1358. @pytest.mark.asyncio
  1359. @pytest.mark.integration
  1360. async def test_status_decryption_broken_with_only_totp_rows(self, async_client, db_session, monkeypatch):
  1361. """B4: the sample-decrypt fallback to UserTOTP fires when there are no
  1362. encrypted OIDC rows but TOTP rows exist. The OIDC-only test above
  1363. proves the primary path; this pins the second branch in the same
  1364. try-block so a future refactor of the row-source switch can't silently
  1365. regress wrong-key detection for TOTP-only deployments.
  1366. """
  1367. from cryptography.fernet import Fernet
  1368. from sqlalchemy import select
  1369. import backend.app.core.encryption as enc_mod
  1370. from backend.app.models.user import User
  1371. from backend.app.models.user_totp import UserTOTP
  1372. token = await self._create_admin_and_login(async_client)
  1373. # Look up the admin user created by login so we can attach a TOTP row.
  1374. admin_row = await db_session.execute(select(User).where(User.username == "admin1219"))
  1375. admin = admin_row.scalar_one()
  1376. # Seed a UserTOTP row encrypted under key A. No OIDC rows exist, so
  1377. # the endpoint's first branch (oidc_providers > 0) misses and the
  1378. # sample falls through to UserTOTP.
  1379. key_a_ciphertext = Fernet(Fernet.generate_key()).encrypt(b"original-totp-secret").decode()
  1380. db_session.add(UserTOTP(user_id=admin.id, _secret_enc=f"fernet:{key_a_ciphertext}", is_enabled=True))
  1381. await db_session.commit()
  1382. # Activate a DIFFERENT key — the TOTP-fallback sample-decrypt must fail.
  1383. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1384. enc_mod._fernet_instance = None
  1385. enc_mod._key_source = None
  1386. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1387. assert resp.status_code == 200, resp.text
  1388. data = resp.json()
  1389. assert data["key_configured"] is True
  1390. assert data["encrypted_rows"]["oidc_providers"] == 0, "test premise: no OIDC rows so TOTP branch fires"
  1391. assert data["encrypted_rows"]["user_totp"] >= 1
  1392. assert data["decryption_broken"] is True, "B4: TOTP-fallback sample-decrypt must detect wrong-key state"
  1393. @pytest.mark.asyncio
  1394. @pytest.mark.integration
  1395. async def test_status_surfaces_real_migration_error_count(self, async_client, db_session, monkeypatch, caplog):
  1396. """B2: a real migration with a poison row produces an error_count that
  1397. flows through to the endpoint's `migration_error_count` field.
  1398. Replaces an earlier tautology that patched the module-level counter
  1399. directly. The chained version verifies the full path: poison row →
  1400. per-row migration skip → ``get_migration_error_count()`` →
  1401. ``GET /encryption-status``.
  1402. """
  1403. import logging
  1404. from backend.app.core.database import _migrate_encrypt_legacy_secrets, get_migration_error_count
  1405. from backend.app.models.oidc_provider import OIDCProvider
  1406. token = await self._create_admin_and_login(async_client)
  1407. # Bind the migration's session factory to the test engine and activate a key.
  1408. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  1409. from backend.app.core import database as db_mod
  1410. test_factory = async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
  1411. monkeypatch.setattr(db_mod, "async_session", test_factory)
  1412. from cryptography.fernet import Fernet
  1413. import backend.app.core.encryption as enc_mod
  1414. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1415. enc_mod._fernet_instance = None
  1416. # Two legacy plaintext rows; force the SECOND row's encrypt call to raise.
  1417. db_session.add_all(
  1418. [
  1419. OIDCProvider(
  1420. name="GoodRow",
  1421. issuer_url="https://good.example.com",
  1422. client_id="c1",
  1423. _client_secret_enc="plaintext-good",
  1424. scopes="openid email profile",
  1425. ),
  1426. OIDCProvider(
  1427. name="BadRow",
  1428. issuer_url="https://bad.example.com",
  1429. client_id="c2",
  1430. _client_secret_enc="plaintext-bad",
  1431. scopes="openid email profile",
  1432. ),
  1433. ]
  1434. )
  1435. await db_session.commit()
  1436. import backend.app.models.oidc_provider as oidc_mod
  1437. real_encrypt = oidc_mod.mfa_encrypt
  1438. call_count = [0]
  1439. def _sometimes_raise(value):
  1440. call_count[0] += 1
  1441. if call_count[0] == 2:
  1442. raise RuntimeError("simulated encrypt failure")
  1443. return real_encrypt(value)
  1444. monkeypatch.setattr(oidc_mod, "mfa_encrypt", _sometimes_raise)
  1445. with caplog.at_level(logging.ERROR, logger="backend.app.core.database"):
  1446. await _migrate_encrypt_legacy_secrets()
  1447. # Sanity: the migration's own counter saw the failure.
  1448. assert get_migration_error_count() == 1
  1449. # The endpoint must surface the same number — full path pinned, not just the getter.
  1450. resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"})
  1451. assert resp.status_code == 200, resp.text
  1452. data = resp.json()
  1453. assert data["migration_error_count"] == 1, (
  1454. "endpoint must report the actual migration outcome, not just read a stub global"
  1455. )
  1456. # ============================================================================
  1457. # TestEncryptionRoundtrip (E2E)
  1458. # ============================================================================
  1459. class TestEncryptionRoundtrip:
  1460. """End-to-end: writes via the property setter store ciphertext at the column
  1461. level; reads via the property getter return the original plaintext."""
  1462. @pytest.mark.asyncio
  1463. @pytest.mark.integration
  1464. async def test_oidc_provider_secret_encrypted_at_rest_e2e(self, db_session, monkeypatch):
  1465. from cryptography.fernet import Fernet
  1466. from sqlalchemy import select
  1467. import backend.app.core.encryption as enc_mod
  1468. from backend.app.models.oidc_provider import OIDCProvider
  1469. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1470. enc_mod._fernet_instance = None
  1471. provider = OIDCProvider(
  1472. name="E2E_OIDC",
  1473. issuer_url="https://e2e.example.com",
  1474. client_id="cid",
  1475. client_secret="my-real-client-secret", # via setter → encrypted
  1476. scopes="openid email profile",
  1477. is_enabled=True,
  1478. )
  1479. db_session.add(provider)
  1480. await db_session.commit()
  1481. # Raw column read: must be ciphertext, not the plaintext.
  1482. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == provider.id))
  1483. fetched = result.scalar_one()
  1484. assert fetched._client_secret_enc.startswith("fernet:")
  1485. assert fetched._client_secret_enc != "my-real-client-secret"
  1486. # Property read: returns original plaintext.
  1487. assert fetched.client_secret == "my-real-client-secret"
  1488. @pytest.mark.asyncio
  1489. @pytest.mark.integration
  1490. async def test_totp_secret_encrypted_at_rest_e2e(self, db_session, monkeypatch):
  1491. from cryptography.fernet import Fernet
  1492. from sqlalchemy import select
  1493. import backend.app.core.encryption as enc_mod
  1494. from backend.app.models.user import User
  1495. from backend.app.models.user_totp import UserTOTP
  1496. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  1497. enc_mod._fernet_instance = None
  1498. user = User(username="e2etotp1219", email="e@example.com", password_hash="x")
  1499. db_session.add(user)
  1500. await db_session.flush()
  1501. totp = UserTOTP(user_id=user.id, secret="JBSWY3DPEHPK3PXP", is_enabled=True)
  1502. db_session.add(totp)
  1503. await db_session.commit()
  1504. result = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
  1505. fetched = result.scalar_one()
  1506. assert fetched._secret_enc.startswith("fernet:")
  1507. assert fetched._secret_enc != "JBSWY3DPEHPK3PXP"
  1508. assert fetched.secret == "JBSWY3DPEHPK3PXP"
  1509. # ============================================================================
  1510. # TestBackupKeyFiles
  1511. # Verifies that .mfa_encryption_key is included in backup ZIPs (so backups
  1512. # are self-contained) and restored with chmod 0600 — and that path-traversal
  1513. # payloads in a malicious ZIP are rejected.
  1514. # ============================================================================
  1515. def _minimal_sqlite_backup() -> bytes:
  1516. """A real, if empty, SQLite database to stand in for a backup's bambuddy.db.
  1517. Restore now refuses a bambuddy.db it cannot open, and refuses it before it
  1518. stops services or overwrites the MFA key file -- a corrupt or truncated
  1519. backup used to be found only by the Postgres import, which by then had
  1520. dropped every table in the live database. These tests are about the key
  1521. handling around the swap, so they need a file that opens; what is in it does
  1522. not matter.
  1523. """
  1524. import sqlite3
  1525. conn = sqlite3.connect(":memory:")
  1526. try:
  1527. conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)")
  1528. conn.commit()
  1529. return conn.serialize()
  1530. finally:
  1531. conn.close()
  1532. class TestBackupKeyFiles:
  1533. @pytest.mark.asyncio
  1534. @pytest.mark.integration
  1535. async def test_backup_includes_mfa_encryption_key_when_present(self, async_client, monkeypatch, tmp_path):
  1536. import zipfile
  1537. from backend.app.api.routes.settings import create_backup_zip
  1538. from backend.app.core.config import settings as app_settings
  1539. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1540. # Ensure `app_settings.base_dir` follows DATA_DIR for this test by
  1541. # patching the module attribute (config caches it at import time).
  1542. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1543. key_path = tmp_path / ".mfa_encryption_key"
  1544. key_path.write_text("test-key-content")
  1545. zip_path, _filename = await create_backup_zip(output_path=tmp_path)
  1546. try:
  1547. with zipfile.ZipFile(zip_path) as zf:
  1548. names = zf.namelist()
  1549. assert ".mfa_encryption_key" in names
  1550. assert zf.read(".mfa_encryption_key").decode() == "test-key-content"
  1551. finally:
  1552. zip_path.unlink(missing_ok=True)
  1553. @pytest.mark.asyncio
  1554. @pytest.mark.integration
  1555. async def test_backup_skips_mfa_encryption_key_when_absent(self, async_client, monkeypatch, tmp_path):
  1556. import zipfile
  1557. from backend.app.api.routes.settings import create_backup_zip
  1558. from backend.app.core.config import settings as app_settings
  1559. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1560. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1561. # No .mfa_encryption_key written — must not crash.
  1562. zip_path, _filename = await create_backup_zip(output_path=tmp_path)
  1563. try:
  1564. with zipfile.ZipFile(zip_path) as zf:
  1565. names = zf.namelist()
  1566. assert ".mfa_encryption_key" not in names
  1567. finally:
  1568. zip_path.unlink(missing_ok=True)
  1569. @pytest.mark.asyncio
  1570. @pytest.mark.integration
  1571. async def test_restore_writes_key_files_with_chmod_0600(self, async_client, monkeypatch, tmp_path):
  1572. """T1: restore endpoint writes key file with mode 0o600.
  1573. Bypasses the SQLite-copy step via patches so execution reaches the
  1574. key-write code unconditionally — the previous version used a stub
  1575. ``b"SQLite format 3"`` which made ``sqlite3.backup()`` fail and the
  1576. key-write code never ran.
  1577. """
  1578. import io
  1579. import zipfile
  1580. from unittest.mock import AsyncMock, patch
  1581. from backend.app.core.config import settings as app_settings
  1582. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1583. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1584. # Build a minimal ZIP with a stub DB and the key file.
  1585. buf = io.BytesIO()
  1586. with zipfile.ZipFile(buf, "w") as zf:
  1587. zf.writestr("bambuddy.db", _minimal_sqlite_backup())
  1588. zf.writestr(".mfa_encryption_key", "test-restored-key")
  1589. buf.seek(0)
  1590. with (
  1591. patch("backend.app.core.db_dialect.is_sqlite", return_value=False),
  1592. patch(
  1593. "backend.app.api.routes.settings._import_sqlite_to_postgres",
  1594. new_callable=AsyncMock,
  1595. ),
  1596. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  1597. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  1598. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  1599. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  1600. ):
  1601. resp = await async_client.post(
  1602. "/api/v1/settings/restore",
  1603. files={"file": ("backup.zip", buf, "application/zip")},
  1604. )
  1605. assert resp.status_code == 200
  1606. restored_key = tmp_path / ".mfa_encryption_key"
  1607. assert restored_key.exists()
  1608. assert restored_key.read_text() == "test-restored-key"
  1609. assert (restored_key.stat().st_mode & 0o777) == 0o600
  1610. @pytest.mark.asyncio
  1611. @pytest.mark.integration
  1612. async def test_restore_handles_missing_key_files(self, async_client, monkeypatch, tmp_path):
  1613. """T2: ZIP without key file → restore succeeds, no key written to DATA_DIR."""
  1614. import io
  1615. import zipfile
  1616. from unittest.mock import AsyncMock, patch
  1617. from backend.app.core.config import settings as app_settings
  1618. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1619. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1620. buf = io.BytesIO()
  1621. with zipfile.ZipFile(buf, "w") as zf:
  1622. zf.writestr("bambuddy.db", _minimal_sqlite_backup())
  1623. # Intentionally no .mfa_encryption_key entry.
  1624. buf.seek(0)
  1625. with (
  1626. patch("backend.app.core.db_dialect.is_sqlite", return_value=False),
  1627. patch(
  1628. "backend.app.api.routes.settings._import_sqlite_to_postgres",
  1629. new_callable=AsyncMock,
  1630. ),
  1631. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  1632. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  1633. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  1634. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  1635. ):
  1636. resp = await async_client.post(
  1637. "/api/v1/settings/restore",
  1638. files={"file": ("backup.zip", buf, "application/zip")},
  1639. )
  1640. assert resp.status_code == 200
  1641. assert not (tmp_path / ".mfa_encryption_key").exists()
  1642. @pytest.mark.asyncio
  1643. @pytest.mark.integration
  1644. async def test_restore_aborts_db_swap_when_key_write_fails(self, async_client, monkeypatch, tmp_path):
  1645. """B1: when MFA key write fails, restore must abort BEFORE the database
  1646. swap so the live DB is not left with rows encrypted under a key that
  1647. no longer exists on disk."""
  1648. import io
  1649. import os
  1650. import zipfile
  1651. from unittest.mock import AsyncMock, patch
  1652. from backend.app.core.config import settings as app_settings
  1653. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1654. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1655. # Build ZIP with a key file that we will fail to write to DATA_DIR.
  1656. buf = io.BytesIO()
  1657. with zipfile.ZipFile(buf, "w") as zf:
  1658. zf.writestr("bambuddy.db", _minimal_sqlite_backup())
  1659. zf.writestr(".mfa_encryption_key", "backup-key-content")
  1660. buf.seek(0)
  1661. # Track whether the database swap functions were called.
  1662. # If B1 is correct, key-write failure aborts BEFORE these run.
  1663. import_pg_mock = AsyncMock()
  1664. reinit_mock = AsyncMock()
  1665. init_mock = AsyncMock()
  1666. original_open = os.open
  1667. def _key_write_fails(path, flags, mode=0o777, **kwargs):
  1668. # `shutil.rmtree` calls os.open(... dir_fd=...) during temp-dir
  1669. # cleanup — accept and forward any extra kwargs so the mock
  1670. # doesn't break the cleanup path.
  1671. if str(path).endswith(".mfa_encryption_key.restore-tmp"):
  1672. raise OSError(28, "No space left on device", str(path))
  1673. return original_open(path, flags, mode, **kwargs)
  1674. with (
  1675. patch("backend.app.core.db_dialect.is_sqlite", return_value=False),
  1676. patch(
  1677. "backend.app.api.routes.settings._import_sqlite_to_postgres",
  1678. import_pg_mock,
  1679. ),
  1680. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  1681. patch("backend.app.core.database.reinitialize_database", reinit_mock),
  1682. patch("backend.app.core.database.init_db", init_mock),
  1683. ):
  1684. monkeypatch.setattr(os, "open", _key_write_fails)
  1685. resp = await async_client.post(
  1686. "/api/v1/settings/restore",
  1687. files={"file": ("backup.zip", buf, "application/zip")},
  1688. )
  1689. assert resp.status_code == 500
  1690. assert "Database is unchanged" in resp.json().get("detail", "")
  1691. # Database swap functions must NOT have been called — the abort
  1692. # happens before that step.
  1693. import_pg_mock.assert_not_awaited()
  1694. reinit_mock.assert_not_awaited()
  1695. init_mock.assert_not_awaited()
  1696. # No partial key file should be left behind.
  1697. assert not (tmp_path / ".mfa_encryption_key").exists()
  1698. @pytest.mark.asyncio
  1699. @pytest.mark.integration
  1700. async def test_restore_resets_encryption_singleton_after_key_replace(self, async_client, monkeypatch, tmp_path):
  1701. """B1: after a successful key replace, the encryption singleton must be
  1702. cleared so init_db's re-encryption migration picks up the restored key
  1703. instead of the cached Fernet from the previous key.
  1704. """
  1705. import io
  1706. import zipfile
  1707. from unittest.mock import AsyncMock, patch
  1708. from cryptography.fernet import Fernet
  1709. import backend.app.core.encryption as enc_mod
  1710. from backend.app.core.config import settings as app_settings
  1711. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1712. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1713. # Pre-warm the singleton with an "old" key so we can detect the reset.
  1714. old_key = Fernet.generate_key().decode()
  1715. monkeypatch.setenv("MFA_ENCRYPTION_KEY", old_key)
  1716. enc_mod._fernet_instance = None
  1717. enc_mod._key_source = None
  1718. # Trigger lazy load → singleton holds the old Fernet.
  1719. assert enc_mod.is_encryption_active() is True
  1720. assert enc_mod._fernet_instance is not None
  1721. old_fernet_obj = enc_mod._fernet_instance
  1722. # Build ZIP that delivers a DIFFERENT key file.
  1723. new_key = Fernet.generate_key().decode()
  1724. assert new_key != old_key
  1725. buf = io.BytesIO()
  1726. with zipfile.ZipFile(buf, "w") as zf:
  1727. zf.writestr("bambuddy.db", _minimal_sqlite_backup())
  1728. zf.writestr(".mfa_encryption_key", new_key)
  1729. buf.seek(0)
  1730. with (
  1731. patch("backend.app.core.db_dialect.is_sqlite", return_value=False),
  1732. patch(
  1733. "backend.app.api.routes.settings._import_sqlite_to_postgres",
  1734. new_callable=AsyncMock,
  1735. ),
  1736. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  1737. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  1738. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  1739. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  1740. ):
  1741. resp = await async_client.post(
  1742. "/api/v1/settings/restore",
  1743. files={"file": ("backup.zip", buf, "application/zip")},
  1744. )
  1745. assert resp.status_code == 200, resp.text
  1746. # The singleton must have been invalidated. The exact post-state depends
  1747. # on whether init_db (mocked) re-loaded the singleton, but the cached
  1748. # _fernet_instance reference from before the restore must not be the
  1749. # active one any more.
  1750. assert enc_mod._fernet_instance is None or enc_mod._fernet_instance is not old_fernet_obj, (
  1751. "B1: encryption singleton must be reset after key replace so init_db's migration picks up the restored key"
  1752. )
  1753. # The key file must be on disk with the new content.
  1754. restored = (tmp_path / ".mfa_encryption_key").read_text()
  1755. assert restored == new_key
  1756. @pytest.mark.asyncio
  1757. @pytest.mark.integration
  1758. async def test_restore_rejects_path_traversal_in_zip(self, async_client, monkeypatch, tmp_path):
  1759. """A4: ZIP with path-traversal entry → HTTP 400, no file written outside temp dir."""
  1760. import io
  1761. import zipfile
  1762. from backend.app.core.config import settings as app_settings
  1763. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1764. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1765. # Build ZIP with a relative path-traversal entry.
  1766. buf = io.BytesIO()
  1767. with zipfile.ZipFile(buf, "w") as zf:
  1768. zf.writestr("../etc/passwd", "root:x:0:0")
  1769. zf.writestr("bambuddy.db", b"SQLite format 3")
  1770. buf.seek(0)
  1771. resp = await async_client.post(
  1772. "/api/v1/settings/restore",
  1773. files={"file": ("backup.zip", buf, "application/zip")},
  1774. )
  1775. assert resp.status_code == 400
  1776. assert "unsafe path" in resp.json().get("detail", "").lower()
  1777. @pytest.mark.asyncio
  1778. @pytest.mark.integration
  1779. async def test_restore_rejects_prefix_collision_zipslip(self, async_client, monkeypatch, tmp_path):
  1780. """T1: ZIP entry with prefix-collision path must be rejected.
  1781. A startswith() check would accept '/tmp/abc_evil/file' when the
  1782. extraction root was '/tmp/abc' — is_relative_to correctly rejects it.
  1783. The restore handler creates a tempfile.TemporaryDirectory inside the
  1784. system temp dir; we craft an entry that resolves to a sibling path
  1785. whose name starts with the temp dir's basename.
  1786. """
  1787. import io
  1788. import zipfile
  1789. from backend.app.core.config import settings as app_settings
  1790. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1791. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1792. # Use a path with traversal — the resolved path will share the parent
  1793. # temp directory's basename as a prefix but NOT be inside the
  1794. # extraction root. We don't know the random extraction-root name at
  1795. # ZIP-build time, so we pick a literal "../poc-evil-prefix-collision/"
  1796. # which traverses up one level from the extraction root and lands in
  1797. # a sibling directory. is_relative_to() must reject this; a naive
  1798. # startswith() against the parent's parent would accept it.
  1799. evil_name = "../escaped-prefix-collision/poc.txt"
  1800. buf = io.BytesIO()
  1801. with zipfile.ZipFile(buf, "w") as zf:
  1802. zf.writestr(evil_name, "pwned")
  1803. zf.writestr("bambuddy.db", b"SQLite format 3\x00")
  1804. buf.seek(0)
  1805. resp = await async_client.post(
  1806. "/api/v1/settings/restore",
  1807. files={"file": ("backup.zip", buf, "application/zip")},
  1808. )
  1809. assert resp.status_code == 400
  1810. assert "unsafe path" in resp.json().get("detail", "").lower()
  1811. @pytest.mark.asyncio
  1812. @pytest.mark.integration
  1813. async def test_restore_rejects_absolute_path_in_zip(self, async_client, monkeypatch, tmp_path):
  1814. """B1: ZIP with an absolute path entry must be rejected by is_relative_to check."""
  1815. import io
  1816. import zipfile
  1817. from backend.app.core.config import settings as app_settings
  1818. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1819. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1820. buf = io.BytesIO()
  1821. with zipfile.ZipFile(buf, "w") as zf:
  1822. # Absolute path in the archive — extracts outside temp_path on
  1823. # systems where (temp_path / "/etc/passwd") resolves to /etc/passwd.
  1824. zf.writestr("/etc/passwd", "root:x:0:0")
  1825. zf.writestr("bambuddy.db", b"SQLite format 3")
  1826. buf.seek(0)
  1827. resp = await async_client.post(
  1828. "/api/v1/settings/restore",
  1829. files={"file": ("backup.zip", buf, "application/zip")},
  1830. )
  1831. assert resp.status_code == 400
  1832. assert "unsafe path" in resp.json().get("detail", "").lower()
  1833. @pytest.mark.asyncio
  1834. @pytest.mark.integration
  1835. async def test_backup_fails_when_key_file_unreadable(self, async_client, monkeypatch, tmp_path):
  1836. """A5: OSError while copying key file propagates out of create_backup_zip."""
  1837. import shutil
  1838. from backend.app.api.routes.settings import create_backup_zip
  1839. from backend.app.core.config import settings as app_settings
  1840. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1841. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1842. (tmp_path / ".mfa_encryption_key").write_text("key")
  1843. original_copy2 = shutil.copy2
  1844. def _raise_on_key(src, dst):
  1845. if ".mfa_encryption_key" in str(src):
  1846. raise OSError("simulated unreadable key file")
  1847. return original_copy2(src, dst)
  1848. monkeypatch.setattr(shutil, "copy2", _raise_on_key)
  1849. import pytest as _pytest
  1850. with _pytest.raises(OSError, match="simulated unreadable"):
  1851. await create_backup_zip(output_path=tmp_path)
  1852. @pytest.mark.asyncio
  1853. @pytest.mark.integration
  1854. async def test_backup_restore_roundtrip_preserves_encrypted_oidc_secret(
  1855. self, async_client, db_session, monkeypatch, tmp_path
  1856. ):
  1857. """T3: encrypt → backup → simulate key loss → restore → decrypt.
  1858. Verifies the user-facing promise that local backup ZIPs are
  1859. self-contained: an OIDC client_secret encrypted under one key still
  1860. decrypts after restore even when the running install no longer has
  1861. the key on disk or in the env. Exercises the B1 key-first restore
  1862. path and the B4 sample-decrypt status check together.
  1863. """
  1864. import zipfile
  1865. from pathlib import Path
  1866. from unittest.mock import AsyncMock, patch
  1867. from cryptography.fernet import Fernet
  1868. from sqlalchemy import select
  1869. import backend.app.core.encryption as enc_mod
  1870. from backend.app.api.routes.settings import create_backup_zip
  1871. from backend.app.core.config import settings as app_settings
  1872. from backend.app.models.oidc_provider import OIDCProvider
  1873. # 1. Pin a key, encrypt an OIDC secret via the property setter.
  1874. key = Fernet.generate_key().decode()
  1875. monkeypatch.setenv("MFA_ENCRYPTION_KEY", key)
  1876. monkeypatch.setenv("DATA_DIR", str(tmp_path))
  1877. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1878. # Persist the key file too, so create_backup_zip picks it up.
  1879. (tmp_path / ".mfa_encryption_key").write_text(key)
  1880. enc_mod._fernet_instance = None
  1881. enc_mod._key_source = None
  1882. provider = OIDCProvider(
  1883. name="RoundtripProv",
  1884. issuer_url="https://rt.example.com",
  1885. client_id="cid",
  1886. client_secret="my-original-secret", # via setter -> encrypted
  1887. scopes="openid email profile",
  1888. is_enabled=True,
  1889. )
  1890. db_session.add(provider)
  1891. await db_session.commit()
  1892. original_id = provider.id
  1893. assert provider._client_secret_enc.startswith("fernet:")
  1894. # 2. Create a backup ZIP (must include .mfa_encryption_key).
  1895. zip_path, _ = await create_backup_zip(output_path=tmp_path)
  1896. try:
  1897. with zipfile.ZipFile(zip_path) as zf:
  1898. names = zf.namelist()
  1899. assert ".mfa_encryption_key" in names, "T3: backup ZIP must include the key file"
  1900. # 3. Simulate key loss: delete the key file from DATA_DIR, drop
  1901. # the env var, reset the cached fernet singleton.
  1902. (tmp_path / ".mfa_encryption_key").unlink()
  1903. monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False)
  1904. enc_mod._fernet_instance = None
  1905. enc_mod._key_source = None
  1906. # 4. Restore the ZIP via the endpoint. Mock out the DB-swap
  1907. # (we keep the live in-memory test DB) and init_db side effects
  1908. # so this test focuses on the key-restore path.
  1909. with (
  1910. patch("backend.app.core.db_dialect.is_sqlite", return_value=False),
  1911. patch(
  1912. "backend.app.api.routes.settings._import_sqlite_to_postgres",
  1913. new_callable=AsyncMock,
  1914. ),
  1915. patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
  1916. patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
  1917. patch("backend.app.core.database.init_db", new_callable=AsyncMock),
  1918. patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
  1919. open(zip_path, "rb") as f,
  1920. ):
  1921. resp = await async_client.post(
  1922. "/api/v1/settings/restore",
  1923. files={"file": ("backup.zip", f, "application/zip")},
  1924. )
  1925. assert resp.status_code == 200, resp.text
  1926. # 5. Reset the singleton again (B1 already does this in production,
  1927. # but here init_db is mocked so we explicitly invalidate).
  1928. enc_mod._fernet_instance = None
  1929. enc_mod._key_source = None
  1930. # 6. The key file must be back on disk with restrictive permissions.
  1931. restored = Path(tmp_path) / ".mfa_encryption_key"
  1932. assert restored.exists(), "T3: key file must be restored to DATA_DIR"
  1933. assert (restored.stat().st_mode & 0o777) == 0o600
  1934. # 7. Decryption works again — the property getter must return the
  1935. # original plaintext, proving the restored key matches the
  1936. # cipher in the (still in-memory) DB row.
  1937. result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == original_id))
  1938. restored_provider = result.scalar_one()
  1939. assert restored_provider.client_secret == "my-original-secret"
  1940. finally:
  1941. zip_path.unlink(missing_ok=True)
  1942. # ============================================================================
  1943. # TestTOTPDecryptionBroken (C9)
  1944. # Verifies the decryption-broken state (encrypted TOTP row + no key) for each
  1945. # TOTP endpoint. Behaviour differs between recovery-aware and non-recovery
  1946. # endpoints:
  1947. # - setup_totp / enable_totp / verify_2fa: HTTP 500 (no backup-code path).
  1948. # - disable_totp / regenerate_backup_codes: fall through to the backup-code
  1949. # branch — HTTP 200 with a valid backup code, HTTP 400 without.
  1950. # ============================================================================
  1951. class TestTOTPDecryptionBroken:
  1952. """C9: RuntimeError from mfa_decrypt — 500 for non-recovery endpoints,
  1953. backup-code fall-through for disable_totp / regenerate_backup_codes."""
  1954. async def _setup_admin_and_totp_user(self, async_client, db_session):
  1955. """Create admin (enables auth), log in as admin, add TOTP record with fernet secret."""
  1956. from backend.app.models.user_totp import UserTOTP
  1957. admin_username = f"admin_c9_{secrets.token_hex(4)}"
  1958. setup = await async_client.post(
  1959. "/api/v1/auth/setup",
  1960. json={
  1961. "auth_enabled": True,
  1962. "admin_username": admin_username,
  1963. "admin_password": "Admin_C9_Pass1!",
  1964. },
  1965. )
  1966. assert setup.status_code in (200, 201), setup.text
  1967. login = await async_client.post(
  1968. "/api/v1/auth/login",
  1969. json={"username": admin_username, "password": "Admin_C9_Pass1!"},
  1970. )
  1971. assert login.status_code == 200, login.text
  1972. token = login.json()["access_token"]
  1973. # Get the admin user_id from the /me endpoint
  1974. me = await async_client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
  1975. assert me.status_code == 200
  1976. user_id = me.json()["id"]
  1977. # Insert a TOTP row with a fernet-prefixed secret directly (no key needed for insert).
  1978. totp = UserTOTP(
  1979. user_id=user_id,
  1980. _secret_enc="fernet:gAAAAA-not-really-encrypted",
  1981. is_enabled=True,
  1982. )
  1983. db_session.add(totp)
  1984. await db_session.commit()
  1985. return token, admin_username, user_id
  1986. @pytest.mark.asyncio
  1987. @pytest.mark.integration
  1988. async def test_enable_totp_returns_500_when_decryption_broken(self, async_client, db_session, monkeypatch):
  1989. """C9: enable endpoint → 500 when TOTP secret is encrypted but key unavailable."""
  1990. import backend.app.core.encryption as enc_mod
  1991. token, _, _ = await self._setup_admin_and_totp_user(async_client, db_session)
  1992. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  1993. enc_mod._fernet_instance = None
  1994. # enable_totp requires setup-but-not-yet-enabled state; force is_enabled=False
  1995. from sqlalchemy import select as _select
  1996. from backend.app.models.user_totp import UserTOTP
  1997. result = await db_session.execute(_select(UserTOTP))
  1998. for t in result.scalars().all():
  1999. t.is_enabled = False
  2000. await db_session.commit()
  2001. resp = await async_client.post(
  2002. "/api/v1/auth/2fa/totp/enable",
  2003. json={"code": "123456"},
  2004. headers={"Authorization": f"Bearer {token}"},
  2005. )
  2006. assert resp.status_code == 500
  2007. assert "unavailable" in resp.json().get("detail", "").lower()
  2008. @pytest.mark.asyncio
  2009. @pytest.mark.integration
  2010. async def test_disable_totp_returns_400_when_decryption_broken_and_no_backup_codes(
  2011. self, async_client, db_session, monkeypatch
  2012. ):
  2013. """B2a + S3: disable falls through to backup-code branch when TOTP secret
  2014. cannot be decrypted; with no backup codes seeded, the request is
  2015. rejected as an invalid code (400), not a server error.
  2016. S3: AND the failed-attempt counter must NOT be incremented — the
  2017. cause was a server-side key loss, not a user mistake.
  2018. """
  2019. from sqlalchemy import select as _select
  2020. import backend.app.core.encryption as enc_mod
  2021. from backend.app.models.auth_ephemeral import AuthRateLimitEvent
  2022. token, admin_username, _ = await self._setup_admin_and_totp_user(async_client, db_session)
  2023. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2024. enc_mod._fernet_instance = None
  2025. resp = await async_client.post(
  2026. "/api/v1/auth/2fa/totp/disable",
  2027. json={"code": "123456"},
  2028. headers={"Authorization": f"Bearer {token}"},
  2029. )
  2030. assert resp.status_code == 400
  2031. assert "invalid" in resp.json().get("detail", "").lower()
  2032. # S3: no fail-counter debit on server-side key loss.
  2033. events = (
  2034. (
  2035. await db_session.execute(
  2036. _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower())
  2037. )
  2038. )
  2039. .scalars()
  2040. .all()
  2041. )
  2042. assert len(events) == 0, "S3: must not debit fail-counter on key-loss"
  2043. @pytest.mark.asyncio
  2044. @pytest.mark.integration
  2045. async def test_regenerate_backup_codes_returns_400_when_decryption_broken_and_no_backup_codes(
  2046. self, async_client, db_session, monkeypatch
  2047. ):
  2048. """B2b + S3: regenerate-backup-codes falls through to backup-code branch when
  2049. TOTP secret cannot be decrypted; with no backup codes seeded, the
  2050. request is rejected as an invalid code (400) AND the fail-counter
  2051. is NOT incremented (S3: server-side cause, not user mistake).
  2052. """
  2053. from sqlalchemy import select as _select
  2054. import backend.app.core.encryption as enc_mod
  2055. from backend.app.models.auth_ephemeral import AuthRateLimitEvent
  2056. token, admin_username, _ = await self._setup_admin_and_totp_user(async_client, db_session)
  2057. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2058. enc_mod._fernet_instance = None
  2059. resp = await async_client.post(
  2060. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  2061. json={"code": "123456"},
  2062. headers={"Authorization": f"Bearer {token}"},
  2063. )
  2064. assert resp.status_code == 400
  2065. assert "invalid" in resp.json().get("detail", "").lower()
  2066. events = (
  2067. (
  2068. await db_session.execute(
  2069. _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower())
  2070. )
  2071. )
  2072. .scalars()
  2073. .all()
  2074. )
  2075. assert len(events) == 0, "S3: must not debit fail-counter on key-loss"
  2076. @pytest.mark.asyncio
  2077. @pytest.mark.integration
  2078. async def test_disable_totp_succeeds_via_backup_code_when_decryption_broken(
  2079. self, async_client, db_session, monkeypatch
  2080. ):
  2081. """B2a: a valid backup code disables TOTP even when the secret cannot
  2082. be decrypted — recovery path for users who lost the encryption key."""
  2083. from sqlalchemy import select as _select
  2084. import backend.app.core.encryption as enc_mod
  2085. from backend.app.api.routes.mfa import _generate_backup_codes
  2086. from backend.app.models.user_totp import UserTOTP
  2087. token, _, user_id = await self._setup_admin_and_totp_user(async_client, db_session)
  2088. # Seed a real backup-code hash on the existing TOTP row.
  2089. plain_codes, hashed_codes = _generate_backup_codes()
  2090. result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2091. totp = result.scalar_one()
  2092. totp.backup_code_hashes = hashed_codes
  2093. await db_session.commit()
  2094. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2095. enc_mod._fernet_instance = None
  2096. resp = await async_client.post(
  2097. "/api/v1/auth/2fa/totp/disable",
  2098. json={"code": plain_codes[0]},
  2099. headers={"Authorization": f"Bearer {token}"},
  2100. )
  2101. assert resp.status_code == 200, resp.text
  2102. # The TOTP row must have been deleted.
  2103. result_after = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2104. assert result_after.scalar_one_or_none() is None
  2105. @pytest.mark.asyncio
  2106. @pytest.mark.integration
  2107. async def test_regenerate_backup_codes_succeeds_via_backup_code_when_decryption_broken(
  2108. self, async_client, db_session, monkeypatch
  2109. ):
  2110. """B2b: a valid backup code rotates the codes even when the secret
  2111. cannot be decrypted — recovery path mirrors disable_totp."""
  2112. from sqlalchemy import select as _select
  2113. import backend.app.core.encryption as enc_mod
  2114. from backend.app.api.routes.mfa import _generate_backup_codes
  2115. from backend.app.models.user_totp import UserTOTP
  2116. token, _, user_id = await self._setup_admin_and_totp_user(async_client, db_session)
  2117. plain_codes, hashed_codes = _generate_backup_codes()
  2118. result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2119. totp = result.scalar_one()
  2120. totp.backup_code_hashes = hashed_codes
  2121. await db_session.commit()
  2122. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2123. enc_mod._fernet_instance = None
  2124. resp = await async_client.post(
  2125. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  2126. json={"code": plain_codes[0]},
  2127. headers={"Authorization": f"Bearer {token}"},
  2128. )
  2129. assert resp.status_code == 200, resp.text
  2130. body = resp.json()
  2131. assert "backup_codes" in body
  2132. assert len(body["backup_codes"]) == 10
  2133. @pytest.mark.asyncio
  2134. @pytest.mark.integration
  2135. async def test_disable_totp_wrong_code_with_seeded_hashes_returns_400_and_debits_counter(
  2136. self, async_client, db_session, monkeypatch
  2137. ):
  2138. """T2: with backup_code_hashes seeded AND a working encryption key,
  2139. a wrong code is rejected (400) AND the fail-counter IS incremented.
  2140. This pins the behaviour that a future refactor swallowing
  2141. compare_digest mismatches would still let the existing 'no codes
  2142. configured' tests pass — only this assertion exercises the actual
  2143. pwd_context.verify mismatch path.
  2144. """
  2145. from cryptography.fernet import Fernet
  2146. from sqlalchemy import select as _select
  2147. import backend.app.core.encryption as enc_mod
  2148. from backend.app.api.routes.mfa import _generate_backup_codes
  2149. from backend.app.models.auth_ephemeral import AuthRateLimitEvent
  2150. from backend.app.models.user_totp import UserTOTP
  2151. # Active key — secret can be decrypted, this is NOT key-loss.
  2152. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  2153. enc_mod._fernet_instance = None
  2154. token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session)
  2155. # Replace stub fernet:-prefixed value with a real encrypted secret so
  2156. # disable_totp's TOTP-decrypt path doesn't throw, AND seed real hashes.
  2157. result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2158. totp = result.scalar_one()
  2159. totp.secret = "JBSWY3DPEHPK3PXP" # via setter -> mfa_encrypt
  2160. plain_codes, hashed_codes = _generate_backup_codes()
  2161. totp.backup_code_hashes = hashed_codes
  2162. await db_session.commit()
  2163. # Submit a code that matches NEITHER the TOTP nor any backup-code hash.
  2164. resp = await async_client.post(
  2165. "/api/v1/auth/2fa/totp/disable",
  2166. json={"code": "WRONGCD1"}, # wrong but well-formed
  2167. headers={"Authorization": f"Bearer {token}"},
  2168. )
  2169. assert resp.status_code == 400
  2170. assert "invalid" in resp.json().get("detail", "").lower()
  2171. # T2 + S3: with key intact, the fail-counter MUST increment for a
  2172. # real wrong-code attempt (this is the user-error path, not key-loss).
  2173. events = (
  2174. (
  2175. await db_session.execute(
  2176. _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower())
  2177. )
  2178. )
  2179. .scalars()
  2180. .all()
  2181. )
  2182. assert len(events) >= 1, "T2: with key intact, wrong code must debit the fail-counter"
  2183. @pytest.mark.asyncio
  2184. @pytest.mark.integration
  2185. async def test_regenerate_backup_codes_wrong_code_with_seeded_hashes_returns_400_and_debits_counter(
  2186. self, async_client, db_session, monkeypatch
  2187. ):
  2188. """T2: same as the disable_totp variant for /regenerate-backup-codes."""
  2189. from cryptography.fernet import Fernet
  2190. from sqlalchemy import select as _select
  2191. import backend.app.core.encryption as enc_mod
  2192. from backend.app.api.routes.mfa import _generate_backup_codes
  2193. from backend.app.models.auth_ephemeral import AuthRateLimitEvent
  2194. from backend.app.models.user_totp import UserTOTP
  2195. monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode())
  2196. enc_mod._fernet_instance = None
  2197. token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session)
  2198. result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2199. totp = result.scalar_one()
  2200. totp.secret = "JBSWY3DPEHPK3PXP"
  2201. plain_codes, hashed_codes = _generate_backup_codes()
  2202. totp.backup_code_hashes = hashed_codes
  2203. await db_session.commit()
  2204. resp = await async_client.post(
  2205. "/api/v1/auth/2fa/totp/regenerate-backup-codes",
  2206. json={"code": "WRONGCD2"},
  2207. headers={"Authorization": f"Bearer {token}"},
  2208. )
  2209. assert resp.status_code == 400
  2210. assert "invalid" in resp.json().get("detail", "").lower()
  2211. events = (
  2212. (
  2213. await db_session.execute(
  2214. _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower())
  2215. )
  2216. )
  2217. .scalars()
  2218. .all()
  2219. )
  2220. assert len(events) >= 1, "T2: with key intact, wrong code must debit the fail-counter"
  2221. @pytest.mark.asyncio
  2222. @pytest.mark.integration
  2223. async def test_disable_totp_wrong_code_with_seeded_hashes_at_keyloss_no_counter_debit(
  2224. self, async_client, db_session, monkeypatch
  2225. ):
  2226. """T2 + S3 cross-check: with hashes seeded but encryption key gone,
  2227. a wrong code returns 400 BUT the fail-counter MUST NOT increment.
  2228. This is the dual of the test above — same wrong-code 400 outcome,
  2229. but the counter debit is gated on the cause of failure (server-side
  2230. key loss must NOT penalise the user).
  2231. """
  2232. from sqlalchemy import select as _select
  2233. import backend.app.core.encryption as enc_mod
  2234. from backend.app.api.routes.mfa import _generate_backup_codes
  2235. from backend.app.models.auth_ephemeral import AuthRateLimitEvent
  2236. from backend.app.models.user_totp import UserTOTP
  2237. token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session)
  2238. # Seed real hashes on the existing TOTP row.
  2239. result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id))
  2240. totp = result.scalar_one()
  2241. plain_codes, hashed_codes = _generate_backup_codes()
  2242. totp.backup_code_hashes = hashed_codes
  2243. await db_session.commit()
  2244. # Now simulate key loss.
  2245. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2246. enc_mod._fernet_instance = None
  2247. resp = await async_client.post(
  2248. "/api/v1/auth/2fa/totp/disable",
  2249. json={"code": "WRONGCD3"},
  2250. headers={"Authorization": f"Bearer {token}"},
  2251. )
  2252. assert resp.status_code == 400
  2253. # S3: counter MUST be unchanged — this is a server-side problem.
  2254. events = (
  2255. (
  2256. await db_session.execute(
  2257. _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower())
  2258. )
  2259. )
  2260. .scalars()
  2261. .all()
  2262. )
  2263. assert len(events) == 0, "S3: must not debit fail-counter when cause is server-side key-loss"
  2264. @pytest.mark.asyncio
  2265. @pytest.mark.integration
  2266. async def test_setup_totp_returns_500_when_decryption_broken(self, async_client, db_session, monkeypatch):
  2267. """B3: setup endpoint → 500 when an active TOTP secret can't be decrypted.
  2268. Replacing an active authenticator requires verifying the current TOTP
  2269. code; with no recovery (backup-code) path on this endpoint, the only
  2270. safe outcome is a 500 surface to the operator.
  2271. """
  2272. import backend.app.core.encryption as enc_mod
  2273. token, _, _ = await self._setup_admin_and_totp_user(async_client, db_session)
  2274. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2275. enc_mod._fernet_instance = None
  2276. resp = await async_client.post(
  2277. "/api/v1/auth/2fa/totp/setup",
  2278. json={"code": "123456"},
  2279. headers={"Authorization": f"Bearer {token}"},
  2280. )
  2281. assert resp.status_code == 500
  2282. assert "unavailable" in resp.json().get("detail", "").lower()
  2283. @pytest.mark.asyncio
  2284. @pytest.mark.integration
  2285. async def test_verify_2fa_returns_500_when_decryption_broken(self, async_client, db_session, monkeypatch):
  2286. """C9: verify endpoint (TOTP method) → 500 when TOTP secret unreadable."""
  2287. from datetime import datetime, timedelta, timezone
  2288. import backend.app.core.encryption as enc_mod
  2289. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  2290. token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session)
  2291. monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none"))
  2292. enc_mod._fernet_instance = None
  2293. # Create a pre_auth token to simulate the post-login 2FA challenge step.
  2294. raw_token = secrets.token_urlsafe(32)
  2295. ephemeral = AuthEphemeralToken(
  2296. token=raw_token,
  2297. token_type="pre_auth",
  2298. username=admin_username,
  2299. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  2300. )
  2301. db_session.add(ephemeral)
  2302. await db_session.commit()
  2303. resp = await async_client.post(
  2304. "/api/v1/auth/2fa/verify",
  2305. json={"pre_auth_token": raw_token, "method": "totp", "code": "123456"},
  2306. )
  2307. assert resp.status_code == 500
  2308. assert "unavailable" in resp.json().get("detail", "").lower()