mirror of
https://github.com/simple-login/app.git
synced 2024-09-30 05:31:30 +02:00
commit
959b372854
29
README.md
29
README.md
@ -755,6 +755,35 @@ Input:
|
|||||||
|
|
||||||
Output: Same output as for `/api/auth/login` endpoint
|
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 /api/aliases
|
||||||
|
|
||||||
Get user aliases.
|
Get user aliases.
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from flask import jsonify, request
|
import random
|
||||||
|
|
||||||
import facebook
|
import facebook
|
||||||
import google.oauth2.credentials
|
import google.oauth2.credentials
|
||||||
import googleapiclient.discovery
|
import googleapiclient.discovery
|
||||||
@ -12,10 +13,15 @@ from app.config import (
|
|||||||
FLASK_SECRET,
|
FLASK_SECRET,
|
||||||
DISABLE_REGISTRATION,
|
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.extensions import db
|
||||||
from app.log import LOG
|
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"])
|
@api_bp.route("/auth/login", methods=["POST"])
|
||||||
@ -55,6 +61,145 @@ def auth_login():
|
|||||||
return jsonify(**auth_payload(user, device)), 200
|
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"])
|
@api_bp.route("/auth/facebook", methods=["POST"])
|
||||||
@cross_origin()
|
@cross_origin()
|
||||||
def auth_facebook():
|
def auth_facebook():
|
||||||
|
@ -6,7 +6,7 @@ import arrow
|
|||||||
import bcrypt
|
import bcrypt
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from sqlalchemy import text, desc
|
from sqlalchemy import text, desc, CheckConstraint
|
||||||
from sqlalchemy_utils import ArrowType
|
from sqlalchemy_utils import ArrowType
|
||||||
|
|
||||||
from app import s3
|
from app import s3
|
||||||
@ -906,3 +906,21 @@ class Mailbox(db.Model, ModelMixin):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Mailbox {self.email}>"
|
return f"<Mailbox {self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
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), 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"),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
40
migrations/versions/2020_022819_5f191273d067_.py
Normal file
40
migrations/versions/2020_022819_5f191273d067_.py
Normal file
@ -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 ###
|
10
templates/emails/transactional/code-activation.html
Normal file
10
templates/emails/transactional/code-activation.html
Normal file
@ -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
templates/emails/transactional/code-activation.txt
Normal file
10
templates/emails/transactional/code-activation.txt
Normal file
@ -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.
|
@ -1,7 +1,7 @@
|
|||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from app.extensions import db
|
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):
|
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"},
|
json={"email": "a@b.c", "password": "password", "device": "Test Device"},
|
||||||
)
|
)
|
||||||
assert r.json["api_key"] == api_key
|
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
|
||||||
|
Loading…
Reference in New Issue
Block a user