Skip to content

Commit

Permalink
Generate a web session from an api key (#1224)
Browse files Browse the repository at this point in the history
* Create a token to exchange for a cookie

* Added Route to exchange token for cookie

* add missing migration



Co-authored-by: Adrià Casajús <[email protected]>
  • Loading branch information
acasajus and acasajus authored Aug 10, 2022
1 parent a9549c1 commit 3a75686
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 5 deletions.
25 changes: 23 additions & 2 deletions app/api/views/user.py
Original file line number Diff line number Diff line change
@@ -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"])
Expand All @@ -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})
1 change: 1 addition & 0 deletions app/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@
fido,
social,
recovery,
api_to_cookie,
)
33 changes: 33 additions & 0 deletions app/auth/views/api_to_cookie.py
Original file line number Diff line number Diff line change
@@ -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"))
18 changes: 18 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 changes: 15 additions & 0 deletions cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
DeletedSubdomain,
PartnerSubscription,
PartnerUser,
ApiToCookieToken,
)
from app.proton.utils import get_proton_partner
from app.utils import sanitize_email
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1066,6 +1077,7 @@ def notify_hibp():
"check_custom_domain",
"check_hibp",
"notify_hibp",
"cleanup_tokens",
],
)
args = parser.parse_args()
Expand Down Expand Up @@ -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()
14 changes: 14 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ###
18 changes: 17 additions & 1 deletion tests/api/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
29 changes: 29 additions & 0 deletions tests/auth/test_api_to_cookie.py
Original file line number Diff line number Diff line change
@@ -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
27 changes: 25 additions & 2 deletions tests/test_cron.py
Original file line number Diff line number Diff line change
@@ -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


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

0 comments on commit 3a75686

Please sign in to comment.