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