diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 0a7a887..6d3c510 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -11,6 +11,7 @@ from app.core.config import settings from app.core.security import get_password_hash from app.models.base import Message from app.models.user import User, NewPassword, Token, UserPublic +from app.models.apikey import ApiKey from app.utils import ( generate_password_reset_token, generate_reset_password_email, @@ -43,6 +44,27 @@ def login_access_token( ) +@router.get("/login/api-key/{api_key}") +def login_apikey( + session: SessionDep, + api_key: str, +) -> Token: + """ + Plain apikey compatible login, get an access token for future requests + """ + user = ApiKey.authenticate(session=session, api_key=api_key) + if not user: + raise HTTPException(status_code=400, detail="Incorrect apikey") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user.id, expires_delta=access_token_expires + ) + ) + + @router.post("/login/test-token", response_model=UserPublic) def test_token(current_user: CurrentUser) -> Any: """ diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 1a29a5b..28b95c2 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -11,7 +11,7 @@ from app.api.deps import ( ) from app.core.config import settings from app.core.security import get_password_hash, verify_password -from app.models.base import Message +from app.models.base import Message, RowId from app.models.user import ( UpdatePassword, User, @@ -25,6 +25,13 @@ from app.models.user import ( PermissionPart, PermissionRight, ) +from app.models.apikey import ( + ApiKey, + ApiKeyCreate, + ApiKeyPublic, + ApiKeysPublic, + ApiKeyGenerate, +) from app.utils import generate_new_account_email, send_email router = APIRouter(prefix="/users", tags=["users"]) @@ -118,6 +125,68 @@ def update_password_me( return Message(message="Password updated successfully") +@router.get("/me/api-key", response_model=ApiKeysPublic) +def read_apikey_me( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve api keys from user + """ + + count_statement = ( + select(func.count()) + .select_from(ApiKey) + .where(ApiKey.user_id == current_user.id) + ) + count = session.exec(count_statement).one() + + statement = select(User).offset(skip).limit(limit) + api_keys = session.exec(statement).all() + + return ApiKeysPublic(data=api_keys, count=count) + + +@router.post("/me/api-key", response_model=ApiKeyPublic) +def create_apikey_met( + *, session: SessionDep, body: ApiKeyGenerate, current_user: CurrentUser +) -> Any: + """ + Generate a new api-key. + """ + + data_obj = body.model_dump(exclude_unset=True) + extra_data = { + "user_id": current_user.id, + } + create_obj = ApiKeyCreate.model_validate(data_obj, update=extra_data) + + api_key = ApiKey.create(session=session, create_obj=create_obj) + current_user.api_keys.append(api_key) + session.add(current_user) + session.commit() + return api_key + + +@router.delete("/me/api-key/{api_key}", response_model=ApiKeyPublic) +def delete_apikey_me( + *, + session: SessionDep, + current_user: CurrentUser, + api_key: RowId, +) -> Message: + """ + Delete a api-key. + """ + + for api_key in current_user.api_keys: + if api_key.id == api_key: + session.delete(api_key) + session.commit() + return Message(message="Api key deleted successfully") + + raise HTTPException(status_code=404, detail="API key not found") + + @router.get("/me", response_model=UserPublic) def read_user_me(current_user: CurrentUser) -> Any: """ diff --git a/backend/app/core/db.py b/backend/app/core/db.py index afd7807..1b8b6f6 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,4 +1,3 @@ -from sqlalchemy.sql import roles, roles from sqlmodel import Session, create_engine, select from app.core.config import settings @@ -11,9 +10,10 @@ from app.models.user import ( PermissionModule, PermissionPart, PermissionRight, - RolePermissionLink, ) +from app.models.apikey import ApiKey + engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) diff --git a/backend/app/models/apikey.py b/backend/app/models/apikey.py new file mode 100644 index 0000000..2de12ff --- /dev/null +++ b/backend/app/models/apikey.py @@ -0,0 +1,107 @@ +import random +from typing import TYPE_CHECKING + +from sqlmodel import Session, Field, Relationship, select + +from .base import ( + RowId, + BaseSQLModel, +) +from . import mixin + +from .user import User + + +# region # API Keys for access ################################################### + + +# Shared properties +class ApiKeyBase(mixin.IsActive, mixin.Name, BaseSQLModel): + user_id: RowId | None = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + +# Properties to receive via API on creation +class ApiKeyCreate(ApiKeyBase): + pass + + +class ApiKeyGenerate(mixin.IsActive, mixin.Name, BaseSQLModel): + pass + + +# Properties to receive via API on creation +class ApiKeyUpdate(ApiKeyBase): + pass + + +# Database model, database table inferred from class name +class ApiKey(mixin.RowId, ApiKeyBase, table=True): + # --- database only items -------------------------------------------------- + api_key: str = Field(unique=True, nullable=False, max_length=64) + + # --- back_populates links ------------------------------------------------- + user: User | None = Relationship(back_populates="api_keys") + + # --- CRUD actions --------------------------------------------------------- + @staticmethod + def generate(size=30, chars="ABCDEFGHJKLMNPQRSTUVWXYZ23456789"): + return "".join(random.choice(chars) for _ in range(size)) + + @classmethod + def create(cls, *, session: Session, create_obj: ApiKeyCreate) -> "ApiKey": + # TODO: User id + data_obj = create_obj.model_dump(exclude_unset=True) + + # Generate new api key + extra_data = { + "api_key": ApiKey.generate(), + } + while cls.authenticate(session=session, api_key=extra_data["api_key"]): + extra_data["api_key"] = ApiKey.generate() + + db_obj = cls.model_validate(data_obj, update=extra_data) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + @classmethod + def update( + cls, *, session: Session, db_obj: "ApiKey", in_obj: ApiKeyUpdate + ) -> "ApiKey": + data_obj = in_obj.model_dump(exclude_unset=True) + db_obj.sqlmodel_update(data_obj) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + @classmethod + def authenticate(cls, *, session: Session, api_key: str) -> "User | None": + statement = select(cls).where(cls.api_key == api_key and cls.is_active) + db_obj = session.exec(statement).first() + + if not db_obj: + return None + if not db_obj.user: + return None + return db_obj.user + + +# Properties to return via API, id is always required +class ApiKeyCreatedPublic(mixin.RowIdPublic, ApiKeyBase): + api_key: str + + +# Properties to return via API, id is always required +class ApiKeyPublic(mixin.RowIdPublic, ApiKeyBase): + pass + + +class ApiKeysPublic(BaseSQLModel): + data: list[ApiKeyPublic] + count: int + + +# endregion diff --git a/backend/app/models/base.py b/backend/app/models/base.py index ec524de..5699697 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -4,12 +4,14 @@ from enum import auto as auto_enum from sqlmodel import SQLModel from uuid import UUID as RowId + __all__ = [ - 'RowId', - 'DocumentedStrEnum', - 'DocumentedIntFlag', - 'auto_enum', - 'BaseSQLModel', + "RowId", + "DocumentedStrEnum", + "DocumentedIntFlag", + "auto_enum", + "BaseSQLModel", + "Message", ] # region SQLModel base class ################################################### diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2093e40..3c8fb2e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,10 +1,8 @@ -import random from typing import TYPE_CHECKING from pydantic import EmailStr from sqlmodel import Session, Field, Relationship, select -from app.core.config import settings from app.core.security import get_password_hash, verify_password from .base import ( @@ -16,8 +14,11 @@ from .base import ( ) from . import mixin +if TYPE_CHECKING: + from .apikey import ApiKey -# region User ################################################################## + +# region # User ################################################################ class PermissionModule(DocumentedStrEnum): @@ -39,7 +40,7 @@ class PermissionRight(DocumentedIntFlag): ADMIN = CREATE | READ | UPDATE | DELETE -# ############################################################################# +# ############################################################################## # link to User (many-to-many) class UserRoleLink(BaseSQLModel, table=True): user_id: RowId | None = Field( @@ -103,6 +104,7 @@ class User(mixin.RowId, UserBase, table=True): hashed_password: str # --- back_populates links ------------------------------------------------- + api_keys: list["ApiKey"] = Relationship(back_populates="user", cascade_delete=True) # --- many-to-many links --------------------------------------------------- roles: list["Role"] = Relationship(back_populates="users", link_model=UserRoleLink) @@ -215,7 +217,7 @@ class UsersPublic(BaseSQLModel): # endregion -# region Password manager ###################################################### +# region # Password manager ###################################################### # JSON payload containing access token @@ -237,7 +239,7 @@ class NewPassword(BaseSQLModel): # endregion -# region Permissions ########################################################### +# region # Permissions ########################################################### # link to Roles (many-to-many) diff --git a/backend/app/tests/api/routes/test_login.py b/backend/app/tests/api/routes/test_login.py index 7a51ec1..a2f9375 100644 --- a/backend/app/tests/api/routes/test_login.py +++ b/backend/app/tests/api/routes/test_login.py @@ -6,6 +6,7 @@ from sqlmodel import Session from app.core.config import settings from app.core.security import verify_password from app.models.user import User, UserCreate +from app.models.apikey import ApiKey, ApiKeyCreate from app.tests.utils.user import user_authentication_headers from app.tests.utils.utils import random_email, random_lower_string from app.utils import generate_password_reset_token @@ -44,6 +45,75 @@ def test_use_access_token( assert "email" in result +def test_use_api_key(client: TestClient, db: Session) -> None: + user_db = User.get_by_email(session=db, email=settings.FIRST_SUPERUSER) + + data = { + "user_id": user_db.id, + "is_active": True, + } + create_obj = ApiKeyCreate.model_validate(data) + + api_key = ApiKey.create(session=db, create_obj=create_obj) + # TODO: Fix user_db.api_keys.append(api_key) + db.add(user_db) + db.commit() + + r = client.get(f"{settings.API_V1_STR}/login/api-key/{api_key.api_key}") + tokens = r.json() + assert r.status_code == 200 + assert "access_token" in tokens + assert tokens["access_token"] + + +def test_use_api_key_inactive(client: TestClient, db: Session) -> None: + user_db = User.get_by_email(session=db, email=settings.FIRST_SUPERUSER) + + data = { + "user_id": user_db.id, + "is_active": False, + } + create_obj = ApiKeyCreate.model_validate(data) + + api_key = ApiKey.create(session=db, create_obj=create_obj) + # TODO: Fix user_db.api_keys.append(api_key) + db.add(user_db) + db.commit() + + r = client.get(f"{settings.API_V1_STR}/login/api-key/{api_key.api_key}") + tokens = r.json() + assert r.status_code == 400 + assert "access_token" in tokens + assert tokens["access_token"] + + +def test_use_api_key_user_inactive(client: TestClient, db: Session) -> None: + user_db = User.get_by_email(session=db, email=settings.FIRST_SUPERUSER) + + data = { + "user_id": user_db.id, + "is_active": True, + } + create_obj = ApiKeyCreate.model_validate(data) + + api_key = ApiKey.create(session=db, create_obj=create_obj) + # TODO: Fix user_db.api_keys.append(api_key) + db.add(user_db) + db.commit() + + # TODO: set user inactive + + r = client.get(f"{settings.API_V1_STR}/login/api-key/{api_key.api_key}") + tokens = r.json() + assert r.status_code == 400 + assert "access_token" in tokens + assert tokens["access_token"] + + # Revert to the old password to keep consistency in test + + # TODO: restore user active + + def test_recovery_password( client: TestClient, normal_user_token_headers: dict[str, str] ) -> None: diff --git a/backend/app/tests/api/routes/test_users.py b/backend/app/tests/api/routes/test_users.py index cc06564..6e1929c 100644 --- a/backend/app/tests/api/routes/test_users.py +++ b/backend/app/tests/api/routes/test_users.py @@ -165,6 +165,7 @@ def test_retrieve_users( assert "count" in all_users for item in all_users["data"]: assert "email" in item + # TODO: To be sure there are no: assert "api_keys" not in item def test_update_user_me( @@ -229,6 +230,31 @@ def test_update_password_me( assert verify_password(settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password) +def test_generate_api_key_me( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"name": "Test api"} + r = client.post( + f"{settings.API_V1_STR}/users/me/api-key", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 200 + api_key = r.json() + assert "api_key" in api_key + assert api_key["name"] == data["name"] + assert api_key["is_active"] + + +# TODO: get api-keys + +# TODO: disable api-key + +# TODO: enable api-key + +# TODO: delete api-key + + def test_update_password_me_incorrect_password( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: