import copy import logging import typing from datetime import datetime from http import HTTPStatus from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy import and_, func, or_ import aurweb.config from aurweb import db, l10n, time, util from aurweb.auth import account_type_required, 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.accepted_term import AcceptedTerm from aurweb.models.account_type import (DEVELOPER, DEVELOPER_ID, TRUSTED_USER, TRUSTED_USER_AND_DEV, TRUSTED_USER_AND_DEV_ID, TRUSTED_USER_ID, USER_ID, AccountType) from aurweb.models.ban import Ban from aurweb.models.ssh_pub_key import SSHPubKey, get_fingerprint from aurweb.models.term import Term from aurweb.models.user import User from aurweb.scripts.notify import ResetKeyNotification from aurweb.templates import make_context, make_variable_context, render_template router = APIRouter() logger = logging.getLogger(__name__) @router.get("/passreset", response_class=HTMLResponse) @auth_required(False) async def passreset(request: Request): context = await make_variable_context(request, "Password Reset") 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 = await make_variable_context(request, "Password Reset") # 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)) 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) 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) 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["inactive"] = args.get("J", False) 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() parts = pubkey.split(" ") if len(parts) == 3: # Remove the host part. pubkey = parts[0] + " " + parts[1] 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) 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 args = dict(await request.form()) context = make_account_form_context(context, request, user, args) ok, errors = process_account_form(request, user, args) if not passwd: context["errors"] = ["Invalid password."] return render_template(request, "account/edit.html", context, status_code=int(HTTPStatus.BAD_REQUEST)) 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() parts = pubkey.split(" ") if len(parts) == 3: # Remove the host part. pubkey = parts[0] + " " + parts[1] 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=pubkey, Fingerprint=fingerprint) elif user.ssh_pub_key.PubKey != pubkey: # A public key already exists, update it. user.ssh_pub_key.PubKey = pubkey 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) account_template = ( "account/show.html", ["Account", "{}"], ["username"] # Query parameters to replace in the title string. ) @router.get("/account/{username}") @auth_required(True, template=account_template, status_code=HTTPStatus.UNAUTHORIZED) async def account(request: Request, username: str): _ = l10n.get_translator_for_request(request) context = await make_variable_context(request, _("Account") + username) user = db.query(User, User.Username == username).first() if not user: raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND)) context["user"] = user return render_template(request, "account/show.html", context) @router.get("/accounts/") @auth_required(True) @account_type_required({TRUSTED_USER, DEVELOPER, TRUSTED_USER_AND_DEV}) async def accounts(request: Request): context = make_context(request, "Accounts") return render_template(request, "account/search.html", context) @router.post("/accounts/") @auth_required(True) @account_type_required({TRUSTED_USER, DEVELOPER, TRUSTED_USER_AND_DEV}) async def accounts_post(request: Request, O: int = Form(default=0), # Offset SB: str = Form(default=str()), # Search By U: str = Form(default=str()), # Username T: str = Form(default=str()), # Account Type S: bool = Form(default=False), # Suspended E: str = Form(default=str()), # Email R: str = Form(default=str()), # Real Name I: str = Form(default=str()), # IRC Nick K: str = Form(default=str())): # PGP Key context = await make_variable_context(request, "Accounts") context["pp"] = pp = 50 # Hits per page. offset = max(O, 0) # Minimize offset at 0. context["offset"] = offset # Offset. context["params"] = dict(await request.form()) if "O" in context["params"]: context["params"].pop("O") # Setup order by criteria based on SB. order_by_columns = { "t": (AccountType.ID.asc(), User.Username.asc()), "r": (User.RealName.asc(), AccountType.ID.asc()), "i": (User.IRCNick.asc(), AccountType.ID.asc()), } default_order = (User.Username.asc(), AccountType.ID.asc()) order_by = order_by_columns.get(SB, default_order) # Convert parameter T to an AccountType ID. account_types = { "u": USER_ID, "t": TRUSTED_USER_ID, "d": DEVELOPER_ID, "td": TRUSTED_USER_AND_DEV_ID } account_type_id = account_types.get(T, None) # Get a query handle to users, populate the total user # count into a jinja2 context variable. query = db.query(User).join(AccountType) context["total_users"] = query.count() # Populate this list with any additional statements to # be ANDed together. statements = [] if account_type_id is not None: statements.append(AccountType.ID == account_type_id) if U: statements.append(User.Username.like(f"%{U}%")) if S: statements.append(User.Suspended == S) if E: statements.append(User.Email.like(f"%{E}%")) if R: statements.append(User.RealName.like(f"%{R}%")) if I: statements.append(User.IRCNick.like(f"%{I}%")) if K: statements.append(User.PGPKey.like(f"%{K}%")) # Filter the query by combining all statements added above into # an AND statement, unless there's just one statement, which # we pass on to filter() as args. if statements: query = query.filter(and_(*statements)) # Finally, order and truncate our users for the current page. users = query.order_by(*order_by).limit(pp).offset(offset) context["users"] = users return render_template(request, "account/index.html", context) def render_terms_of_service(request: Request, context: dict, terms: typing.Iterable): if not terms: return RedirectResponse("/", status_code=int(HTTPStatus.SEE_OTHER)) context["unaccepted_terms"] = terms return render_template(request, "tos/index.html", context) @router.get("/tos") @auth_required(True, redirect="/") async def terms_of_service(request: Request): # Query the database for terms that were previously accepted, # but now have a bumped Revision that needs to be accepted. diffs = db.query(Term).join(AcceptedTerm).filter( AcceptedTerm.Revision < Term.Revision).all() # Query the database for any terms that have not yet been accepted. unaccepted = db.query(Term).filter( ~Term.ID.in_(db.query(AcceptedTerm.TermsID))).all() # Translate the 'Terms of Service' part of our page title. _ = l10n.get_translator_for_request(request) title = f"AUR {_('Terms of Service')}" context = await make_variable_context(request, title) accept_needed = sorted(unaccepted + diffs) return render_terms_of_service(request, context, accept_needed) @router.post("/tos") @auth_required(True, redirect="/") async def terms_of_service_post(request: Request, accept: bool = Form(default=False)): # Query the database for terms that were previously accepted, # but now have a bumped Revision that needs to be accepted. diffs = db.query(Term).join(AcceptedTerm).filter( AcceptedTerm.Revision < Term.Revision).all() # Query the database for any terms that have not yet been accepted. unaccepted = db.query(Term).filter( ~Term.ID.in_(db.query(AcceptedTerm.TermsID))).all() if not accept: # Translate the 'Terms of Service' part of our page title. _ = l10n.get_translator_for_request(request) title = f"AUR {_('Terms of Service')}" context = await make_variable_context(request, title) # We already did the database filters here, so let's just use # them instead of reiterating the process in terms_of_service. accept_needed = sorted(unaccepted + diffs) return render_terms_of_service(request, context, accept_needed) # For each term we found, query for the matching accepted term # and update its Revision to the term's current Revision. for term in diffs: accepted_term = request.user.accepted_terms.filter( AcceptedTerm.TermsID == term.ID).first() accepted_term.Revision = term.Revision # For each term that was never accepted, accept it! for term in unaccepted: db.create(AcceptedTerm, User=request.user, Term=term, Revision=term.Revision, autocommit=False) if diffs or unaccepted: # If we had any terms to update, commit the changes. db.commit() return RedirectResponse("/", status_code=int(HTTPStatus.SEE_OTHER))