mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
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 aurweb.scripts modules. At this point, notification configuration is now required to complete tests involved with notifications properly, like passreset. `conf/config.dev` has been modified to include [notifications] sendmail, sender and reply-to overrides. Dockerfile and .gitlab-ci.yml have been updated to setup /etc/hosts and start postfix before running tests. * setup.cfg: ignore E741, C901 in aurweb.routers.accounts These two warnings (shown in the commit) are not dangerous and a bi-product of maintaining compatibility with our current code flow. Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
parent
4423326cec
commit
a33d076d8b
15 changed files with 552 additions and 41 deletions
|
@ -15,6 +15,8 @@ before_script:
|
||||||
python-itsdangerous python-httpx python-jinja python-pytest-cov
|
python-itsdangerous python-httpx python-jinja python-pytest-cov
|
||||||
python-requests python-aiofiles python-python-multipart
|
python-requests python-aiofiles python-python-multipart
|
||||||
python-pytest-asyncio python-coverage python-bcrypt
|
python-pytest-asyncio python-coverage python-bcrypt
|
||||||
|
- bash -c "echo '127.0.0.1' > /etc/hosts"
|
||||||
|
- bash -c "echo '::1' >> /etc/hosts"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
script:
|
script:
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import http
|
import http
|
||||||
import os
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
@ -11,7 +10,7 @@ import aurweb.config
|
||||||
|
|
||||||
from aurweb.auth import BasicAuthBackend
|
from aurweb.auth import BasicAuthBackend
|
||||||
from aurweb.db import get_engine
|
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()
|
routes = set()
|
||||||
|
|
||||||
|
@ -43,6 +42,7 @@ async def app_startup():
|
||||||
app.include_router(sso.router)
|
app.include_router(sso.router)
|
||||||
app.include_router(html.router)
|
app.include_router(html.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(accounts.router)
|
||||||
|
|
||||||
# Initialize the database engine and ORM.
|
# Initialize the database engine and ORM.
|
||||||
get_engine()
|
get_engine()
|
||||||
|
|
70
aurweb/db.py
70
aurweb/db.py
|
@ -145,35 +145,21 @@ def connect():
|
||||||
return get_engine().connect()
|
return get_engine().connect()
|
||||||
|
|
||||||
|
|
||||||
class Connection:
|
class ConnectionExecutor:
|
||||||
_conn = None
|
_conn = None
|
||||||
_paramstyle = None
|
_paramstyle = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, conn, backend=aurweb.config.get("database", "backend")):
|
||||||
aur_db_backend = aurweb.config.get('database', 'backend')
|
self._conn = conn
|
||||||
|
if backend == "mysql":
|
||||||
if aur_db_backend == 'mysql':
|
|
||||||
import mysql.connector
|
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
|
self._paramstyle = mysql.connector.paramstyle
|
||||||
elif aur_db_backend == 'sqlite':
|
elif backend == "sqlite":
|
||||||
import sqlite3
|
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
|
self._paramstyle = sqlite3.paramstyle
|
||||||
else:
|
|
||||||
raise ValueError('unsupported database backend')
|
def paramstyle(self):
|
||||||
|
return self._paramstyle
|
||||||
|
|
||||||
def execute(self, query, params=()):
|
def execute(self, query, params=()):
|
||||||
if self._paramstyle in ('format', 'pyformat'):
|
if self._paramstyle in ('format', 'pyformat'):
|
||||||
|
@ -193,3 +179,43 @@ class Connection:
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self._conn.close()
|
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()
|
||||||
|
|
102
aurweb/routers/accounts.py
Normal file
102
aurweb/routers/accounts.py
Normal file
|
@ -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))
|
|
@ -6,6 +6,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
import aurweb.config
|
import aurweb.config
|
||||||
|
|
||||||
|
from aurweb.auth import auth_required
|
||||||
from aurweb.models.user import User
|
from aurweb.models.user import User
|
||||||
from aurweb.templates import make_context, render_template
|
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)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
@auth_required(False)
|
||||||
async def login_get(request: Request, next: str = "/"):
|
async def login_get(request: Request, next: str = "/"):
|
||||||
""" Homepage route. """
|
|
||||||
return login_template(request, next)
|
return login_template(request, next)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_class=HTMLResponse)
|
@router.post("/login", response_class=HTMLResponse)
|
||||||
|
@auth_required(False)
|
||||||
async def login_post(request: Request,
|
async def login_post(request: Request,
|
||||||
next: str = Form(...),
|
next: str = Form(...),
|
||||||
user: str = Form(default=str()),
|
user: str = Form(default=str()),
|
||||||
|
@ -45,8 +47,8 @@ async def login_post(request: Request,
|
||||||
cookie_timeout = aurweb.config.getint(
|
cookie_timeout = aurweb.config.getint(
|
||||||
"options", "persistent_cookie_timeout")
|
"options", "persistent_cookie_timeout")
|
||||||
|
|
||||||
_, sid = user.login(request, passwd, cookie_timeout)
|
sid = user.login(request, passwd, cookie_timeout)
|
||||||
if not _:
|
if not sid:
|
||||||
return login_template(request, next,
|
return login_template(request, next,
|
||||||
errors=["Bad username or password."])
|
errors=["Bad username or password."])
|
||||||
|
|
||||||
|
@ -62,6 +64,7 @@ async def login_post(request: Request,
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
|
@auth_required()
|
||||||
async def logout(request: Request, next: str = "/"):
|
async def logout(request: Request, next: str = "/"):
|
||||||
""" A GET and POST route for logging out.
|
""" A GET and POST route for logging out.
|
||||||
|
|
||||||
|
@ -81,5 +84,6 @@ async def logout(request: Request, next: str = "/"):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
|
@auth_required()
|
||||||
async def logout_post(request: Request, next: str = "/"):
|
async def logout_post(request: Request, next: str = "/"):
|
||||||
return await logout(request=request, next=next)
|
return await logout(request=request, next=next)
|
||||||
|
|
|
@ -25,6 +25,12 @@ disable_http_login = 0
|
||||||
enable-maintenance = 0
|
enable-maintenance = 0
|
||||||
localedir = YOUR_AUR_ROOT/web/locale
|
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.
|
; Single sign-on; see doc/sso.txt.
|
||||||
[sso]
|
[sso]
|
||||||
openid_configuration = http://127.0.0.1:8083/auth/realms/aurweb/.well-known/openid-configuration
|
openid_configuration = http://127.0.0.1:8083/auth/realms/aurweb/.well-known/openid-configuration
|
||||||
|
|
13
setup.cfg
13
setup.cfg
|
@ -2,6 +2,19 @@
|
||||||
max-line-length = 127
|
max-line-length = 127
|
||||||
max-complexity = 10
|
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]
|
[isort]
|
||||||
line_length = 127
|
line_length = 127
|
||||||
lines_between_types = 1
|
lines_between_types = 1
|
||||||
|
|
15
templates/partials/error.html
Normal file
15
templates/partials/error.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% 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 %}
|
76
templates/passreset.html
Normal file
76
templates/passreset.html
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
{% 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
|
||||||
- python-pytest-cov
|
- python-pytest-cov
|
||||||
- python-pytest-asyncio
|
- python-pytest-asyncio
|
||||||
|
- postfix
|
||||||
|
|
||||||
Running tests
|
Running tests
|
||||||
-------------
|
-------------
|
||||||
|
@ -37,6 +38,10 @@ First, setup the test configuration:
|
||||||
|
|
||||||
$ sed -r 's;YOUR_AUR_ROOT;$(pwd);g' conf/config.dev > conf/config
|
$ 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
|
With those installed, one can run Python tests manually with any AUR config
|
||||||
specified by `AUR_CONFIG`:
|
specified by `AUR_CONFIG`:
|
||||||
|
|
||||||
|
|
218
test/test_accounts_routes.py
Normal file
218
test/test_accounts_routes.py
Normal file
|
@ -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")
|
|
@ -14,8 +14,12 @@ from aurweb.models.session import Session
|
||||||
from aurweb.testing import setup_test_db
|
from aurweb.testing import setup_test_db
|
||||||
from aurweb.testing.models import make_user
|
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
|
user = None
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,7 +31,8 @@ def setup():
|
||||||
|
|
||||||
account_type = query(AccountType,
|
account_type = query(AccountType,
|
||||||
AccountType.AccountType == "User").first()
|
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",
|
RealName="Test User", Passwd="testPassword",
|
||||||
AccountType=account_type)
|
AccountType=account_type)
|
||||||
|
|
||||||
|
@ -40,16 +45,16 @@ def test_login_logout():
|
||||||
}
|
}
|
||||||
|
|
||||||
with client as request:
|
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)
|
assert response.status_code == int(HTTPStatus.OK)
|
||||||
|
|
||||||
response = request.post("/login", data=post_data,
|
response = request.post("/login", data=post_data,
|
||||||
allow_redirects=False)
|
allow_redirects=False)
|
||||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||||
|
|
||||||
response = request.get(response.headers.get("location"), cookies={
|
# Simulate following the redirect location from above's response.
|
||||||
"AURSID": response.cookies.get("AURSID")
|
response = request.get(response.headers.get("location"))
|
||||||
})
|
|
||||||
assert response.status_code == int(HTTPStatus.OK)
|
assert response.status_code == int(HTTPStatus.OK)
|
||||||
|
|
||||||
response = request.post("/logout", data=post_data,
|
response = request.post("/logout", data=post_data,
|
||||||
|
@ -60,9 +65,37 @@ def test_login_logout():
|
||||||
"AURSID": response.cookies.get("AURSID")
|
"AURSID": response.cookies.get("AURSID")
|
||||||
}, allow_redirects=False)
|
}, allow_redirects=False)
|
||||||
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
assert response.status_code == int(HTTPStatus.SEE_OTHER)
|
||||||
|
|
||||||
assert "AURSID" not in response.cookies
|
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():
|
def test_login_missing_username():
|
||||||
post_data = {
|
post_data = {
|
||||||
"passwd": "testPassword",
|
"passwd": "testPassword",
|
||||||
|
@ -75,8 +108,6 @@ def test_login_missing_username():
|
||||||
|
|
||||||
|
|
||||||
def test_login_remember_me():
|
def test_login_remember_me():
|
||||||
from aurweb.db import session
|
|
||||||
|
|
||||||
post_data = {
|
post_data = {
|
||||||
"user": "test",
|
"user": "test",
|
||||||
"passwd": "testPassword",
|
"passwd": "testPassword",
|
||||||
|
@ -94,8 +125,8 @@ def test_login_remember_me():
|
||||||
"options", "persistent_cookie_timeout")
|
"options", "persistent_cookie_timeout")
|
||||||
expected_ts = datetime.utcnow().timestamp() + cookie_timeout
|
expected_ts = datetime.utcnow().timestamp() + cookie_timeout
|
||||||
|
|
||||||
_session = session.query(Session).filter(
|
_session = query(Session,
|
||||||
Session.UsersID == user.ID).first()
|
Session.UsersID == user.ID).first()
|
||||||
|
|
||||||
# Expect that LastUpdateTS was within 5 seconds of the expected_ts,
|
# Expect that LastUpdateTS was within 5 seconds of the expected_ts,
|
||||||
# which is equal to the current timestamp + persistent_cookie_timeout.
|
# which is equal to the current timestamp + persistent_cookie_timeout.
|
||||||
|
|
|
@ -3,7 +3,6 @@ import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
|
@ -185,3 +184,15 @@ def test_create_delete():
|
||||||
db.delete(AccountType, AccountType.AccountType == "test")
|
db.delete(AccountType, AccountType.AccountType == "test")
|
||||||
record = db.query(AccountType, AccountType.AccountType == "test").first()
|
record = db.query(AccountType, AccountType.AccountType == "test").first()
|
||||||
assert record is None
|
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"
|
||||||
|
|
|
@ -28,8 +28,8 @@ def setup():
|
||||||
|
|
||||||
setup_test_db("Users", "Sessions", "Bans")
|
setup_test_db("Users", "Sessions", "Bans")
|
||||||
|
|
||||||
account_type = session.query(AccountType).filter(
|
account_type = query(AccountType,
|
||||||
AccountType.AccountType == "User").first()
|
AccountType.AccountType == "User").first()
|
||||||
|
|
||||||
user = make_user(Username="test", Email="test@example.org",
|
user = make_user(Username="test", Email="test@example.org",
|
||||||
RealName="Test User", Passwd="testPassword",
|
RealName="Test User", Passwd="testPassword",
|
||||||
|
@ -67,7 +67,7 @@ def test_user_login_logout():
|
||||||
assert user.session.User == user
|
assert user.session.User == user
|
||||||
|
|
||||||
# Search for the user via query API.
|
# 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.
|
# Compare the result and our original user.
|
||||||
assert result == user
|
assert result == user
|
||||||
|
|
2
util/sendmail
Executable file
2
util/sendmail
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
Loading…
Add table
Reference in a new issue