Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Touched at timestamp tokens #64

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions alembic/versions/d44da2eb423b_token_touched_at_column.py
Original file line number Diff line number Diff line change
@@ -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 ###
87 changes: 85 additions & 2 deletions brood/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1113,13 +1114,63 @@ 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")
token_object = cast(Token, token_object)
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.
Expand Down Expand Up @@ -1193,6 +1244,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,
Expand Down
32 changes: 32 additions & 0 deletions brood/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions brood/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions brood/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down