diff --git a/aurweb/auth.py b/aurweb/auth.py index 53c853de..a4ff2167 100644 --- a/aurweb/auth.py +++ b/aurweb/auth.py @@ -7,12 +7,22 @@ from fastapi.responses import RedirectResponse from starlette.authentication import AuthCredentials, AuthenticationBackend, AuthenticationError from starlette.requests import HTTPConnection +import aurweb.config + from aurweb.models.session import Session from aurweb.models.user import User from aurweb.templates import make_context, render_template class AnonymousUser: + # Stub attributes used to mimic a real user. + ID = 0 + LangPreference = aurweb.config.get("options", "default_lang") + Timezone = aurweb.config.get("options", "default_timezone") + + # A stub ssh_pub_key relationship. + ssh_pub_key = None + @staticmethod def is_authenticated(): return False diff --git a/aurweb/routers/accounts.py b/aurweb/routers/accounts.py index db23bc3a..a43ba9f7 100644 --- a/aurweb/routers/accounts.py +++ b/aurweb/routers/accounts.py @@ -1,12 +1,20 @@ +import copy + from http import HTTPStatus from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse -from sqlalchemy import or_ +from sqlalchemy import and_, func, or_ -from aurweb import db +import aurweb.config + +from aurweb import db, l10n, time, util from aurweb.auth import auth_required +from aurweb.captcha import get_captcha_answer, get_captcha_salts, get_captcha_token from aurweb.l10n import get_translator_for_request +from aurweb.models.account_type import AccountType +from aurweb.models.ban import Ban +from aurweb.models.ssh_pub_key import SSHPubKey, get_fingerprint from aurweb.models.user import User from aurweb.scripts.notify import ResetKeyNotification from aurweb.templates import make_variable_context, render_template @@ -93,3 +101,311 @@ async def passreset_post(request: Request, # Render ?step=confirm. return RedirectResponse(url="/passreset?step=confirm", status_code=int(HTTPStatus.SEE_OTHER)) + + +def process_account_form(request: Request, user: User, args: dict): + """ Process an account form. All fields are optional and only checks + requirements in the case they are present. + + ``` + context = await make_variable_context(request, "Accounts") + ok, errors = process_account_form(request, user, **kwargs) + if not ok: + context["errors"] = errors + return render_template(request, "some_account_template.html", context) + ``` + + :param request: An incoming FastAPI request + :param user: The user model of the account being processed + :param args: A dictionary of arguments generated via request.form() + :return: A (passed processing boolean, list of errors) tuple + """ + + # Get a local translator. + _ = get_translator_for_request(request) + + host = request.client.host + ban = db.query(Ban, Ban.IPAddress == host).first() + if ban: + return False, [ + "Account registration has been disabled for your " + + "IP address, probably due to sustained spam attacks. " + + "Sorry for the inconvenience." + ] + + if request.user.is_authenticated(): + if not request.user.valid_password(args.get("passwd", None)): + return False, ["Invalid password."] + + email = args.get("E", None) + username = args.get("U", None) + + if not email or not username: + return False, ["Missing a required field."] + + username_min_len = aurweb.config.getint("options", "username_min_len") + username_max_len = aurweb.config.getint("options", "username_max_len") + if not util.valid_username(args.get("U")): + return False, [ + "The username is invalid.", + [ + _("It must be between %s and %s characters long") % ( + username_min_len, username_max_len), + "Start and end with a letter or number", + "Can contain only one period, underscore or hyphen.", + ] + ] + + password = args.get("P", None) + if password: + confirmation = args.get("C", None) + if not util.valid_password(password): + return False, [ + _("Your password must be at least %s characters.") % ( + username_min_len) + ] + elif not confirmation: + return False, ["Please confirm your new password."] + elif password != confirmation: + return False, ["Password fields do not match."] + + backup_email = args.get("BE", None) + homepage = args.get("HP", None) + pgp_key = args.get("K", None) + ssh_pubkey = args.get("PK", None) + language = args.get("L", None) + timezone = args.get("TZ", None) + + def username_exists(username): + return and_(User.ID != user.ID, + func.lower(User.Username) == username.lower()) + + def email_exists(email): + return and_(User.ID != user.ID, + func.lower(User.Email) == email.lower()) + + if not util.valid_email(email): + return False, ["The email address is invalid."] + elif backup_email and not util.valid_email(backup_email): + return False, ["The backup email address is invalid."] + elif homepage and not util.valid_homepage(homepage): + return False, [ + "The home page is invalid, please specify the full HTTP(s) URL."] + elif pgp_key and not util.valid_pgp_fingerprint(pgp_key): + return False, ["The PGP key fingerprint is invalid."] + elif ssh_pubkey and not util.valid_ssh_pubkey(ssh_pubkey): + return False, ["The SSH public key is invalid."] + elif language and language not in l10n.SUPPORTED_LANGUAGES: + return False, ["Language is not currently supported."] + elif timezone and timezone not in time.SUPPORTED_TIMEZONES: + return False, ["Timezone is not currently supported."] + elif db.query(User, username_exists(username)).first(): + # If the username already exists... + return False, [ + _("The username, %s%s%s, is already in use.") % ( + "", username, "") + ] + elif db.query(User, email_exists(email)).first(): + # If the email already exists... + return False, [ + _("The address, %s%s%s, is already in use.") % ( + "", email, "") + ] + + def ssh_fingerprint_exists(fingerprint): + return and_(SSHPubKey.UserID != user.ID, + SSHPubKey.Fingerprint == fingerprint) + + if ssh_pubkey: + fingerprint = get_fingerprint(ssh_pubkey.strip().rstrip()) + if fingerprint is None: + return False, ["The SSH public key is invalid."] + + if db.query(SSHPubKey, ssh_fingerprint_exists(fingerprint)).first(): + return False, [ + _("The SSH public key, %s%s%s, is already in use.") % ( + "", fingerprint, "") + ] + + captcha_salt = args.get("captcha_salt", None) + if captcha_salt and captcha_salt not in get_captcha_salts(): + return False, ["This CAPTCHA has expired. Please try again."] + + captcha = args.get("captcha", None) + if captcha: + answer = get_captcha_answer(get_captcha_token(captcha_salt)) + if captcha != answer: + return False, ["The entered CAPTCHA answer is invalid."] + + return True, [] + + +def make_account_form_context(context: dict, + request: Request, + user: User, + args: dict): + """ Modify a FastAPI context and add attributes for the account form. + + :param context: FastAPI context + :param request: FastAPI request + :param user: Target user + :param args: Persistent arguments: request.form() + :return: FastAPI context adjusted for account form + """ + # Do not modify the original context. + context = copy.copy(context) + + context["account_types"] = [ + (1, "Normal User"), + (2, "Trusted User") + ] + + user_account_type_id = context.get("account_types")[0][0] + + if request.user.has_credential("CRED_ACCOUNT_EDIT_DEV"): + context["account_types"].append((3, "Developer")) + context["account_types"].append((4, "Trusted User & Developer")) + + if request.user.is_authenticated(): + context["username"] = args.get("U", user.Username) + context["account_type"] = args.get("T", user.AccountType.ID) + context["suspended"] = args.get("S", user.Suspended) + context["email"] = args.get("E", user.Email) + context["hide_email"] = args.get("H", user.HideEmail) + context["backup_email"] = args.get("BE", user.BackupEmail) + context["realname"] = args.get("R", user.RealName) + context["homepage"] = args.get("HP", user.Homepage or str()) + context["ircnick"] = args.get("I", user.IRCNick) + context["pgp"] = args.get("K", user.PGPKey or str()) + context["lang"] = args.get("L", user.LangPreference) + context["tz"] = args.get("TZ", user.Timezone) + ssh_pk = user.ssh_pub_key.PubKey if user.ssh_pub_key else str() + context["ssh_pk"] = args.get("PK", ssh_pk) + context["cn"] = args.get("CN", user.CommentNotify) + context["un"] = args.get("UN", user.UpdateNotify) + context["on"] = args.get("ON", user.OwnershipNotify) + else: + context["username"] = args.get("U", str()) + context["account_type"] = args.get("T", user_account_type_id) + context["suspended"] = args.get("S", False) + context["email"] = args.get("E", str()) + context["hide_email"] = args.get("H", False) + context["backup_email"] = args.get("BE", str()) + context["realname"] = args.get("R", str()) + context["homepage"] = args.get("HP", str()) + context["ircnick"] = args.get("I", str()) + context["pgp"] = args.get("K", str()) + context["lang"] = args.get("L", context.get("language")) + context["tz"] = args.get("TZ", context.get("timezone")) + context["ssh_pk"] = args.get("PK", str()) + context["cn"] = args.get("CN", True) + context["un"] = args.get("UN", False) + context["on"] = args.get("ON", True) + + context["password"] = args.get("P", str()) + context["confirm"] = args.get("C", str()) + + return context + + +@router.get("/register", response_class=HTMLResponse) +@auth_required(False) +async def account_register(request: Request, + U: str = Form(default=str()), # Username + 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 FP + L: str = Form(default=aurweb.config.get( + "options", "default_lang")), + TZ: str = Form(default=aurweb.config.get( + "options", "default_timezone")), + PK: str = Form(default=None), + CN: bool = Form(default=False), # Comment Notify + CU: bool = Form(default=False), # Update Notify + CO: bool = Form(default=False), # Owner Notify + captcha: str = Form(default=str())): + context = await make_variable_context(request, "Register") + context["captcha_salt"] = get_captcha_salts()[0] + context = make_account_form_context(context, request, None, dict()) + return render_template(request, "register.html", context) + + +@router.post("/register", response_class=HTMLResponse) +@auth_required(False) +async def account_register_post(request: Request, + U: str = Form(default=str()), # Username + E: str = Form(default=str()), # Email + H: str = Form(default=False), # Hide Email + BE: str = Form(default=None), # Backup Email + R: str = Form(default=''), # 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(default=aurweb.config.get( + "options", "default_lang")), + TZ: str = Form(default=aurweb.config.get( + "options", "default_timezone")), + PK: str = Form(default=None), # SSH PubKey + CN: bool = Form(default=False), + UN: bool = Form(default=False), + ON: bool = Form(default=False), + captcha: str = Form(default=None), + captcha_salt: str = Form(...)): + from aurweb.db import session + + context = await make_variable_context(request, "Register") + + args = dict(await request.form()) + context = make_account_form_context(context, request, None, args) + + ok, errors = process_account_form(request, request.user, args) + + if not ok: + # If the field values given do not meet the requirements, + # return HTTP 400 with an error. + context["errors"] = errors + return render_template(request, "register.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + if not captcha: + context["errors"] = ["The CAPTCHA is missing."] + return render_template(request, "register.html", context, + status_code=int(HTTPStatus.BAD_REQUEST)) + + # Create a user with no password with a resetkey, then send + # an email off about it. + resetkey = db.make_random_value(User, User.ResetKey) + + # By default, we grab the User account type to associate with. + account_type = db.query(AccountType, + AccountType.AccountType == "User").first() + + # Create a user given all parameters available. + user = db.create(User, Username=U, Email=E, HideEmail=H, BackupEmail=BE, + RealName=R, Homepage=HP, IRCNick=I, PGPKey=K, + LangPreference=L, Timezone=TZ, CommentNotify=CN, + UpdateNotify=UN, OwnershipNotify=ON, ResetKey=resetkey, + AccountType=account_type) + + # If a PK was given and either one does not exist or the given + # PK mismatches the existing user's SSHPubKey.PubKey. + if PK: + # Get the second element in the PK, which is the actual key. + pubkey = PK.strip().rstrip() + fingerprint = get_fingerprint(pubkey) + user.ssh_pub_key = SSHPubKey(UserID=user.ID, + PubKey=pubkey, + Fingerprint=fingerprint) + session.commit() + + # Send a reset key notification to the new user. + executor = db.ConnectionExecutor(db.get_engine().raw_connection()) + ResetKeyNotification(executor, user.ID).send() + + context["complete"] = True + context["user"] = user + return render_template(request, "register.html", context) diff --git a/aurweb/util.py b/aurweb/util.py index 65f18a4c..5e1717bd 100644 --- a/aurweb/util.py +++ b/aurweb/util.py @@ -1,7 +1,91 @@ +import base64 import random +import re import string +from urllib.parse import urlparse + +import jinja2 + +from email_validator import EmailNotValidError, EmailUndeliverableError, validate_email + +import aurweb.config + def make_random_string(length): return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + +def valid_username(username): + min_len = aurweb.config.getint("options", "username_min_len") + max_len = aurweb.config.getint("options", "username_max_len") + if not (min_len <= len(username) <= max_len): + return False + + # Check that username contains: one or more alphanumeric + # characters, an optional separator of '.', '-' or '_', followed + # by alphanumeric characters. + return re.match(r'^[a-zA-Z0-9]+[.\-_]?[a-zA-Z0-9]+$', username) + + +def valid_email(email): + try: + validate_email(email) + except EmailUndeliverableError: + return False + except EmailNotValidError: + return False + return True + + +def valid_homepage(homepage): + parts = urlparse(homepage) + return parts.scheme in ("http", "https") and bool(parts.netloc) + + +def valid_password(password): + min_len = aurweb.config.getint("options", "passwd_min_len") + return len(password) >= min_len + + +def valid_pgp_fingerprint(fp): + fp = fp.replace(" ", "") + try: + # Attempt to convert the fingerprint to an int via base16. + # If it can't, it's not a hex string. + int(fp, 16) + except ValueError: + return False + + # Check the length; must be 40 hexadecimal digits. + return len(fp) == 40 + + +def valid_ssh_pubkey(pk): + valid_prefixes = ("ssh-rsa", "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", + "ssh-ed25519") + + has_valid_prefix = False + for prefix in valid_prefixes: + if "%s " % prefix in pk: + has_valid_prefix = True + break + if not has_valid_prefix: + return False + + tokens = pk.strip().rstrip().split(" ") + if len(tokens) < 2: + return False + + return base64.b64encode(base64.b64decode(tokens[1])).decode() == tokens[1] + + +@jinja2.contextfilter +def account_url(context, user): + request = context.get("request") + base = f"{request.url.scheme}://{request.url.hostname}" + if request.url.scheme == "http" and request.url.port != 80: + base += f":{request.url.port}" + return f"{base}/account/{user.Username}" diff --git a/templates/partials/account_form.html b/templates/partials/account_form.html new file mode 100644 index 00000000..3af13368 --- /dev/null +++ b/templates/partials/account_form.html @@ -0,0 +1,343 @@ + +
diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 00000000..a15971a1 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,30 @@ +{% extends "partials/layout.html" %} + +{% block pageContent %} ++ {% trans %}A password reset key has been sent to your e-mail address.{% endtrans %} +
+ {% else %} + {% if errors %} + {% include "partials/error.html" %} + {% else %} ++ {% trans %}Use this form to create an account.{% endtrans %} +
+ {% endif %} + + {% set form_type = "NewAccount" %} + {% include "partials/account_form.html" %} + {% endif %} +