Generate a web session from an api key (#1224)
* Create a token to exchange for a cookie * Added Route to exchange token for cookie * add missing migration Co-authored-by: Adrià Casajús <adria.casajus@proton.ch>
This commit is contained in:
parent
a9549c11d7
commit
3a75686898
|
@ -1,10 +1,11 @@
|
||||||
from flask import jsonify, g
|
from flask import jsonify, g
|
||||||
from sqlalchemy_utils.types.arrow import arrow
|
from sqlalchemy_utils.types.arrow import arrow
|
||||||
|
|
||||||
from app.api.base import api_bp, require_api_sudo
|
from app.api.base import api_bp, require_api_sudo, require_api_auth
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.extensions import limiter
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import Job
|
from app.models import Job, ApiToCookieToken
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/user", methods=["DELETE"])
|
@api_bp.route("/user", methods=["DELETE"])
|
||||||
|
@ -23,3 +24,23 @@ def delete_user():
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return jsonify(ok=True)
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/user/cookie_token", methods=["GET"])
|
||||||
|
@require_api_auth
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
def get_api_session_token():
|
||||||
|
"""
|
||||||
|
Get a temporary token to exchange it for a cookie based session
|
||||||
|
Output:
|
||||||
|
200 and a temporary random token
|
||||||
|
{
|
||||||
|
token: "asdli3ldq39h9hd3",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
token = ApiToCookieToken.create(
|
||||||
|
user=g.user,
|
||||||
|
api_key_id=g.api_key.id,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return jsonify({"token": token.code})
|
||||||
|
|
|
@ -15,4 +15,5 @@ from .views import (
|
||||||
fido,
|
fido,
|
||||||
social,
|
social,
|
||||||
recovery,
|
recovery,
|
||||||
|
api_to_cookie,
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import arrow
|
||||||
|
from flask import redirect, url_for, request, flash
|
||||||
|
from flask_login import current_user, login_user
|
||||||
|
|
||||||
|
from app.auth.base import auth_bp
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import ApiToCookieToken
|
||||||
|
from app.utils import sanitize_next_url
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/api_to_cookie", methods=["GET"])
|
||||||
|
def api_to_cookie():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
LOG.d("user is already authenticated, redirect to dashboard")
|
||||||
|
return redirect(url_for("dashboard.index"))
|
||||||
|
code = request.args.get("token")
|
||||||
|
if not code:
|
||||||
|
flash("Missing token", "error")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
token = ApiToCookieToken.get_by(code=code)
|
||||||
|
if not token or token.created_at < arrow.now().shift(minutes=-5):
|
||||||
|
flash("Missing token", "error")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
login_user(token.user)
|
||||||
|
ApiToCookieToken.delete(token.id, commit=True)
|
||||||
|
|
||||||
|
next_url = sanitize_next_url(request.args.get("next"))
|
||||||
|
if next_url:
|
||||||
|
return redirect(next_url)
|
||||||
|
else:
|
||||||
|
return redirect(url_for("dashboard.index"))
|
|
@ -6,6 +6,7 @@ import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from typing import List, Tuple, Optional, Union
|
from typing import List, Tuple, Optional, Union
|
||||||
|
@ -3296,3 +3297,20 @@ class NewsletterUser(Base, ModelMixin):
|
||||||
|
|
||||||
user = orm.relationship(User)
|
user = orm.relationship(User)
|
||||||
newsletter = orm.relationship(Newsletter)
|
newsletter = orm.relationship(Newsletter)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToCookieToken(Base, ModelMixin):
|
||||||
|
|
||||||
|
__tablename__ = "api_cookie_token"
|
||||||
|
code = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||||
|
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||||
|
api_key_id = sa.Column(sa.ForeignKey(ApiKey.id, ondelete="cascade"), nullable=False)
|
||||||
|
|
||||||
|
user = orm.relationship(User)
|
||||||
|
api_key = orm.relationship(ApiKey)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, **kwargs):
|
||||||
|
code = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
return super().create(code=code, **kwargs)
|
||||||
|
|
15
cron.py
15
cron.py
|
@ -66,6 +66,7 @@ from app.models import (
|
||||||
DeletedSubdomain,
|
DeletedSubdomain,
|
||||||
PartnerSubscription,
|
PartnerSubscription,
|
||||||
PartnerUser,
|
PartnerUser,
|
||||||
|
ApiToCookieToken,
|
||||||
)
|
)
|
||||||
from app.proton.utils import get_proton_partner
|
from app.proton.utils import get_proton_partner
|
||||||
from app.utils import sanitize_email
|
from app.utils import sanitize_email
|
||||||
|
@ -875,6 +876,16 @@ def delete_old_monitoring():
|
||||||
LOG.d("delete monitoring records older than %s, nb row %s", max_time, nb_row)
|
LOG.d("delete monitoring records older than %s, nb row %s", max_time, nb_row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_expired_tokens():
|
||||||
|
"""
|
||||||
|
Delete old tokens
|
||||||
|
"""
|
||||||
|
max_time = arrow.now().shift(hours=-1)
|
||||||
|
nb_row = ApiToCookieToken.filter(ApiToCookieToken.created_at < max_time).delete()
|
||||||
|
Session.commit()
|
||||||
|
LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row)
|
||||||
|
|
||||||
|
|
||||||
async def _hibp_check(api_key, queue):
|
async def _hibp_check(api_key, queue):
|
||||||
"""
|
"""
|
||||||
Uses a single API key to check the queue as fast as possible.
|
Uses a single API key to check the queue as fast as possible.
|
||||||
|
@ -1066,6 +1077,7 @@ if __name__ == "__main__":
|
||||||
"check_custom_domain",
|
"check_custom_domain",
|
||||||
"check_hibp",
|
"check_hibp",
|
||||||
"notify_hibp",
|
"notify_hibp",
|
||||||
|
"cleanup_tokens",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
@ -1104,3 +1116,6 @@ if __name__ == "__main__":
|
||||||
elif args.job == "notify_hibp":
|
elif args.job == "notify_hibp":
|
||||||
LOG.d("Notify users about HIBP breaches")
|
LOG.d("Notify users about HIBP breaches")
|
||||||
notify_hibp()
|
notify_hibp()
|
||||||
|
elif args.job == "cleanup_tokens":
|
||||||
|
LOG.d("Cleanup expired tokens")
|
||||||
|
delete_expired_tokens()
|
||||||
|
|
14
docs/api.md
14
docs/api.md
|
@ -12,6 +12,7 @@
|
||||||
- [GET /api/user_info](#get-apiuser_info): Get user's information.
|
- [GET /api/user_info](#get-apiuser_info): Get user's information.
|
||||||
- [PATCH /api/sudo](#patch-apisudo): Enable sudo mode.
|
- [PATCH /api/sudo](#patch-apisudo): Enable sudo mode.
|
||||||
- [DELETE /api/user](#delete-apiuser): Delete the current user.
|
- [DELETE /api/user](#delete-apiuser): Delete the current user.
|
||||||
|
- [GET /api/user/cookie_token](#get_apiusergookie_token): Get a one time use token to exchange it for a valid cookie
|
||||||
- [PATCH /api/user_info](#patch-apiuser_info): Update user's information.
|
- [PATCH /api/user_info](#patch-apiuser_info): Update user's information.
|
||||||
- [POST /api/api_key](#post-apiapi_key): Create a new API key.
|
- [POST /api/api_key](#post-apiapi_key): Create a new API key.
|
||||||
- [GET /api/logout](#get-apilogout): Log out.
|
- [GET /api/logout](#get-apilogout): Log out.
|
||||||
|
@ -260,6 +261,19 @@ Output:
|
||||||
- 403 with ```{"error": "Some error"}``` if there is an error.
|
- 403 with ```{"error": "Some error"}``` if there is an error.
|
||||||
|
|
||||||
|
|
||||||
|
#### GET /api/user/cookie_token
|
||||||
|
|
||||||
|
Get a one time use cookie to exchange it for a valid cookie in the web app
|
||||||
|
|
||||||
|
Input:
|
||||||
|
|
||||||
|
- `Authentication` header that contains the api key
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- 200 with ```{"token": "token value"}```
|
||||||
|
- 403 with ```{"error": "Some error"}``` if there is an error.
|
||||||
|
|
||||||
#### POST /api/api_key
|
#### POST /api/api_key
|
||||||
|
|
||||||
Create a new API Key
|
Create a new API Key
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Add api to cookie token
|
||||||
|
|
||||||
|
Revision ID: 9cc0f0712b29
|
||||||
|
Revises: c66f2c5b6cb1
|
||||||
|
Create Date: 2022-08-10 16:54:46.979196
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '9cc0f0712b29'
|
||||||
|
down_revision = 'c66f2c5b6cb1'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('api_cookie_token',
|
||||||
|
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('code', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('api_key_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['api_key_id'], ['api_key.id'], ondelete='cascade'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('api_cookie_token')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -4,7 +4,7 @@ from flask import url_for
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.models import Job
|
from app.models import Job, ApiToCookieToken
|
||||||
from tests.api.utils import get_new_user_and_api_key
|
from tests.api.utils import get_new_user_and_api_key
|
||||||
|
|
||||||
|
|
||||||
|
@ -50,3 +50,19 @@ def test_delete_with_sudo(flask_client):
|
||||||
job = jobs[0]
|
job = jobs[0]
|
||||||
assert job.name == config.JOB_DELETE_ACCOUNT
|
assert job.name == config.JOB_DELETE_ACCOUNT
|
||||||
assert job.payload == {"user_id": user.id}
|
assert job.payload == {"user_id": user.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cookie_token(flask_client):
|
||||||
|
user, api_key = get_new_user_and_api_key()
|
||||||
|
|
||||||
|
r = flask_client.get(
|
||||||
|
url_for("api.get_api_session_token"),
|
||||||
|
headers={"Authentication": api_key.code},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
code = r.json["token"]
|
||||||
|
token = ApiToCookieToken.get_by(code=code)
|
||||||
|
assert token is not None
|
||||||
|
assert token.user_id == user.id
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
from app.models import ApiToCookieToken, ApiKey
|
||||||
|
from tests.utils import create_new_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_cookie(flask_client):
|
||||||
|
user = create_new_user()
|
||||||
|
api_key = ApiKey.create(
|
||||||
|
user_id=user.id,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
token = ApiToCookieToken.create(
|
||||||
|
user_id=user.id,
|
||||||
|
api_key_id=api_key.id,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
token_code = token.code
|
||||||
|
token_id = token.id
|
||||||
|
|
||||||
|
r = flask_client.get(
|
||||||
|
url_for(
|
||||||
|
"auth.api_to_cookie", token=token_code, next=url_for("dashboard.setting")
|
||||||
|
),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ApiToCookieToken.get(token_id) is None
|
||||||
|
assert r.headers.getlist("Set-Cookie") is not None
|
|
@ -1,7 +1,7 @@
|
||||||
import arrow
|
import arrow
|
||||||
|
|
||||||
from app.models import CoinbaseSubscription
|
from app.models import CoinbaseSubscription, ApiToCookieToken, ApiKey
|
||||||
from cron import notify_manual_sub_end
|
from cron import notify_manual_sub_end, delete_expired_tokens
|
||||||
from tests.utils import create_new_user
|
from tests.utils import create_new_user
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,3 +13,26 @@ def test_notify_manual_sub_end(flask_client):
|
||||||
)
|
)
|
||||||
|
|
||||||
notify_manual_sub_end()
|
notify_manual_sub_end()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_tokens(flask_client):
|
||||||
|
user = create_new_user()
|
||||||
|
api_key = ApiKey.create(
|
||||||
|
user_id=user.id,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
id_to_clean = ApiToCookieToken.create(
|
||||||
|
user_id=user.id,
|
||||||
|
api_key_id=api_key.id,
|
||||||
|
commit=True,
|
||||||
|
created_at=arrow.now().shift(days=-1),
|
||||||
|
).id
|
||||||
|
|
||||||
|
id_to_keep = ApiToCookieToken.create(
|
||||||
|
user_id=user.id,
|
||||||
|
api_key_id=api_key.id,
|
||||||
|
commit=True,
|
||||||
|
).id
|
||||||
|
delete_expired_tokens()
|
||||||
|
assert ApiToCookieToken.get(id_to_clean) is None
|
||||||
|
assert ApiToCookieToken.get(id_to_keep) is not None
|
||||||
|
|
Loading…
Reference in New Issue