diff --git a/README.md b/README.md index f07f2fe4..5d050cc2 100644 --- a/README.md +++ b/README.md @@ -659,7 +659,7 @@ Output: if api key is correct, return a json with user name and whether user is If api key is incorrect, return 401. -#### GET /api/v3/alias/options +#### GET /api/v4/alias/options User alias info and suggestion. Used by the first extension screen when user opens the extension. @@ -669,7 +669,7 @@ Input: Output: a json with the following field: - can_create: boolean. Whether user can create new alias -- suffixes: list of string. List of alias `suffix` that user can use. If user doesn't have custom domain, this list has a single element which is the alias default domain (simplelogin.co). +- suffixes: list of `[suffix, signed-suffix]`. List of alias `suffix` that user can use. The `signed-suffix` is necessary to avoid request tampering. - prefix_suggestion: string. Suggestion for the `alias prefix`. Usually this is the website name extracted from `hostname`. If no `hostname`, then the `prefix_suggestion` is empty. - recommendation: optional field, dictionary. If an alias is already used for this website, the recommendation will be returned. There are 2 subfields in `recommendation`: `alias` which is the recommended alias and `hostname` is the website on which this alias is used before. @@ -677,20 +677,21 @@ For ex: ```json { "can_create": true, - "prefix_suggestion": "test", - "recommendation": { - "alias": "e1.cat@sl.local", - "hostname": "www.test.com" - }, + "prefix_suggestion": "", "suffixes": [ - "@very-long-domain.com.net.org", - "@ab.cd", - ".cat@sl.local" + [ + "@ab.cd", + "@ab.cd.Xq2BOA.zBebBB-QYikFkbPZ9CPKGpJ2-PU" + ], + [ + ".yeah@local1.localhost", + ".yeah@local1.localhost.Xq2BOA.dM9gyHyHcSXuJ8ps4i3wpJZ_Frw" + ] ] } ``` -#### POST /api/alias/custom/new +#### POST /api/v2/alias/custom/new Create a new custom alias. @@ -699,7 +700,7 @@ Input: - (Optional but recommended) `hostname` passed in query string - Request Message Body in json (`Content-Type` is `application/json`) - alias_prefix: string. The first part of the alias that user can choose. - - alias_suffix: should be one of the suffixes returned in the `GET /api/v2/alias/options` endpoint. + - signed_suffix: should be one of the suffixes returned in the `GET /api/v4/alias/options` endpoint. - (Optional) note: alias note Output: @@ -1168,7 +1169,9 @@ Generate the migration script and make sure to review it before committing it. S flask db migrate ``` -In local the database creation in Sqlite doesn't use migration and uses directly `db.create_all()` (cf `fake_data()` method). This is because Sqlite doesn't handle well the migration. As sqlite is only used during development, the database is deleted and re-populated at each run. +In local the database creation in Sqlite doesn't use migration and uses directly `db.create_all()` (cf `fake_data()` method). +This is because Sqlite doesn't handle well the migration. As sqlite is only used during development, the database is deleted +and re-populated at each run. ### Code structure diff --git a/app/api/views/alias_options.py b/app/api/views/alias_options.py index 92ed46c9..70860780 100644 --- a/app/api/views/alias_options.py +++ b/app/api/views/alias_options.py @@ -4,6 +4,7 @@ from sqlalchemy import desc from app.api.base import api_bp, require_api_auth from app.config import ALIAS_DOMAINS, DISABLE_ALIAS_SUFFIX +from app.dashboard.views.custom_alias import available_suffixes from app.extensions import db from app.log import LOG from app.models import AliasUsedOn, Alias, User @@ -26,7 +27,7 @@ def options(): existing: array of existing aliases """ - LOG.warning("/v2/alias/options should be used instead") + LOG.warning("/alias/options is obsolete") user = g.user hostname = request.args.get("hostname") @@ -106,6 +107,8 @@ def options_v2(): """ + LOG.warning("/v2/alias/options is obsolete") + user = g.user hostname = request.args.get("hostname") @@ -185,6 +188,7 @@ def options_v3(): """ + LOG.warning("/v3/alias/options is obsolete") user = g.user hostname = request.args.get("hostname") @@ -239,3 +243,71 @@ def options_v3(): ret["suffixes"] = list(reversed(ret["suffixes"])) return jsonify(ret) + + +@api_bp.route("/v4/alias/options") +@cross_origin() +@require_api_auth +def options_v4(): + """ + Return what options user has when creating new alias. + Same as v3 but return time-based signed-suffix in addition to suffix. To be used with /v2/alias/custom/new + Input: + a valid api-key in "Authentication" header and + optional "hostname" in args + Output: cf README + can_create: bool + suffixes: [[suffix, signed_suffix]] + prefix_suggestion: str + recommendation: Optional dict + alias: str + hostname: str + + + """ + user = g.user + hostname = request.args.get("hostname") + + ret = { + "can_create": user.can_create_new_alias(), + "suffixes": [], + "prefix_suggestion": "", + } + + # recommendation alias if exist + if hostname: + # put the latest used alias first + q = ( + db.session.query(AliasUsedOn, Alias, User) + .filter( + AliasUsedOn.alias_id == Alias.id, + Alias.user_id == user.id, + AliasUsedOn.hostname == hostname, + ) + .order_by(desc(AliasUsedOn.created_at)) + ) + + r = q.first() + if r: + _, alias, _ = r + LOG.d("found alias %s %s %s", alias, hostname, user) + ret["recommendation"] = {"alias": alias.email, "hostname": hostname} + + # custom alias suggestion and suffix + if hostname: + # keep only the domain name of hostname, ignore TLD and subdomain + # for ex www.groupon.com -> groupon + domain_name = hostname + if "." in hostname: + parts = hostname.split(".") + domain_name = parts[-2] + domain_name = convert_to_id(domain_name) + ret["prefix_suggestion"] = domain_name + + # List of (is_custom_domain, alias-suffix, time-signed alias-suffix) + suffixes = available_suffixes(user) + + # custom domain should be put first + ret["suffixes"] = list([suffix[1], suffix[2]] for suffix in suffixes) + + return jsonify(ret) diff --git a/app/api/views/new_custom_alias.py b/app/api/views/new_custom_alias.py index be188d0b..5d6a6596 100644 --- a/app/api/views/new_custom_alias.py +++ b/app/api/views/new_custom_alias.py @@ -1,11 +1,12 @@ from flask import g from flask import jsonify, request from flask_cors import cross_origin +from itsdangerous import SignatureExpired from app.api.base import api_bp, require_api_auth from app.api.serializer import serialize_alias_info, get_alias_info from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_DOMAINS -from app.dashboard.views.custom_alias import verify_prefix_suffix +from app.dashboard.views.custom_alias import verify_prefix_suffix, signer from app.extensions import db from app.log import LOG from app.models import Alias, AliasUsedOn, User, CustomDomain @@ -28,6 +29,7 @@ def new_custom_alias(): 409 if the alias already exists """ + LOG.warning("/alias/custom/new is obsolete") user: User = g.user if not user.can_create_new_alias(): LOG.d("user %s cannot create any custom alias", user) @@ -39,7 +41,6 @@ def new_custom_alias(): 400, ) - user_custom_domains = [cd.domain for cd in user.verified_custom_domains()] hostname = request.args.get("hostname") data = request.get_json() @@ -51,7 +52,85 @@ def new_custom_alias(): note = data.get("note") alias_prefix = convert_to_id(alias_prefix) - if not verify_prefix_suffix(user, alias_prefix, alias_suffix, user_custom_domains): + if not verify_prefix_suffix(user, alias_prefix, alias_suffix): + return jsonify(error="wrong alias prefix or suffix"), 400 + + full_alias = alias_prefix + alias_suffix + if Alias.get_by(email=full_alias): + LOG.d("full alias already used %s", full_alias) + return jsonify(error=f"alias {full_alias} already exists"), 409 + + alias = Alias.create( + user_id=user.id, email=full_alias, mailbox_id=user.default_mailbox_id, note=note + ) + + if alias_suffix.startswith("@"): + alias_domain = alias_suffix[1:] + if alias_domain not in ALIAS_DOMAINS: + domain = CustomDomain.get_by(domain=alias_domain) + LOG.d("set alias %s to domain %s", full_alias, domain) + alias.custom_domain_id = domain.id + + db.session.commit() + + if hostname: + AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id) + db.session.commit() + + return jsonify(alias=full_alias, **serialize_alias_info(get_alias_info(alias))), 201 + + +@api_bp.route("/v2/alias/custom/new", methods=["POST"]) +@cross_origin() +@require_api_auth +def new_custom_alias_v2(): + """ + Create a new custom alias + Same as v1 but signed_suffix is actually the suffix with signature, e.g. + .random_word@SL.co.Xq19rQ.s99uWQ7jD1s5JZDZqczYI5TbNNU + Input: + alias_prefix, for ex "www_groupon_com" + signed_suffix, either .random_letters@simplelogin.co or @my-domain.com + optional "hostname" in args + optional "note" + Output: + 201 if success + 409 if the alias already exists + + """ + user: User = g.user + if not user.can_create_new_alias(): + LOG.d("user %s cannot create any custom alias", user) + return ( + jsonify( + error="You have reached the limitation of a free account with the maximum of " + f"{MAX_NB_EMAIL_FREE_PLAN} aliases, please upgrade your plan to create more aliases" + ), + 400, + ) + + hostname = request.args.get("hostname") + + data = request.get_json() + if not data: + return jsonify(error="request body cannot be empty"), 400 + + alias_prefix = data.get("alias_prefix", "").strip() + signed_suffix = data.get("signed_suffix", "").strip() + note = data.get("note") + alias_prefix = convert_to_id(alias_prefix) + + # hypothesis: user will click on the button in the 300 secs + try: + alias_suffix = signer.unsign(signed_suffix, max_age=300).decode() + except SignatureExpired: + LOG.error("Alias creation time expired") + return jsonify(error="alias creation is expired, please try again"), 400 + except Exception: + LOG.error("Alias suffix is tampered, user %s", user) + return jsonify(error="Tampered suffix"), 400 + + if not verify_prefix_suffix(user, alias_prefix, alias_suffix): return jsonify(error="wrong alias prefix or suffix"), 400 full_alias = alias_prefix + alias_suffix diff --git a/app/config.py b/app/config.py index 86c65a42..1ffc40ea 100644 --- a/app/config.py +++ b/app/config.py @@ -123,6 +123,7 @@ DB_URI = os.environ["DB_URI"] # Flask secret FLASK_SECRET = os.environ["FLASK_SECRET"] MAILBOX_SECRET = FLASK_SECRET + "mailbox" +CUSTOM_ALIAS_SECRET = FLASK_SECRET + "custom_alias" # AWS AWS_REGION = "eu-west-3" diff --git a/app/dashboard/templates/dashboard/custom_alias.html b/app/dashboard/templates/dashboard/custom_alias.html index 1767da02..edb7d9af 100644 --- a/app/dashboard/templates/dashboard/custom_alias.html +++ b/app/dashboard/templates/dashboard/custom_alias.html @@ -42,7 +42,7 @@
- diff --git a/app/dashboard/views/custom_alias.py b/app/dashboard/views/custom_alias.py index 7330a892..b7fda5da 100644 --- a/app/dashboard/views/custom_alias.py +++ b/app/dashboard/views/custom_alias.py @@ -1,14 +1,41 @@ from flask import render_template, redirect, url_for, flash, request from flask_login import login_required, current_user +from itsdangerous import TimestampSigner, SignatureExpired -from app.config import DISABLE_ALIAS_SUFFIX, ALIAS_DOMAINS +from app.config import ( + DISABLE_ALIAS_SUFFIX, + ALIAS_DOMAINS, + CUSTOM_ALIAS_SECRET, +) from app.dashboard.base import dashboard_bp from app.email_utils import email_belongs_to_alias_domains, get_email_domain_part from app.extensions import db from app.log import LOG -from app.models import Alias, CustomDomain, DeletedAlias, Mailbox +from app.models import Alias, CustomDomain, DeletedAlias, Mailbox, User from app.utils import convert_to_id, random_word, word_exist +signer = TimestampSigner(CUSTOM_ALIAS_SECRET) + + +def available_suffixes(user: User) -> [bool, str, str]: + """Return (is_custom_domain, alias-suffix, time-signed alias-suffix)""" + user_custom_domains = [cd.domain for cd in user.verified_custom_domains()] + + # List of (is_custom_domain, alias-suffix, time-signed alias-suffix) + suffixes = [] + + # put custom domain first + for alias_domain in user_custom_domains: + suffix = "@" + alias_domain + suffixes.append((True, suffix, signer.sign(suffix).decode())) + + # then default domain + for domain in ALIAS_DOMAINS: + suffix = ("" if DISABLE_ALIAS_SUFFIX else "." + random_word()) + "@" + domain + suffixes.append((False, suffix, signer.sign(suffix).decode())) + + return suffixes + @dashboard_bp.route("/custom_alias", methods=["GET", "POST"]) @login_required @@ -24,27 +51,14 @@ def custom_alias(): return redirect(url_for("dashboard.index")) user_custom_domains = [cd.domain for cd in current_user.verified_custom_domains()] - # List of (is_custom_domain, alias-suffix) - suffixes = [] - - # put custom domain first - for alias_domain in user_custom_domains: - suffixes.append((True, "@" + alias_domain)) - - # then default domain - for domain in ALIAS_DOMAINS: - suffixes.append( - ( - False, - ("" if DISABLE_ALIAS_SUFFIX else "." + random_word()) + "@" + domain, - ) - ) + # List of (is_custom_domain, alias-suffix, time-signed alias-suffix) + suffixes = available_suffixes(current_user) mailboxes = [mb.email for mb in current_user.mailboxes()] if request.method == "POST": alias_prefix = request.form.get("prefix") - alias_suffix = request.form.get("suffix") + signed_suffix = request.form.get("suffix") mailbox_email = request.form.get("mailbox") alias_note = request.form.get("note") @@ -55,9 +69,19 @@ def custom_alias(): flash("Something went wrong, please retry", "warning") return redirect(url_for("dashboard.custom_alias")) - if verify_prefix_suffix( - current_user, alias_prefix, alias_suffix, user_custom_domains - ): + # hypothesis: user will click on the button in the 300 secs + try: + alias_suffix = signer.unsign(signed_suffix, max_age=300).decode() + except SignatureExpired: + LOG.error("Alias creation time expired") + flash("Alias creation time is expired, please retry", "warning") + return redirect(url_for("dashboard.custom_alias")) + except Exception: + LOG.error("Alias suffix is tampered, user %s", current_user) + flash("Unknown error, refresh the page", "error") + return redirect(url_for("dashboard.custom_alias")) + + if verify_prefix_suffix(current_user, alias_prefix, alias_suffix): full_alias = alias_prefix + alias_suffix if Alias.get_by(email=full_alias) or DeletedAlias.get_by(email=full_alias): @@ -91,14 +115,20 @@ def custom_alias(): else: flash("something went wrong", "warning") - return render_template("dashboard/custom_alias.html", **locals()) + return render_template( + "dashboard/custom_alias.html", + user_custom_domains=user_custom_domains, + suffixes=suffixes, + mailboxes=mailboxes, + ) -def verify_prefix_suffix(user, alias_prefix, alias_suffix, user_custom_domains) -> bool: +def verify_prefix_suffix(user, alias_prefix, alias_suffix) -> bool: """verify if user could create an alias with the given prefix and suffix""" if not alias_prefix or not alias_suffix: # should be caught on frontend return False + user_custom_domains = [cd.domain for cd in user.verified_custom_domains()] alias_prefix = alias_prefix.strip() alias_prefix = convert_to_id(alias_prefix) diff --git a/app/models.py b/app/models.py index c3013845..2cedaf81 100644 --- a/app/models.py +++ b/app/models.py @@ -314,7 +314,9 @@ class User(db.Model, ModelMixin, UserMixin): """return suggested email and other email choices """ website_name = convert_to_id(website_name) - all_aliases = [ge.email for ge in Alias.filter_by(user_id=self.id)] + all_aliases = [ + ge.email for ge in Alias.filter_by(user_id=self.id, enabled=True) + ] if self.can_create_new_alias(): suggested_alias = Alias.create_new(self, prefix=website_name).email else: diff --git a/app/oauth/templates/oauth/authorize.html b/app/oauth/templates/oauth/authorize.html index 5d04bd22..93e2d4fa 100644 --- a/app/oauth/templates/oauth/authorize.html +++ b/app/oauth/templates/oauth/authorize.html @@ -82,7 +82,7 @@
- {% for email in other_emails %} @@ -108,7 +108,7 @@ style="padding-left: 5px"> +