From 5c7490577dcfeb2f5cde8dc0c4065fb007bd4ca9 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Mon, 29 May 2023 10:58:48 +0000 Subject: [PATCH 1/3] Bulk token revoke --- brood/actions.py | 32 ++++++++++++++++++++++++++++++++ brood/api.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/brood/actions.py b/brood/actions.py index 7984a69..7ae0e3e 100644 --- a/brood/actions.py +++ b/brood/actions.py @@ -1193,6 +1193,38 @@ def revoke_token( return target_object +def revoke_tokens( + session: Session, + token: uuid.UUID, + target_tokens: List[uuid.UUID] = [], +) -> List[Token]: + """ + Revoke tokens with the given IDs (if it exists). + """ + auth_token_object = get_token(session, token) + if auth_token_object.restricted: + raise exceptions.RestrictedTokenUnauthorized( + "Restricted tokens are not authorized to revoke tokens" + ) + + revoke_token_objects = ( + session.query(Token) + .filter(Token.user_id == auth_token_object.user_id) + .filter(Token.id.in_(target_tokens)) + ).all() + + for revoke_token in revoke_token_objects: + revoke_token.active = False + session.add(revoke_token) + session.commit() + + logger.info( + f"Revoked {len(revoke_token_objects)} tokens by user {auth_token_object.user_id}" + ) + + return revoke_token_objects + + def login( session: Session, username: str, diff --git a/brood/api.py b/brood/api.py index bf7a8ff..98064fd 100644 --- a/brood/api.py +++ b/brood/api.py @@ -395,6 +395,38 @@ async def get_tokens_handler( } +@app.delete("/tokens", tags=["tokens"]) +async def delete_tokens_handler( + request: Request, + access_token: uuid.UUID = Depends(oauth2_scheme), + token: List[uuid.UUID] = Form(...), + db_session=Depends(yield_db_session_from_env), +) -> List[uuid.UUID]: + """ + Revoke list of tokens. + + - **target_tokens** (List[uuid]): Token IDs to revoke + """ + authorization: str = request.headers.get("Authorization") # type: ignore + scheme_raw, _ = get_authorization_scheme_param(authorization) + scheme = scheme_raw.lower() + if scheme != "bearer": + raise HTTPException(status_code=400, detail="Unaccepted scheme") + try: + tokens = actions.revoke_tokens( + session=db_session, token=access_token, target_tokens=token + ) + except actions.TokenNotFound: + raise HTTPException(status_code=404, detail="Given token does not exist") + except exceptions.RestrictedTokenUnauthorized as e: + raise HTTPException(status_code=403, detail=str(e)) + except Exception: + logger.error("Unhandled error during bulk token revoke") + raise HTTPException(status_code=500) + + return [token.id for token in tokens] + + @app.get("/user", tags=["users"], response_model=data.UserResponse) async def get_user_handler( user_authorization: Tuple[bool, models.User] = Depends(request_user_authorization), From fe152e35b27996cd463df62222a3b2486ccd7032 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Mon, 29 May 2023 13:26:58 +0000 Subject: [PATCH 2/3] Touched at column for tokens and function for it --- .../d44da2eb423b_token_touched_at_column.py | 56 +++++++++++++++++++ brood/models.py | 6 ++ 2 files changed, 62 insertions(+) create mode 100644 alembic/versions/d44da2eb423b_token_touched_at_column.py diff --git a/alembic/versions/d44da2eb423b_token_touched_at_column.py b/alembic/versions/d44da2eb423b_token_touched_at_column.py new file mode 100644 index 0000000..e5fab0d --- /dev/null +++ b/alembic/versions/d44da2eb423b_token_touched_at_column.py @@ -0,0 +1,56 @@ +"""Token touched_at column + +Revision ID: d44da2eb423b +Revises: 6bc0ee79a3ce +Create Date: 2023-05-29 12:08:08.412359 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd44da2eb423b' +down_revision = '6bc0ee79a3ce' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tokens', sa.Column('touched_at', sa.DateTime(timezone=True), server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), nullable=False)) + + # Manual part + op.execute("""CREATE FUNCTION fx_tokens_select_with_touch(t_id UUID) + RETURNS TABLE(id uuid, + user_id uuid, + active boolean, + token_type token_type, + note varchar, + restricted boolean, + created_at timestamptz, + touched_at timestamptz, + updated_at timestamptz) LANGUAGE plpgsql VOLATILE AS $func$ +BEGIN + UPDATE tokens SET touched_at = NOW() WHERE tokens.id = t_id; + RETURN QUERY(SELECT tokens.id, + tokens.user_id, + tokens.active, + tokens.token_type, + tokens.note, + tokens.restricted, + tokens.created_at, + tokens.touched_at, + tokens.updated_at FROM tokens WHERE tokens.id = t_id); +END; +$func$;""") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tokens', 'touched_at') + + # Manual part + op.execute("DROP FUNCTION fx_tokens_select_with_touch;") + # ### end Alembic commands ### diff --git a/brood/models.py b/brood/models.py index 58005f7..a23ca06 100644 --- a/brood/models.py +++ b/brood/models.py @@ -204,6 +204,12 @@ class Token(Base): # type: ignore created_at = Column( DateTime(timezone=True), server_default=utcnow(), nullable=False ) + touched_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) updated_at = Column( DateTime(timezone=True), server_default=utcnow(), From d6bf9e818e7f1cd5cef68994789110f46cc49311 Mon Sep 17 00:00:00 2001 From: kompotkot Date: Tue, 30 May 2023 07:58:07 +0000 Subject: [PATCH 3/3] Action get_token_with_touch and raw func output to pydantic mapping --- brood/actions.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++-- brood/data.py | 1 + 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/brood/actions.py b/brood/actions.py index 7ae0e3e..17b4275 100644 --- a/brood/actions.py +++ b/brood/actions.py @@ -14,11 +14,12 @@ from passlib.context import CryptContext from sendgrid import SendGridAPIClient from sendgrid.helpers.mail import Mail -from sqlalchemy import and_, func, or_ +from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Query +from sqlalchemy.orm import Query, aliased from sqlalchemy.orm.exc import MultipleResultsFound from sqlalchemy.orm.session import Session +from sqlalchemy.sql.functions import GenericFunction from web3login.auth import to_checksum_address, verify from web3login.exceptions import Web3VerificationError @@ -1113,6 +1114,7 @@ def get_token(session: Session, token: uuid.UUID) -> Token: """ Retrieve the token with the given ID from the database (if it exists). """ + token_object = session.query(Token).filter(Token.id == token).first() if token_object is None: raise TokenNotFound(f"Token not found") @@ -1120,6 +1122,55 @@ def get_token(session: Session, token: uuid.UUID) -> Token: return token_object +def fx_tokens_select_with_touch_parser(raw_result) -> data.TokenResponse: + """ + Function `fx_tokens_select_with_touch` returns raw string, according to migration + d44da2eb423b_token_touched_at_column it should be mapped from table: + - id uuid + - user_id uuid + - active boolean + - token_type token_type + - note varchar + - restricted boolean + - created_at timestamptz + - touched_at timestamptz + - updated_at timestamptz + """ + raw_result_lst = raw_result.strip('()').replace('"', '').split(',') + return data.TokenResponse( + id=raw_result_lst[0], + user_id=raw_result_lst[1], + active=raw_result_lst[2], + token_type=raw_result_lst[3], + note=raw_result_lst[4], + restricted=raw_result_lst[5], + created_at=raw_result_lst[6], + touched_at=raw_result_lst[7], + updated_at=raw_result_lst[8], + + access_token=raw_result_lst[0], + ) + + +def get_token_with_touch(session: Session, token: uuid.UUID) -> data.TokenResponse: + raw_result = session.query(func.fx_tokens_select_with_touch(token)).first() + + raw_result_len = len(raw_result) + if raw_result_len == 0: + raise TokenNotFound("Token not found") + elif raw_result_len > 1: + raise Exception(f"Too many tokens were found: {raw_result_len}") + + session.commit() + + try: + token = fx_tokens_select_with_touch_parser(raw_result[0]) + except Exception: + raise Exception("Unable to parse token from function raw result") + + return token + + def get_tokens(session: Session, user_id: uuid.UUID) -> List[Token]: """ Retrieve the list of tokens for user. diff --git a/brood/data.py b/brood/data.py index 27b6ddb..494c36c 100644 --- a/brood/data.py +++ b/brood/data.py @@ -64,6 +64,7 @@ class TokenResponse(BaseModel): token_type: Optional[TokenType] note: Optional[str] created_at: datetime + touched_at: datetime updated_at: datetime restricted: bool