2026-10-01 20:16:27 +02:00
|
|
|
"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset
|
|
|
|
|
flow is in test_password_reset.py."""
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
import pyotp
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from httpx import ASGITransport
|
|
|
|
|
from sqlalchemy import delete
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
|
|
|
|
from app.identity.passwords import hash_password
|
|
|
|
|
from app.identity.sessions import create_session
|
|
|
|
|
from app.main import create_app
|
|
|
|
|
from app.models import User
|
|
|
|
|
|
|
|
|
|
CSRF = "csrf-token"
|
|
|
|
|
PASSWORD = "hunter2pass"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Ctx:
|
|
|
|
|
app: object
|
|
|
|
|
client: httpx.AsyncClient
|
|
|
|
|
user_id: uuid.UUID
|
|
|
|
|
handle: str
|
|
|
|
|
session: AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hdr() -> dict[str, str]:
|
|
|
|
|
return {"X-CSRF": CSRF}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def ctx() -> AsyncIterator[Ctx]:
|
|
|
|
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
|
|
|
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
session = sm()
|
|
|
|
|
handle = f"id{uuid.uuid4().hex[:8]}@test.local"
|
|
|
|
|
user = User(handle=handle, password_hash=hash_password(PASSWORD), recovery_email="r@x.com")
|
|
|
|
|
session.add(user)
|
|
|
|
|
await session.commit()
|
|
|
|
|
token = await create_session(user.id, "pytest")
|
|
|
|
|
app = create_app()
|
|
|
|
|
client = httpx.AsyncClient(
|
|
|
|
|
transport=ASGITransport(app=app),
|
|
|
|
|
base_url="http://testserver",
|
|
|
|
|
cookies={"session": token, "csrf": CSRF},
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
yield Ctx(app=app, client=client, user_id=user.id, handle=handle, session=session)
|
|
|
|
|
finally:
|
|
|
|
|
await client.aclose()
|
|
|
|
|
await session.execute(delete(User).where(User.id == user.id))
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.close()
|
|
|
|
|
await engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fresh_client(app: object) -> httpx.AsyncClient:
|
|
|
|
|
return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_totp_setup_enable_and_login(ctx: Ctx) -> None:
|
|
|
|
|
setup = await ctx.client.post("/api/auth/totp/setup", headers=_hdr())
|
|
|
|
|
assert setup.status_code == 200
|
|
|
|
|
secret = setup.json()["secret"]
|
|
|
|
|
assert setup.json()["provisioning_uri"].startswith("otpauth://")
|
|
|
|
|
|
|
|
|
|
# wrong code rejected
|
|
|
|
|
bad = await ctx.client.post("/api/auth/totp/enable", json={"code": "000000"}, headers=_hdr())
|
|
|
|
|
assert bad.status_code == 400
|
|
|
|
|
|
|
|
|
|
code = pyotp.TOTP(secret).now()
|
|
|
|
|
ok = await ctx.client.post("/api/auth/totp/enable", json={"code": code}, headers=_hdr())
|
|
|
|
|
assert ok.status_code == 200
|
|
|
|
|
|
|
|
|
|
# login now requires the 2FA code
|
|
|
|
|
async with _fresh_client(ctx.app) as anon:
|
|
|
|
|
no_totp = await anon.post(
|
|
|
|
|
"/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}
|
|
|
|
|
)
|
|
|
|
|
assert no_totp.status_code == 401
|
|
|
|
|
assert no_totp.json()["error"]["code"] == "totp_required"
|
|
|
|
|
|
|
|
|
|
with_totp = await anon.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"handle": ctx.handle, "password": PASSWORD, "totp": pyotp.TOTP(secret).now()},
|
|
|
|
|
)
|
|
|
|
|
assert with_totp.status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_totp_disable(ctx: Ctx) -> None:
|
|
|
|
|
# enable first
|
|
|
|
|
secret = (await ctx.client.post("/api/auth/totp/setup", headers=_hdr())).json()["secret"]
|
|
|
|
|
await ctx.client.post(
|
|
|
|
|
"/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
|
|
|
|
|
|
|
|
|
|
# wrong password is rejected
|
|
|
|
|
bad = await ctx.client.post("/api/auth/totp/disable", json={"password": "nope"}, headers=_hdr())
|
|
|
|
|
assert bad.status_code == 401
|
|
|
|
|
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
|
|
|
|
|
|
|
|
|
|
# correct password disables it
|
|
|
|
|
ok = await ctx.client.post(
|
|
|
|
|
"/api/auth/totp/disable", json={"password": PASSWORD}, headers=_hdr()
|
|
|
|
|
)
|
|
|
|
|
assert ok.status_code == 200
|
|
|
|
|
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is False
|
|
|
|
|
|
|
|
|
|
# login now works without a code
|
|
|
|
|
async with _fresh_client(ctx.app) as anon:
|
|
|
|
|
resp = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
|
|
|
|
|
# create a second session for the same user (a different device)
|
|
|
|
|
other_token = await create_session(ctx.user_id, "other-device")
|
|
|
|
|
|
|
|
|
|
listing = await ctx.client.get("/api/auth/sessions")
|
|
|
|
|
assert listing.status_code == 200
|
|
|
|
|
rows = listing.json()
|
|
|
|
|
assert len(rows) == 2
|
|
|
|
|
current = [r for r in rows if r["current"]]
|
|
|
|
|
assert len(current) == 1
|
|
|
|
|
|
|
|
|
|
other_sid = next(r["sid"] for r in rows if not r["current"])
|
|
|
|
|
revoked = await ctx.client.request("DELETE", f"/api/auth/sessions/{other_sid}", headers=_hdr())
|
|
|
|
|
assert revoked.status_code == 200
|
|
|
|
|
|
|
|
|
|
# the revoked token no longer resolves
|
|
|
|
|
async with _fresh_client(ctx.app) as anon:
|
|
|
|
|
anon.cookies.set("session", other_token)
|
|
|
|
|
me = await anon.get("/api/auth/me")
|
|
|
|
|
assert me.status_code == 401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
|
|
|
|
|
# must not leak whether a handle exists
|
|
|
|
|
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
|
|
|
|
|
assert resp.status_code == 200
|