Merge pull request #40 from simple-login/api-mobile

Api mobile
This commit is contained in:
Son Nguyen Kim 2020-01-22 09:12:24 +01:00 committed by GitHub
commit 2c53bf8315
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 272 additions and 6 deletions

View file

@ -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,36 @@ 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.
#### POST /api/auth/mfa
Input:
- mfa_token: OTP token that user enters
- mfa_key: MFA key obtained in previous auth request, e.g. /api/auth/login
- device: the device name, used to create an ApiKey associated with this device
Output:
- name: user name, could be an empty string
- 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`

View file

@ -1 +1,8 @@
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,
auth_mfa,
)

View file

@ -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

65
app/api/views/auth_mfa.py Normal file
View file

@ -0,0 +1,65 @@
import pyotp
from flask import jsonify, request
from flask_cors import cross_origin
from itsdangerous import Signer, BadSignature
from app.api.base import api_bp
from app.config import FLASK_SECRET
from app.extensions import db
from app.models import User, ApiKey
@api_bp.route("/auth/mfa", methods=["POST"])
@cross_origin()
def auth_mfa():
"""
Validate the OTP Token
Input:
mfa_token: OTP token that user enters
mfa_key: MFA key obtained in previous auth request, e.g. /api/auth/login
device: the device name, used to create an ApiKey associated with this device
Output:
200 and user info containing:
{
name: "John Wick",
api_key: "a long string"
}
"""
data = request.get_json()
if not data:
return jsonify(error="request body cannot be empty"), 400
mfa_token = data.get("mfa_token")
mfa_key = data.get("mfa_key")
device = data.get("device")
s = Signer(FLASK_SECRET)
try:
user_id = int(s.unsign(mfa_key))
except BadSignature:
return jsonify(error="Invalid mfa_key"), 400
user = User.get(user_id)
if not user:
return jsonify(error="Invalid mfa_key"), 400
elif not user.enable_otp:
return (
jsonify(error="This endpoint should only be used by user who enables MFA"),
400,
)
totp = pyotp.TOTP(user.otp_secret)
if not totp.verify(mfa_token):
return jsonify(error="Wrong TOTP Token"), 400
ret = {
"name": user.name,
}
api_key = ApiKey.create(user.id, device)
db.session.commit()
ret["api_key"] = api_key.code
return jsonify(**ret), 200

View file

@ -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"

View file

@ -0,0 +1,58 @@
import pyotp
from flask import url_for
from itsdangerous import Signer
from app.config import FLASK_SECRET
from app.extensions import db
from app.models import User
def test_auth_mfa_success(flask_client):
user = User.create(
email="a@b.c",
password="password",
name="Test User",
activated=True,
enable_otp=True,
otp_secret="base32secret3232",
)
db.session.commit()
totp = pyotp.TOTP(user.otp_secret)
s = Signer(FLASK_SECRET)
mfa_key = s.sign(str(user.id))
r = flask_client.post(
url_for("api.auth_mfa"),
json={"mfa_token": totp.now(), "mfa_key": mfa_key, "device": "Test Device"},
)
assert r.status_code == 200
assert r.json["api_key"]
assert r.json["name"] == "Test User"
def test_auth_wrong_mfa_key(flask_client):
user = User.create(
email="a@b.c",
password="password",
name="Test User",
activated=True,
enable_otp=True,
otp_secret="base32secret3232",
)
db.session.commit()
totp = pyotp.TOTP(user.otp_secret)
r = flask_client.post(
url_for("api.auth_mfa"),
json={
"mfa_token": totp.now(),
"mfa_key": "wrong mfa key",
"device": "Test Device",
},
)
assert r.status_code == 400
assert r.json["error"]