From 4e9ef6fb00211378ca7373b0e41ee29479c9aa44 Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Thu, 28 Jan 2021 20:34:27 -0800 Subject: [PATCH] add account edit (settings) routes * Added account_url filter to jinja2 environment. This produces a path to the user's account url (/account/{username}). * Updated archdev-navbar to link to new edit route. + Added migrate_cookies(request, response) to aurweb.util, a function that simply migrates the request cookies to response and returns it. + Added account_edit tests to test_accounts_routes.py. Signed-off-by: Kevin Morris --- aurweb/routers/accounts.py | 145 ++++++++++++ aurweb/templates.py | 5 +- aurweb/util.py | 6 + templates/account/edit.html | 46 ++++ templates/partials/account_form.html | 9 + templates/partials/archdev-navbar.html | 20 +- test/test_accounts_routes.py | 296 +++++++++++++++++++++++++ 7 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 templates/account/edit.html diff --git a/aurweb/routers/accounts.py b/aurweb/routers/accounts.py index a43ba9f7..689f7f58 100644 --- a/aurweb/routers/accounts.py +++ b/aurweb/routers/accounts.py @@ -1,5 +1,6 @@ import copy +from datetime import datetime from http import HTTPStatus from fastapi import APIRouter, Form, Request @@ -284,6 +285,7 @@ def make_account_form_context(context: dict, context["cn"] = args.get("CN", user.CommentNotify) context["un"] = args.get("UN", user.UpdateNotify) context["on"] = args.get("ON", user.OwnershipNotify) + context["inactive"] = args.get("J", user.InactivityTS != 0) else: context["username"] = args.get("U", str()) context["account_type"] = args.get("T", user_account_type_id) @@ -301,6 +303,7 @@ def make_account_form_context(context: dict, context["cn"] = args.get("CN", True) context["un"] = args.get("UN", False) context["on"] = args.get("ON", True) + context["inactive"] = args.get("J", False) context["password"] = args.get("P", str()) context["confirm"] = args.get("C", str()) @@ -409,3 +412,145 @@ async def account_register_post(request: Request, context["complete"] = True context["user"] = user return render_template(request, "register.html", context) + + +def cannot_edit(request, user): + """ Return a 401 HTMLResponse if the request user doesn't + have authorization, otherwise None. """ + has_dev_cred = request.user.has_credential("CRED_ACCOUNT_EDIT_DEV", + approved=[user]) + if not has_dev_cred: + return HTMLResponse(status_code=int(HTTPStatus.UNAUTHORIZED)) + return None + + +@router.get("/account/{username}/edit", response_class=HTMLResponse) +@auth_required(True) +async def account_edit(request: Request, + username: str): + user = db.query(User, User.Username == username).first() + response = cannot_edit(request, user) + if response: + return response + + context = await make_variable_context(request, "Accounts") + context["user"] = user + + context = make_account_form_context(context, request, user, dict()) + return render_template(request, "account/edit.html", context) + + +@router.post("/account/{username}/edit", response_class=HTMLResponse) +@auth_required(True) +async def account_edit_post(request: Request, + username: str, + U: str = Form(default=str()), # Username + J: bool = Form(default=False), + E: str = Form(default=str()), # Email + H: str = Form(default=False), # Hide Email + BE: str = Form(default=None), # Backup Email + R: str = Form(default=None), # Real Name + HP: str = Form(default=None), # Homepage + I: str = Form(default=None), # IRC Nick + K: str = Form(default=None), # PGP Key + L: str = Form(aurweb.config.get( + "options", "default_lang")), + TZ: str = Form(aurweb.config.get( + "options", "default_timezone")), + P: str = Form(default=str()), # New Password + C: str = Form(default=None), # Password Confirm + PK: str = Form(default=None), # PubKey + CN: bool = Form(default=False), # Comment Notify + UN: bool = Form(default=False), # Update Notify + ON: bool = Form(default=False), # Owner Notify + passwd: str = Form(default=str())): + from aurweb.db import session + + user = session.query(User).filter(User.Username == username).first() + response = cannot_edit(request, user) + if response: + return response + + context = await make_variable_context(request, "Accounts") + context["user"] = user + + if not passwd: + context["errors"] = ["Invalid password."] + return render_template(request, "account/edit.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + args = dict(await request.form()) + context = make_account_form_context(context, request, user, args) + ok, errors = process_account_form(request, user, args) + + if not ok: + context["errors"] = errors + return render_template(request, "account/edit.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + # Set all updated fields as needed. + user.Username = U or user.Username + user.Email = E or user.Email + user.HideEmail = bool(H) + user.BackupEmail = BE or user.BackupEmail + user.RealName = R or user.RealName + user.Homepage = HP or user.Homepage + user.IRCNick = I or user.IRCNick + user.PGPKey = K or user.PGPKey + user.InactivityTS = datetime.utcnow().timestamp() if J else 0 + + # If we update the language, update the cookie as well. + if L and L != user.LangPreference: + request.cookies["AURLANG"] = L + user.LangPreference = L + context["language"] = L + + # If we update the timezone, also update the cookie. + if TZ and TZ != user.Timezone: + user.Timezone = TZ + request.cookies["AURTZ"] = TZ + context["timezone"] = TZ + + user.CommentNotify = bool(CN) + user.UpdateNotify = bool(UN) + user.OwnershipNotify = bool(ON) + + # If a PK is given, compare it against the target user's PK. + if PK: + # Get the second token in the public key, which is the actual key. + pubkey = PK.strip().rstrip() + fingerprint = get_fingerprint(pubkey) + if not user.ssh_pub_key: + # No public key exists, create one. + user.ssh_pub_key = SSHPubKey(UserID=user.ID, + PubKey=PK, + Fingerprint=fingerprint) + elif user.ssh_pub_key.Fingerprint != fingerprint: + # A public key already exists, update it. + user.ssh_pub_key.PubKey = PK + user.ssh_pub_key.Fingerprint = fingerprint + elif user.ssh_pub_key: + # Else, if the user has a public key already, delete it. + session.delete(user.ssh_pub_key) + + # Commit changes, if any. + session.commit() + + if P and not user.valid_password(P): + # Remove the fields we consumed for passwords. + context["P"] = context["C"] = str() + + # If a password was given and it doesn't match the user's, update it. + user.update_password(P) + if user == request.user: + # If the target user is the request user, login with + # the updated password and update AURSID. + request.cookies["AURSID"] = user.login(request, P) + + if not errors: + context["complete"] = True + + # Update cookies with requests, in case they were changed. + response = render_template(request, "account/edit.html", context) + return util.migrate_cookies(request, response) +>>>>>> > dddd1137... add account edit(settings) routes diff --git a/aurweb/templates.py b/aurweb/templates.py index d548e92b..c0472b2e 100644 --- a/aurweb/templates.py +++ b/aurweb/templates.py @@ -12,7 +12,7 @@ from fastapi.responses import HTMLResponse import aurweb.config -from aurweb import captcha, l10n, time +from aurweb import captcha, l10n, time, util # Prepare jinja2 objects. loader = jinja2.FileSystemLoader(os.path.join( @@ -27,6 +27,9 @@ env.filters["tr"] = l10n.tr env.filters["captcha_salt"] = captcha.captcha_salt_filter env.filters["captcha_cmdline"] = captcha.captcha_cmdline_filter +# Add account utility filters. +env.filters["account_url"] = util.account_url + def make_context(request: Request, title: str, next: str = None): """ Create a context for a jinja2 TemplateResponse. """ diff --git a/aurweb/util.py b/aurweb/util.py index 5e1717bd..8b6ddbe7 100644 --- a/aurweb/util.py +++ b/aurweb/util.py @@ -82,6 +82,12 @@ def valid_ssh_pubkey(pk): return base64.b64encode(base64.b64decode(tokens[1])).decode() == tokens[1] +def migrate_cookies(request, response): + for k, v in request.cookies.items(): + response.set_cookie(k, v) + return response + + @jinja2.contextfilter def account_url(context, user): request = context.get("request") diff --git a/templates/account/edit.html b/templates/account/edit.html new file mode 100644 index 00000000..f8895d92 --- /dev/null +++ b/templates/account/edit.html @@ -0,0 +1,46 @@ +{% extends "partials/layout.html" %} + +{% block pageContent %} +
+

{% trans %}Accounts{% endtrans %}

+ + {% if complete %} + + {{ + "The account, %s%s%s, has been successfully modified." + | tr + | format("", user.Username, "") + | safe + }} + + {% else %} + {% if errors %} + {% include "partials/error.html" %} + {% else %} +

+ {{ "Click %shere%s if you want to permanently delete this account." + | tr + | format('' | format(user | account_url), + "") + | safe + }} + {{ "Click %shere%s for user details." + | tr + | format('' | format(user | account_url), + "") + | safe + }} + {{ "Click %shere%s to list the comments made by this account." + | tr + | format('' | format(user | account_url), + "") + | safe + }} +

+ {% endif %} + + {% set form_type = "UpdateAccount" %} + {% include "partials/account_form.html" %} + {% endif %} +
+{% endblock %} diff --git a/templates/partials/account_form.html b/templates/partials/account_form.html index 3af13368..5ae18131 100644 --- a/templates/partials/account_form.html +++ b/templates/partials/account_form.html @@ -42,6 +42,15 @@ "account is inactive." | tr }}

+

+ + +

+ {% if request.user.has_credential("CRED_ACCOUNT_CHANGE_TYPE") %}

  • AUR {% trans %}Home{% endtrans %}
  • {% endif %}
  • {% trans %}Packages{% endtrans %}
  • -
  • {% trans %}Register{% endtrans %}
  • -
  • - {% if request.user.is_authenticated() %} + {% if request.user.is_authenticated() %} +
  • + + {% trans %} My Account{% endtrans %} + +
  • +
  • {% trans %}Logout{% endtrans %} - {% else %} +
  • + {% else %} +
  • + + {% trans %}Register{% endtrans %} + +
  • +
  • {% trans %}Login{% endtrans %} +
  • {% endif %} diff --git a/test/test_accounts_routes.py b/test/test_accounts_routes.py index d79137bf..540adde7 100644 --- a/test/test_accounts_routes.py +++ b/test/test_accounts_routes.py @@ -5,6 +5,7 @@ from datetime import datetime from http import HTTPStatus from subprocess import Popen +import lxml.html import pytest from fastapi.testclient import TestClient @@ -574,3 +575,298 @@ def test_post_register_with_ssh_pubkey(): response = post_register(request, PK=pk) assert response.status_code == int(HTTPStatus.OK) + + +def test_get_account_edit(): + request = Request() + sid = user.login(request, "testPassword") + + with client as request: + response = request.get("/account/test/edit", cookies={ + "AURSID": sid + }, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + +def test_get_account_edit_unauthorized(): + request = Request() + sid = user.login(request, "testPassword") + + create(User, Username="test2", Email="test2@example.org", + Passwd="testPassword") + + with client as request: + # Try to edit `test2` while authenticated as `test`. + response = request.get("/account/test2/edit", cookies={ + "AURSID": sid + }, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.UNAUTHORIZED) + + +def test_post_account_edit(): + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test666@example.org", + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + expected = "The account, test, " + expected += "has been successfully modified." + assert expected in response.content.decode() + + +def test_post_account_edit_dev(): + from aurweb.db import session + + # Modify our user to be a "Trusted User & Developer" + name = "Trusted User & Developer" + tu_or_dev = query(AccountType, AccountType.AccountType == name).first() + user.AccountType = tu_or_dev + session.commit() + + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test666@example.org", + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + expected = "The account, test, " + expected += "has been successfully modified." + assert expected in response.content.decode() + + +def test_post_account_edit_language(): + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "L": "de", # German + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + # Parse the response content html into an lxml root, then make + # sure we see a 'de' option selected on the page. + content = response.content.decode() + root = lxml.html.fromstring(content) + lang_nodes = root.xpath('//option[@value="de"]/@selected') + assert lang_nodes and len(lang_nodes) != 0 + assert lang_nodes[0] == "selected" + + +def test_post_account_edit_timezone(): + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "TZ": "CET", + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + +def test_post_account_edit_error_missing_password(): + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "TZ": "CET", + "passwd": "" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + + content = response.content.decode() + assert "Invalid password." in content + + +def test_post_account_edit_error_invalid_password(): + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "TZ": "CET", + "passwd": "invalid" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + + content = response.content.decode() + assert "Invalid password." in content + + +def test_post_account_edit_error_unauthorized(): + request = Request() + sid = user.login(request, "testPassword") + + test2 = create(User, Username="test2", Email="test2@example.org", + Passwd="testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "TZ": "CET", + "passwd": "testPassword" + } + + with client as request: + # Attempt to edit 'test2' while logged in as 'test'. + response = request.post("/account/test2/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.UNAUTHORIZED) + + +def test_post_account_edit_ssh_pub_key(): + pk = str() + + # Create a public key with ssh-keygen (this adds ssh-keygen as a + # dependency to passing this test). + with tempfile.TemporaryDirectory() as tmpdir: + with open("/dev/null", "w") as null: + proc = Popen(["ssh-keygen", "-f", f"{tmpdir}/test.ssh", "-N", ""], + stdout=null, stderr=null) + proc.wait() + assert proc.returncode == 0 + + # Read in the public key, then delete the temp dir we made. + pk = open(f"{tmpdir}/test.ssh.pub").read().rstrip() + + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "PK": pk, + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + # Now let's update what's already there to gain coverage over that path. + pk = str() + with tempfile.TemporaryDirectory() as tmpdir: + with open("/dev/null", "w") as null: + proc = Popen(["ssh-keygen", "-f", f"{tmpdir}/test.ssh", "-N", ""], + stdout=null, stderr=null) + proc.wait() + assert proc.returncode == 0 + + # Read in the public key, then delete the temp dir we made. + pk = open(f"{tmpdir}/test.ssh.pub").read().rstrip() + + post_data["PK"] = pk + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + +def test_post_account_edit_invalid_ssh_pubkey(): + pubkey = "ssh-rsa fake key" + + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "P": "newPassword", + "C": "newPassword", + "PK": pubkey, + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + + +def test_post_account_edit_password(): + request = Request() + sid = user.login(request, "testPassword") + + post_data = { + "U": "test", + "E": "test@example.org", + "P": "newPassword", + "C": "newPassword", + "passwd": "testPassword" + } + + with client as request: + response = request.post("/account/test/edit", cookies={ + "AURSID": sid + }, data=post_data, allow_redirects=False) + + assert response.status_code == int(HTTPStatus.OK) + + assert user.valid_password("newPassword") + + +>>>>>> > dddd1137... add account edit(settings) routes