Bläddra i källkod

Add related endpoints for registration

POST /api/auth/register
POST /api/auth/activate
POST /api/auth/reactivate
Son NK 5 år sedan
förälder
incheckning
32cd2fd650

+ 29 - 0
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.

+ 148 - 3
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():

+ 10 - 0
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("<h1>" + code + "</h1>")}}
+  {{ render_text('Thanks, <br />SimpleLogin Team.') }}
+{% endblock %}
+

+ 10 - 0
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.

+ 138 - 1
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