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

View File

@ -15,4 +15,5 @@ from .views import (
fido,
social,
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 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
View File

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

View File

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

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

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