diff --git a/README.md b/README.md index c53f0bf..b34d8c5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python Flask web application implementing user accounts, payment, and virtual ma ``` # get an instance of postgres running locally on port 5432 # (you don't have to use docker, but we thought this might be the easiest for a how-to example) -docker run --rm -it -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres & +docker run --rm -it -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres # install dependencies sudo apt install pipenv python3-dev libpq-dev diff --git a/capsulflask/auth.py b/capsulflask/auth.py index f3731a2..4bc8c60 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -16,6 +16,7 @@ from flask_mail import Message from werkzeug.exceptions import abort from capsulflask.db import get_model +from capsulflask.shared import validate_email bp = Blueprint("auth", __name__, url_prefix="/auth") @@ -53,10 +54,9 @@ def login(): email = request.form["email"] errors = list() - if not email: - errors.append("email is required") - elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0: - errors.append("enter a valid email address") + email_error = validate_email(email) + if email_error != None: + errors.append(email_error) if len(errors) == 0: result = get_model().login(email) @@ -109,6 +109,24 @@ def magiclink(token): abort(404, f"Token {token} doesn't exist or has already been used.") +@bp.route("/change-email/", methods=("GET", )) +def email_change_landing(token): + return render_template("change-email-landing.html", token=token) + +@bp.route("/change-email/", methods=("POST",)) +@account_required +def email_change_confirmation(token): + new_email, status_code, error_message = get_model().try_update_email(session["account"], token) + if error_message: + abort(status_code, error_message) + return + + session.clear() + session["account"] = new_email + session["csrf-token"] = generate() + + return redirect(url_for("console.index")) + @bp.route("/logout") def logout(): session.clear() diff --git a/capsulflask/console.py b/capsulflask/console.py index 4b92b04..810a1c0 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -17,7 +17,7 @@ from nanoid import generate from capsulflask.metrics import metric_durations from capsulflask.auth import account_required from capsulflask.db import get_model -from capsulflask.shared import my_exec_info_message, get_warnings_list, get_warning_headline, get_vm_months_float, get_account_balance, average_number_of_days_in_a_month +from capsulflask.shared import my_exec_info_message, get_warnings_list, get_warning_headline, get_vm_months_float, get_account_balance, validate_email, average_number_of_days_in_a_month from capsulflask.payment import poll_btcpay_session, check_if_shortterm_flag_can_be_unset from capsulflask import cli @@ -381,10 +381,9 @@ def get_payments(): -@bp.route("/account-balance") +@bp.route("/account", methods=("GET",)) @account_required -def account_balance(): - +def account(): payment_sessions = get_model().list_payment_sessions_for_account(session['account']) for payment_session in payment_sessions: if payment_session['type'] == 'btcpay': @@ -420,9 +419,9 @@ def account_balance(): months=format(vm_months, '.3f'), dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f') )) - + return render_template( - "account-balance.html", + "account.html", has_vms=len(vms_billed)>0, vms_billed=vms_billed, warning_text=warning_text, @@ -437,5 +436,52 @@ def account_balance(): payments )), has_payments=len(payments)>0, - account_balance=format(balance_now, '.2f') + account_balance=format(balance_now, '.2f'), + csrf_token=session["csrf-token"], ) + + +@bp.route("/account", methods=("POST",)) +@account_required +def change_email_address(): + if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: + return abort(418, f"u want tea") + + new_email = request.form["new-email"] + + email_error = validate_email(new_email) + if email_error != None: + flash(email_error) + return redirect(f"{url_for('console.account')}") + + token = generate() + + email_update_error = get_model().try_insert_email_update_row(session['account'], new_email, token) + if email_update_error != None: + flash(email_update_error) + return redirect(f"{url_for('console.account')}") + + link = f"{current_app.config['BASE_URL']}/auth/change-email/{token}" + + message = (f"Navigate to {link} to finalize your email change.\n" + "\nIf you didn't request this, you may ignore this message.") + + if current_app.config["MAIL_SERVER"] != "": + current_app.config["FLASK_MAIL_INSTANCE"].send( + Message( + "Click This Link to change your Capsul email", + sender=current_app.config["MAIL_DEFAULT_SENDER"], + body=message, + recipients=[new_email] + ) + ) + current_app.logger.info(f"Email change {session['account']} -> {new_email} email was sent.") + + flash(f"An email has been sent to {new_email}. Click the link in that email to finalize your email change.") + else: + current_app.logger.info(f"Email change [{session['account']} -> {new_email}] message: {message}") + + flash(f"No email server is configured. The email message was printed to the log.") + + return redirect(f"{url_for('console.account')}") + + diff --git a/capsulflask/db.py b/capsulflask/db.py index 72b3930..970d498 100644 --- a/capsulflask/db.py +++ b/capsulflask/db.py @@ -50,7 +50,7 @@ def init_app(app, is_running_server): hasSchemaVersionTable = False actionWasTaken = False schemaVersion = 0 - desiredSchemaVersion = 25 + desiredSchemaVersion = 26 cursor = connection.cursor() diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index bb68a29..25fb835 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -512,3 +512,52 @@ class DBModel: def set_broadcast_message(self, message): self.cursor.execute("DELETE FROM broadcast_message; INSERT INTO broadcast_message (message) VALUES (%s)", (message, )) self.connection.commit() + + + def try_insert_email_update_row(self, old_email, new_email, token): + + self.cursor.execute("SELECT new_email from email_updates WHERE new_email = %s OR new_email = %s", (new_email, new_email.lower())) + already_existing_email_update = self.cursor.fetchone() + self.cursor.execute("SELECT email from accounts WHERE email = %s or lower_case_email = %s", (new_email, new_email.lower())) + already_existing_account = self.cursor.fetchone() + + if already_existing_email_update or already_existing_account: + return "another account is already using that email address" + + self.cursor.execute("INSERT INTO email_updates (current_email, new_email, token) VALUES (%s, %s, %s)", (old_email, new_email, token)) + self.connection.commit() + return None + + def try_update_email(self, old_email, token): + + self.cursor.execute("SELECT new_email from email_updates WHERE current_email = %s AND token = %s", (old_email, token)) + row = self.cursor.fetchone() + if not row: + # this is here to prevent xss + if token and not re.match(r"^[a-zA-Z0-9_-]+$", token): + token = '___________' + + return None, 404, f"Token '{token}' doesn't exist or has already been used." + + new_email = row[0] + self.cursor.execute("SELECT email from accounts WHERE email = %s or lower_case_email = %s", (new_email, new_email.lower())) + already_existing_account = self.cursor.fetchone() + + if already_existing_account: + return None, 400, "another account is already using that email address." + + self.cursor.execute("DELETE FROM email_updates WHERE current_email = %s", (old_email, )) + self.cursor.execute("UPDATE accounts SET lower_case_email = %s WHERE email = %s", (new_email.lower(), old_email)) + self.cursor.execute("UPDATE accounts SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE login_tokens SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE operations SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE payment_sessions SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE payments SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE ssh_public_keys SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE unresolved_btcpay_invoices SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE vm_ssh_authorized_key SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE vm_ssh_host_key SET email = %s WHERE email = %s", (new_email, old_email)) + self.cursor.execute("UPDATE vms SET email = %s WHERE email = %s", (new_email, old_email)) + self.connection.commit() + + return new_email, None, None \ No newline at end of file diff --git a/capsulflask/payment.py b/capsulflask/payment.py index badf03a..5dd6559 100644 --- a/capsulflask/payment.py +++ b/capsulflask/payment.py @@ -74,7 +74,7 @@ def btcpay_payment(): currency="USD", itemDesc="Capsul Cloud Compute", transactionSpeed="high", - redirectURL=f"{current_app.config['BASE_URL']}/console/account-balance", + redirectURL=f"{current_app.config['BASE_URL']}/console/account", notificationURL=f"{current_app.config['BASE_URL']}/payment/btcpay/webhook" )) except: diff --git a/capsulflask/schema_migrations/26_down_email_updates.sql b/capsulflask/schema_migrations/26_down_email_updates.sql new file mode 100644 index 0000000..bd4e3a0 --- /dev/null +++ b/capsulflask/schema_migrations/26_down_email_updates.sql @@ -0,0 +1,3 @@ +DROP TABLE email_updates; + +UPDATE schemaversion SET version = 25; diff --git a/capsulflask/schema_migrations/26_up_email_updates.sql b/capsulflask/schema_migrations/26_up_email_updates.sql new file mode 100644 index 0000000..96b504f --- /dev/null +++ b/capsulflask/schema_migrations/26_up_email_updates.sql @@ -0,0 +1,7 @@ +CREATE TABLE email_updates ( + current_email TEXT NOT NULL, + new_email TEXT PRIMARY KEY NOT NULL, + token TEXT NOT NULL +); + +UPDATE schemaversion SET version = 26; diff --git a/capsulflask/shared.py b/capsulflask/shared.py index b712204..db7c6f3 100644 --- a/capsulflask/shared.py +++ b/capsulflask/shared.py @@ -60,6 +60,14 @@ def authorized_as_hub(headers): return auth_header_value == current_app.config["HUB_TOKEN"] return False +def validate_email(email): + if not email: + return "email is required" + elif len(email.strip()) < 6 or email.count('@') != 1 or email.split('@')[1].count('.') == 0: + return "enter a valid email address" + + return None + def get_account_balance(vms, payments, as_of): vm_cost_dollars = 0.0 @@ -104,7 +112,7 @@ def get_warnings_list(): get_subject=lambda _: "Capsul One Week Payment Reminder", get_body=lambda base_url, pluralize_capsul: ( f"{get_warning_headline('zero_1w', pluralize_capsul)}" - f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + f"Log in now to re-fill your account! {base_url}/console/account\n\n" "If you believe you have recieved this message in error, please let us know: support@cyberia.club" ) ), @@ -114,7 +122,7 @@ def get_warnings_list(): get_subject=lambda _: "Capsul One Day Payment Reminder", get_body=lambda base_url, pluralize_capsul: ( f"{get_warning_headline('zero_1d', pluralize_capsul)}" - f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + f"Log in now to re-fill your account! {base_url}/console/account\n\n" "If you believe you have recieved this message in error, please let us know: support@cyberia.club" ) ), @@ -124,7 +132,7 @@ def get_warnings_list(): get_subject=lambda _: "Your Capsul Account is No Longer Funded", get_body=lambda base_url, pluralize_capsul: ( f"{get_warning_headline('zero_now', pluralize_capsul)}" - f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + f"Log in now to re-fill your account! {base_url}/console/account\n\n" f"If you need help decomissioning your Capsul{pluralize_capsul}, " "would like to request backups, or de-activate your account, please contact: support@cyberia.club" ) @@ -135,7 +143,7 @@ def get_warnings_list(): get_subject=lambda pluralize_capsul: f"Your Capsul{pluralize_capsul} Will be Deleted In Less Than a Week", get_body=lambda base_url, pluralize_capsul: ( f"{get_warning_headline('delete_1w', pluralize_capsul)}" - f"Log in now to re-fill your account! {base_url}/console/account-balance\n\n" + f"Log in now to re-fill your account! {base_url}/console/account\n\n" f"If you need help decomissioning your Capsul{pluralize_capsul}, " "would like to request backups, or de-activate your account, please contact: support@cyberia.club" ) @@ -146,7 +154,7 @@ def get_warnings_list(): get_subject=lambda pluralize_capsul: f"Last Chance to Save your Capsul{pluralize_capsul}: Gone Tomorrow", get_body=lambda base_url, pluralize_capsul: ( f"{get_warning_headline('delete_1d', pluralize_capsul)}" - f"{base_url}/console/account-balance" + f"{base_url}/console/account" ) ), dict( diff --git a/capsulflask/static/change-email-landing.js b/capsulflask/static/change-email-landing.js new file mode 100644 index 0000000..401989c --- /dev/null +++ b/capsulflask/static/change-email-landing.js @@ -0,0 +1,7 @@ +document.addEventListener("DOMContentLoaded", function(){ + document.querySelector("form").submit() + document.querySelector("form input").style.display = "none"; + const pleaseWaitMessage = document.createElement("p"); + pleaseWaitMessage.textContent = "Please wait while your email change is finalized..." + document.querySelector("form").appendChild(pleaseWaitMessage); +}) \ No newline at end of file diff --git a/capsulflask/templates/account-balance.html b/capsulflask/templates/account.html similarity index 80% rename from capsulflask/templates/account-balance.html rename to capsulflask/templates/account.html index 75ce760..8828521 100644 --- a/capsulflask/templates/account-balance.html +++ b/capsulflask/templates/account.html @@ -1,8 +1,9 @@ {% extends 'base.html' %} -{% block title %}Account Balance{% endblock %} +{% block title %}Account{% endblock %} {% block content %} +

Account Balance: ${{ account_balance }}

@@ -44,7 +45,7 @@