From 45178795f42095c65026e92ca12323940eb81f95 Mon Sep 17 00:00:00 2001 From: Son NK Date: Fri, 28 Feb 2020 18:13:43 +0700 Subject: [PATCH 1/4] Change text email signature --- templates/emails/com/new-app.txt | 2 +- templates/emails/com/onboarding-1.txt | 2 +- templates/emails/com/safari-extension.txt | 2 +- templates/emails/com/welcome.txt | 2 +- templates/emails/transactional/activation.txt | 4 ++-- templates/emails/transactional/change-email.txt | 4 ++-- templates/emails/transactional/manual-subscription-end.txt | 2 +- templates/emails/transactional/reset-password.txt | 4 ++-- templates/emails/transactional/test-email.txt | 2 +- templates/emails/transactional/trial-end.txt | 2 +- templates/emails/transactional/verify-mailbox-change.txt | 2 +- templates/emails/transactional/verify-mailbox.txt | 2 +- 12 files changed, 15 insertions(+), 15 deletions(-) diff --git a/templates/emails/com/new-app.txt b/templates/emails/com/new-app.txt index 1de54cd4..af63f92f 100644 --- a/templates/emails/com/new-app.txt +++ b/templates/emails/com/new-app.txt @@ -7,4 +7,4 @@ Even though I lead the company, Iā€™m the "product person" and the user experien Our users and developers love SimpleLogin and its simplicity (hence the "simple" in the name šŸ˜‰), but if there's anything that's bugging you, even the smallest of issues that could be done better, I want to hear about it - so hit the reply button. Thanks! -Son - SimpleLogin founder. +SimpleLogin Team. diff --git a/templates/emails/com/onboarding-1.txt b/templates/emails/com/onboarding-1.txt index 01ec9564..c19e4db9 100644 --- a/templates/emails/com/onboarding-1.txt +++ b/templates/emails/com/onboarding-1.txt @@ -18,4 +18,4 @@ Here are the steps: As usual, let me know if you have any question by replying to this email. Best regards, -Son - SimpleLogin founder. \ No newline at end of file +SimpleLogin Team. \ No newline at end of file diff --git a/templates/emails/com/safari-extension.txt b/templates/emails/com/safari-extension.txt index d3cc2ecc..2999e6bb 100644 --- a/templates/emails/com/safari-extension.txt +++ b/templates/emails/com/safari-extension.txt @@ -13,4 +13,4 @@ https://apps.apple.com/us/app/simplelogin/id1494051017?mt=12&fbclid=IwAR0M0nnEKg As usual, let me know if you have any question by replying to this email. Best regards, -Son - SimpleLogin founder. \ No newline at end of file +SimpleLogin Team. \ No newline at end of file diff --git a/templates/emails/com/welcome.txt b/templates/emails/com/welcome.txt index 4a0f963d..b7790355 100644 --- a/templates/emails/com/welcome.txt +++ b/templates/emails/com/welcome.txt @@ -29,4 +29,4 @@ If there's anything that's bugging you, even the smallest of issues that could b Thanks. -Son - SimpleLogin founder. +SimpleLogin Team. diff --git a/templates/emails/transactional/activation.txt b/templates/emails/transactional/activation.txt index 65df1a38..13ea956b 100644 --- a/templates/emails/transactional/activation.txt +++ b/templates/emails/transactional/activation.txt @@ -4,5 +4,5 @@ Thank you for choosing SimpleLogin. To get started, please confirm that {{email}} is your email address using this link {{activation_link}} within 1 hour. -Cheers, -Son - SimpleLogin founder. +Thanks, +SimpleLogin Team. diff --git a/templates/emails/transactional/change-email.txt b/templates/emails/transactional/change-email.txt index 0d475bc1..ad63b114 100644 --- a/templates/emails/transactional/change-email.txt +++ b/templates/emails/transactional/change-email.txt @@ -8,5 +8,5 @@ To confirm, please click on this link: {{link}} -Cheers, -Son - SimpleLogin founder. +Thanks, +SimpleLogin Team. diff --git a/templates/emails/transactional/manual-subscription-end.txt b/templates/emails/transactional/manual-subscription-end.txt index abfc69a6..dec845bc 100644 --- a/templates/emails/transactional/manual-subscription-end.txt +++ b/templates/emails/transactional/manual-subscription-end.txt @@ -12,4 +12,4 @@ When the subscription ends: You can upgrade today to continue using all these Premium features (and much more coming). Best, -Son - SimpleLogin founder. +SimpleLogin Team. diff --git a/templates/emails/transactional/reset-password.txt b/templates/emails/transactional/reset-password.txt index fa4e5add..f6cf19d3 100644 --- a/templates/emails/transactional/reset-password.txt +++ b/templates/emails/transactional/reset-password.txt @@ -4,5 +4,5 @@ To reset or change your password, please click on this link: {{reset_password_link}} -Cheers, -Son - SimpleLogin founder. +Thanks, +SimpleLogin Team. diff --git a/templates/emails/transactional/test-email.txt b/templates/emails/transactional/test-email.txt index 628bbe2d..dc5339fa 100644 --- a/templates/emails/transactional/test-email.txt +++ b/templates/emails/transactional/test-email.txt @@ -5,4 +5,4 @@ This is a test to make sure that you receive emails sent to your alias {{alias}} If you have any questions, feel free to reply to this email. Have a nice day! -Son - SimpleLogin founder. +SimpleLogin Team. diff --git a/templates/emails/transactional/trial-end.txt b/templates/emails/transactional/trial-end.txt index fc0120fa..de391fd1 100644 --- a/templates/emails/transactional/trial-end.txt +++ b/templates/emails/transactional/trial-end.txt @@ -14,4 +14,4 @@ You can upgrade today to continue using all these Premium features (and much mor Let me know if you need to extend your trial period. Best, -Son - SimpleLogin founder. +SimpleLogin Team. diff --git a/templates/emails/transactional/verify-mailbox-change.txt b/templates/emails/transactional/verify-mailbox-change.txt index 1976d1df..232e1144 100644 --- a/templates/emails/transactional/verify-mailbox-change.txt +++ b/templates/emails/transactional/verify-mailbox-change.txt @@ -7,4 +7,4 @@ To confirm, please click on this link: {{link}} Regards, -Son - SimpleLogin founder. +SimpleLogin Team. diff --git a/templates/emails/transactional/verify-mailbox.txt b/templates/emails/transactional/verify-mailbox.txt index 6a804029..134ef112 100644 --- a/templates/emails/transactional/verify-mailbox.txt +++ b/templates/emails/transactional/verify-mailbox.txt @@ -7,4 +7,4 @@ To confirm, please click on this link: {{link}} Regards, -Son - SimpleLogin founder. +SimpleLogin Team. From c025acc826751fdf29b6bc37bf710cd8a0098c23 Mon Sep 17 00:00:00 2001 From: Son NK Date: Fri, 28 Feb 2020 19:00:45 +0700 Subject: [PATCH 2/4] Add AccountActivation model --- app/models.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/models.py b/app/models.py index 14c1384a..f1980d56 100644 --- a/app/models.py +++ b/app/models.py @@ -6,7 +6,7 @@ import arrow import bcrypt from flask import url_for from flask_login import UserMixin -from sqlalchemy import text, desc +from sqlalchemy import text, desc, CheckConstraint from sqlalchemy_utils import ArrowType from app import s3 @@ -906,3 +906,21 @@ class Mailbox(db.Model, ModelMixin): def __repr__(self): return f"" + + +class AccountActivation(db.Model, ModelMixin): + """contains code to activate the user account when they sign up on mobile""" + + user_id = db.Column( + db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True + ) + # the activation code is usually 6 digits + code = db.Column(db.String(10), unique=True, nullable=False) + + # nb tries decrements each time user enters wrong code + tries = db.Column(db.Integer, default=3, nullable=False) + + __table_args__ = ( + CheckConstraint(tries >= 0, name="account_activation_tries_positive"), + {}, + ) From 32cd2fd6504ad32be9a6755e29195045569138ab Mon Sep 17 00:00:00 2001 From: Son NK Date: Fri, 28 Feb 2020 19:01:54 +0700 Subject: [PATCH 3/4] Add related endpoints for registration POST /api/auth/register POST /api/auth/activate POST /api/auth/reactivate --- README.md | 29 ++++ app/api/views/auth_login.py | 151 +++++++++++++++++- .../emails/transactional/code-activation.html | 10 ++ .../emails/transactional/code-activation.txt | 10 ++ tests/api/test_auth_login.py | 139 +++++++++++++++- 5 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 templates/emails/transactional/code-activation.html create mode 100644 templates/emails/transactional/code-activation.txt diff --git a/README.md b/README.md index 4d51f2e8..b3bdbae0 100644 --- a/README.md +++ b/README.md @@ -755,6 +755,35 @@ Input: Output: Same output as for `/api/auth/login` endpoint + +#### POST /api/auth/register + +Input: +- email +- password + +Output: 200 means user is going to receive an email that contains an *activation code*. User needs to enter this code to confirm their account -> next endpoint. + + +#### POST /api/auth/activate + +Input: +- email +- code: the activation code + +Output: +- 200: account is activated. User can login now +- 400: wrong email, code +- 410: wrong code too many times. User needs to ask for an reactivation -> next endpoint + +#### POST /api/auth/reactivate + +Input: +- email + +Output: +- 200: user is going to receive an email that contains the activation code. + #### GET /api/aliases Get user aliases. diff --git a/app/api/views/auth_login.py b/app/api/views/auth_login.py index cd29e918..7bf7dbb7 100644 --- a/app/api/views/auth_login.py +++ b/app/api/views/auth_login.py @@ -1,4 +1,5 @@ -from flask import jsonify, request +import random + import facebook import google.oauth2.credentials import googleapiclient.discovery @@ -12,10 +13,15 @@ from app.config import ( FLASK_SECRET, DISABLE_REGISTRATION, ) -from app.email_utils import can_be_used_as_personal_email, email_already_used +from app.email_utils import ( + can_be_used_as_personal_email, + email_already_used, + send_email, + render, +) from app.extensions import db from app.log import LOG -from app.models import User, ApiKey, SocialAuth +from app.models import User, ApiKey, SocialAuth, AccountActivation @api_bp.route("/auth/login", methods=["POST"]) @@ -55,6 +61,145 @@ def auth_login(): return jsonify(**auth_payload(user, device)), 200 +@api_bp.route("/auth/register", methods=["POST"]) +@cross_origin() +def auth_register(): + """ + User signs up - will need to activate their account with an activation code. + Input: + email + password + Output: + 200: user needs to confirm their account + + """ + data = request.get_json() + if not data: + return jsonify(error="request body cannot be empty"), 400 + + email = data.get("email") + password = data.get("password") + + if DISABLE_REGISTRATION: + return jsonify(error="registration is closed"), 400 + if not can_be_used_as_personal_email(email) or email_already_used(email): + return jsonify(error=f"cannot use {email} as personal inbox"), 400 + + if not password or len(password) < 8: + return jsonify(error="password too short"), 400 + + LOG.debug("create user %s", email) + user = User.create(email=email, name="", password=password) + db.session.flush() + + # create activation code + code = "".join([str(random.randint(0, 9)) for _ in range(6)]) + AccountActivation.create(user_id=user.id, code=code) + db.session.commit() + + send_email( + email, + f"Just one more step to join SimpleLogin", + render("transactional/code-activation.txt", code=code), + render("transactional/code-activation.html", code=code), + ) + + return jsonify(msg="User needs to confirm their account"), 200 + + +@api_bp.route("/auth/activate", methods=["POST"]) +@cross_origin() +def auth_activate(): + """ + User enters the activation code to confirm their account. + Input: + email + code + Output: + 200: user account is now activated, user can login now + 400: wrong email, code + 410: wrong code too many times + + """ + data = request.get_json() + if not data: + return jsonify(error="request body cannot be empty"), 400 + + email = data.get("email") + code = data.get("code") + + user = User.get_by(email=email) + + # do not use a different message to avoid exposing existing email + if not user or user.activated: + return jsonify(error="Wrong email or code"), 400 + + account_activation = AccountActivation.get_by(user_id=user.id) + if not account_activation: + return jsonify(error="Wrong email or code"), 400 + + if account_activation.code != code: + # decrement nb tries + account_activation.tries -= 1 + db.session.commit() + + if account_activation.tries == 0: + AccountActivation.delete(account_activation.id) + db.session.commit() + return jsonify(error="Too many wrong tries"), 410 + + return jsonify(error="Wrong email or code"), 400 + + LOG.debug("activate user %s", user) + user.activated = True + AccountActivation.delete(account_activation.id) + db.session.commit() + + return jsonify(msg="Account is activated, user can login now"), 200 + + +@api_bp.route("/auth/reactivate", methods=["POST"]) +@cross_origin() +def auth_reactivate(): + """ + User asks for another activation code + Input: + email + Output: + 200: user is going to receive an email for activate their account + + """ + data = request.get_json() + if not data: + return jsonify(error="request body cannot be empty"), 400 + + email = data.get("email") + user = User.get_by(email=email) + + # do not use a different message to avoid exposing existing email + if not user or user.activated: + return jsonify(error="Something went wrong"), 400 + + account_activation = AccountActivation.get_by(user_id=user.id) + if account_activation: + AccountActivation.delete(account_activation.id) + db.session.commit() + + # create activation code + code = "".join([str(random.randint(0, 9)) for _ in range(6)]) + AccountActivation.create(user_id=user.id, code=code) + db.session.commit() + + send_email( + email, + f"Just one more step to join SimpleLogin", + render("transactional/code-activation.txt", code=code), + render("transactional/code-activation.html", code=code), + ) + + return jsonify(msg="User needs to confirm their account"), 200 + + @api_bp.route("/auth/facebook", methods=["POST"]) @cross_origin() def auth_facebook(): diff --git a/templates/emails/transactional/code-activation.html b/templates/emails/transactional/code-activation.html new file mode 100644 index 00000000..3184fcba --- /dev/null +++ b/templates/emails/transactional/code-activation.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} + {{ render_text("Hi") }} + {{ render_text("Thank you for choosing SimpleLogin.") }} + {{ render_text("To get started, please activate your account by entering the following code into the application:") }} + {{ render_text("

" + code + "

")}} + {{ render_text('Thanks,
SimpleLogin Team.') }} +{% endblock %} + diff --git a/templates/emails/transactional/code-activation.txt b/templates/emails/transactional/code-activation.txt new file mode 100644 index 00000000..990304db --- /dev/null +++ b/templates/emails/transactional/code-activation.txt @@ -0,0 +1,10 @@ +Hi, + +Thank you for choosing SimpleLogin. + +To get started, please activate your account by entering the following code into the application: + +{{code}} + +Thanks, +SimpleLogin Team. \ No newline at end of file diff --git a/tests/api/test_auth_login.py b/tests/api/test_auth_login.py index 96241333..ae99cb1e 100644 --- a/tests/api/test_auth_login.py +++ b/tests/api/test_auth_login.py @@ -1,7 +1,7 @@ from flask import url_for from app.extensions import db -from app.models import User +from app.models import User, AccountActivation def test_auth_login_success_mfa_disabled(flask_client): @@ -63,3 +63,140 @@ def test_auth_login_device_exist(flask_client): json={"email": "a@b.c", "password": "password", "device": "Test Device"}, ) assert r.json["api_key"] == api_key + + +def test_auth_register_success(flask_client): + assert AccountActivation.get(1) is None + + r = flask_client.post( + url_for("api.auth_register"), json={"email": "a@b.c", "password": "password"}, + ) + + assert r.status_code == 200 + assert r.json["msg"] + + # make sure an activation code is created + act_code = AccountActivation.get(1) + assert act_code + assert len(act_code.code) == 6 + assert act_code.tries == 3 + + +def test_auth_register_too_short_password(flask_client): + r = flask_client.post( + url_for("api.auth_register"), json={"email": "a@b.c", "password": "short"}, + ) + + assert r.status_code == 400 + assert r.json["error"] == "password too short" + + +def test_auth_activate_success(flask_client): + r = flask_client.post( + url_for("api.auth_register"), json={"email": "a@b.c", "password": "password"}, + ) + + assert r.status_code == 200 + assert r.json["msg"] + + # get the activation code + act_code = AccountActivation.get(1) + assert act_code + assert len(act_code.code) == 6 + + r = flask_client.post( + url_for("api.auth_activate"), json={"email": "a@b.c", "code": act_code.code}, + ) + assert r.status_code == 200 + + +def test_auth_activate_wrong_email(flask_client): + r = flask_client.post( + url_for("api.auth_activate"), json={"email": "a@b.c", "code": "123456"}, + ) + assert r.status_code == 400 + + +def test_auth_activate_user_already_activated(flask_client): + User.create(email="a@b.c", password="password", name="Test User", activated=True) + db.session.commit() + + r = flask_client.post( + url_for("api.auth_activate"), json={"email": "a@b.c", "code": "123456"}, + ) + assert r.status_code == 400 + + +def test_auth_activate_wrong_code(flask_client): + r = flask_client.post( + url_for("api.auth_register"), json={"email": "a@b.c", "password": "password"}, + ) + + assert r.status_code == 200 + assert r.json["msg"] + + # get the activation code + act_code = AccountActivation.get(1) + assert act_code + assert len(act_code.code) == 6 + assert act_code.tries == 3 + + # make sure to create a wrong code + wrong_code = act_code.code + "123" + + r = flask_client.post( + url_for("api.auth_activate"), json={"email": "a@b.c", "code": wrong_code}, + ) + assert r.status_code == 400 + + # make sure the nb tries decrements + act_code = AccountActivation.get(1) + assert act_code.tries == 2 + + +def test_auth_activate_too_many_wrong_code(flask_client): + r = flask_client.post( + url_for("api.auth_register"), json={"email": "a@b.c", "password": "password"}, + ) + + assert r.status_code == 200 + assert r.json["msg"] + + # get the activation code + act_code = AccountActivation.get(1) + assert act_code + assert len(act_code.code) == 6 + assert act_code.tries == 3 + + # make sure to create a wrong code + wrong_code = act_code.code + "123" + + for _ in range(2): + r = flask_client.post( + url_for("api.auth_activate"), json={"email": "a@b.c", "code": wrong_code}, + ) + assert r.status_code == 400 + + # the activation code is deleted + r = flask_client.post( + url_for("api.auth_activate"), json={"email": "a@b.c", "code": wrong_code}, + ) + + assert r.status_code == 410 + + # make sure the nb tries decrements + assert AccountActivation.get(1) is None + + +def test_auth_reactivate_success(flask_client): + User.create(email="a@b.c", password="password", name="Test User") + db.session.commit() + + r = flask_client.post(url_for("api.auth_reactivate"), json={"email": "a@b.c"},) + assert r.status_code == 200 + + # make sure an activation code is created + act_code = AccountActivation.get(1) + assert act_code + assert len(act_code.code) == 6 + assert act_code.tries == 3 From bf3cbd033e87b69c47bfaa6c73466d78e373b88d Mon Sep 17 00:00:00 2001 From: Son NK Date: Fri, 28 Feb 2020 19:09:01 +0700 Subject: [PATCH 4/4] add migration script --- app/models.py | 2 +- .../versions/2020_022819_5f191273d067_.py | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/2020_022819_5f191273d067_.py diff --git a/app/models.py b/app/models.py index f1980d56..f4e936dc 100644 --- a/app/models.py +++ b/app/models.py @@ -915,7 +915,7 @@ class AccountActivation(db.Model, ModelMixin): db.ForeignKey(User.id, ondelete="cascade"), nullable=False, unique=True ) # the activation code is usually 6 digits - code = db.Column(db.String(10), unique=True, nullable=False) + code = db.Column(db.String(10), nullable=False) # nb tries decrements each time user enters wrong code tries = db.Column(db.Integer, default=3, nullable=False) diff --git a/migrations/versions/2020_022819_5f191273d067_.py b/migrations/versions/2020_022819_5f191273d067_.py new file mode 100644 index 00000000..d3da2281 --- /dev/null +++ b/migrations/versions/2020_022819_5f191273d067_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: 5f191273d067 +Revises: 75093e7ded27 +Create Date: 2020-02-28 19:08:15.570326 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5f191273d067' +down_revision = '75093e7ded27' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('account_activation', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=10), nullable=False), + sa.Column('tries', sa.Integer(), nullable=False), + sa.CheckConstraint('tries >= 0', name='account_activation_tries_positive'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('account_activation') + # ### end Alembic commands ###