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 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.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import Job
|
||||
from app.models import Job, ApiToCookieToken
|
||||
|
||||
|
||||
@api_bp.route("/user", methods=["DELETE"])
|
||||
|
@ -23,3 +24,23 @@ def delete_user():
|
|||
commit=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,
|
||||
social,
|
||||
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 os
|
||||
import random
|
||||
import secrets
|
||||
import uuid
|
||||
from email.utils import formataddr
|
||||
from typing import List, Tuple, Optional, Union
|
||||
|
@ -3296,3 +3297,20 @@ class NewsletterUser(Base, ModelMixin):
|
|||
|
||||
user = orm.relationship(User)
|
||||
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,
|
||||
PartnerSubscription,
|
||||
PartnerUser,
|
||||
ApiToCookieToken,
|
||||
)
|
||||
from app.proton.utils import get_proton_partner
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Uses a single API key to check the queue as fast as possible.
|
||||
|
@ -1066,6 +1077,7 @@ if __name__ == "__main__":
|
|||
"check_custom_domain",
|
||||
"check_hibp",
|
||||
"notify_hibp",
|
||||
"cleanup_tokens",
|
||||
],
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
@ -1104,3 +1116,6 @@ if __name__ == "__main__":
|
|||
elif args.job == "notify_hibp":
|
||||
LOG.d("Notify users about HIBP breaches")
|
||||
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.
|
||||
- [PATCH /api/sudo](#patch-apisudo): Enable sudo mode.
|
||||
- [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.
|
||||
- [POST /api/api_key](#post-apiapi_key): Create a new API key.
|
||||
- [GET /api/logout](#get-apilogout): Log out.
|
||||
|
@ -260,6 +261,19 @@ Output:
|
|||
- 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
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
|
@ -50,3 +50,19 @@ def test_delete_with_sudo(flask_client):
|
|||
job = jobs[0]
|
||||
assert job.name == config.JOB_DELETE_ACCOUNT
|
||||
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
|
||||
|
||||
from app.models import CoinbaseSubscription
|
||||
from cron import notify_manual_sub_end
|
||||
from app.models import CoinbaseSubscription, ApiToCookieToken, ApiKey
|
||||
from cron import notify_manual_sub_end, delete_expired_tokens
|
||||
from tests.utils import create_new_user
|
||||
|
||||
|
||||
|
@ -13,3 +13,26 @@ def test_notify_manual_sub_end(flask_client):
|
|||
)
|
||||
|
||||
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