diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4ad97393..db7dec9b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,8 @@ before_script: python-itsdangerous python-httpx python-jinja python-pytest-cov python-requests python-aiofiles python-python-multipart python-pytest-asyncio python-coverage python-bcrypt + - bash -c "echo '127.0.0.1' > /etc/hosts" + - bash -c "echo '::1' >> /etc/hosts" test: script: diff --git a/aurweb/asgi.py b/aurweb/asgi.py index b15e5874..1a61b1f4 100644 --- a/aurweb/asgi.py +++ b/aurweb/asgi.py @@ -1,5 +1,4 @@ import http -import os from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse @@ -11,7 +10,7 @@ import aurweb.config from aurweb.auth import BasicAuthBackend from aurweb.db import get_engine -from aurweb.routers import auth, html, sso, errors +from aurweb.routers import accounts, auth, errors, html, sso routes = set() @@ -43,6 +42,7 @@ async def app_startup(): app.include_router(sso.router) app.include_router(html.router) app.include_router(auth.router) + app.include_router(accounts.router) # Initialize the database engine and ORM. get_engine() diff --git a/aurweb/db.py b/aurweb/db.py index 3f5731a9..7dab6c4a 100644 --- a/aurweb/db.py +++ b/aurweb/db.py @@ -145,35 +145,21 @@ def connect(): return get_engine().connect() -class Connection: +class ConnectionExecutor: _conn = None _paramstyle = None - def __init__(self): - aur_db_backend = aurweb.config.get('database', 'backend') - - if aur_db_backend == 'mysql': + def __init__(self, conn, backend=aurweb.config.get("database", "backend")): + self._conn = conn + if backend == "mysql": import mysql.connector - aur_db_host = aurweb.config.get('database', 'host') - aur_db_name = aurweb.config.get('database', 'name') - aur_db_user = aurweb.config.get('database', 'user') - aur_db_pass = aurweb.config.get('database', 'password') - aur_db_socket = aurweb.config.get('database', 'socket') - self._conn = mysql.connector.connect(host=aur_db_host, - user=aur_db_user, - passwd=aur_db_pass, - db=aur_db_name, - unix_socket=aur_db_socket, - buffered=True) self._paramstyle = mysql.connector.paramstyle - elif aur_db_backend == 'sqlite': + elif backend == "sqlite": import sqlite3 - aur_db_name = aurweb.config.get('database', 'name') - self._conn = sqlite3.connect(aur_db_name) - self._conn.create_function("POWER", 2, math.pow) self._paramstyle = sqlite3.paramstyle - else: - raise ValueError('unsupported database backend') + + def paramstyle(self): + return self._paramstyle def execute(self, query, params=()): if self._paramstyle in ('format', 'pyformat'): @@ -193,3 +179,43 @@ class Connection: def close(self): self._conn.close() + + +class Connection: + _executor = None + _conn = None + + def __init__(self): + aur_db_backend = aurweb.config.get('database', 'backend') + + if aur_db_backend == 'mysql': + import mysql.connector + aur_db_host = aurweb.config.get('database', 'host') + aur_db_name = aurweb.config.get('database', 'name') + aur_db_user = aurweb.config.get('database', 'user') + aur_db_pass = aurweb.config.get('database', 'password') + aur_db_socket = aurweb.config.get('database', 'socket') + self._conn = mysql.connector.connect(host=aur_db_host, + user=aur_db_user, + passwd=aur_db_pass, + db=aur_db_name, + unix_socket=aur_db_socket, + buffered=True) + elif aur_db_backend == 'sqlite': + import sqlite3 + aur_db_name = aurweb.config.get('database', 'name') + self._conn = sqlite3.connect(aur_db_name) + self._conn.create_function("POWER", 2, math.pow) + else: + raise ValueError('unsupported database backend') + + self._conn = ConnectionExecutor(self._conn) + + def execute(self, query, params=()): + return self._conn.execute(query, params) + + def commit(self): + self._conn.commit() + + def close(self): + self._conn.close() diff --git a/aurweb/routers/accounts.py b/aurweb/routers/accounts.py new file mode 100644 index 00000000..0839f64e --- /dev/null +++ b/aurweb/routers/accounts.py @@ -0,0 +1,102 @@ +from http import HTTPStatus + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import or_ + +from aurweb import db +from aurweb.auth import auth_required +from aurweb.l10n import get_translator_for_request +from aurweb.models.user import User +from aurweb.scripts.notify import ResetKeyNotification +from aurweb.templates import make_context, render_template + +router = APIRouter() + + +@router.get("/passreset", response_class=HTMLResponse) +@auth_required(False) +async def passreset(request: Request): + context = make_context(request, "Password Reset") + + for k, v in request.query_params.items(): + context[k] = v + + return render_template(request, "passreset.html", context) + + +@router.post("/passreset", response_class=HTMLResponse) +@auth_required(False) +async def passreset_post(request: Request, + user: str = Form(...), + resetkey: str = Form(default=None), + password: str = Form(default=None), + confirm: str = Form(default=None)): + from aurweb.db import session + + context = make_context(request, "Password Reset") + + for k, v in dict(await request.form()).items(): + context[k] = v + + # The user parameter being required, we can match against + user = db.query(User, or_(User.Username == user, + User.Email == user)).first() + if not user: + context["errors"] = ["Invalid e-mail."] + return render_template(request, "passreset.html", context, + status_code=int(HTTPStatus.NOT_FOUND)) + + if resetkey: + context["resetkey"] = resetkey + + if not user.ResetKey or resetkey != user.ResetKey: + context["errors"] = ["Invalid e-mail."] + return render_template(request, "passreset.html", context, + status_code=int(HTTPStatus.NOT_FOUND)) + + if not user or not password: + context["errors"] = ["Missing a required field."] + return render_template(request, "passreset.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + if password != confirm: + # If the provided password does not match the provided confirm. + context["errors"] = ["Password fields do not match."] + return render_template(request, "passreset.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + if len(password) < User.minimum_passwd_length(): + # Translate the error here, which simplifies error output + # in the jinja2 template. + _ = get_translator_for_request(request) + context["errors"] = [_( + "Your password must be at least %s characters.") % ( + str(User.minimum_passwd_length()))] + return render_template(request, "passreset.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + # We got to this point; everything matched up. Update the password + # and remove the ResetKey. + user.ResetKey = str() + user.update_password(password) + + if user.session: + session.delete(user.session) + session.commit() + + # Render ?step=complete. + return RedirectResponse(url="/passreset?step=complete", + status_code=int(HTTPStatus.SEE_OTHER)) + + # If we got here, we continue with issuing a resetkey for the user. + resetkey = db.make_random_value(User, User.ResetKey) + user.ResetKey = resetkey + session.commit() + + executor = db.ConnectionExecutor(db.get_engine().raw_connection()) + ResetKeyNotification(executor, user.ID).send() + + # Render ?step=confirm. + return RedirectResponse(url="/passreset?step=confirm", + status_code=int(HTTPStatus.SEE_OTHER)) diff --git a/aurweb/routers/auth.py b/aurweb/routers/auth.py index 3a1c7192..e4864424 100644 --- a/aurweb/routers/auth.py +++ b/aurweb/routers/auth.py @@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse import aurweb.config +from aurweb.auth import auth_required from aurweb.models.user import User from aurweb.templates import make_context, render_template @@ -21,12 +22,13 @@ def login_template(request: Request, next: str, errors: list = None): @router.get("/login", response_class=HTMLResponse) +@auth_required(False) async def login_get(request: Request, next: str = "/"): - """ Homepage route. """ return login_template(request, next) @router.post("/login", response_class=HTMLResponse) +@auth_required(False) async def login_post(request: Request, next: str = Form(...), user: str = Form(default=str()), @@ -45,8 +47,8 @@ async def login_post(request: Request, cookie_timeout = aurweb.config.getint( "options", "persistent_cookie_timeout") - _, sid = user.login(request, passwd, cookie_timeout) - if not _: + sid = user.login(request, passwd, cookie_timeout) + if not sid: return login_template(request, next, errors=["Bad username or password."]) @@ -62,6 +64,7 @@ async def login_post(request: Request, @router.get("/logout") +@auth_required() async def logout(request: Request, next: str = "/"): """ A GET and POST route for logging out. @@ -81,5 +84,6 @@ async def logout(request: Request, next: str = "/"): @router.post("/logout") +@auth_required() async def logout_post(request: Request, next: str = "/"): return await logout(request=request, next=next) diff --git a/conf/config.dev b/conf/config.dev index 194a3bf8..94775a92 100644 --- a/conf/config.dev +++ b/conf/config.dev @@ -25,6 +25,12 @@ disable_http_login = 0 enable-maintenance = 0 localedir = YOUR_AUR_ROOT/web/locale +[notifications] +; For development/testing, use /usr/bin/sendmail +sendmail = YOUR_AUR_ROOT/util/sendmail +sender = notify@localhost +reply-to = noreply@localhost + ; Single sign-on; see doc/sso.txt. [sso] openid_configuration = http://127.0.0.1:8083/auth/realms/aurweb/.well-known/openid-configuration diff --git a/setup.cfg b/setup.cfg index b868c096..98261651 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,19 @@ max-line-length = 127 max-complexity = 10 +# Ignore some unavoidable flake8 warnings; we know this is against +# pycodestyle, but some of the existing codebase uses `I` variables, +# so specifically silence warnings about it in pre-defined files. +# In E741, the 'I', 'O', 'l' are ambiguous variable names. +# Our current implementation uses these variables through HTTP +# and the FastAPI form specification wants them named as such. +# In C901's case, our process_account_form function is way too +# complex for PEP (too many if statements). However, we need to +# process these anyways, and making it any more complex would +# just add confusion to the implementation. +per-file-ignores = + aurweb/routers/accounts.py:E741,C901 + [isort] line_length = 127 lines_between_types = 1 diff --git a/templates/partials/error.html b/templates/partials/error.html new file mode 100644 index 00000000..6043dfd1 --- /dev/null +++ b/templates/partials/error.html @@ -0,0 +1,15 @@ +{% if errors %} + +{% endif %} diff --git a/templates/passreset.html b/templates/passreset.html new file mode 100644 index 00000000..d2c3c2ee --- /dev/null +++ b/templates/passreset.html @@ -0,0 +1,76 @@ +{% extends "partials/layout.html" %} + +{% block pageContent %} +
+

{% trans %}Password Reset{% endtrans %}

+ +

+ {% if step == "confirm" %} + {% trans %}Check your e-mail for the confirmation link.{% endtrans %} + {% elif step == "complete" %} + {% trans %}Your password has been reset successfully.{% endtrans %} + {% elif resetkey %} + + {% include "partials/error.html" %} + +

+ + + + + + + + + + + + + + + +
{% trans %}Confirm your user name or primary e-mail address:{% endtrans %} + +
{% trans %}Enter your new password:{% endtrans %} + +
{% trans %}Confirm your new password:{% endtrans %} + +
+
+ + +
+ {% else %} + + {% set url = "https://mailman.archlinux.org/mailman/listinfo/aur-general" %} + {{ "If you have forgotten the user name and the primary e-mail " + "address you used to register, please send a message to the " + "%saur-general%s mailing list." + | tr + | format( + '' | format(url), + "") + | safe + }} +

+ + {% include "partials/error.html" %} + +
+

+ {% trans %}Enter your user name or your primary e-mail address:{% endtrans %} + +

+ +
+ {% endif %} +

+
+{% endblock %} diff --git a/test/README.md b/test/README.md index 3261899b..872d980b 100644 --- a/test/README.md +++ b/test/README.md @@ -27,6 +27,7 @@ For all the test to run, the following Arch packages should be installed: - python-pytest - python-pytest-cov - python-pytest-asyncio +- postfix Running tests ------------- @@ -37,6 +38,10 @@ First, setup the test configuration: $ sed -r 's;YOUR_AUR_ROOT;$(pwd);g' conf/config.dev > conf/config +You'll need to make sure that emails can be sent out by aurweb.scripts.notify. +If you don't have anything setup, just install postfix and start it before +running tests. + With those installed, one can run Python tests manually with any AUR config specified by `AUR_CONFIG`: diff --git a/test/test_accounts_routes.py b/test/test_accounts_routes.py new file mode 100644 index 00000000..0f548805 --- /dev/null +++ b/test/test_accounts_routes.py @@ -0,0 +1,218 @@ +from http import HTTPStatus + +import pytest + +from fastapi.testclient import TestClient + +from aurweb.asgi import app +from aurweb.db import query +from aurweb.models.account_type import AccountType +from aurweb.models.session import Session +from aurweb.models.user import User +from aurweb.testing import setup_test_db +from aurweb.testing.models import make_user +from aurweb.testing.requests import Request + +# Some test global constants. +TEST_USERNAME = "test" +TEST_EMAIL = "test@example.org" + +# Global mutables. +client = TestClient(app) +user = None + + +@pytest.fixture(autouse=True) +def setup(): + global user + + setup_test_db("Users", "Sessions", "Bans") + + account_type = query(AccountType, + AccountType.AccountType == "User").first() + user = make_user(Username=TEST_USERNAME, Email=TEST_EMAIL, + RealName="Test User", Passwd="testPassword", + AccountType=account_type) + + +def test_get_passreset_authed_redirects(): + sid = user.login(Request(), "testPassword") + assert sid is not None + + with client as request: + response = request.get("/passreset", cookies={"AURSID": sid}, + allow_redirects=False) + + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/" + + +def test_get_passreset(): + with client as request: + response = request.get("/passreset") + assert response.status_code == int(HTTPStatus.OK) + + +def test_get_passreset_translation(): + # Test that translation works. + with client as request: + response = request.get("/passreset", cookies={"AURLANG": "de"}) + + # The header title should be translated. + assert "Passwort zurücksetzen".encode("utf-8") in response.content + + # The form input label should be translated. + assert "Benutzername oder primäre E-Mail-Adresse eingeben:".encode( + "utf-8") in response.content + + # And the button. + assert "Weiter".encode("utf-8") in response.content + + +def test_get_passreset_with_resetkey(): + with client as request: + response = request.get("/passreset", data={"resetkey": "abcd"}) + assert response.status_code == int(HTTPStatus.OK) + + +def test_post_passreset_authed_redirects(): + sid = user.login(Request(), "testPassword") + assert sid is not None + + with client as request: + response = request.post("/passreset", + cookies={"AURSID": sid}, + data={"user": "blah"}, + allow_redirects=False) + + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/" + + +def test_post_passreset_user(): + # With username. + with client as request: + response = request.post("/passreset", data={"user": TEST_USERNAME}) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/passreset?step=confirm" + + # With e-mail. + with client as request: + response = request.post("/passreset", data={"user": TEST_EMAIL}) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/passreset?step=confirm" + + +def test_post_passreset_resetkey(): + from aurweb.db import session + + user.session = Session(UsersID=user.ID, SessionID="blah", + LastUpdateTS=datetime.utcnow().timestamp()) + session.commit() + + # Prepare a password reset. + with client as request: + response = request.post("/passreset", data={"user": TEST_USERNAME}) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/passreset?step=confirm" + + # Now that we've prepared the password reset, prepare a POST + # request with the user's ResetKey. + resetkey = user.ResetKey + post_data = { + "user": TEST_USERNAME, + "resetkey": resetkey, + "password": "abcd1234", + "confirm": "abcd1234" + } + + with client as request: + response = request.post("/passreset", data=post_data) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/passreset?step=complete" + + +def test_post_passreset_error_invalid_email(): + # First, test with a user that doesn't even exist. + with client as request: + response = request.post("/passreset", data={"user": "invalid"}) + assert response.status_code == int(HTTPStatus.NOT_FOUND) + + error = "Invalid e-mail." + assert error in response.content.decode("utf-8") + + # Then, test with an invalid resetkey for a real user. + _ = make_resetkey() + post_data = make_passreset_data("fake") + post_data["password"] = "abcd1234" + post_data["confirm"] = "abcd1234" + + with client as request: + response = request.post("/passreset", data=post_data) + assert response.status_code == int(HTTPStatus.NOT_FOUND) + assert error in response.content.decode("utf-8") + + +def make_resetkey(): + with client as request: + response = request.post("/passreset", data={"user": TEST_USERNAME}) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert response.headers.get("location") == "/passreset?step=confirm" + return user.ResetKey + + +def make_passreset_data(resetkey): + return { + "user": user.Username, + "resetkey": resetkey + } + + +def test_post_passreset_error_missing_field(): + # Now that we've prepared the password reset, prepare a POST + # request with the user's ResetKey. + resetkey = make_resetkey() + post_data = make_passreset_data(resetkey) + + with client as request: + response = request.post("/passreset", data=post_data) + + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + + error = "Missing a required field." + assert error in response.content.decode("utf-8") + + +def test_post_passreset_error_password_mismatch(): + resetkey = make_resetkey() + post_data = make_passreset_data(resetkey) + + post_data["password"] = "abcd1234" + post_data["confirm"] = "mismatched" + + with client as request: + response = request.post("/passreset", data=post_data) + + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + + error = "Password fields do not match." + assert error in response.content.decode("utf-8") + + +def test_post_passreset_error_password_requirements(): + resetkey = make_resetkey() + post_data = make_passreset_data(resetkey) + + passwd_min_len = User.minimum_passwd_length() + assert passwd_min_len >= 4 + + post_data["password"] = "x" + post_data["confirm"] = "x" + + with client as request: + response = request.post("/passreset", data=post_data) + + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + + error = f"Your password must be at least {passwd_min_len} characters." + assert error in response.content.decode("utf-8") diff --git a/test/test_auth_routes.py b/test/test_auth_routes.py index adf75329..ff8a08e9 100644 --- a/test/test_auth_routes.py +++ b/test/test_auth_routes.py @@ -14,8 +14,12 @@ from aurweb.models.session import Session from aurweb.testing import setup_test_db from aurweb.testing.models import make_user -client = TestClient(app) +# Some test global constants. +TEST_USERNAME = "test" +TEST_EMAIL = "test@example.org" +# Global mutables. +client = TestClient(app) user = None @@ -27,7 +31,8 @@ def setup(): account_type = query(AccountType, AccountType.AccountType == "User").first() - user = make_user(Username="test", Email="test@example.org", + + user = make_user(Username=TEST_USERNAME, Email=TEST_EMAIL, RealName="Test User", Passwd="testPassword", AccountType=account_type) @@ -40,16 +45,16 @@ def test_login_logout(): } with client as request: - response = client.get("/login") + # 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) - response = request.get(response.headers.get("location"), cookies={ - "AURSID": response.cookies.get("AURSID") - }) + # 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, @@ -60,9 +65,37 @@ def test_login_logout(): "AURSID": response.cookies.get("AURSID") }, allow_redirects=False) assert response.status_code == int(HTTPStatus.SEE_OTHER) + assert "AURSID" not in response.cookies +def test_authenticated_login_forbidden(): + post_data = { + "user": "test", + "passwd": "testPassword", + "next": "/" + } + + with client as request: + # Login. + response = request.post("/login", data=post_data, + allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + + # Now, let's verify that we receive 403 Forbidden when we + # try to get /login as an authenticated user. + response = request.get("/login", allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + + +def test_unauthenticated_logout_unauthorized(): + with client as request: + # Alright, let's verify that attempting to /logout when not + # authenticated returns 401 Unauthorized. + response = request.get("/logout", allow_redirects=False) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + + def test_login_missing_username(): post_data = { "passwd": "testPassword", @@ -75,8 +108,6 @@ def test_login_missing_username(): def test_login_remember_me(): - from aurweb.db import session - post_data = { "user": "test", "passwd": "testPassword", @@ -94,8 +125,8 @@ def test_login_remember_me(): "options", "persistent_cookie_timeout") expected_ts = datetime.utcnow().timestamp() + cookie_timeout - _session = session.query(Session).filter( - Session.UsersID == user.ID).first() + _session = query(Session, + Session.UsersID == user.ID).first() # Expect that LastUpdateTS was within 5 seconds of the expected_ts, # which is equal to the current timestamp + persistent_cookie_timeout. diff --git a/test/test_db.py b/test/test_db.py index 1eb0dc28..e0946ed5 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -3,7 +3,6 @@ import re import sqlite3 import tempfile -from datetime import datetime from unittest import mock import mysql.connector @@ -185,3 +184,15 @@ def test_create_delete(): db.delete(AccountType, AccountType.AccountType == "test") record = db.query(AccountType, AccountType.AccountType == "test").first() assert record is None + + +@mock.patch("mysql.connector.paramstyle", "qmark") +def test_connection_executor_mysql_paramstyle(): + executor = db.ConnectionExecutor(None, backend="mysql") + assert executor.paramstyle() == "qmark" + + +@mock.patch("sqlite3.paramstyle", "pyformat") +def test_connection_executor_sqlite_paramstyle(): + executor = db.ConnectionExecutor(None, backend="sqlite") + assert executor.paramstyle() == "pyformat" diff --git a/test/test_user.py b/test/test_user.py index b8d4248a..4f144819 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -28,8 +28,8 @@ def setup(): setup_test_db("Users", "Sessions", "Bans") - account_type = session.query(AccountType).filter( - AccountType.AccountType == "User").first() + account_type = query(AccountType, + AccountType.AccountType == "User").first() user = make_user(Username="test", Email="test@example.org", RealName="Test User", Passwd="testPassword", @@ -67,7 +67,7 @@ def test_user_login_logout(): assert user.session.User == user # Search for the user via query API. - result = session.query(User).filter(User.ID == user.ID).first() + result = query(User, User.ID == user.ID).first() # Compare the result and our original user. assert result == user diff --git a/util/sendmail b/util/sendmail new file mode 100755 index 00000000..06bd9865 --- /dev/null +++ b/util/sendmail @@ -0,0 +1,2 @@ +#!/bin/bash +exit 0