mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
Previously, we were just relying on the cookie expiration for sessions to expire. We were not cleaning up Session records either. Rework timing to depend on an AURREMEMBER cookie which is now emitted on login during BasicAuthBackend processing. If the SID does still have a session but it's expired, we now delete the session record before returning. Otherwise, we update the session's LastUpdateTS to the current time. In addition, stored the unauthenticated result value in a variable to reduce redundancy. Signed-off-by: Kevin Morris <kevr@0cost.org>
330 lines
11 KiB
Python
330 lines
11 KiB
Python
from datetime import datetime
|
|
from http import HTTPStatus
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
import aurweb.config
|
|
|
|
from aurweb import db
|
|
from aurweb.asgi import app
|
|
from aurweb.models.account_type import USER_ID
|
|
from aurweb.models.session import Session
|
|
from aurweb.models.user import User
|
|
|
|
# Some test global constants.
|
|
TEST_USERNAME = "test"
|
|
TEST_EMAIL = "test@example.org"
|
|
TEST_REFERER = {
|
|
"referer": aurweb.config.get("options", "aur_location") + "/login",
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup(db_test):
|
|
return
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
client = TestClient(app=app)
|
|
|
|
# Necessary for forged login CSRF protection on the login route. Set here
|
|
# instead of only on the necessary requests for convenience.
|
|
client.headers.update(TEST_REFERER)
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def user() -> User:
|
|
with db.begin():
|
|
user = db.create(User, Username=TEST_USERNAME, Email=TEST_EMAIL,
|
|
RealName="Test User", Passwd="testPassword",
|
|
AccountTypeID=USER_ID)
|
|
yield user
|
|
|
|
|
|
def test_login_logout(client: TestClient, user: User):
|
|
post_data = {
|
|
"user": "test",
|
|
"passwd": "testPassword",
|
|
"next": "/"
|
|
}
|
|
|
|
with client as request:
|
|
# First, let's test get /login.
|
|
response = request.get("/login")
|
|
assert response.status_code == int(HTTPStatus.OK)
|
|
|
|
response = request.post("/login", data=post_data,
|
|
allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
|
|
# Simulate following the redirect location from above's response.
|
|
response = request.get(response.headers.get("location"))
|
|
assert response.status_code == int(HTTPStatus.OK)
|
|
|
|
response = request.post("/logout", data=post_data,
|
|
allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
|
|
response = request.post("/logout", data=post_data, cookies={
|
|
"AURSID": response.cookies.get("AURSID")
|
|
}, allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
|
|
assert "AURSID" not in response.cookies
|
|
|
|
|
|
def mock_getboolean(a, b):
|
|
if a == "options" and b == "disable_http_login":
|
|
return True
|
|
return bool(aurweb.config.get(a, b))
|
|
|
|
|
|
@mock.patch("aurweb.config.getboolean", side_effect=mock_getboolean)
|
|
def test_secure_login(getboolean: bool, client: TestClient, user: User):
|
|
""" In this test, we check to verify the course of action taken
|
|
by starlette when providing secure=True to a response cookie.
|
|
This is achieved by mocking aurweb.config.getboolean to return
|
|
True (or 1) when looking for `options.disable_http_login`.
|
|
When we receive a response with `disable_http_login` enabled,
|
|
we check the fields in cookies received for the secure and
|
|
httponly fields, in addition to the rest of the fields given
|
|
on such a request. """
|
|
|
|
# Create a local TestClient here since we mocked configuration.
|
|
# client = TestClient(app)
|
|
|
|
# Necessary for forged login CSRF protection on the login route. Set here
|
|
# instead of only on the necessary requests for convenience.
|
|
# client.headers.update(TEST_REFERER)
|
|
|
|
# Data used for our upcoming http post request.
|
|
post_data = {
|
|
"user": user.Username,
|
|
"passwd": "testPassword",
|
|
"next": "/"
|
|
}
|
|
|
|
# Perform a login request with the data matching our user.
|
|
with client as request:
|
|
response = request.post("/login", data=post_data,
|
|
allow_redirects=False)
|
|
|
|
# Make sure we got the expected status out of it.
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
|
|
# Let's check what we got in terms of cookies for AURSID.
|
|
# Make sure that a secure cookie got passed to us.
|
|
cookie = next(c for c in response.cookies if c.name == "AURSID")
|
|
assert cookie.secure is True
|
|
assert cookie.has_nonstandard_attr("HttpOnly") is True
|
|
assert cookie.has_nonstandard_attr("SameSite") is True
|
|
assert cookie.get_nonstandard_attr("SameSite") == "strict"
|
|
assert cookie.value is not None and len(cookie.value) > 0
|
|
|
|
# Let's make sure we actually have a session relationship
|
|
# with the AURSID we ended up with.
|
|
record = db.query(Session, Session.SessionID == cookie.value).first()
|
|
assert record is not None and record.User == user
|
|
assert user.session == record
|
|
|
|
|
|
def test_authenticated_login(client: TestClient, user: User):
|
|
post_data = {
|
|
"user": user.Username,
|
|
"passwd": "testPassword",
|
|
"next": "/"
|
|
}
|
|
|
|
with client as request:
|
|
# Try to login.
|
|
response = request.post("/login", data=post_data,
|
|
allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
assert response.headers.get("location") == "/"
|
|
|
|
# Now, let's verify that we get the logged in rendering
|
|
# when requesting GET /login as an authenticated user.
|
|
# Now, let's verify that we receive 403 Forbidden when we
|
|
# try to get /login as an authenticated user.
|
|
response = request.get("/login", cookies=response.cookies,
|
|
allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.OK)
|
|
assert "Logged-in as: <strong>test</strong>" in response.text
|
|
|
|
|
|
def test_unauthenticated_logout_unauthorized(client: TestClient):
|
|
with client as request:
|
|
# Alright, let's verify that attempting to /logout when not
|
|
# authenticated returns 401 Unauthorized.
|
|
response = request.post("/logout", allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
assert response.headers.get("location").startswith("/login")
|
|
|
|
|
|
def test_login_missing_username(client: TestClient):
|
|
post_data = {
|
|
"passwd": "testPassword",
|
|
"next": "/"
|
|
}
|
|
|
|
with client as request:
|
|
response = request.post("/login", data=post_data)
|
|
assert "AURSID" not in response.cookies
|
|
|
|
# Make sure password isn't prefilled and remember_me isn't checked.
|
|
content = response.content.decode()
|
|
assert post_data["passwd"] not in content
|
|
assert "checked" not in content
|
|
|
|
|
|
def test_login_remember_me(client: TestClient, user: User):
|
|
post_data = {
|
|
"user": "test",
|
|
"passwd": "testPassword",
|
|
"next": "/",
|
|
"remember_me": True
|
|
}
|
|
|
|
with client as request:
|
|
response = request.post("/login", data=post_data,
|
|
allow_redirects=False)
|
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
|
assert "AURSID" in response.cookies
|
|
|
|
cookie_timeout = aurweb.config.getint(
|
|
"options", "persistent_cookie_timeout")
|
|
now_ts = int(datetime.utcnow().timestamp())
|
|
session = db.query(Session).filter(Session.UsersID == user.ID).first()
|
|
|
|
# Expect that LastUpdateTS is not past the cookie timeout
|
|
# for a remembered session.
|
|
assert session.LastUpdateTS > (now_ts - cookie_timeout)
|
|
|
|
|
|
def test_login_incorrect_password_remember_me(client: TestClient, user: User):
|
|
post_data = {
|
|
"user": "test",
|
|
"passwd": "badPassword",
|
|
"next": "/",
|
|
"remember_me": "on"
|
|
}
|
|
|
|
with client as request:
|
|
response = request.post("/login", data=post_data)
|
|
assert "AURSID" not in response.cookies
|
|
|
|
# Make sure username is prefilled, password isn't prefilled,
|
|
# and remember_me is checked.
|
|
assert post_data["user"] in response.text
|
|
assert post_data["passwd"] not in response.text
|
|
assert "checked" in response.text
|
|
|
|
|
|
def test_login_missing_password(client: TestClient):
|
|
post_data = {
|
|
"user": "test",
|
|
"next": "/"
|
|
}
|
|
|
|
with client as request:
|
|
response = request.post("/login", data=post_data)
|
|
assert "AURSID" not in response.cookies
|
|
|
|
# Make sure username is prefilled and remember_me isn't checked.
|
|
assert post_data["user"] in response.text
|
|
assert "checked" not in response.text
|
|
|
|
|
|
def test_login_incorrect_password(client: TestClient):
|
|
post_data = {
|
|
"user": "test",
|
|
"passwd": "badPassword",
|
|
"next": "/"
|
|
}
|
|
|
|
with client as request:
|
|
response = request.post("/login", data=post_data)
|
|
assert "AURSID" not in response.cookies
|
|
|
|
# Make sure username is prefilled, password isn't prefilled
|
|
# and remember_me isn't checked.
|
|
assert post_data["user"] in response.text
|
|
assert post_data["passwd"] not in response.text
|
|
assert "checked" not in response.text
|
|
|
|
|
|
def test_login_bad_referer(client: TestClient):
|
|
post_data = {
|
|
"user": "test",
|
|
"passwd": "testPassword",
|
|
"next": "/",
|
|
}
|
|
|
|
# Create new TestClient without a Referer header.
|
|
client = TestClient(app)
|
|
|
|
with client as request:
|
|
response = request.post("/login", data=post_data)
|
|
assert "AURSID" not in response.cookies
|
|
|
|
BAD_REFERER = {
|
|
"referer": aurweb.config.get("options", "aur_location") + ".mal.local",
|
|
}
|
|
with client as request:
|
|
response = request.post("/login", data=post_data, headers=BAD_REFERER)
|
|
assert response.status_code == int(HTTPStatus.BAD_REQUEST)
|
|
assert "AURSID" not in response.cookies
|
|
|
|
|
|
def test_generate_unique_sid_exhausted(client: TestClient, user: User,
|
|
caplog: pytest.LogCaptureFixture):
|
|
"""
|
|
In this test, we mock up generate_unique_sid() to infinitely return
|
|
the same SessionID given to `user`. Within that mocking, we try
|
|
to login as `user2` and expect the internal server error rendering
|
|
by our error handler.
|
|
|
|
This exercises the bad path of /login, where we can't find a unique
|
|
SID to assign the user.
|
|
"""
|
|
now = int(datetime.utcnow().timestamp())
|
|
with db.begin():
|
|
# Create a second user; we'll login with this one.
|
|
user2 = db.create(User, Username="test2", Email="test2@example.org",
|
|
ResetKey="testReset", Passwd="testPassword",
|
|
AccountTypeID=USER_ID)
|
|
|
|
# Create a session with ID == "testSession" for `user`.
|
|
db.create(Session, User=user, SessionID="testSession",
|
|
LastUpdateTS=now)
|
|
|
|
# Mock out generate_unique_sid; always return "testSession" which
|
|
# causes us to eventually error out and raise an internal error.
|
|
def mock_generate_sid():
|
|
return "testSession"
|
|
|
|
# Login as `user2`; we expect an internal server error response
|
|
# with a relevent detail.
|
|
post_data = {
|
|
"user": user2.Username,
|
|
"passwd": "testPassword",
|
|
"next": "/",
|
|
}
|
|
generate_unique_sid_ = "aurweb.models.session.generate_unique_sid"
|
|
with mock.patch(generate_unique_sid_, mock_generate_sid):
|
|
with client as request:
|
|
# Set cookies = {} to remove any previous login kept by TestClient.
|
|
response = request.post("/login", data=post_data, cookies={})
|
|
assert response.status_code == int(HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
|
|
expected = "Unable to generate a unique session ID"
|
|
assert expected in response.text
|
|
assert "500 - Internal Server Error" in response.text
|
|
|
|
# Make sure an IntegrityError from the DB got logged out.
|
|
assert "IntegrityError" in caplog.text
|