diff --git a/app/api/views/user.py b/app/api/views/user.py index c3c304083..8a98e328e 100644 --- a/app/api/views/user.py +++ b/app/api/views/user.py @@ -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}) diff --git a/app/auth/__init__.py b/app/auth/__init__.py index e91fd8cb9..61fe17edf 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -15,4 +15,5 @@ fido, social, recovery, + api_to_cookie, ) diff --git a/app/auth/views/api_to_cookie.py b/app/auth/views/api_to_cookie.py new file mode 100644 index 000000000..701641324 --- /dev/null +++ b/app/auth/views/api_to_cookie.py @@ -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")) diff --git a/app/models.py b/app/models.py index 9fb8ca8a0..ef9a29358 100644 --- a/app/models.py +++ b/app/models.py @@ -6,6 +6,7 @@ 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) diff --git a/cron.py b/cron.py index 7b81e43cd..e7f16f3b9 100644 --- a/cron.py +++ b/cron.py @@ -66,6 +66,7 @@ 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 @@ def notify_hibp(): "check_custom_domain", "check_hibp", "notify_hibp", + "cleanup_tokens", ], ) args = parser.parse_args() @@ -1104,3 +1116,6 @@ def notify_hibp(): 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() diff --git a/docs/api.md b/docs/api.md index a501123a4..053b438dd 100644 --- a/docs/api.md +++ b/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 diff --git a/migrations/versions/2022_081016_9cc0f0712b29_add_api_to_cookie_token.py b/migrations/versions/2022_081016_9cc0f0712b29_add_api_to_cookie_token.py new file mode 100644 index 000000000..73daf7457 --- /dev/null +++ b/migrations/versions/2022_081016_9cc0f0712b29_add_api_to_cookie_token.py @@ -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 ### diff --git a/tests/api/test_user.py b/tests/api/test_user.py index f6476b79d..db2068b16 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -4,7 +4,7 @@ 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 diff --git a/tests/auth/test_api_to_cookie.py b/tests/auth/test_api_to_cookie.py new file mode 100644 index 000000000..8ff12c4c1 --- /dev/null +++ b/tests/auth/test_api_to_cookie.py @@ -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 diff --git a/tests/test_cron.py b/tests/test_cron.py index a881e1dbb..99ca67de1 100644 --- a/tests/test_cron.py +++ b/tests/test_cron.py @@ -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