mirror of
https://github.com/simple-login/app.git
synced 2024-11-11 01:42:54 +08:00
commit
2c53bf8315
6 changed files with 272 additions and 6 deletions
40
README.md
40
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,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`
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
64
app/api/views/auth_login.py
Normal file
64
app/api/views/auth_login.py
Normal 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
65
app/api/views/auth_mfa.py
Normal 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
|
42
tests/api/test_auth_login.py
Normal file
42
tests/api/test_auth_login.py
Normal 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"
|
58
tests/api/test_auth_mfa.py
Normal file
58
tests/api/test_auth_mfa.py
Normal 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"]
|
Loading…
Reference in a new issue