Commit a33d076d authored by Kevin Morris's avatar Kevin Morris
Browse files

add passreset routes

Introduced `get|post` `/passreset` routes. These routes mimic the
behavior of the existing PHP implementation, with the exception of
HTTP status code returns.

Routes added:
    GET /passreset
    POST /passreset

Routers added:
    aurweb.routers.accounts

* On an unknown user or mismatched resetkey (where resetkey must ==
  user.resetkey), return HTTP status NOT_FOUND (404).
* On another error in the request, return HTTP status BAD_REQUEST (400).

Both `get|post` routes requires that the current user is **not**
authenticated, hence `@auth_required(False, redirect="/")`.

+ Added auth_required decorator to aurweb.auth.
+ Added some more utility to aurweb.models.user.User.
+ Added `partials/error.html` template.
+ Added `passreset.html` template.
+ Added aurweb.db.ConnectionExecutor functor for paramstyle logic.
  Decoupling the executor logic from the database connection logic
  is needed for us to easily use the same logic with a fastapi
  database session, when we need to use aurwe...
parent 4423326c
......@@ -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:
......
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()
......
......@@ -145,10 +145,46 @@ def connect():
return get_engine().connect()
class Connection:
class ConnectionExecutor:
_conn = None
_paramstyle = None
def __init__(self, conn, backend=aurweb.config.get("database", "backend")):
self._conn = conn
if backend == "mysql":
import mysql.connector
self._paramstyle = mysql.connector.paramstyle
elif backend == "sqlite":
import sqlite3
self._paramstyle = sqlite3.paramstyle
def paramstyle(self):
return self._paramstyle
def execute(self, query, params=()):
if self._paramstyle in ('format', 'pyformat'):
query = query.replace('%', '%%').replace('?', '%s')
elif self._paramstyle == 'qmark':
pass
else:
raise ValueError('unsupported paramstyle')
cur = self._conn.cursor()
cur.execute(query, params)
return cur
def commit(self):
self._conn.commit()
def close(self):
self._conn.close()
class Connection:
_executor = None
_conn = None
def __init__(self):
aur_db_backend = aurweb.config.get('database', 'backend')
......@@ -165,28 +201,18 @@ class Connection:
db=aur_db_name,
unix_socket=aur_db_socket,
buffered=True)
self._paramstyle = mysql.connector.paramstyle
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)
self._paramstyle = sqlite3.paramstyle
else:
raise ValueError('unsupported database backend')
def execute(self, query, params=()):
if self._paramstyle in ('format', 'pyformat'):
query = query.replace('%', '%%').replace('?', '%s')
elif self._paramstyle == 'qmark':
pass
else:
raise ValueError('unsupported paramstyle')
cur = self._conn.cursor()
cur.execute(query, params)
self._conn = ConnectionExecutor(self._conn)
return cur
def execute(self, query, params=()):
return self._conn.execute(query, params)
def commit(self):
self._conn.commit()
......
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))
......@@ -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)
......@@ -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
......
......@@ -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
......
{% if errors %}
<ul class="errorlist">
{% for error in errors %}
{% if error is string %}
<li>{{ error | tr | safe }}</li>
{% elif error is iterable %}
<ul>
{% for e in error %}
<li>{{ e | tr | safe }}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% extends "partials/layout.html" %}
{% block pageContent %}
<div class="box">
<h2>{% trans %}Password Reset{% endtrans %}</h2>
<p>
{% 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 %}
<!-- Provided with a resetkey. -->
{% include "partials/error.html" %}
<form method="post">
<table>
<tbody>
<tr>
<td>{% trans %}Confirm your user name or primary e-mail address:{% endtrans %}</td>
<td>
<input type="text" name="user" size="30" maxlength="64"
value="{{ user or '' }}">
</td>
</tr>
<tr>
<td>{% trans %}Enter your new password:{% endtrans %}</td>
<td>
<input type="password" name="password" size="30"
value="{{ password or '' }}">
</td>
</tr>
<tr>
<td>{% trans %}Confirm your new password:{% endtrans %}</td>
<td>
<input type="password" name="confirm" size="30"
value="{{ confirm or '' }}">
</td>
</tr>
</tbody>
</table>
<br>
<input type="hidden" name="resetkey"
value="{{ resetkey }}">
<input class="button" type="submit"
value="{% trans %}Continue{% endtrans %}">
</form>
{% else %}
<!-- Default page with prompt for user name/e-mail. -->
{% 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(
'<a href="%s">' | format(url),
"</a>")
| safe
}}
</p>
{% include "partials/error.html" %}
<form method="post">
<p>
{% trans %}Enter your user name or your primary e-mail address:{% endtrans %}
<input type="text" name="user" size="30" maxlength="64"
value="{{ user or '' }}">
</p>
<input class="button" type="submit"
value="{% trans %}Continue{% endtrans %}">
</form>
{% endif %}
</p>
</div>
{% endblock %}
......@@ -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`:
......
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