From d1734c3cf977e0c58533871ae931a25d44dcfa00 Mon Sep 17 00:00:00 2001 From: Son NK Date: Mon, 20 Jan 2020 14:36:39 +0100 Subject: [PATCH] Create /api/auth/login --- README.md | 26 ++++++++++++--- app/api/__init__.py | 2 +- app/api/views/auth_login.py | 64 ++++++++++++++++++++++++++++++++++++ tests/api/test_auth_login.py | 42 +++++++++++++++++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 app/api/views/auth_login.py create mode 100644 tests/api/test_auth_login.py diff --git a/README.md b/README.md index 160df2ff..71591a24 100644 --- a/README.md +++ b/README.md @@ -437,13 +437,13 @@ john@wick.com / password ### API -For now the only API client is the Chrome/Firefox extension. This extension relies on `API Code` for authentication. +SimpleLogin current API clients are Chrome/Firefox/Safari extension and mobile (iOS/Android) app. +These clients rely on `API Code` for authentication. -In every request, the extension sends +Once the `Api Code` is obtained, either via user entering it (in Browser extension case) or by logging in (in Mobile case), +the client includes the `api code` in `Authentication` header in almost all requests. -- the `API Code` is set in `Authentication` header. The check is done via the `verify_api_key` wrapper, implemented in `app/api/base.py` - -- (Optional but recommended) `hostname` passed in query string. hostname is the the URL hostname (cf https://en.wikipedia.org/wiki/URL), for ex if URL is http://www.example.com/index.html then the hostname is `www.example.com`. This information is important to know where an alias is used in order to suggest user the same alias if they want to create on alias on the same website in the future. +For some endpoints, the `hostname` should be passed in query string. `hostname` is the the URL hostname (cf https://en.wikipedia.org/wiki/URL), for ex if URL is http://www.example.com/index.html then the hostname is `www.example.com`. This information is important to know where an alias is used in order to suggest user the same alias if they want to create on alias on the same website in the future. If error, the API returns 4** with body containing the error message, for example: @@ -553,6 +553,22 @@ If success, 201 with the new alias, for example } ``` +#### POST /api/auth/login + +Input: +- email +- password +- device: device name. Used to create the API Key. Should be humanly readable so user can manage later on the "API Key" page. + +Output: +- name: user name, could be an empty string +- mfa_enabled: boolean +- mfa_key: only useful when user enables MFA. In this case, user needs to enter their OTP token in order to login. +- api_key: if MFA is not enabled, the `api key` is returned right away. + +The `api_key` is used in all subsequent requests. It's empty if MFA is enabled. +If user hasn't enabled MFA, `mfa_key` is empty. + ### Database migration The database migration is handled by `alembic` diff --git a/app/api/__init__.py b/app/api/__init__.py index 268191e9..71a704b6 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1 +1 @@ -from .views import alias_options, new_custom_alias, new_random_alias, user_info +from .views import alias_options, new_custom_alias, new_random_alias, user_info, auth_login diff --git a/app/api/views/auth_login.py b/app/api/views/auth_login.py new file mode 100644 index 00000000..173bb5eb --- /dev/null +++ b/app/api/views/auth_login.py @@ -0,0 +1,64 @@ +from flask import g +from flask import jsonify, request +from flask_cors import cross_origin +from itsdangerous import Signer + +from app.api.base import api_bp, verify_api_key +from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN, FLASK_SECRET +from app.extensions import db +from app.log import LOG +from app.models import GenEmail, AliasUsedOn, User, ApiKey +from app.utils import convert_to_id + + +@api_bp.route("/auth/login", methods=["POST"]) +@cross_origin() +def auth_login(): + """ + Authenticate user + Input: + email + password + device: to create an ApiKey associated with this device + Output: + 200 and user info containing: + { + name: "John Wick", + mfa_enabled: true, + mfa_key: "a long string", + api_key: "a long string" + } + + """ + data = request.get_json() + if not data: + return jsonify(error="request body cannot be empty"), 400 + + email = data.get("email") + password = data.get("password") + device = data.get("device") + + user = User.filter_by(email=email).first() + + if not user or not user.check_password(password): + return jsonify(error="Email or password incorrect"), 400 + elif not user.activated: + return jsonify(error="Account not activated"), 400 + + ret = { + "name": user.name, + "mfa_enabled": user.enable_otp, + } + + # do not give api_key, user can only obtain api_key after OTP verification + if user.enable_otp: + s = Signer(FLASK_SECRET) + ret["mfa_key"] = s.sign(str(user.id)) + ret["api_key"] = "" + else: + api_key = ApiKey.create(user.id, device) + db.session.commit() + ret["mfa_key"] = "" + ret["api_key"] = api_key.code + + return jsonify(**ret), 200 diff --git a/tests/api/test_auth_login.py b/tests/api/test_auth_login.py new file mode 100644 index 00000000..0dbfb4a3 --- /dev/null +++ b/tests/api/test_auth_login.py @@ -0,0 +1,42 @@ +from flask import url_for + +from app.extensions import db +from app.models import User + + +def test_auth_login_success_mfa_disabled(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_login"), + json={"email": "a@b.c", "password": "password", "device": "Test Device"}, + ) + + assert r.status_code == 200 + assert r.json["api_key"] + assert r.json["mfa_enabled"] == False + assert r.json["mfa_key"] == "" + assert r.json["name"] == "Test User" + + +def test_auth_login_success_mfa_enabled(flask_client): + User.create( + email="a@b.c", + password="password", + name="Test User", + activated=True, + enable_otp=True, + ) + db.session.commit() + + r = flask_client.post( + url_for("api.auth_login"), + json={"email": "a@b.c", "password": "password", "device": "Test Device"}, + ) + + assert r.status_code == 200 + assert r.json["api_key"] == "" + assert r.json["mfa_enabled"] == True + assert r.json["mfa_key"] + assert r.json["name"] == "Test User"