diff --git a/app/api/api_v2/api.py b/app/api/api_v2/api.py index 621e214..ad61e15 100644 --- a/app/api/api_v2/api.py +++ b/app/api/api_v2/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.api_v2.endpoints import like, tags, user_thumbnail, users +from app.api.api_v2.endpoints import like, tags, user_update, users from app.api.api_v2.endpoints.video import delete, list_videos, patch_video, upload api_router = APIRouter() @@ -9,6 +9,6 @@ api_router.include_router(delete.router, tags=['video']) api_router.include_router(patch_video.router, tags=['video']) api_router.include_router(tags.router, tags=['tags']) -api_router.include_router(user_thumbnail.router, tags=['user']) +api_router.include_router(user_update.router, tags=['user']) api_router.include_router(users.router, tags=['user']) api_router.include_router(like.router, tags=['video']) diff --git a/app/api/api_v2/endpoints/user_thumbnail.py b/app/api/api_v2/endpoints/user_update.py similarity index 75% rename from app/api/api_v2/endpoints/user_thumbnail.py rename to app/api/api_v2/endpoints/user_update.py index ede92b7..9db34db 100644 --- a/app/api/api_v2/endpoints/user_thumbnail.py +++ b/app/api/api_v2/endpoints/user_update.py @@ -9,16 +9,17 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from sqlmodel.ext.asyncio.session import AsyncSession +from api.security import cognito_signed_in, generate_api_key_internals, get_fernet from app.api.dependencies import get_boto, yield_db_session -from app.api.security import cognito_signed_in from app.api.services import await_ffmpeg, generate_user_thumbnail from app.core.config import settings -from app.models.klepp import User, UserRead +from app.models.klepp import ListResponse, User, UserRead +from models.klepp import UserReadAPIKey router = APIRouter() -@router.put('/user', response_model=UserRead, status_code=status.HTTP_201_CREATED) +@router.put('/me', response_model=UserRead, status_code=status.HTTP_201_CREATED) async def user_thumbnail( file: UploadFile = File(..., description='File to upload'), user: User = Depends(cognito_signed_in), @@ -70,3 +71,21 @@ async def user_thumbnail( await db_session.commit() await db_session.refresh(user) return user.dict() + + +@router.post('/me/generate-api-key', response_model=ListResponse[UserReadAPIKey], dependencies=[]) +async def api_key( + db_session: AsyncSession = Depends(yield_db_session), + user: User = Depends(cognito_signed_in), +) -> User: + """ + Get a list of users + """ + api_key_and_salt = generate_api_key_internals() + user.api_key = get_fernet(api_key_and_salt.salt).encrypt(api_key_and_salt.api_key) + user.salt = api_key_and_salt.salt + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + user.api_key = api_key_and_salt.api_key + return user diff --git a/app/api/api_v2/endpoints/users.py b/app/api/api_v2/endpoints/users.py index f21b5a9..469dd52 100644 --- a/app/api/api_v2/endpoints/users.py +++ b/app/api/api_v2/endpoints/users.py @@ -14,7 +14,7 @@ @router.get('/users', response_model=ListResponse[UserRead], dependencies=[Depends(cognito_scheme_or_anonymous)]) async def get_users( - session: AsyncSession = Depends(yield_db_session), + db_session: AsyncSession = Depends(yield_db_session), offset: int = 0, limit: int = Query(default=100, lte=100), ) -> dict[str, int | list]: @@ -30,8 +30,8 @@ async def get_users( tag_statement = tag_statement.offset(offset=offset).limit(limit=limit) # Do DB requests async tasks = [ - asyncio.create_task(session.exec(tag_statement)), # type: ignore - asyncio.create_task(session.exec(count_statement)), + asyncio.create_task(db_session.exec(tag_statement)), # type: ignore + asyncio.create_task(db_session.exec(count_statement)), ] results, count = await asyncio.gather(*tasks) count_number = count.one_or_none() diff --git a/app/api/security.py b/app/api/security.py index 0942063..f39e882 100644 --- a/app/api/security.py +++ b/app/api/security.py @@ -4,10 +4,16 @@ security. If you're using this library as inspiration for anything, please keep that in mind. """ +import base64 import logging +import secrets from datetime import datetime, timedelta from typing import Any +from cryptography.fernet import Fernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2AuthorizationCodeBearer, SecurityScopes from fastapi.security.base import SecurityBase @@ -23,6 +29,9 @@ from app.core.config import settings from app.models.klepp import User from app.schemas.schemas_v1.user import User as CognitoUser +from schemas.schemas_v2.api_key import APIKeyAndSalt + +backend = default_backend() class InvalidAuth(HTTPException): @@ -224,3 +233,25 @@ async def cognito_signed_in( await db_session.refresh(new_user) return new_user return user # type: ignore + + +def get_fernet(salt: bytes) -> Fernet: + """ + Returns a fernet that can be used for encrypting and decrypting a byte string. + """ + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + info=b'klepp', + backend=backend, + ) + fernet_key = hkdf.derive(settings.SECRET_KEY.encode()) + return Fernet(base64.urlsafe_b64encode(fernet_key)) + + +def generate_api_key_internals() -> APIKeyAndSalt: + """ + Generate a new API key and salt + """ + return APIKeyAndSalt(api_key=secrets.token_urlsafe(32).encode(), salt=secrets.token_urlsafe(32).encode()) diff --git a/app/models/klepp.py b/app/models/klepp.py index ad9dcf5..2f19a54 100644 --- a/app/models/klepp.py +++ b/app/models/klepp.py @@ -45,6 +45,12 @@ class User(UserBase, table=True): id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True, index=True, nullable=False) videos: List['Video'] = Relationship(back_populates='user') liked_videos: List['Video'] = Relationship(back_populates='likes', link_model=VideoLikeLink) + api_key: Optional[bytes] = Field(default=None, description='User API key, encrypted') + salt: Optional[bytes] = Field(default=None, description='The salt used to encrypt the API key with') + + +class UserReadAPIKey(UserBase): + api_key: str = Field(description='API Key, non encrypted. Only visible once') class UserRead(UserBase): diff --git a/app/schemas/schemas_v2/__init__.py b/app/schemas/schemas_v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/schemas_v2/api_key.py b/app/schemas/schemas_v2/api_key.py new file mode 100644 index 0000000..99b1969 --- /dev/null +++ b/app/schemas/schemas_v2/api_key.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class APIKeyAndSalt(BaseModel): + api_key: bytes + salt: bytes diff --git a/migrations/versions/bb761dee7bcd_api_key.py b/migrations/versions/bb761dee7bcd_api_key.py new file mode 100644 index 0000000..15c8883 --- /dev/null +++ b/migrations/versions/bb761dee7bcd_api_key.py @@ -0,0 +1,31 @@ +"""api-key + +Revision ID: bb761dee7bcd +Revises: 39c1fdf365c0 +Create Date: 2022-04-26 22:28:19.688767 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision = 'bb761dee7bcd' +down_revision = '39c1fdf365c0' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('api_key', sa.LargeBinary(), nullable=True)) + op.add_column('user', sa.Column('salt', sa.LargeBinary(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'salt') + op.drop_column('user', 'api_key') + # ### end Alembic commands ###