138 lines
5.8 KiB
Python
138 lines
5.8 KiB
Python
"""Sessions: HTTPS-only flags follow the trusted proxy, logins are throttled
|
|
by address, logout is a POST, and a session speaks for the user as they are."""
|
|
import time
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from quart import session
|
|
|
|
from steward.auth.middleware import refresh_session_user
|
|
from steward.auth.throttle import LoginThrottle, login_throttle
|
|
from steward.core.proxy import trusted_value
|
|
from steward.models.users import UserRole
|
|
|
|
|
|
@pytest.mark.parametrize("values,hops,want", [
|
|
(["https"], 0, None),
|
|
(["https"], 1, "https"),
|
|
(["http, https"], 1, "https"),
|
|
(["evil", "https"], 1, "https"),
|
|
(["https, http"], 2, "https"),
|
|
(["https"], 2, None),
|
|
([], 1, None),
|
|
])
|
|
def test_trusted_value_reads_from_the_right(values, hops, want):
|
|
assert trusted_value(values, hops) == want
|
|
|
|
|
|
@pytest.mark.parametrize("hops,headers,secure", [
|
|
(0, {"X-Forwarded-Proto": "https"}, False),
|
|
(1, {"X-Forwarded-Proto": "https"}, True),
|
|
(1, {}, False),
|
|
])
|
|
async def test_secure_cookie_and_hsts_follow_the_trusted_proxy(app, client, hops, headers, secure):
|
|
app.config["TRUSTED_PROXY_HOPS"] = hops
|
|
response = await client.get("/login", headers=headers)
|
|
cookies = [c for c in response.headers.getlist("Set-Cookie") if c.startswith("session=")]
|
|
assert cookies, "the login page sets a session (its CSRF token)"
|
|
assert ("; Secure" in cookies[0]) is secure
|
|
assert ("Strict-Transport-Security" in response.headers) is secure
|
|
|
|
|
|
def test_throttle_counts_failures_within_the_window():
|
|
t = LoginThrottle()
|
|
for i in range(3):
|
|
assert t.retry_after("1.2.3.4", 3, 60, now=100 + i) == 0
|
|
t.failed("1.2.3.4", now=100 + i)
|
|
assert t.retry_after("1.2.3.4", 3, 60, now=103) > 0
|
|
assert t.retry_after("5.6.7.8", 3, 60, now=103) == 0
|
|
assert t.retry_after("1.2.3.4", 3, 60, now=161) == 0 # the first failure aged out
|
|
t.failed("1.2.3.4", now=161)
|
|
t.succeeded("1.2.3.4")
|
|
assert t.retry_after("1.2.3.4", 3, 60, now=162) == 0
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_throttle():
|
|
login_throttle._failures.clear()
|
|
yield login_throttle
|
|
login_throttle._failures.clear()
|
|
|
|
|
|
async def test_login_is_refused_after_repeated_failures(app, client, fresh_throttle):
|
|
app.config["LOGIN_MAX_FAILURES"] = 2
|
|
async with client.session_transaction() as sess:
|
|
sess["_csrf"] = "t"
|
|
form = {"username": "admin", "password": "wrong"}
|
|
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
|
|
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=None)):
|
|
for _ in range(2):
|
|
assert (await client.post("/login", form=form, headers={"X-CSRF-Token": "t"})).status_code == 400
|
|
refused = await client.post("/login", form=form, headers={"X-CSRF-Token": "t"})
|
|
assert refused.status_code == 429
|
|
assert "Too many failed logins" in await refused.get_data(as_text=True)
|
|
|
|
|
|
async def test_logout_is_a_post(client):
|
|
async with client.session_transaction() as sess:
|
|
sess.update(user_id="u", user_role="admin", username="a", _csrf="t")
|
|
assert (await client.get("/logout")).status_code == 405
|
|
response = await client.post("/logout", headers={"X-CSRF-Token": "t"})
|
|
assert response.status_code == 302
|
|
async with client.session_transaction() as sess:
|
|
assert "user_id" not in sess
|
|
|
|
|
|
class _DB:
|
|
def __init__(self, user):
|
|
self.user = user
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def get(self, model, key):
|
|
return self.user
|
|
|
|
|
|
@pytest.mark.parametrize("user,login_age_s,keeps,role", [
|
|
# demoted, and a time zone set on the Account page since (milestone 490)
|
|
(SimpleNamespace(is_active=True, role=UserRole.viewer, time_zone="Europe/London", sessions_since=None),
|
|
60, True, "viewer"),
|
|
(SimpleNamespace(is_active=False, role=UserRole.admin, time_zone=None, sessions_since=None),
|
|
60, False, None), # deactivated
|
|
(None, 60, False, None), # deleted
|
|
(SimpleNamespace(is_active=True, role=UserRole.admin, time_zone=None, sessions_since=None),
|
|
9 * 3600, False, None), # expired
|
|
])
|
|
async def test_session_speaks_for_the_user_as_they_are(app, user, login_age_s, keeps, role):
|
|
app.db_sessionmaker = lambda: _DB(user)
|
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=8)
|
|
async with app.test_request_context("/"):
|
|
session.update(user_id="u", user_role="admin", username="a",
|
|
login_at=time.time() - login_age_s)
|
|
await refresh_session_user()
|
|
assert ("user_id" in session) is keeps
|
|
assert session.get("user_role") == role
|
|
assert session.get("tz") == (user.time_zone if keeps else None)
|
|
|
|
|
|
@pytest.mark.parametrize("reset_ago_s,keeps", [(30, False), (120, True)])
|
|
async def test_a_password_reset_ends_the_sessions_begun_before_it(app, reset_ago_s, keeps):
|
|
"""#5427: signed in a minute ago; a reset 30 s ago ends the session, one
|
|
two minutes ago (before this sign-in) doesn't."""
|
|
from datetime import datetime, timezone
|
|
now = time.time()
|
|
user = SimpleNamespace(is_active=True, role=UserRole.viewer, time_zone=None,
|
|
sessions_since=datetime.fromtimestamp(now - reset_ago_s, timezone.utc))
|
|
app.db_sessionmaker = lambda: _DB(user)
|
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=8)
|
|
async with app.test_request_context("/"):
|
|
session.update(user_id="u", user_role="viewer", username="a", login_at=now - 60)
|
|
await refresh_session_user()
|
|
assert ("user_id" in session) is keeps
|