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:
Adrià Casajús 2022-08-10 18:48:32 +02:00 committed by GitHub
parent a9549c11d7
commit 3a75686898
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 215 additions and 5 deletions

View File

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

View File

@ -15,4 +15,5 @@ from .views import (
fido, fido,
social, social,
recovery, recovery,
api_to_cookie,
) )

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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