From da677b7b8d68c4cca1fbeb08028167d48156d1a8 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 25 May 2026 21:35:33 +0330 Subject: [PATCH 01/24] feat(api_keys): implement API key management with CRUD operations and permissions --- app/db/crud/api_key.py | 80 ++++++++++++ .../c9b48df42f10_add_api_keys_table.py | 115 ++++++++++++++++++ app/db/models.py | 24 ++++ app/models/admin_role.py | 8 ++ app/models/api_key.py | 46 +++++++ app/operation/api_key.py | 89 ++++++++++++++ app/routers/__init__.py | 6 +- app/routers/api_key.py | 63 ++++++++++ app/routers/authentication.py | 112 +++++++++++++++-- 9 files changed, 530 insertions(+), 13 deletions(-) create mode 100644 app/db/crud/api_key.py create mode 100644 app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py create mode 100644 app/models/api_key.py create mode 100644 app/operation/api_key.py create mode 100644 app/routers/api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py new file mode 100644 index 00000000..ca33e24b --- /dev/null +++ b/app/db/crud/api_key.py @@ -0,0 +1,80 @@ +import hashlib +import uuid +from datetime import datetime as dt + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Admin, APIKey + + +def hash_api_key(raw_api_key: str) -> str: + return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() + + +async def create_api_key( + db: AsyncSession, + *, + admin_id: int, + role_id: int, + name: str, + note: str | None, + expire_date: dt | None, +) -> tuple[str, APIKey]: + raw_key = str(uuid.uuid4()) + db_key = APIKey( + admin_id=admin_id, + role_id=role_id, + name=name, + note=note, + key_hash=hash_api_key(raw_key), + expire_date=expire_date, + ) + db.add(db_key) + await db.flush() + await db.refresh(db_key) + return raw_key, db_key + + +async def get_api_key_by_hash(db: AsyncSession, key_hash: str) -> APIKey | None: + stmt = ( + select(APIKey) + .where(APIKey.key_hash == key_hash) + .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) + ) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_key_by_id(db: AsyncSession, key_id: int) -> APIKey | None: + stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin), selectinload(APIKey.role)) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_key_by_id_for_admin(db: AsyncSession, *, key_id: int, admin_id: int | None = None) -> APIKey | None: + stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin), selectinload(APIKey.role)) + if admin_id is not None: + stmt = stmt.where(APIKey.admin_id == admin_id) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_key_by_name(db: AsyncSession, *, admin_id: int, name: str) -> APIKey | None: + stmt = select(APIKey).where(APIKey.admin_id == admin_id, APIKey.name == name) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_api_keys(db: AsyncSession, *, admin_id: int | None, offset: int, limit: int) -> tuple[list[APIKey], int]: + stmt = select(APIKey).options(selectinload(APIKey.role)) + if admin_id is not None: + stmt = stmt.where(APIKey.admin_id == admin_id) + + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 + + stmt = stmt.order_by(APIKey.created_at.desc()).offset(offset).limit(limit) + rows = list((await db.execute(stmt)).scalars().all()) + return rows, total + + +async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: + await db.delete(db_key) + await db.flush() diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py new file mode 100644 index 00000000..c8a635f2 --- /dev/null +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -0,0 +1,115 @@ +"""add api keys table + +Revision ID: c9b48df42f10 +Revises: 2c6e9d34a1f0 +Create Date: 2026-05-25 00:00:00.000000 + +""" + +import json + +from alembic import op +import sqlalchemy as sa +import app.db.compiles_types + + +# revision identifiers, used by Alembic. +revision = "c9b48df42f10" +down_revision = "2c6e9d34a1f0" +branch_labels = None +depends_on = None + + +OWNER_ADMIN_API_KEY_PERMS = { + "create": True, + "read": True, + "read_simple": True, + "delete": True, +} + +OPERATOR_API_KEY_PERMS = { + "read": {"scope": 1}, + "read_simple": {"scope": 1}, + "delete": {"scope": 1}, +} + + +def _normalize_permissions(value): + if value is None: + return {} + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return {} + if isinstance(value, dict): + return value + return {} + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("id", app.db.compiles_types.SqliteCompatibleBigInteger(), autoincrement=True, nullable=False), + sa.Column("admin_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("note", sa.String(length=512), nullable=True), + sa.Column("key_hash", sa.String(length=128), nullable=False), + sa.Column("role_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins")), + sa.ForeignKeyConstraint(["role_id"], ["admin_roles.id"], name=op.f("fk_api_keys_role_id_admin_roles")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_api_keys")), + sa.UniqueConstraint("key_hash", name=op.f("uq_api_keys_key_hash")), + sa.UniqueConstraint("admin_id", "name", name="uq_api_keys_admin_id_name"), + ) + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_api_keys_admin_id"), ["admin_id"], unique=False) + batch_op.create_index(batch_op.f("ix_api_keys_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_api_keys_expire_date"), ["expire_date"], unique=False) + + conn = op.get_bind() + admin_roles = sa.table( + "admin_roles", + sa.column("id", sa.Integer), + sa.column("name", sa.String), + sa.column("permissions", sa.JSON), + ) + + rows = conn.execute(sa.select(admin_roles.c.id, admin_roles.c.name, admin_roles.c.permissions)).fetchall() + for role_id, role_name, role_permissions in rows: + permissions = _normalize_permissions(role_permissions) + if "api_keys" in permissions: + continue + + if role_name in {"owner", "administrator"}: + permissions["api_keys"] = OWNER_ADMIN_API_KEY_PERMS + else: + permissions["api_keys"] = OPERATOR_API_KEY_PERMS + + conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions)) + + +def downgrade() -> None: + conn = op.get_bind() + admin_roles = sa.table( + "admin_roles", + sa.column("id", sa.Integer), + sa.column("permissions", sa.JSON), + ) + + rows = conn.execute(sa.select(admin_roles.c.id, admin_roles.c.permissions)).fetchall() + for role_id, role_permissions in rows: + permissions = _normalize_permissions(role_permissions) + if "api_keys" not in permissions: + continue + permissions.pop("api_keys", None) + conn.execute(admin_roles.update().where(admin_roles.c.id == role_id).values(permissions=permissions)) + + with op.batch_alter_table("api_keys", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_api_keys_expire_date")) + batch_op.drop_index(batch_op.f("ix_api_keys_created_at")) + batch_op.drop_index(batch_op.f("ix_api_keys_admin_id")) + + op.drop_table("api_keys") diff --git a/app/db/models.py b/app/db/models.py index fd57a3a8..7b4016d1 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -84,6 +84,9 @@ class Admin(Base, IdMixin, CreatedAtUTCMixin): notification_reminders: Mapped[List["AdminNotificationReminder"]] = relationship( back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" ) + api_keys: Mapped[List["APIKey"]] = relationship( + back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan" + ) password_reset_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None) @@ -838,6 +841,7 @@ class AdminRole(Base, IdMixin, CreatedAtUTCMixin): disabled_when_limited: Mapped[bool] = mapped_column(default=False, server_default="0") disable_users_when_limited: Mapped[bool] = mapped_column(default=True, server_default="1") admins: Mapped[List["Admin"]] = relationship(back_populates="role", init=False, viewonly=True, lazy="noload") + api_keys: Mapped[List["APIKey"]] = relationship(back_populates="role", init=False, lazy="noload") @hybrid_property def is_builtin(self) -> bool: @@ -849,6 +853,26 @@ def is_builtin(cls): return cls.id <= 3 +class APIKey(Base, IdMixin, CreatedAtUTCMixin): + __tablename__ = "api_keys" + __table_args__ = ( + UniqueConstraint("key_hash"), + UniqueConstraint("admin_id", "name"), + Index("ix_api_keys_admin_id", "admin_id"), + Index("ix_api_keys_created_at", "created_at"), + Index("ix_api_keys_expire_date", "expire_date"), + ) + + admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") + admin: Mapped["Admin"] = relationship(back_populates="api_keys", init=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + note: Mapped[Optional[str]] = mapped_column(String(512), default=None) + key_hash: Mapped[str] = mapped_column(String(128), nullable=False) + role_id: Mapped[int] = fk_id_column("admin_roles.id") + role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") + expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + + class TempKey(Base): __tablename__ = "temp_keys" diff --git a/app/models/admin_role.py b/app/models/admin_role.py index 0ef486d4..79191ff8 100644 --- a/app/models/admin_role.py +++ b/app/models/admin_role.py @@ -81,6 +81,13 @@ class HwidsPermissions(_ResourcePermissions): delete: RoleActionValue | None = None +class APIKeysPermissions(_ResourcePermissions): + create: RoleActionValue | None = None + read: RoleActionValue | None = None + read_simple: RoleActionValue | None = None + delete: RoleActionValue | None = None + + class RoleLimits(BaseModel): max_users: int | None = None data_limit_min: int | None = None @@ -126,6 +133,7 @@ class RolePermissions(BaseModel): system: SystemPermissions | None = None hwids: HwidsPermissions | None = None admin_roles: CRUDPermissions | None = None + api_keys: APIKeysPermissions | None = None model_config = ConfigDict(from_attributes=True) diff --git a/app/models/api_key.py b/app/models/api_key.py new file mode 100644 index 00000000..42b237a3 --- /dev/null +++ b/app/models/api_key.py @@ -0,0 +1,46 @@ +from datetime import datetime as dt, timezone as tz +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.utils.helpers import fix_datetime_timezone + + +class APIKeyBase(BaseModel): + name: str = Field(min_length=1, max_length=128) + note: str | None = Field(default=None, max_length=512) + role_id: int = Field(ge=1) + expire_date: dt | None = None + + model_config = ConfigDict(from_attributes=True) + + +class APIKeyCreate(APIKeyBase): + @field_validator("expire_date", mode="before") + @classmethod + def validate_expire_date(cls, value): + if value is None: + return None + parsed = fix_datetime_timezone(value) + if parsed <= dt.now(tz.utc): + raise ValueError("expire_date must be in the future") + return parsed + + +class APIKeyResponse(APIKeyBase): + id: int + admin_id: int + created_at: dt + + +class APIKeyCreateResponse(APIKeyResponse): + api_key: str + + +class APIKeysResponse(BaseModel): + api_keys: list[APIKeyResponse] + total: int + + +Offset = Annotated[int, Field(default=0, ge=0)] +Limit = Annotated[int, Field(default=50, ge=1, le=200)] diff --git a/app/operation/api_key.py b/app/operation/api_key.py new file mode 100644 index 00000000..36b98812 --- /dev/null +++ b/app/operation/api_key.py @@ -0,0 +1,89 @@ +from datetime import datetime as dt, timezone as tz + +from sqlalchemy.exc import IntegrityError + +from app.db import AsyncSession +from app.db.crud.admin_role import get_role +from app.db.crud.api_key import ( + create_api_key, + delete_api_key, + get_api_key_by_id, + get_api_key_by_id_for_admin, + get_api_key_by_name, + get_api_keys, +) +from app.models.admin import AdminDetails +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.operation import BaseOperation + + +class APIKeyOperation(BaseOperation): + async def create_api_key( + self, db: AsyncSession, *, admin: AdminDetails, data: APIKeyCreate + ) -> APIKeyCreateResponse: + if admin.id is None: + await self.raise_error(message="API key creation is not available for env admins", code=403) + + role = await get_role(db, data.role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + + if not admin.is_owner and admin.role and role.id != admin.role.id: + await self.raise_error(message="Only owner can create API keys with a different role", code=403) + + duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=data.name) + if duplicate is not None: + await self.raise_error(message="API key name already exists", code=409) + + if data.expire_date is not None and data.expire_date <= dt.now(tz.utc): + await self.raise_error(message="expire_date must be in the future", code=422) + + try: + raw_key, db_key = await create_api_key( + db, + admin_id=admin.id, + role_id=data.role_id, + name=data.name, + note=data.note, + expire_date=data.expire_date, + ) + await db.commit() + except IntegrityError: + await self.raise_error(message="API key already exists", code=409, db=db) + + return APIKeyCreateResponse( + id=db_key.id, + admin_id=db_key.admin_id, + name=db_key.name, + note=db_key.note, + role_id=db_key.role_id, + created_at=db_key.created_at, + expire_date=db_key.expire_date, + api_key=raw_key, + ) + + async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, offset: int, limit: int) -> APIKeysResponse: + scope_admin_id = None if admin.is_owner else admin.id + rows, total = await get_api_keys(db, admin_id=scope_admin_id, offset=offset, limit=limit) + return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) + + async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: + db_key = await get_api_key_by_id_for_admin( + db, + key_id=key_id, + admin_id=None if admin.is_owner else admin.id, + ) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + return APIKeyResponse.model_validate(db_key) + + async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> None: + db_key = await get_api_key_by_id(db, key_id) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + + if not admin.is_owner and db_key.admin_id != admin.id: + await self.raise_error(message="Permission denied", code=403) + + await delete_api_key(db, db_key) + await db.commit() diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 18dcc6d3..c7def101 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -3,11 +3,13 @@ from . import ( admin, admin_role, - core, + api_key, client_template, + core, group, home, host, + hwid, node, settings, setup, @@ -15,7 +17,6 @@ system, user, user_template, - hwid, ) api_router = APIRouter() @@ -23,6 +24,7 @@ routers = [ home.router, admin.router, + api_key.router, admin_role.router, setup.router, system.router, diff --git a/app/routers/api_key.py b/app/routers/api_key.py new file mode 100644 index 00000000..b7969076 --- /dev/null +++ b/app/routers/api_key.py @@ -0,0 +1,63 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status + +from app.db import AsyncSession, get_db +from app.models.admin import AdminDetails +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.operation import OperatorType +from app.operation.api_key import APIKeyOperation +from app.utils import responses + +from .authentication import require_permission + +router = APIRouter( + tags=["API Keys"], + prefix="/api/api_key", + responses={401: responses._401, 403: responses._403}, +) + +api_key_operator = APIKeyOperation(operator_type=OperatorType.API) + + +@router.post( + "", + response_model=APIKeyCreateResponse, + status_code=status.HTTP_201_CREATED, + responses={409: responses._409}, +) +async def create_api_key( + data: APIKeyCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "create")), +): + return await api_key_operator.create_api_key(db, admin=admin, data=data) + + +@router.get("s", response_model=APIKeysResponse) +async def list_api_keys( + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "read")), +): + return await api_key_operator.list_api_keys(db, admin=admin, offset=offset, limit=limit) + + +@router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404}) +async def get_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "read")), +): + return await api_key_operator.get_api_key(db, admin=admin, key_id=key_id) + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: responses._404}) +async def remove_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "delete")), +): + await api_key_operator.delete_api_key(db, admin=admin, key_id=key_id) + return {} diff --git a/app/routers/authentication.py b/app/routers/authentication.py index b7bdb215..e9d04472 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,7 +1,8 @@ -from datetime import timezone as tz +from datetime import datetime as dt, timezone as tz +from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy import func, select @@ -12,6 +13,7 @@ get_admin_by_id as get_admin_by_id_crud, get_admin_by_telegram_id, ) +from app.db.crud.api_key import get_api_key_by_hash, hash_api_key from app.db.models import Admin, AdminUsageLogs, User from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions @@ -21,7 +23,7 @@ from app.utils.jwt import get_admin_payload from config import auth_settings, runtime_settings -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token", auto_error=False) # Owner-level role data given to env admins — full permissions, bypasses all checks _ENV_ADMIN_ROLE = AdminRoleData( @@ -73,6 +75,74 @@ def _is_token_valid_for_admin(db_admin: Admin, payload: dict) -> bool: return db_admin.password_reset_at.astimezone(tz.utc) <= payload.get("created_at") +def _extract_api_key(request: Request) -> str | None: + auth = request.headers.get("Authorization") + x_api_key = request.headers.get("X-Api-Key") + + if x_api_key: + return x_api_key.strip() + + if not auth: + return None + + scheme, _, credentials = auth.partition(" ") + if not scheme or not credentials: + return None + + scheme = scheme.lower().strip() + credentials = credentials.strip() + if scheme == "apikey": + return credentials + return None + + +async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, int]: + total_users = (await db.execute(select(func.count(User.id)).where(User.admin_id == admin_id))).scalar() or 0 + reseted_usage = ( + await db.execute( + select(func.coalesce(func.sum(AdminUsageLogs.used_traffic_at_reset), 0)).where( + AdminUsageLogs.admin_id == admin_id + ) + ) + ).scalar() or 0 + return int(total_users), int(reseted_usage) + + +async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None: + if not raw_key: + return None + + try: + parsed_key = UUID(raw_key) + except ValueError: + return None + if parsed_key.version != 4: + return None + + db_key = await get_api_key_by_hash(db, hash_api_key(str(parsed_key))) + if db_key is None: + return None + + db_admin = db_key.admin + if db_admin is None: + return None + + if db_key.expire_date is not None: + expire_at = db_key.expire_date if db_key.expire_date.tzinfo else db_key.expire_date.replace(tzinfo=tz.utc) + if expire_at.astimezone(tz.utc) <= dt.now(tz.utc): + return None + + if with_metrics: + total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id) + admin = _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) + else: + admin = _build_admin_details(db_admin) + + if db_key.role is not None: + admin.role = AdminRoleData.model_validate(db_key.role) + return admin + + async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None: payload = await get_admin_payload(token) if not payload: @@ -134,36 +204,56 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | return None -async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)): - admin: AdminDetails | None = await get_admin(db, token) +async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)): + admin: AdminDetails | None = None + + if token: + admin = await get_admin(db, token) + else: + api_key = _extract_api_key(request) + if api_key: + admin = await get_admin_from_api_key(db, api_key) + if not admin: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) return admin -async def get_current_with_metrics(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)): - admin: AdminDetails | None = await get_admin_with_metrics(db, token) +async def get_current_with_metrics( + request: Request, + db: AsyncSession = Depends(get_db), + token: str | None = Depends(oauth2_scheme), +): + admin: AdminDetails | None = None + + if token: + admin = await get_admin_with_metrics(db, token) + else: + api_key = _extract_api_key(request) + if api_key: + admin = await get_admin_from_api_key(db, api_key, with_metrics=True) + if not admin: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer, ApiKey"}, ) return admin From 75e2a8e047c271ce0823bc373c01da67138987cc Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:06:25 +0330 Subject: [PATCH 02/24] chore: better code --- app/db/crud/api_key.py | 16 +++++++--------- app/operation/api_key.py | 13 +++++-------- app/routers/api_key.py | 4 ++-- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index ca33e24b..6db7e773 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import selectinload from app.db.models import Admin, APIKey +from app.models.api_key import APIKeyCreate def hash_api_key(raw_api_key: str) -> str: @@ -17,19 +18,16 @@ async def create_api_key( db: AsyncSession, *, admin_id: int, - role_id: int, - name: str, - note: str | None, - expire_date: dt | None, + mmodel:APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) db_key = APIKey( admin_id=admin_id, - role_id=role_id, - name=name, - note=note, - key_hash=hash_api_key(raw_key), - expire_date=expire_date, + role_id=model.role_id, + name=model.name, + note=model.note, + key_hash=hash_api_key(model.raw_key), + expire_date=model.expire_date, ) db.add(db_key) await db.flush() diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 36b98812..99e46a85 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -19,33 +19,30 @@ class APIKeyOperation(BaseOperation): async def create_api_key( - self, db: AsyncSession, *, admin: AdminDetails, data: APIKeyCreate + self, db: AsyncSession, *, admin: AdminDetails, model: APIKeyCreate ) -> APIKeyCreateResponse: if admin.id is None: await self.raise_error(message="API key creation is not available for env admins", code=403) - role = await get_role(db, data.role_id) + role = await get_role(db, model.role_id) if role is None: await self.raise_error(message="Role not found", code=404) if not admin.is_owner and admin.role and role.id != admin.role.id: await self.raise_error(message="Only owner can create API keys with a different role", code=403) - duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=data.name) + duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=model.name) if duplicate is not None: await self.raise_error(message="API key name already exists", code=409) - if data.expire_date is not None and data.expire_date <= dt.now(tz.utc): + if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): await self.raise_error(message="expire_date must be in the future", code=422) try: raw_key, db_key = await create_api_key( db, admin_id=admin.id, - role_id=data.role_id, - name=data.name, - note=data.note, - expire_date=data.expire_date, + model=model, ) await db.commit() except IntegrityError: diff --git a/app/routers/api_key.py b/app/routers/api_key.py index b7969076..b74962b6 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -27,11 +27,11 @@ responses={409: responses._409}, ) async def create_api_key( - data: APIKeyCreate, + model: APIKeyCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("api_keys", "create")), ): - return await api_key_operator.create_api_key(db, admin=admin, data=data) + return await api_key_operator.create_api_key(db, admin=admin, model=model) @router.get("s", response_model=APIKeysResponse) From d4a3f26a347eb04e24dcdc3fec439ff6260c3f1f Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:18:24 +0330 Subject: [PATCH 03/24] fix --- app/db/crud/api_key.py | 10 ++-------- app/utils/crypto.py | 4 ++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 6db7e773..803f2acc 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,24 +1,18 @@ -import hashlib import uuid -from datetime import datetime as dt - from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.models import Admin, APIKey from app.models.api_key import APIKeyCreate - - -def hash_api_key(raw_api_key: str) -> str: - return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() +from app.utils.crypto import hash_api_key async def create_api_key( db: AsyncSession, *, admin_id: int, - mmodel:APIKeyCreate, + model:APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) db_key = APIKey( diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 3380cb94..58bec66b 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,4 +1,5 @@ import base64 +import hashlib import binascii from cryptography import x509 @@ -97,3 +98,6 @@ def generate_wireguard_keypair() -> tuple[str, str]: base64.b64encode(private_key_bytes).decode("ascii"), base64.b64encode(public_key_bytes).decode("ascii"), ) + +def hash_api_key(raw_api_key: str) -> str: + return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() From c23cf347b4ccbeb2652c67cd7b2ff263ae0eff59 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:28:26 +0330 Subject: [PATCH 04/24] fix --- app/db/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/models.py b/app/db/models.py index 7cf9f203..0259f089 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -867,10 +867,10 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): admin_id: Mapped[int] = fk_id_column("admins.id", ondelete="CASCADE") admin: Mapped["Admin"] = relationship(back_populates="api_keys", init=False) name: Mapped[str] = mapped_column(String(128), nullable=False) - note: Mapped[Optional[str]] = mapped_column(String(512), default=None) key_hash: Mapped[str] = mapped_column(String(128), nullable=False) role_id: Mapped[int] = fk_id_column("admin_roles.id") role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") + note: Mapped[Optional[str]] = mapped_column(String(512), default=None) expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) From 6a1ebe300c5f90bf8336c443e4a86723dd4a45bb Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:33:02 +0330 Subject: [PATCH 05/24] Update c9b48df42f10_add_api_keys_table.py --- app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index c8a635f2..80851706 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -58,11 +58,11 @@ def upgrade() -> None: sa.Column("role_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins")), + sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins"), ondelete="CASCADE"), sa.ForeignKeyConstraint(["role_id"], ["admin_roles.id"], name=op.f("fk_api_keys_role_id_admin_roles")), sa.PrimaryKeyConstraint("id", name=op.f("pk_api_keys")), sa.UniqueConstraint("key_hash", name=op.f("uq_api_keys_key_hash")), - sa.UniqueConstraint("admin_id", "name", name="uq_api_keys_admin_id_name"), + sa.UniqueConstraint("admin_id", "name", name=op.f("uq_api_keys_admin_id")), ) with op.batch_alter_table("api_keys", schema=None) as batch_op: batch_op.create_index(batch_op.f("ix_api_keys_admin_id"), ["admin_id"], unique=False) From d9a240e1dbbd23c513a137b70e4a2c1dc40e288b Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:41:34 +0330 Subject: [PATCH 06/24] Potential fix for pull request finding 'CodeQL / Use of a broken or weak cryptographic hashing algorithm on sensitive data' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- app/utils/crypto.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 58bec66b..c038d759 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,6 +1,7 @@ import base64 import hashlib import binascii +import os from cryptography import x509 from cryptography.hazmat.backends import default_backend @@ -100,4 +101,14 @@ def generate_wireguard_keypair() -> tuple[str, str]: ) def hash_api_key(raw_api_key: str) -> str: - return hashlib.sha256(raw_api_key.encode("utf-8")).hexdigest() + iterations = 310000 + salt = os.urandom(16) + derived_key = hashlib.pbkdf2_hmac( + "sha256", + raw_api_key.encode("utf-8"), + salt, + iterations, + ) + salt_b64 = base64.b64encode(salt).decode("ascii") + dk_b64 = base64.b64encode(derived_key).decode("ascii") + return f"pbkdf2_sha256${iterations}${salt_b64}${dk_b64}" From cf9e23e8de593a96d62b5fe8d0606f0d23d7cb57 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 08:44:34 +0330 Subject: [PATCH 07/24] format code --- app/db/crud/api_key.py | 3 +-- app/operation/user.py | 17 ++++++++++++----- app/utils/crypto.py | 1 + tests/api/test_user.py | 24 ++++++++++++++---------- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 803f2acc..28abb14f 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -10,9 +10,8 @@ async def create_api_key( db: AsyncSession, - *, admin_id: int, - model:APIKeyCreate, + model: APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) db_key = APIKey( diff --git a/app/operation/user.py b/app/operation/user.py index 38544de6..f0708d4f 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -545,8 +545,10 @@ async def create_user( await self.raise_error( message=f"HWID limit cannot be less than {effective_hwid_conf.min_limit}", code=400, db=db ) - if effective_hwid_conf.max_limit is not None and effective_hwid_conf.max_limit > 0 and ( - new_user.hwid_limit > effective_hwid_conf.max_limit or new_user.hwid_limit == 0 + if ( + effective_hwid_conf.max_limit is not None + and effective_hwid_conf.max_limit > 0 + and (new_user.hwid_limit > effective_hwid_conf.max_limit or new_user.hwid_limit == 0) ): await self.raise_error( message=f"HWID limit cannot exceed {effective_hwid_conf.max_limit}", code=400, db=db @@ -643,12 +645,17 @@ async def _prepare_modified_user( admin.role.hwid if admin.role is not None else None, ) if effective_hwid_conf is not None: - if effective_hwid_conf.min_limit is not None and modified_user.hwid_limit < effective_hwid_conf.min_limit: + if ( + effective_hwid_conf.min_limit is not None + and modified_user.hwid_limit < effective_hwid_conf.min_limit + ): await self.raise_error( message=f"HWID limit cannot be less than {effective_hwid_conf.min_limit}", code=400, db=db ) - if effective_hwid_conf.max_limit is not None and effective_hwid_conf.max_limit > 0 and ( - modified_user.hwid_limit > effective_hwid_conf.max_limit or modified_user.hwid_limit == 0 + if ( + effective_hwid_conf.max_limit is not None + and effective_hwid_conf.max_limit > 0 + and (modified_user.hwid_limit > effective_hwid_conf.max_limit or modified_user.hwid_limit == 0) ): await self.raise_error( message=f"HWID limit cannot exceed {effective_hwid_conf.max_limit}", code=400, db=db diff --git a/app/utils/crypto.py b/app/utils/crypto.py index c038d759..4e3893f5 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -100,6 +100,7 @@ def generate_wireguard_keypair() -> tuple[str, str]: base64.b64encode(public_key_bytes).decode("ascii"), ) + def hash_api_key(raw_api_key: str) -> str: iterations = 310000 salt = os.urandom(16) diff --git a/tests/api/test_user.py b/tests/api/test_user.py index 1e1c3ae9..101ad884 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -1267,11 +1267,13 @@ def test_xray_subscription_uses_host_specific_template_override(access_token): access_token, name=unique_name("xray_host_override_template"), template_type="xray_subscription", - content=json.dumps({ - "log": {"loglevel": "warning"}, - "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], - "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], - }), + content=json.dumps( + { + "log": {"loglevel": "warning"}, + "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], + "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], + } + ), ) host_response = client.post( @@ -1337,11 +1339,13 @@ def test_xray_subscription_template_override_isolated_per_host(access_token): access_token, name=unique_name("xray_host_isolated_template"), template_type="xray_subscription", - content=json.dumps({ - "log": {"loglevel": "warning"}, - "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], - "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], - }), + content=json.dumps( + { + "log": {"loglevel": "warning"}, + "inbounds": [{"tag": "placeholder", "protocol": "vmess", "settings": {"clients": []}}], + "outbounds": [{"tag": "template-marker", "protocol": "freedom", "settings": {}}], + } + ), ) first_host_response = client.post( From a5413b65c3473f8df1380f9d080eb6a37036a30d Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 09:07:41 +0330 Subject: [PATCH 08/24] refactor: apikey search --- app/db/crud/api_key.py | 26 ++++++++++++------------ app/models/api_key.py | 7 +++++++ app/operation/api_key.py | 30 +++++++++++++++------------- app/routers/api_key.py | 12 +++++------ app/routers/dependencies/__init__.py | 3 +++ app/routers/dependencies/api_key.py | 15 ++++++++++++++ 6 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 app/routers/dependencies/api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 28abb14f..6b00eeb3 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -42,22 +42,22 @@ async def get_api_key_by_id(db: AsyncSession, key_id: int) -> APIKey | None: return (await db.execute(stmt)).scalar_one_or_none() -async def get_api_key_by_id_for_admin(db: AsyncSession, *, key_id: int, admin_id: int | None = None) -> APIKey | None: - stmt = select(APIKey).where(APIKey.id == key_id).options(selectinload(APIKey.admin), selectinload(APIKey.role)) - if admin_id is not None: - stmt = stmt.where(APIKey.admin_id == admin_id) - return (await db.execute(stmt)).scalar_one_or_none() - - -async def get_api_key_by_name(db: AsyncSession, *, admin_id: int, name: str) -> APIKey | None: - stmt = select(APIKey).where(APIKey.admin_id == admin_id, APIKey.name == name) - return (await db.execute(stmt)).scalar_one_or_none() - - -async def get_api_keys(db: AsyncSession, *, admin_id: int | None, offset: int, limit: int) -> tuple[list[APIKey], int]: +async def get_api_keys( + db: AsyncSession, + *, + admin_id: int | None, + offset: int, + limit: int, + key_id: int | None = None, + name: str | None = None, +) -> tuple[list[APIKey], int]: stmt = select(APIKey).options(selectinload(APIKey.role)) if admin_id is not None: stmt = stmt.where(APIKey.admin_id == admin_id) + if key_id is not None: + stmt = stmt.where(APIKey.id == key_id) + if name is not None: + stmt = stmt.where(APIKey.name == name) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 diff --git a/app/models/api_key.py b/app/models/api_key.py index 42b237a3..a641fa52 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -44,3 +44,10 @@ class APIKeysResponse(BaseModel): Offset = Annotated[int, Field(default=0, ge=0)] Limit = Annotated[int, Field(default=50, ge=1, le=200)] + + +class APIKeysQuery(BaseModel): + offset: Offset = 0 + limit: Limit = 50 + key_id: int | None = Field(default=None, ge=1) + name: str | None = Field(default=None, min_length=1, max_length=128) diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 99e46a85..13bdf993 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -8,12 +8,10 @@ create_api_key, delete_api_key, get_api_key_by_id, - get_api_key_by_id_for_admin, - get_api_key_by_name, get_api_keys, ) from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import BaseOperation @@ -31,8 +29,8 @@ async def create_api_key( if not admin.is_owner and admin.role and role.id != admin.role.id: await self.raise_error(message="Only owner can create API keys with a different role", code=403) - duplicate = await get_api_key_by_name(db, admin_id=admin.id, name=model.name) - if duplicate is not None: + duplicates, _ = await get_api_keys(db, admin_id=admin.id, offset=0, limit=1, name=model.name) + if duplicates: await self.raise_error(message="API key name already exists", code=409) if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): @@ -59,20 +57,24 @@ async def create_api_key( api_key=raw_key, ) - async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, offset: int, limit: int) -> APIKeysResponse: + async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: APIKeysQuery) -> APIKeysResponse: scope_admin_id = None if admin.is_owner else admin.id - rows, total = await get_api_keys(db, admin_id=scope_admin_id, offset=offset, limit=limit) + rows, total = await get_api_keys( + db, + admin_id=scope_admin_id, + offset=query.offset, + limit=query.limit, + key_id=query.key_id, + name=query.name, + ) return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: - db_key = await get_api_key_by_id_for_admin( - db, - key_id=key_id, - admin_id=None if admin.is_owner else admin.id, - ) - if db_key is None: + scope_admin_id = None if admin.is_owner else admin.id + rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id) + if not rows: await self.raise_error(message="API key not found", code=404) - return APIKeyResponse.model_validate(db_key) + return APIKeyResponse.model_validate(rows[0]) async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> None: db_key = await get_api_key_by_id(db, key_id) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index b74962b6..015c0fc7 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -1,12 +1,11 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, status from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysResponse +from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import OperatorType from app.operation.api_key import APIKeyOperation +from app.routers.dependencies import get_api_key_list_query from app.utils import responses from .authentication import require_permission @@ -36,12 +35,11 @@ async def create_api_key( @router.get("s", response_model=APIKeysResponse) async def list_api_keys( - offset: Annotated[int, Query(ge=0)] = 0, - limit: Annotated[int, Query(ge=1, le=200)] = 50, + query: APIKeysQuery = Depends(get_api_key_list_query), db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(require_permission("api_keys", "read")), ): - return await api_key_operator.list_api_keys(db, admin=admin, offset=offset, limit=limit) + return await api_key_operator.list_api_keys(db, admin=admin, query=query) @router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404}) diff --git a/app/routers/dependencies/__init__.py b/app/routers/dependencies/__init__.py index f091ff4e..809769f4 100644 --- a/app/routers/dependencies/__init__.py +++ b/app/routers/dependencies/__init__.py @@ -1,5 +1,6 @@ from .admin import get_admin_list_query, get_admin_simple_list_query, get_admin_usage_query from .admin_role import get_admin_role_list_query +from .api_key import get_api_key_list_query from .client_template import get_client_template_list_query, get_client_template_simple_list_query from .core import get_core_list_query, get_core_simple_list_query from .group import get_group_list_query, get_group_simple_list_query @@ -28,6 +29,8 @@ "get_admin_usage_query", # admin_role "get_admin_role_list_query", + # api_key + "get_api_key_list_query", # client_template "get_client_template_list_query", "get_client_template_simple_list_query", diff --git a/app/routers/dependencies/api_key.py b/app/routers/dependencies/api_key.py new file mode 100644 index 00000000..b6fa31bc --- /dev/null +++ b/app/routers/dependencies/api_key.py @@ -0,0 +1,15 @@ +from fastapi import Query + +from app.models.api_key import APIKeysQuery + +from ._common import make_query_dependency + +get_api_key_list_query = make_query_dependency( + APIKeysQuery, + field_overrides={ + "offset": Query(None), + "limit": Query(None), + "key_id": Query(None), + "name": Query(None), + }, +) From 89e82b01cc4e87ae398ed038952d3c7688b16dfd Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 11:03:26 +0330 Subject: [PATCH 09/24] feat: Add limits for creating apikeys and control disabled admin apikeys --- app/db/crud/api_key.py | 32 +++++++++++++++++-- .../c9b48df42f10_add_api_keys_table.py | 10 ++++++ app/db/models.py | 30 +++++++++++++++++ app/models/api_key.py | 11 +++++++ app/operation/api_key.py | 19 +++++++++++ app/operation/user.py | 1 - app/routers/authentication.py | 11 ++++++- app/routers/dependencies/api_key.py | 1 + 8 files changed, 110 insertions(+), 5 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 6b00eeb3..c3287682 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,9 +1,11 @@ import uuid +from datetime import datetime as dt, timezone as tz + from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.db.models import Admin, APIKey +from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus from app.models.api_key import APIKeyCreate from app.utils.crypto import hash_api_key @@ -31,10 +33,19 @@ async def create_api_key( async def get_api_key_by_hash(db: AsyncSession, key_hash: str) -> APIKey | None: stmt = ( select(APIKey) - .where(APIKey.key_hash == key_hash) + .where( + APIKey.key_hash == key_hash, + APIKey.status != APIKeyStatus.disabled, + ) .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) ) - return (await db.execute(stmt)).scalar_one_or_none() + db_key = (await db.execute(stmt)).scalar_one_or_none() + if db_key is None: + return None + # Reject if the owning admin is disabled + if db_key.admin is not None and db_key.admin.status == AdminStatus.disabled: + return None + return db_key async def get_api_key_by_id(db: AsyncSession, key_id: int) -> APIKey | None: @@ -50,6 +61,7 @@ async def get_api_keys( limit: int, key_id: int | None = None, name: str | None = None, + status: APIKeyStatus | None = None, ) -> tuple[list[APIKey], int]: stmt = select(APIKey).options(selectinload(APIKey.role)) if admin_id is not None: @@ -58,6 +70,20 @@ async def get_api_keys( stmt = stmt.where(APIKey.id == key_id) if name is not None: stmt = stmt.where(APIKey.name == name) + if status is not None: + now = dt.now(tz.utc) + if status == APIKeyStatus.expired: + # expired = expire_date is set and in the past + stmt = stmt.where(APIKey.expire_date.isnot(None), APIKey.expire_date <= now) + elif status == APIKeyStatus.active: + # active = stored status is active AND not past expire_date + stmt = stmt.where( + APIKey.status == APIKeyStatus.active, + (APIKey.expire_date.is_(None)) | (APIKey.expire_date > now), + ) + else: + # disabled = stored status is disabled (expire_date irrelevant) + stmt = stmt.where(APIKey.status == APIKeyStatus.disabled) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index 80851706..7da382ac 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -58,6 +58,12 @@ def upgrade() -> None: sa.Column("role_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "status", + sa.Enum("active", "disabled", "expired", name="apikeystatus"), + nullable=False, + server_default="active", + ), sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins"), ondelete="CASCADE"), sa.ForeignKeyConstraint(["role_id"], ["admin_roles.id"], name=op.f("fk_api_keys_role_id_admin_roles")), sa.PrimaryKeyConstraint("id", name=op.f("pk_api_keys")), @@ -113,3 +119,7 @@ def downgrade() -> None: batch_op.drop_index(batch_op.f("ix_api_keys_admin_id")) op.drop_table("api_keys") + + # Drop the enum type for PostgreSQL + if conn.dialect.name == "postgresql": + op.execute("DROP TYPE IF EXISTS apikeystatus") diff --git a/app/db/models.py b/app/db/models.py index 0259f089..8a5fbe8a 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -854,6 +854,12 @@ def is_builtin(cls): return cls.id <= 3 +class APIKeyStatus(str, Enum): + active = "active" + disabled = "disabled" + expired = "expired" + + class APIKey(Base, IdMixin, CreatedAtUTCMixin): __tablename__ = "api_keys" __table_args__ = ( @@ -872,6 +878,30 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") note: Mapped[Optional[str]] = mapped_column(String(512), default=None) expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + status: Mapped[APIKeyStatus] = mapped_column( + SQLEnum(APIKeyStatus, name="apikeystatus", create_constraint=True), + default=APIKeyStatus.active, + server_default="active", + ) + + @hybrid_property + def computed_status(self) -> "APIKeyStatus": + """Returns expired when past expire_date, otherwise the stored status.""" + if self.expire_date is not None: + expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) + if expire_at <= dt.now(tz.utc): + return APIKeyStatus.expired + return self.status + + @computed_status.expression + def computed_status(cls): + return case( + ( + and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()), + APIKeyStatus.expired.value, + ), + else_=cls.status, + ) class TempKey(Base): diff --git a/app/models/api_key.py b/app/models/api_key.py index a641fa52..3b51fd74 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from app.db.models import APIKeyStatus from app.utils.helpers import fix_datetime_timezone @@ -31,6 +32,15 @@ class APIKeyResponse(APIKeyBase): id: int admin_id: int created_at: dt + status: APIKeyStatus = APIKeyStatus.active + + @classmethod + def model_validate(cls, obj, *args, **kwargs): + # Use computed_status (which accounts for expiry) when building the response + instance = super().model_validate(obj, *args, **kwargs) + if hasattr(obj, "computed_status"): + instance.status = obj.computed_status + return instance class APIKeyCreateResponse(APIKeyResponse): @@ -51,3 +61,4 @@ class APIKeysQuery(BaseModel): limit: Limit = 50 key_id: int | None = Field(default=None, ge=1) name: str | None = Field(default=None, min_length=1, max_length=128) + status: APIKeyStatus | None = None diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 13bdf993..436d5d80 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -13,6 +13,8 @@ from app.models.admin import AdminDetails from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import BaseOperation +from app.operation.permissions import get_effective_limits +from app.utils.system import readable_duration class APIKeyOperation(BaseOperation): @@ -36,6 +38,22 @@ async def create_api_key( if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): await self.raise_error(message="expire_date must be in the future", code=422) + if not admin.is_owner: + limits = get_effective_limits(admin) + seconds = (model.expire_date - dt.now(tz.utc)).total_seconds() if model.expire_date is not None else None + if limits.expire_max is not None and (seconds is None or seconds > limits.expire_max): + await self.raise_error( + message=f"expire_date cannot exceed {readable_duration(limits.expire_max)} from now", + code=400, + db=db, + ) + if limits.expire_min is not None and (seconds is None or seconds < limits.expire_min): + await self.raise_error( + message=f"expire_date must be at least {readable_duration(limits.expire_min)} from now", + code=400, + db=db, + ) + try: raw_key, db_key = await create_api_key( db, @@ -66,6 +84,7 @@ async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: A limit=query.limit, key_id=query.key_id, name=query.name, + status=query.status, ) return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) diff --git a/app/operation/user.py b/app/operation/user.py index f0708d4f..01290a60 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -55,7 +55,6 @@ from app.db.models import User, UserStatus, UserTemplate from app.models.admin import AdminDetails from app.models.proxy import ProxyTable -from app.models.settings import HWIDSettings from app.models.stats import ( Period, UserCountMetric, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index e9d04472..f1f58ae0 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -14,7 +14,7 @@ get_admin_by_telegram_id, ) from app.db.crud.api_key import get_api_key_by_hash, hash_api_key -from app.db.models import Admin, AdminUsageLogs, User +from app.db.models import Admin, AdminUsageLogs, User, APIKeyStatus from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions from app.models.settings import Telegram @@ -127,6 +127,15 @@ async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics if db_admin is None: return None + # Reject if the key itself is manually disabled + if db_key.status == APIKeyStatus.disabled: + return None + + # Reject if the owning admin is disabled — keys are not stored as disabled, + # we just block them at auth time + if db_admin.status == AdminStatus.disabled: + return None + if db_key.expire_date is not None: expire_at = db_key.expire_date if db_key.expire_date.tzinfo else db_key.expire_date.replace(tzinfo=tz.utc) if expire_at.astimezone(tz.utc) <= dt.now(tz.utc): diff --git a/app/routers/dependencies/api_key.py b/app/routers/dependencies/api_key.py index b6fa31bc..a3f9e8b6 100644 --- a/app/routers/dependencies/api_key.py +++ b/app/routers/dependencies/api_key.py @@ -11,5 +11,6 @@ "limit": Query(None), "key_id": Query(None), "name": Query(None), + "status": Query(None), }, ) From 14ebea71edf72753fe50080f2f762b5be7c49a92 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 11:56:28 +0330 Subject: [PATCH 10/24] refactor: Add is_usable property and remove limit on apikey creation --- app/db/crud/api_key.py | 12 +----- .../c9b48df42f10_add_api_keys_table.py | 2 +- app/db/models.py | 37 ++++++++++--------- app/models/api_key.py | 9 +---- app/operation/api_key.py | 16 -------- app/routers/authentication.py | 26 ++++--------- 6 files changed, 30 insertions(+), 72 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index c3287682..d8196d21 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,5 +1,4 @@ import uuid -from datetime import datetime as dt, timezone as tz from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -71,16 +70,9 @@ async def get_api_keys( if name is not None: stmt = stmt.where(APIKey.name == name) if status is not None: - now = dt.now(tz.utc) - if status == APIKeyStatus.expired: - # expired = expire_date is set and in the past - stmt = stmt.where(APIKey.expire_date.isnot(None), APIKey.expire_date <= now) - elif status == APIKeyStatus.active: + if status == APIKeyStatus.active: # active = stored status is active AND not past expire_date - stmt = stmt.where( - APIKey.status == APIKeyStatus.active, - (APIKey.expire_date.is_(None)) | (APIKey.expire_date > now), - ) + stmt = stmt.where(APIKey.status == APIKeyStatus.active, ~APIKey.is_expired) else: # disabled = stored status is disabled (expire_date irrelevant) stmt = stmt.where(APIKey.status == APIKeyStatus.disabled) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index 7da382ac..e134cb25 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -60,7 +60,7 @@ def upgrade() -> None: sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), sa.Column( "status", - sa.Enum("active", "disabled", "expired", name="apikeystatus"), + sa.Enum("active", "disabled" , name="apikeystatus"), nullable=False, server_default="active", ), diff --git a/app/db/models.py b/app/db/models.py index 8a5fbe8a..1c7a8575 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -857,7 +857,6 @@ def is_builtin(cls): class APIKeyStatus(str, Enum): active = "active" disabled = "disabled" - expired = "expired" class APIKey(Base, IdMixin, CreatedAtUTCMixin): @@ -885,23 +884,25 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): ) @hybrid_property - def computed_status(self) -> "APIKeyStatus": - """Returns expired when past expire_date, otherwise the stored status.""" - if self.expire_date is not None: - expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) - if expire_at <= dt.now(tz.utc): - return APIKeyStatus.expired - return self.status - - @computed_status.expression - def computed_status(cls): - return case( - ( - and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()), - APIKeyStatus.expired.value, - ), - else_=cls.status, - ) + def is_expired(self) -> bool: + """True when expire_date is set and is in the past.""" + if self.expire_date is None: + return False + expire_at = self.expire_date if self.expire_date.tzinfo else self.expire_date.replace(tzinfo=tz.utc) + return expire_at <= dt.now(tz.utc) + + @is_expired.expression + def is_expired(cls): + return and_(cls.expire_date.isnot(None), cls.expire_date <= func.current_timestamp()) + + @property + def is_usable(self) -> bool: + """False if the key is disabled, its admin is missing/disabled, or it has expired.""" + if self.status == APIKeyStatus.disabled: + return False + if self.admin is None or self.admin.status == AdminStatus.disabled: + return False + return not self.is_expired class TempKey(Base): diff --git a/app/models/api_key.py b/app/models/api_key.py index 3b51fd74..8788aad1 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -33,14 +33,7 @@ class APIKeyResponse(APIKeyBase): admin_id: int created_at: dt status: APIKeyStatus = APIKeyStatus.active - - @classmethod - def model_validate(cls, obj, *args, **kwargs): - # Use computed_status (which accounts for expiry) when building the response - instance = super().model_validate(obj, *args, **kwargs) - if hasattr(obj, "computed_status"): - instance.status = obj.computed_status - return instance + is_expired: bool = False class APIKeyCreateResponse(APIKeyResponse): diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 436d5d80..750e2af3 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -38,22 +38,6 @@ async def create_api_key( if model.expire_date is not None and model.expire_date <= dt.now(tz.utc): await self.raise_error(message="expire_date must be in the future", code=422) - if not admin.is_owner: - limits = get_effective_limits(admin) - seconds = (model.expire_date - dt.now(tz.utc)).total_seconds() if model.expire_date is not None else None - if limits.expire_max is not None and (seconds is None or seconds > limits.expire_max): - await self.raise_error( - message=f"expire_date cannot exceed {readable_duration(limits.expire_max)} from now", - code=400, - db=db, - ) - if limits.expire_min is not None and (seconds is None or seconds < limits.expire_min): - await self.raise_error( - message=f"expire_date must be at least {readable_duration(limits.expire_min)} from now", - code=400, - db=db, - ) - try: raw_key, db_key = await create_api_key( db, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index f1f58ae0..53275298 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,4 +1,3 @@ -from datetime import datetime as dt, timezone as tz from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data @@ -110,36 +109,25 @@ async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, in async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None: if not raw_key: - return None + return try: parsed_key = UUID(raw_key) except ValueError: - return None + return if parsed_key.version != 4: - return None + return db_key = await get_api_key_by_hash(db, hash_api_key(str(parsed_key))) if db_key is None: - return None + return db_admin = db_key.admin if db_admin is None: - return None + return - # Reject if the key itself is manually disabled - if db_key.status == APIKeyStatus.disabled: - return None - - # Reject if the owning admin is disabled — keys are not stored as disabled, - # we just block them at auth time - if db_admin.status == AdminStatus.disabled: - return None - - if db_key.expire_date is not None: - expire_at = db_key.expire_date if db_key.expire_date.tzinfo else db_key.expire_date.replace(tzinfo=tz.utc) - if expire_at.astimezone(tz.utc) <= dt.now(tz.utc): - return None + if not db_key.is_usable: + return if with_metrics: total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id) From efc3b8a8a53fc17bd393abd948dae90bc5186ede Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 12:51:56 +0330 Subject: [PATCH 11/24] fix: change apikey role when admin role is changed --- app/db/crud/api_key.py | 8 +++++++- app/db/models.py | 5 +++++ app/operation/admin.py | 6 ++++++ app/routers/authentication.py | 8 ++++---- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index d8196d21..a9ed4438 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -87,3 +87,9 @@ async def get_api_keys( async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: await db.delete(db_key) await db.flush() + + +async def update_api_keys_role(db: AsyncSession, admin_id: int, new_role_id: int) -> int: + """Update role_id on all API keys belonging to admin_id. Returns affected row count.""" + result = await db.execute(update(APIKey).where(APIKey.admin_id == admin_id).values(role_id=new_role_id)) + return result.rowcount diff --git a/app/db/models.py b/app/db/models.py index 1c7a8575..6605d383 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -153,6 +153,11 @@ def users_sync_blocked(self) -> bool: def total_users(self) -> int: return len(self.users) + @property + def has_api_keys(self) -> bool: + """True when the admin owns at least one API key.""" + return len(self.api_keys) > 0 + class AdminUsageLogs(Base, IdMixin): __tablename__ = "admin_usage_logs" diff --git a/app/operation/admin.py b/app/operation/admin.py index 79bcbbd3..1aa9e57e 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -18,6 +18,7 @@ reset_admin_usage, update_admin, ) +from app.db.crud.api_key import update_api_keys_role from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users from app.db.crud.user import get_users, remove_users from app.models.user import UserListQuery @@ -121,6 +122,11 @@ async def _modify_admin( old_status = db_admin.status db_admin = await update_admin(db, db_admin, modified_admin) + # Keep API key roles in sync with the admin's new role + if modified_admin.role_id is not None and db_admin.has_api_keys: + await update_api_keys_role(db, db_admin.id, modified_admin.role_id) + await db.commit() + # Sync users to nodes if admin status changed due to data_limit change if modified_admin.data_limit is not None: if old_status != AdminStatus.limited and db_admin.status == AdminStatus.limited: diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 53275298..b688b667 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -215,13 +215,13 @@ async def get_current(request: Request, db: AsyncSession = Depends(get_db), toke raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) return admin @@ -244,13 +244,13 @@ async def get_current_with_metrics( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) if admin.status == AdminStatus.disabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="your account has been disabled", - headers={"WWW-Authenticate": "Bearer, ApiKey"}, + headers={"WWW-Authenticate": "Bearer"}, ) return admin From 0e77317da904d37b1f7f351679c4d934ff6b5ce5 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:08:34 +0330 Subject: [PATCH 12/24] fix: Hash the generated key, not model.raw_key --- app/db/crud/api_key.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index a9ed4438..253a7575 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -20,7 +20,7 @@ async def create_api_key( role_id=model.role_id, name=model.name, note=model.note, - key_hash=hash_api_key(model.raw_key), + key_hash=hash_api_key(raw_key), expire_date=model.expire_date, ) db.add(db_key) From e584000e69d81db7ee3a75ef6964b491e136dd73 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:24:18 +0330 Subject: [PATCH 13/24] fix: API key role sync should be atomic with admin update --- app/db/crud/admin.py | 4 ++++ app/operation/admin.py | 6 ------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py index 6d95b0f8..2b224ba0 100644 --- a/app/db/crud/admin.py +++ b/app/db/crud/admin.py @@ -9,6 +9,7 @@ get_complete_period_start_for_filter, to_utc_for_filter, ) +from app.db.crud.api_key import update_api_keys_role from app.db.models import Admin, AdminNotificationReminder, AdminRole, AdminUsageLogs, NodeUserUsage, ReminderType, User from app.models.admin import ( AdminCreate, @@ -147,6 +148,9 @@ async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminM if modified_admin.notification_enable is not None: db_admin.notification_enable = modified_admin.notification_enable.model_dump() + if modified_admin.role_id is not None: + await update_api_keys_role(db, db_admin.id, modified_admin.role_id) + await db.commit() await db.refresh(db_admin) await load_admin_attrs(db_admin) diff --git a/app/operation/admin.py b/app/operation/admin.py index 1aa9e57e..79bcbbd3 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -18,7 +18,6 @@ reset_admin_usage, update_admin, ) -from app.db.crud.api_key import update_api_keys_role from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users from app.db.crud.user import get_users, remove_users from app.models.user import UserListQuery @@ -122,11 +121,6 @@ async def _modify_admin( old_status = db_admin.status db_admin = await update_admin(db, db_admin, modified_admin) - # Keep API key roles in sync with the admin's new role - if modified_admin.role_id is not None and db_admin.has_api_keys: - await update_api_keys_role(db, db_admin.id, modified_admin.role_id) - await db.commit() - # Sync users to nodes if admin status changed due to data_limit change if modified_admin.data_limit is not None: if old_status != AdminStatus.limited and db_admin.status == AdminStatus.limited: From 58d59dc3f4ea6a61e84a7e83d3550c7317d74ca0 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:27:22 +0330 Subject: [PATCH 14/24] fix: 204 delete response body --- app/routers/api_key.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index 015c0fc7..d629c95e 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, status +from starlette.responses import Response from app.db import AsyncSession, get_db from app.models.admin import AdminDetails @@ -58,4 +59,4 @@ async def remove_api_key( admin: AdminDetails = Depends(require_permission("api_keys", "delete")), ): await api_key_operator.delete_api_key(db, admin=admin, key_id=key_id) - return {} + return Response(status_code=status.HTTP_204_NO_CONTENT) From 99cae347f67eb7e67f92a2a6333a5c8ede0574be Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:32:05 +0330 Subject: [PATCH 15/24] fix: Keep the API-key fallback when bearer auth is invalid --- app/routers/authentication.py | 41 ++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/app/routers/authentication.py b/app/routers/authentication.py index b688b667..32c4554a 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -1,3 +1,4 @@ +from datetime import timezone as tz from uuid import UUID from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data @@ -13,7 +14,7 @@ get_admin_by_telegram_id, ) from app.db.crud.api_key import get_api_key_by_hash, hash_api_key -from app.db.models import Admin, AdminUsageLogs, User, APIKeyStatus +from app.db.models import Admin, AdminUsageLogs, User from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions from app.models.settings import Telegram @@ -201,15 +202,36 @@ async def get_admin_with_metrics(db: AsyncSession, token: str) -> AdminDetails | return None -async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)): +async def _get_admin_from_request_credentials( + request: Request, + db: AsyncSession, + token: str | None, + *, + with_metrics: bool = False, +) -> AdminDetails | None: admin: AdminDetails | None = None if token: - admin = await get_admin(db, token) - else: + try: + if with_metrics: + admin = await get_admin_with_metrics(db, token) + else: + admin = await get_admin(db, token) + except HTTPException as exc: + if exc.status_code not in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN): + raise + admin = None + + if not admin: api_key = _extract_api_key(request) if api_key: - admin = await get_admin_from_api_key(db, api_key) + admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics) + + return admin + + +async def get_current(request: Request, db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme)): + admin = await _get_admin_from_request_credentials(request, db, token) if not admin: raise HTTPException( @@ -231,14 +253,7 @@ async def get_current_with_metrics( db: AsyncSession = Depends(get_db), token: str | None = Depends(oauth2_scheme), ): - admin: AdminDetails | None = None - - if token: - admin = await get_admin_with_metrics(db, token) - else: - api_key = _extract_api_key(request) - if api_key: - admin = await get_admin_from_api_key(db, api_key, with_metrics=True) + admin = await _get_admin_from_request_credentials(request, db, token, with_metrics=True) if not admin: raise HTTPException( From 8f827549f6bccfe23600d0b66502c4bfef4cc1ec Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:40:58 +0330 Subject: [PATCH 16/24] fix: Salted hashes cannot be used for exact DB lookup --- app/db/crud/api_key.py | 10 ++++---- app/routers/authentication.py | 4 ++-- app/utils/crypto.py | 44 ++++++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 253a7575..59f207be 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -6,7 +6,7 @@ from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus from app.models.api_key import APIKeyCreate -from app.utils.crypto import hash_api_key +from app.utils.crypto import API_KEY_HASH_VERSION, api_key_lookup_id, hash_api_key, verify_api_key async def create_api_key( @@ -29,17 +29,19 @@ async def create_api_key( return raw_key, db_key -async def get_api_key_by_hash(db: AsyncSession, key_hash: str) -> APIKey | None: +async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | None: + lookup_prefix = f"{API_KEY_HASH_VERSION}${api_key_lookup_id(raw_api_key)}$" stmt = ( select(APIKey) .where( - APIKey.key_hash == key_hash, + APIKey.key_hash.startswith(lookup_prefix), APIKey.status != APIKeyStatus.disabled, ) .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) + .limit(1) ) db_key = (await db.execute(stmt)).scalar_one_or_none() - if db_key is None: + if db_key is None or not verify_api_key(raw_api_key, db_key.key_hash): return None # Reject if the owning admin is disabled if db_key.admin is not None and db_key.admin.status == AdminStatus.disabled: diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 32c4554a..8d03167e 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -13,7 +13,7 @@ get_admin_by_id as get_admin_by_id_crud, get_admin_by_telegram_id, ) -from app.db.crud.api_key import get_api_key_by_hash, hash_api_key +from app.db.crud.api_key import get_api_key_by_raw_key from app.db.models import Admin, AdminUsageLogs, User from app.models.admin import AdminDetails, AdminRoleData, AdminStatus, AdminValidationResult, verify_password from app.models.admin_role import RoleAccess, RoleFeatures, RoleLimits, RolePermissions @@ -119,7 +119,7 @@ async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics if parsed_key.version != 4: return - db_key = await get_api_key_by_hash(db, hash_api_key(str(parsed_key))) + db_key = await get_api_key_by_raw_key(db, str(parsed_key)) if db_key is None: return diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 4e3893f5..8ba345e0 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -1,6 +1,7 @@ import base64 import hashlib import binascii +import hmac import os from cryptography import x509 @@ -101,15 +102,52 @@ def generate_wireguard_keypair() -> tuple[str, str]: ) +API_KEY_HASH_VERSION = "v1" +API_KEY_HASH_ALGORITHM = "pbkdf2_sha256" +API_KEY_HASH_ITERATIONS = 310000 +API_KEY_LOOKUP_BYTES = 16 + + +def api_key_lookup_id(raw_api_key: str) -> str: + digest = hashlib.sha256(raw_api_key.encode("utf-8")).digest()[:API_KEY_LOOKUP_BYTES] + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") + + def hash_api_key(raw_api_key: str) -> str: - iterations = 310000 salt = os.urandom(16) derived_key = hashlib.pbkdf2_hmac( "sha256", raw_api_key.encode("utf-8"), salt, - iterations, + API_KEY_HASH_ITERATIONS, ) salt_b64 = base64.b64encode(salt).decode("ascii") dk_b64 = base64.b64encode(derived_key).decode("ascii") - return f"pbkdf2_sha256${iterations}${salt_b64}${dk_b64}" + lookup_id = api_key_lookup_id(raw_api_key) + return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HASH_ALGORITHM}${API_KEY_HASH_ITERATIONS}${salt_b64}${dk_b64}" + + +def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: + parts = stored_hash.split("$") + lookup_id: str | None = None + if len(parts) == 6 and parts[0] == API_KEY_HASH_VERSION: + _, lookup_id, algorithm, iterations_raw, salt_b64, dk_b64 = parts + elif len(parts) == 4: + algorithm, iterations_raw, salt_b64, dk_b64 = parts + else: + return False + + if algorithm != API_KEY_HASH_ALGORITHM: + return False + if lookup_id is not None and not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): + return False + + try: + iterations = int(iterations_raw) + salt = base64.b64decode(salt_b64, validate=True) + expected_key = base64.b64decode(dk_b64, validate=True) + except (ValueError, binascii.Error): + return False + + derived_key = hashlib.pbkdf2_hmac("sha256", raw_api_key.encode("utf-8"), salt, iterations) + return hmac.compare_digest(derived_key, expected_key) From 2120cd4ab877ac338e87caa5253c8d9f9d77b82e Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 13:42:32 +0330 Subject: [PATCH 17/24] format code --- app/operation/api_key.py | 2 -- app/utils/crypto.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 750e2af3..cb0d3619 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -13,8 +13,6 @@ from app.models.admin import AdminDetails from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse from app.operation import BaseOperation -from app.operation.permissions import get_effective_limits -from app.utils.system import readable_duration class APIKeyOperation(BaseOperation): diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 8ba345e0..1b1bb36c 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -146,7 +146,7 @@ def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: iterations = int(iterations_raw) salt = base64.b64decode(salt_b64, validate=True) expected_key = base64.b64decode(dk_b64, validate=True) - except (ValueError, binascii.Error): + except ValueError, binascii.Error: return False derived_key = hashlib.pbkdf2_hmac("sha256", raw_api_key.encode("utf-8"), salt, iterations) From d2435b9a002217d2229af480aae1d585ea615a04 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 16:20:51 +0330 Subject: [PATCH 18/24] feat: Add catch for apikeys and add notifications for apikeys and add mmodify endpoint --- app/db/crud/api_key.py | 29 ++++++-- .../c9b48df42f10_add_api_keys_table.py | 2 + app/models/admin_role.py | 1 + app/models/api_key.py | 18 +++++ app/models/notification_enable.py | 1 + app/models/settings.py | 1 + app/notification/__init__.py | 16 +++++ app/notification/discord/__init__.py | 4 ++ app/notification/discord/api_key.py | 68 +++++++++++++++++++ app/notification/discord/messages.py | 18 +++++ app/notification/telegram/__init__.py | 4 ++ app/notification/telegram/api_key.py | 57 ++++++++++++++++ app/notification/telegram/messages.py | 35 ++++++++++ app/operation/api_key.py | 52 +++++++++++++- app/routers/api_key.py | 19 +++++- app/routers/authentication.py | 17 ++++- app/utils/crypto.py | 41 +++++++---- 17 files changed, 361 insertions(+), 22 deletions(-) create mode 100644 app/notification/discord/api_key.py create mode 100644 app/notification/telegram/api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 59f207be..19a1423b 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,12 +1,14 @@ import uuid -from sqlalchemy import func, select, update +from aiocache import cached +from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus from app.models.api_key import APIKeyCreate from app.utils.crypto import API_KEY_HASH_VERSION, api_key_lookup_id, hash_api_key, verify_api_key +from app.utils.jwt import get_secret_key async def create_api_key( @@ -15,12 +17,13 @@ async def create_api_key( model: APIKeyCreate, ) -> tuple[str, APIKey]: raw_key = str(uuid.uuid4()) + pepper = await get_secret_key() db_key = APIKey( admin_id=admin_id, role_id=model.role_id, name=model.name, note=model.note, - key_hash=hash_api_key(raw_key), + key_hash=hash_api_key(raw_key, pepper=pepper), expire_date=model.expire_date, ) db.add(db_key) @@ -30,18 +33,26 @@ async def create_api_key( async def get_api_key_by_raw_key(db: AsyncSession, raw_api_key: str) -> APIKey | None: - lookup_prefix = f"{API_KEY_HASH_VERSION}${api_key_lookup_id(raw_api_key)}$" + pepper = await get_secret_key() + lookup_id = api_key_lookup_id(raw_api_key) + stmt = ( select(APIKey) .where( - APIKey.key_hash.startswith(lookup_prefix), + or_( + APIKey.key_hash.startswith(f"v2${lookup_id}$"), + APIKey.key_hash.startswith(f"v1${lookup_id}$"), + # Handle cases where version prefix might be missing in some older implementations + APIKey.key_hash.startswith(f"{lookup_id}$"), + ), APIKey.status != APIKeyStatus.disabled, ) .options(selectinload(APIKey.admin).selectinload(Admin.role), selectinload(APIKey.role)) .limit(1) ) db_key = (await db.execute(stmt)).scalar_one_or_none() - if db_key is None or not verify_api_key(raw_api_key, db_key.key_hash): + + if db_key is None or not verify_api_key(raw_api_key, db_key.key_hash, pepper=pepper): return None # Reject if the owning admin is disabled if db_key.admin is not None and db_key.admin.status == AdminStatus.disabled: @@ -91,6 +102,14 @@ async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: await db.flush() +async def update_api_key(db: AsyncSession, db_key: APIKey, update_data: dict) -> APIKey: + for key, value in update_data.items(): + setattr(db_key, key, value) + await db.flush() + await db.refresh(db_key) + return db_key + + async def update_api_keys_role(db: AsyncSession, admin_id: int, new_role_id: int) -> int: """Update role_id on all API keys belonging to admin_id. Returns affected row count.""" result = await db.execute(update(APIKey).where(APIKey.admin_id == admin_id).values(role_id=new_role_id)) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index e134cb25..58f57a16 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -24,12 +24,14 @@ "create": True, "read": True, "read_simple": True, + "modify": True, "delete": True, } OPERATOR_API_KEY_PERMS = { "read": {"scope": 1}, "read_simple": {"scope": 1}, + "modify": {"scope": 1}, "delete": {"scope": 1}, } diff --git a/app/models/admin_role.py b/app/models/admin_role.py index 79191ff8..8b54441b 100644 --- a/app/models/admin_role.py +++ b/app/models/admin_role.py @@ -85,6 +85,7 @@ class APIKeysPermissions(_ResourcePermissions): create: RoleActionValue | None = None read: RoleActionValue | None = None read_simple: RoleActionValue | None = None + modify: RoleActionValue | None = None delete: RoleActionValue | None = None diff --git a/app/models/api_key.py b/app/models/api_key.py index 8788aad1..1a5bda96 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -28,6 +28,24 @@ def validate_expire_date(cls, value): return parsed +class APIKeyUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + note: str | None = Field(default=None, max_length=512) + role_id: int | None = Field(default=None, ge=1) + expire_date: dt | None = None + status: APIKeyStatus | None = None + + @field_validator("expire_date", mode="before") + @classmethod + def validate_expire_date(cls, value): + if value is None: + return None + parsed = fix_datetime_timezone(value) + if parsed <= dt.now(tz.utc): + raise ValueError("expire_date must be in the future") + return parsed + + class APIKeyResponse(APIKeyBase): id: int admin_id: int diff --git a/app/models/notification_enable.py b/app/models/notification_enable.py index 4902d11a..f97cd8ca 100644 --- a/app/models/notification_enable.py +++ b/app/models/notification_enable.py @@ -48,5 +48,6 @@ class NotificationEnable(BaseModel): node: NodeNotificationEnable = Field(default_factory=NodeNotificationEnable) user: UserNotificationEnable = Field(default_factory=UserNotificationEnable) user_template: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) + api_key: BaseNotificationEnable = Field(default_factory=BaseNotificationEnable) days_left: bool = Field(default=True) percentage_reached: bool = Field(default=True) diff --git a/app/models/settings.py b/app/models/settings.py index a2939f8e..ccecca17 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -135,6 +135,7 @@ class NotificationChannels(BaseModel): node: NotificationChannel = Field(default_factory=NotificationChannel) user: NotificationChannel = Field(default_factory=NotificationChannel) user_template: NotificationChannel = Field(default_factory=NotificationChannel) + api_key: NotificationChannel = Field(default_factory=NotificationChannel) class NotificationSettings(BaseModel): diff --git a/app/notification/__init__.py b/app/notification/__init__.py index 43e16549..f3ea01d0 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -6,6 +6,7 @@ from app.models.group import GroupResponse from app.models.host import BaseHost from app.models.node import NodeNotification, NodeResponse +from app.models.api_key import APIKeyResponse from app.models.user import UserNotificationResponse from app.models.user_template import UserTemplateResponse from app.settings import notification_enable @@ -13,6 +14,21 @@ from . import discord as ds, telegram as tg, webhook as wh +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.create: + await asyncio.gather(ds.create_api_key_ds(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by)) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.modify: + await asyncio.gather(ds.modify_api_key_ds(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by)) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + if (await notification_enable()).api_key.delete: + await asyncio.gather(ds.remove_api_key_ds(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by)) + + async def create_admin_role(role: AdminRoleResponse, by: str): if (await notification_enable()).admin_role.create: await asyncio.gather(ds.create_admin_role(role, by), tg.create_admin_role(role, by)) diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index 06e69cee..afaeab63 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -1,5 +1,6 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role +from .api_key import create_api_key as create_api_key_ds, modify_api_key as modify_api_key_ds, remove_api_key as remove_api_key_ds from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host @@ -19,6 +20,9 @@ "create_admin_role", "modify_admin_role", "remove_admin_role", + "create_api_key_ds", + "modify_api_key_ds", + "remove_api_key_ds", "create_host", "modify_host", "remove_host", diff --git a/app/notification/discord/api_key.py b/app/notification/discord/api_key.py new file mode 100644 index 00000000..8a1a2a65 --- /dev/null +++ b/app/notification/discord/api_key.py @@ -0,0 +1,68 @@ +from app.models.api_key import APIKeyResponse +from app.models.settings import NotificationSettings +from app.notification.client import send_discord_message +from app.notification.helpers import get_discord_webhook +from app.settings import notification_settings + +from . import colors, messages + +ENTITY = "api_key" + + +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.CREATE_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.GREEN + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_message(data, webhook_url) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.MODIFY_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + status=api_key.status.value, + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.BLUE + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_message(data, webhook_url) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + data = messages.REMOVE_API_KEY.copy() + data["description"] = data["description"].format( + name=api_key.name, + ) + data["footer"]["text"] = data["footer"]["text"].format( + id=api_key.id, + admin_username=admin_username, + by=by, + ) + data["color"] = colors.RED + + settings: NotificationSettings = await notification_settings() + if settings.notify_discord: + webhook_url = get_discord_webhook(settings, ENTITY) + await send_discord_message(data, webhook_url) diff --git a/app/notification/discord/messages.py b/app/notification/discord/messages.py index 7a47fc9d..35ca1c48 100644 --- a/app/notification/discord/messages.py +++ b/app/notification/discord/messages.py @@ -291,3 +291,21 @@ "description": "**Name:** {name}\n", "footer": {"text": "ID: {id}\nBy: {by}"}, } + +CREATE_API_KEY = { + "title": "🆕 Create API Key", + "description": "**Name:** {name}\n**Role ID:** {role_id}\n**Expire Date:** {expire_date}", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} + +MODIFY_API_KEY = { + "title": "✏️ Modify API Key", + "description": "**Name:** {name}\n**Role ID:** {role_id}\n**Expire Date:** {expire_date}\n**Status:** {status}", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} + +REMOVE_API_KEY = { + "title": "🗑️ Remove API Key", + "description": "**Name:** {name}\n", + "footer": {"text": "ID: {id}\nBelongs To: {admin_username}\nBy: {by}"}, +} diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index b910e947..f15ce7e3 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -1,5 +1,6 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role +from .api_key import create_api_key as create_api_key_tg, modify_api_key as modify_api_key_tg, remove_api_key as remove_api_key_tg from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host @@ -19,6 +20,9 @@ "create_admin_role", "modify_admin_role", "remove_admin_role", + "create_api_key_tg", + "modify_api_key_tg", + "remove_api_key_tg", "create_host", "modify_host", "remove_host", diff --git a/app/notification/telegram/api_key.py b/app/notification/telegram/api_key.py new file mode 100644 index 00000000..25a1e0c7 --- /dev/null +++ b/app/notification/telegram/api_key.py @@ -0,0 +1,57 @@ +from app.models.api_key import APIKeyResponse +from app.models.settings import NotificationSettings +from app.notification.client import send_telegram_message +from app.notification.helpers import get_telegram_channel +from app.settings import notification_settings +from app.utils.helpers import escape_tg_html + +from . import messages + +ENTITY = "api_key" + + +async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.CREATE_API_KEY.format( + id=api_key.id, + name=name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) + + +async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.MODIFY_API_KEY.format( + id=api_key.id, + name=name, + role_id=api_key.role_id, + expire_date=api_key.expire_date or "Never", + status=api_key.status.value, + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) + + +async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): + name, admin_username, by = escape_tg_html((api_key.name, admin_username, by)) + data = messages.REMOVE_API_KEY.format( + id=api_key.id, + name=name, + admin_username=admin_username, + by=by, + ) + settings: NotificationSettings = await notification_settings() + if settings.notify_telegram: + chat_id, topic_id = get_telegram_channel(settings, ENTITY) + await send_telegram_message(data, chat_id, topic_id) diff --git a/app/notification/telegram/messages.py b/app/notification/telegram/messages.py index d6b64c90..d756090d 100644 --- a/app/notification/telegram/messages.py +++ b/app/notification/telegram/messages.py @@ -361,3 +361,38 @@ ID: {id} By: #{by} """ + +CREATE_API_KEY = """ +#Create_API_Key +➖➖➖➖➖➖➖➖➖ +Name: {name} +Role ID: {role_id} +Expire Date: {expire_date} +➖➖➖➖➖➖➖➖➖ +ID: {id} +Belongs To: {admin_username} +By: #{by} +""" + +MODIFY_API_KEY = """ +✏️ #Modify_API_Key +➖➖➖➖➖➖➖➖➖ +Name: {name} +Role ID: {role_id} +Expire Date: {expire_date} +Status: {status} +➖➖➖➖➖➖➖➖➖ +ID: {id} +Belongs To: {admin_username} +By: #{by} +""" + +REMOVE_API_KEY = """ +#Remove_API_Key +➖➖➖➖➖➖➖➖➖ +Name: {name} +➖➖➖➖➖➖➖➖➖ +ID: {id} +Belongs To: {admin_username} +By: #{by} +""" diff --git a/app/operation/api_key.py b/app/operation/api_key.py index cb0d3619..61cf405d 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -9,9 +9,22 @@ delete_api_key, get_api_key_by_id, get_api_keys, + update_api_key, +) +from app.notification import ( + create_api_key as notify_create, + modify_api_key as notify_modify, + remove_api_key as notify_delete, ) from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse +from app.models.api_key import ( + APIKeyCreate, + APIKeyCreateResponse, + APIKeyResponse, + APIKeyUpdate, + APIKeysQuery, + APIKeysResponse, +) from app.operation import BaseOperation @@ -43,6 +56,7 @@ async def create_api_key( model=model, ) await db.commit() + await notify_create(APIKeyResponse.model_validate(db_key), admin.username, admin.username) except IntegrityError: await self.raise_error(message="API key already exists", code=409, db=db) @@ -70,6 +84,38 @@ async def list_api_keys(self, db: AsyncSession, *, admin: AdminDetails, query: A ) return APIKeysResponse(api_keys=[APIKeyResponse.model_validate(row) for row in rows], total=total) + async def modify_api_key( + self, db: AsyncSession, *, admin: AdminDetails, key_id: int, model: APIKeyUpdate + ) -> APIKeyResponse: + db_key = await get_api_key_by_id(db, key_id) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + + if not admin.is_owner and db_key.admin_id != admin.id: + await self.raise_error(message="Permission denied", code=403) + + if model.name is not None and model.name != db_key.name: + duplicates, _ = await get_api_keys(db, admin_id=db_key.admin_id, offset=0, limit=1, name=model.name) + if duplicates: + await self.raise_error(message="API key name already exists", code=409) + + if model.role_id is not None and model.role_id != db_key.role_id: + role = await get_role(db, model.role_id) + if role is None: + await self.raise_error(message="Role not found", code=404) + if not admin.is_owner and admin.role and role.id != admin.role.id: + await self.raise_error(message="Only owner can assign a different role to API keys", code=403) + + update_data = model.model_dump(exclude_unset=True) + db_key = await update_api_key(db, db_key, update_data) + await db.commit() + + api_key_resp = APIKeyResponse.model_validate(db_key) + admin_username = db_key.admin.username if db_key.admin else "Unknown" + await notify_modify(api_key_resp, admin_username, admin.username) + + return api_key_resp + async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: scope_admin_id = None if admin.is_owner else admin.id rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id) @@ -85,5 +131,9 @@ async def delete_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: if not admin.is_owner and db_key.admin_id != admin.id: await self.raise_error(message="Permission denied", code=403) + api_key_resp = APIKeyResponse.model_validate(db_key) + admin_username = db_key.admin.username if db_key.admin else "Unknown" + await delete_api_key(db, db_key) await db.commit() + await notify_delete(api_key_resp, admin_username, admin.username) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index d629c95e..4ae632f7 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -3,7 +3,14 @@ from app.db import AsyncSession, get_db from app.models.admin import AdminDetails -from app.models.api_key import APIKeyCreate, APIKeyCreateResponse, APIKeyResponse, APIKeysQuery, APIKeysResponse +from app.models.api_key import ( + APIKeyCreate, + APIKeyCreateResponse, + APIKeyResponse, + APIKeyUpdate, + APIKeysQuery, + APIKeysResponse, +) from app.operation import OperatorType from app.operation.api_key import APIKeyOperation from app.routers.dependencies import get_api_key_list_query @@ -43,6 +50,16 @@ async def list_api_keys( return await api_key_operator.list_api_keys(db, admin=admin, query=query) +@router.patch("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404, 409: responses._409}) +async def modify_api_key( + key_id: int, + model: APIKeyUpdate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "modify")), +): + return await api_key_operator.modify_api_key(db, admin=admin, key_id=key_id, model=model) + + @router.get("/{key_id}", response_model=APIKeyResponse, responses={404: responses._404}) async def get_api_key( key_id: int, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 8d03167e..4ca68d17 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -6,7 +6,8 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy import func, select -from app.db import AsyncSession, get_db +from aiocache import cached +from app.db import AsyncSession, GetDB, get_db from app.db.crud.admin import ( find_admins_by_telegram_id, get_admin as get_admin_by_username, @@ -109,6 +110,18 @@ async def _build_admin_metrics(db: AsyncSession, admin_id: int) -> tuple[int, in async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics: bool = False) -> AdminDetails | None: + return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) + + +@cached(ttl=60) +async def _get_admin_from_api_key_cached(raw_key: str, with_metrics: bool) -> AdminDetails | None: + async with GetDB() as db: + return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) + + +async def _get_admin_from_api_key_internal( + db: AsyncSession, raw_key: str, *, with_metrics: bool = False +) -> AdminDetails | None: if not raw_key: return @@ -225,7 +238,7 @@ async def _get_admin_from_request_credentials( if not admin: api_key = _extract_api_key(request) if api_key: - admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics) + admin = await _get_admin_from_api_key_cached(api_key, with_metrics) return admin diff --git a/app/utils/crypto.py b/app/utils/crypto.py index 1b1bb36c..86b2d945 100644 --- a/app/utils/crypto.py +++ b/app/utils/crypto.py @@ -103,7 +103,8 @@ def generate_wireguard_keypair() -> tuple[str, str]: API_KEY_HASH_VERSION = "v1" -API_KEY_HASH_ALGORITHM = "pbkdf2_sha256" +API_KEY_HMAC_ALGORITHM = "hmac_sha256" +API_KEY_PBKDF2_ALGORITHM = "pbkdf2_sha256" API_KEY_HASH_ITERATIONS = 310000 API_KEY_LOOKUP_BYTES = 16 @@ -113,31 +114,45 @@ def api_key_lookup_id(raw_api_key: str) -> str: return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") -def hash_api_key(raw_api_key: str) -> str: +def hash_api_key(raw_api_key: str, pepper: str = "") -> str: salt = os.urandom(16) - derived_key = hashlib.pbkdf2_hmac( - "sha256", - raw_api_key.encode("utf-8"), - salt, - API_KEY_HASH_ITERATIONS, - ) salt_b64 = base64.b64encode(salt).decode("ascii") - dk_b64 = base64.b64encode(derived_key).decode("ascii") lookup_id = api_key_lookup_id(raw_api_key) - return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HASH_ALGORITHM}${API_KEY_HASH_ITERATIONS}${salt_b64}${dk_b64}" + + # Use HMAC-SHA256 + key = pepper.encode("utf-8") + salt + h = hmac.new(key, raw_api_key.encode("utf-8"), hashlib.sha256) + hash_hex = h.hexdigest() + return f"{API_KEY_HASH_VERSION}${lookup_id}${API_KEY_HMAC_ALGORITHM}${salt_b64}${hash_hex}" -def verify_api_key(raw_api_key: str, stored_hash: str) -> bool: +def verify_api_key(raw_api_key: str, stored_hash: str, pepper: str = "") -> bool: parts = stored_hash.split("$") + + # Handle HMAC-SHA256 (regardless of v1/v2 prefix as long as algorithm matches) + if len(parts) == 5 and parts[2] == API_KEY_HMAC_ALGORITHM: + _, lookup_id, algorithm, salt_b64, hash_hex = parts + if not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): + return False + try: + salt = base64.b64decode(salt_b64, validate=True) + except ValueError, binascii.Error: + return False + + key = pepper.encode("utf-8") + salt + h = hmac.new(key, raw_api_key.encode("utf-8"), hashlib.sha256) + return hmac.compare_digest(h.hexdigest(), hash_hex) + + # Handle PBKDF2-SHA256 (Legacy) lookup_id: str | None = None - if len(parts) == 6 and parts[0] == API_KEY_HASH_VERSION: + if len(parts) == 6: _, lookup_id, algorithm, iterations_raw, salt_b64, dk_b64 = parts elif len(parts) == 4: algorithm, iterations_raw, salt_b64, dk_b64 = parts else: return False - if algorithm != API_KEY_HASH_ALGORITHM: + if algorithm != API_KEY_PBKDF2_ALGORITHM: return False if lookup_id is not None and not hmac.compare_digest(lookup_id, api_key_lookup_id(raw_api_key)): return False From 75f2070c517f3c8c792f2591b2c9410716af7f54 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 16:22:17 +0330 Subject: [PATCH 19/24] format code --- app/notification/__init__.py | 12 +++++++++--- app/notification/discord/__init__.py | 6 +++++- app/notification/telegram/__init__.py | 6 +++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/notification/__init__.py b/app/notification/__init__.py index f3ea01d0..26024dcd 100644 --- a/app/notification/__init__.py +++ b/app/notification/__init__.py @@ -16,17 +16,23 @@ async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): if (await notification_enable()).api_key.create: - await asyncio.gather(ds.create_api_key_ds(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by)) + await asyncio.gather( + ds.create_api_key_ds(api_key, admin_username, by), tg.create_api_key_tg(api_key, admin_username, by) + ) async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): if (await notification_enable()).api_key.modify: - await asyncio.gather(ds.modify_api_key_ds(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by)) + await asyncio.gather( + ds.modify_api_key_ds(api_key, admin_username, by), tg.modify_api_key_tg(api_key, admin_username, by) + ) async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): if (await notification_enable()).api_key.delete: - await asyncio.gather(ds.remove_api_key_ds(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by)) + await asyncio.gather( + ds.remove_api_key_ds(api_key, admin_username, by), tg.remove_api_key_tg(api_key, admin_username, by) + ) async def create_admin_role(role: AdminRoleResponse, by: str): diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py index afaeab63..9d34da7e 100644 --- a/app/notification/discord/__init__.py +++ b/app/notification/discord/__init__.py @@ -1,6 +1,10 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role -from .api_key import create_api_key as create_api_key_ds, modify_api_key as modify_api_key_ds, remove_api_key as remove_api_key_ds +from .api_key import ( + create_api_key as create_api_key_ds, + modify_api_key as modify_api_key_ds, + remove_api_key as remove_api_key_ds, +) from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py index f15ce7e3..3e139361 100644 --- a/app/notification/telegram/__init__.py +++ b/app/notification/telegram/__init__.py @@ -1,6 +1,10 @@ from .admin import admin_login, admin_reset_usage, admin_usage_limit_reached, create_admin, modify_admin, remove_admin from .admin_role import create_admin_role, modify_admin_role, remove_admin_role -from .api_key import create_api_key as create_api_key_tg, modify_api_key as modify_api_key_tg, remove_api_key as remove_api_key_tg +from .api_key import ( + create_api_key as create_api_key_tg, + modify_api_key as modify_api_key_tg, + remove_api_key as remove_api_key_tg, +) from .core import create_core, modify_core, remove_core from .group import create_group, modify_group, remove_group from .host import create_host, modify_host, modify_hosts, remove_host From efd3376ae97e0efde35e143b233c26d43e8a5f24 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 31 May 2026 16:26:41 +0330 Subject: [PATCH 20/24] fix import error --- app/notification/discord/api_key.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/notification/discord/api_key.py b/app/notification/discord/api_key.py index 8a1a2a65..dd3e2cc5 100644 --- a/app/notification/discord/api_key.py +++ b/app/notification/discord/api_key.py @@ -1,6 +1,6 @@ from app.models.api_key import APIKeyResponse from app.models.settings import NotificationSettings -from app.notification.client import send_discord_message +from app.notification.client import send_discord_webhook from app.notification.helpers import get_discord_webhook from app.settings import notification_settings @@ -26,7 +26,7 @@ async def create_api_key(api_key: APIKeyResponse, admin_username: str, by: str): settings: NotificationSettings = await notification_settings() if settings.notify_discord: webhook_url = get_discord_webhook(settings, ENTITY) - await send_discord_message(data, webhook_url) + await send_discord_webhook(data, webhook_url) async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): @@ -47,7 +47,7 @@ async def modify_api_key(api_key: APIKeyResponse, admin_username: str, by: str): settings: NotificationSettings = await notification_settings() if settings.notify_discord: webhook_url = get_discord_webhook(settings, ENTITY) - await send_discord_message(data, webhook_url) + await send_discord_webhook(data, webhook_url) async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): @@ -65,4 +65,4 @@ async def remove_api_key(api_key: APIKeyResponse, admin_username: str, by: str): settings: NotificationSettings = await notification_settings() if settings.notify_discord: webhook_url = get_discord_webhook(settings, ENTITY) - await send_discord_message(data, webhook_url) + await send_discord_webhook(data, webhook_url) From 0b5072caa5f41da8d72b1ab60cb4da62c58ebc99 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 7 Jun 2026 12:28:47 +0330 Subject: [PATCH 21/24] feat: Add ablity to revoke api keys --- app/db/crud/api_key.py | 15 +++- .../c9b48df42f10_add_api_keys_table.py | 7 +- app/db/models.py | 1 + app/models/api_key.py | 1 + app/operation/api_key.py | 33 +++++++++ app/routers/api_key.py | 9 +++ app/routers/authentication.py | 11 +-- tests/api/test_api_key.py | 71 +++++++++++++++++++ 8 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 tests/api/test_api_key.py diff --git a/app/db/crud/api_key.py b/app/db/crud/api_key.py index 19a1423b..91424c17 100644 --- a/app/db/crud/api_key.py +++ b/app/db/crud/api_key.py @@ -1,13 +1,13 @@ import uuid +from datetime import datetime as dt, timezone as tz -from aiocache import cached from sqlalchemy import func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.db.models import Admin, AdminStatus, APIKey, APIKeyStatus from app.models.api_key import APIKeyCreate -from app.utils.crypto import API_KEY_HASH_VERSION, api_key_lookup_id, hash_api_key, verify_api_key +from app.utils.crypto import api_key_lookup_id, hash_api_key, verify_api_key from app.utils.jwt import get_secret_key @@ -102,6 +102,17 @@ async def delete_api_key(db: AsyncSession, db_key: APIKey) -> None: await db.flush() +async def revoke_api_key(db: AsyncSession, db_key: APIKey) -> tuple[str, APIKey]: + raw_key = str(uuid.uuid4()) + pepper = await get_secret_key() + db_key.key_hash = hash_api_key(raw_key, pepper=pepper) + db_key.revoked_at = dt.now(tz.utc) + db_key.status = APIKeyStatus.active + await db.flush() + await db.refresh(db_key) + return raw_key, db_key + + async def update_api_key(db: AsyncSession, db_key: APIKey, update_data: dict) -> APIKey: for key, value in update_data.items(): setattr(db_key, key, value) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index 58f57a16..f0288fd8 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -60,13 +60,16 @@ def upgrade() -> None: sa.Column("role_id", app.db.compiles_types.SqliteCompatibleBigInteger(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("expire_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "status", - sa.Enum("active", "disabled" , name="apikeystatus"), + sa.Enum("active", "disabled", name="apikeystatus"), nullable=False, server_default="active", ), - sa.ForeignKeyConstraint(["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins"), ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["admin_id"], ["admins.id"], name=op.f("fk_api_keys_admin_id_admins"), ondelete="CASCADE" + ), sa.ForeignKeyConstraint(["role_id"], ["admin_roles.id"], name=op.f("fk_api_keys_role_id_admin_roles")), sa.PrimaryKeyConstraint("id", name=op.f("pk_api_keys")), sa.UniqueConstraint("key_hash", name=op.f("uq_api_keys_key_hash")), diff --git a/app/db/models.py b/app/db/models.py index 6605d383..feb4e6e4 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -882,6 +882,7 @@ class APIKey(Base, IdMixin, CreatedAtUTCMixin): role: Mapped["AdminRole"] = relationship(back_populates="api_keys", init=False, lazy="selectin") note: Mapped[Optional[str]] = mapped_column(String(512), default=None) expire_date: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) + revoked_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None) status: Mapped[APIKeyStatus] = mapped_column( SQLEnum(APIKeyStatus, name="apikeystatus", create_constraint=True), default=APIKeyStatus.active, diff --git a/app/models/api_key.py b/app/models/api_key.py index 1a5bda96..60658d1d 100644 --- a/app/models/api_key.py +++ b/app/models/api_key.py @@ -50,6 +50,7 @@ class APIKeyResponse(APIKeyBase): id: int admin_id: int created_at: dt + revoked_at: dt | None = None status: APIKeyStatus = APIKeyStatus.active is_expired: bool = False diff --git a/app/operation/api_key.py b/app/operation/api_key.py index 61cf405d..e6985c93 100644 --- a/app/operation/api_key.py +++ b/app/operation/api_key.py @@ -9,6 +9,7 @@ delete_api_key, get_api_key_by_id, get_api_keys, + revoke_api_key as revoke_api_key_crud, update_api_key, ) from app.notification import ( @@ -68,6 +69,9 @@ async def create_api_key( role_id=db_key.role_id, created_at=db_key.created_at, expire_date=db_key.expire_date, + revoked_at=db_key.revoked_at, + status=db_key.status, + is_expired=db_key.is_expired, api_key=raw_key, ) @@ -116,6 +120,35 @@ async def modify_api_key( return api_key_resp + async def revoke_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyCreateResponse: + db_key = await get_api_key_by_id(db, key_id) + if db_key is None: + await self.raise_error(message="API key not found", code=404) + + if not admin.is_owner and db_key.admin_id != admin.id: + await self.raise_error(message="Permission denied", code=403) + + raw_key, db_key = await revoke_api_key_crud(db, db_key) + await db.commit() + + api_key_resp = APIKeyResponse.model_validate(db_key) + admin_username = db_key.admin.username if db_key.admin else "Unknown" + await notify_modify(api_key_resp, admin_username, admin.username) + + return APIKeyCreateResponse( + id=db_key.id, + admin_id=db_key.admin_id, + name=db_key.name, + note=db_key.note, + role_id=db_key.role_id, + created_at=db_key.created_at, + expire_date=db_key.expire_date, + revoked_at=db_key.revoked_at, + status=db_key.status, + is_expired=db_key.is_expired, + api_key=raw_key, + ) + async def get_api_key(self, db: AsyncSession, *, admin: AdminDetails, key_id: int) -> APIKeyResponse: scope_admin_id = None if admin.is_owner else admin.id rows, _ = await get_api_keys(db, admin_id=scope_admin_id, offset=0, limit=1, key_id=key_id) diff --git a/app/routers/api_key.py b/app/routers/api_key.py index 4ae632f7..83f17fbd 100644 --- a/app/routers/api_key.py +++ b/app/routers/api_key.py @@ -69,6 +69,15 @@ async def get_api_key( return await api_key_operator.get_api_key(db, admin=admin, key_id=key_id) +@router.post("/{key_id}/revoke", response_model=APIKeyCreateResponse, responses={404: responses._404}) +async def revoke_api_key( + key_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(require_permission("api_keys", "delete")), +): + return await api_key_operator.revoke_api_key(db, admin=admin, key_id=key_id) + + @router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: responses._404}) async def remove_api_key( key_id: int, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 4ca68d17..1b9c5e8d 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -6,8 +6,7 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy import func, select -from aiocache import cached -from app.db import AsyncSession, GetDB, get_db +from app.db import AsyncSession, get_db from app.db.crud.admin import ( find_admins_by_telegram_id, get_admin as get_admin_by_username, @@ -113,12 +112,6 @@ async def get_admin_from_api_key(db: AsyncSession, raw_key: str, *, with_metrics return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) -@cached(ttl=60) -async def _get_admin_from_api_key_cached(raw_key: str, with_metrics: bool) -> AdminDetails | None: - async with GetDB() as db: - return await _get_admin_from_api_key_internal(db, raw_key, with_metrics=with_metrics) - - async def _get_admin_from_api_key_internal( db: AsyncSession, raw_key: str, *, with_metrics: bool = False ) -> AdminDetails | None: @@ -238,7 +231,7 @@ async def _get_admin_from_request_credentials( if not admin: api_key = _extract_api_key(request) if api_key: - admin = await _get_admin_from_api_key_cached(api_key, with_metrics) + admin = await get_admin_from_api_key(db, api_key, with_metrics=with_metrics) return admin diff --git a/tests/api/test_api_key.py b/tests/api/test_api_key.py new file mode 100644 index 00000000..add53e38 --- /dev/null +++ b/tests/api/test_api_key.py @@ -0,0 +1,71 @@ +import asyncio + +from fastapi import status +from sqlalchemy import select + +from app.db.models import APIKey +from tests.api import TestSession, client +from tests.api.helpers import auth_headers, create_admin, delete_admin, unique_name + + +def _login(username: str, password: str) -> str: + response = client.post( + "/api/admin/token", + data={"username": username, "password": password, "grant_type": "password"}, + ) + assert response.status_code == status.HTTP_200_OK + return response.json()["access_token"] + + +def _api_key_state(key_id: int) -> tuple[str | None, str]: + async def _get_state(): + async with TestSession() as session: + result = await session.execute(select(APIKey).where(APIKey.id == key_id)) + db_key = result.scalar_one() + revoked_at = db_key.revoked_at.isoformat() if db_key.revoked_at else None + return revoked_at, db_key.status.value + + return asyncio.run(_get_state()) + + +def test_revoke_api_key_rotates_secret_and_blocks_old_key(access_token): + admin = create_admin(access_token, role_id=2) + admin_token = _login(admin["username"], admin["password"]) + + try: + create_response = client.post( + "/api/api_key", + headers=auth_headers(admin_token), + json={"name": unique_name("api_key"), "role_id": 2}, + ) + assert create_response.status_code == status.HTTP_201_CREATED + created = create_response.json() + raw_api_key = created["api_key"] + assert created["revoked_at"] is None + assert created["status"] == "active" + + auth_response = client.get("/api/admin", headers={"X-Api-Key": raw_api_key}) + assert auth_response.status_code == status.HTTP_200_OK + assert auth_response.json()["username"] == admin["username"] + + revoke_response = client.post(f"/api/api_key/{created['id']}/revoke", headers=auth_headers(admin_token)) + assert revoke_response.status_code == status.HTTP_200_OK + revoked = revoke_response.json() + new_api_key = revoked["api_key"] + assert new_api_key != raw_api_key + assert revoked["id"] == created["id"] + assert revoked["revoked_at"] is not None + assert revoked["status"] == "active" + + db_revoked_at, db_status = _api_key_state(created["id"]) + assert db_revoked_at is not None + assert db_status == "active" + + revoked_auth_response = client.get("/api/admin", headers={"X-Api-Key": raw_api_key}) + assert revoked_auth_response.status_code == status.HTTP_401_UNAUTHORIZED + + new_auth_response = client.get("/api/admin", headers={"X-Api-Key": new_api_key}) + assert new_auth_response.status_code == status.HTTP_200_OK + assert new_auth_response.json()["username"] == admin["username"] + finally: + delete_admin(access_token, admin["username"]) From 2516c9b67400ef90e9502a212b9dad7c66eabc1f Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 8 Jun 2026 19:59:40 +0330 Subject: [PATCH 22/24] fix igration id --- app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py index f0288fd8..4a7a7b6a 100644 --- a/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py +++ b/app/db/migrations/versions/c9b48df42f10_add_api_keys_table.py @@ -1,7 +1,7 @@ """add api keys table Revision ID: c9b48df42f10 -Revises: 2c6e9d34a1f0 +Revises: f9c69a49f544 Create Date: 2026-05-25 00:00:00.000000 """ @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "c9b48df42f10" -down_revision = "2c6e9d34a1f0" +down_revision = "f9c69a49f544" branch_labels = None depends_on = None From 9dd06498b2155346dfcecfc13aa1c28516500023 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 8 Jun 2026 20:34:14 +0330 Subject: [PATCH 23/24] fix migration error --- app/db/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/models.py b/app/db/models.py index cbc77d97..38f8d74d 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -902,7 +902,7 @@ class APIKeyStatus(str, Enum): disabled = "disabled" -class APIKey(Base, IdMixin, CreatedAtUTCMixin): +class APIKey(Base, CreatedAtUTCMixin): __tablename__ = "api_keys" __table_args__ = ( UniqueConstraint("key_hash"), From 4297559f2bda38f46a03ffb78bfc4d4d83b6eeef Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 8 Jun 2026 20:40:33 +0330 Subject: [PATCH 24/24] format code and fix ruff error --- app/operation/user.py | 3 +-- app/routers/authentication.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/operation/user.py b/app/operation/user.py index 09780041..add2777c 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -358,8 +358,7 @@ async def _persist_bulk_users( public_key, usernames = duplicate_key await self.raise_error( message=( - f"wireguard public_key {public_key} is assigned to multiple new users: " - f"{', '.join(usernames[:2])}" + f"wireguard public_key {public_key} is assigned to multiple new users: {', '.join(usernames[:2])}" ), code=400, db=db, diff --git a/app/routers/authentication.py b/app/routers/authentication.py index 4f7c0ec1..92668c68 100644 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -108,9 +108,9 @@ async def _get_admin_from_api_key_internal( if with_metrics: total_users, reseted_usage = await _build_admin_metrics(db, db_admin.id) - admin = _build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) + admin = build_admin_details(db_admin, total_users=total_users, reseted_usage=reseted_usage) else: - admin = _build_admin_details(db_admin) + admin = build_admin_details(db_admin) if db_key.role is not None: admin.role = AdminRoleData.model_validate(db_key.role)