Compare commits

..

20 commits
0.5.0 ... main

Author SHA1 Message Date
Brian Wiborg
b5691f3133
🔖 0.6.2 2025-11-05 21:37:34 +01:00
Brian Wiborg
5f80a7a86f
🐛 Fix model-free apps and middleware installer 2025-11-05 21:29:25 +01:00
Brian Wiborg
b588ebcf8a
🚑️ Remove roles claim 2025-10-28 14:45:18 +01:00
Brian Wiborg
a9b88d87d6
🔖 0.6.0 2025-10-28 14:40:29 +01:00
Brian Wiborg
7163fe778e
♻️ Refactor ohmyapi_auth
- remove Group and UserGroups
  (should be handled by dedicated app, if even)
- enforce User.Schema() include-fields
2025-10-28 14:39:42 +01:00
Brian Wiborg
458ffc6b2c
🔖 0.5.6 2025-10-27 11:13:02 +01:00
Brian Wiborg
22ca522615
🐛 Catch invalid user refresh 2025-10-27 11:03:12 +01:00
Brian Wiborg
8c2cf01f40
🔖 0.5.5 2025-10-27 10:47:05 +01:00
Brian Wiborg
9d2e284da3
🐛 Strict proxy-table field naming
This worked in SQlite3, but threw when using PostgreSQL.
2025-10-27 10:45:19 +01:00
Brian Wiborg
ed30291a4c
🔖 0.5.4 2025-10-26 21:49:13 +01:00
Brian Wiborg
31f4da773c
⬆️ Upgrade deps 2025-10-26 21:48:48 +01:00
Brian Wiborg
715b7a030a
🐛 Createsuperuser in single asyncio task 2025-10-26 21:47:03 +01:00
Brian Wiborg
4a5bafd889
🔖 0.5.3 2025-10-25 11:18:07 +02:00
Brian Wiborg
b50cbc4341
🐛 Fix /auth/refresh 2025-10-25 11:17:05 +02:00
Brian Wiborg
10681cc15b
🚚 Move hmac_hash() to ohmyapi_auth.utils 2025-10-24 20:54:16 +02:00
Brian Wiborg
58f1387aaf
🔖 0.5.2 2025-10-22 11:26:50 +02:00
Brian Wiborg
6b87bfeefb
🐛 email_hash is not optional 2025-10-22 11:25:40 +02:00
Brian Wiborg
812e89ede9
🔖 0.5.1 2025-10-11 13:31:28 +02:00
Brian Wiborg
e25c9d1715
🩹 Define explicit user_groups proxy-table 2025-10-11 13:30:46 +02:00
Brian Wiborg
d494396728
✏️ Fix typo 2025-10-11 13:12:48 +02:00
11 changed files with 187 additions and 66 deletions

View file

@ -1,7 +1,7 @@
# Apps
Apps are a way to group database models and API routes that contextually belong together.
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` and `Group` model and provides API endpoints for JWT authentication.
For example, OhMyAPI comes bundled with an `auth` app that carries a `User` model and provides API endpoints for JWT authentication.
Apps help organizing projects by isolating individual components (or "features") from one another.

View file

@ -40,6 +40,6 @@ router = APIRouter()
@router.get("/")
def get(user: auth.User = Depends(permissions.required_authenticated)):
def get(user: auth.User = Depends(permissions.require_authenticated)):
...
```

113
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "aerich"
@ -62,7 +62,7 @@ version = "4.11.0"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "dev"]
files = [
{file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"},
{file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"},
@ -216,7 +216,7 @@ version = "2025.8.3"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
groups = ["main", "dev"]
files = [
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
@ -543,19 +543,66 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "dev"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
groups = ["main"]
groups = ["main", "dev"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
@ -564,6 +611,18 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "ipython"
version = "9.6.0"
@ -1024,6 +1083,22 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.14.1)"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@ -1260,6 +1335,28 @@ files = [
{file = "pypika_tortoise-0.6.2.tar.gz", hash = "sha256:f95ab59d9b6454db2e8daa0934728458350a1f3d56e81d9d1debc8eebeff26b3"},
]
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1488,7 +1585,7 @@ version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["main"]
groups = ["main", "dev"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
@ -1604,7 +1701,7 @@ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
markers = {dev = "python_version == \"3.11\""}
markers = {dev = "python_version < \"3.13\""}
[[package]]
name = "typing-inspection"
@ -1719,4 +1816,4 @@ auth = ["argon2-cffi", "crypto", "passlib", "pyjwt", "python-multipart"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "3d301460081dada359d425d69feefc63c1e5135aa64b6f000f554bfc1231febd"
content-hash = "cc1604995d3b73ee302e63731dd300ea17c4d95d0cfc6c386626dd9a9f60e8a7"

View file

@ -1,6 +1,6 @@
[project]
name = "ohmyapi"
version = "0.5.0"
version = "0.6.2"
description = "Django-flavored scaffolding and management layer around FastAPI, Pydantic, TortoiseORM and Aerich migrations"
license = "MIT"
keywords = ["fastapi", "tortoise", "orm", "pydantic", "async", "web-framework"]
@ -30,6 +30,8 @@ ipython = ">=9.5.0,<10.0.0"
black = "^25.9.0"
isort = "^6.0.1"
mkdocs = "^1.6.1"
pytest = "^8.4.2"
httpx = "^0.28.1"
[project.optional-dependencies]
auth = ["passlib", "pyjwt", "crypto", "argon2-cffi", "python-multipart"]

View file

@ -1 +1 @@
__VERSION__ = "0.5.0"
__VERSION__ = "0.6.2"

View file

@ -1,53 +1,39 @@
import hmac
import hashlib
import base64
from functools import wraps
from secrets import token_bytes
from typing import List, Optional
from uuid import UUID
from passlib.context import CryptContext
from tortoise.contrib.pydantic import pydantic_queryset_creator
from ohmyapi.db import Model, field, Q
from ohmyapi.router import HTTPException
import settings
from .utils import hmac_hash
from datetime import datetime
from passlib.context import CryptContext
from typing import Optional
from uuid import UUID
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key")
def hmac_hash(data: str) -> str:
digest = hmac.new(SECRET_KEY.encode("UTF-8"), data.encode("utf-8"), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8")
class Group(Model):
id: UUID = field.data.UUIDField(pk=True)
name: str = field.CharField(max_length=42, index=True)
def __str__(self):
return self.name if self.name else ""
class User(Model):
id: UUID = field.data.UUIDField(pk=True)
username: str = field.CharField(max_length=150, unique=True)
email_hash: Optional[str] = field.CharField(max_length=255, unique=True, index=True)
email_hash: str = field.CharField(max_length=255, unique=True, index=True)
password_hash: str = field.CharField(max_length=128)
is_admin: bool = field.BooleanField(default=False)
is_staff: bool = field.BooleanField(default=False)
groups: field.ManyToManyRelation[Group] = field.ManyToManyField(
"ohmyapi_auth.Group", related_name="users", through="usergroups"
)
created_at: datetime = field.DatetimeField(auto_now_add=True)
updated_at: datetime = field.DatetimeField(auto_now=True)
class Schema:
exclude = ["password_hash", "email_hash"]
include = {
"id",
"username",
"is_admin",
"is_staff"
"created_at",
"updated_at",
}
def __str__(self):
fields = {
'username': self.username if self.username else "-",
'username': self.username,
'is_admin': 'y' if self.is_admin else 'n',
'is_staff': 'y' if self.is_staff else 'n',
}

View file

@ -6,8 +6,9 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request, st
from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import BaseModel
from tortoise.exceptions import DoesNotExist
from ohmyapi.builtin.auth.models import Group, User
from ohmyapi.builtin.auth.models import User
import jwt
import settings
@ -52,7 +53,6 @@ class Claims(BaseModel):
type: str
sub: str
user: ClaimsUser
roles: List[str]
exp: str
@ -79,7 +79,7 @@ class TokenType(str, Enum):
refresh = "refresh"
def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claims:
def claims(token_type: TokenType, user: User = []) -> Claims:
return Claims(
type=token_type,
sub=str(user.id),
@ -88,7 +88,6 @@ def claims(token_type: TokenType, user: User, groups: List[Group] = []) -> Claim
is_admin=user.is_admin,
is_staff=user.is_staff,
),
roles=[g.name for g in groups],
exp="",
)
@ -200,17 +199,25 @@ async def login(form_data: LoginRequest = Body(...)):
)
class TokenRefresh(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=AccessToken)
async def refresh_token(refresh_token: str):
async def refresh_token(refresh_token: TokenRefresh = Body(...)):
"""Exchange refresh token for new access token."""
payload = decode_token(refresh_token)
payload = decode_token(refresh_token.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
user_id = payload.get("sub")
user = await User.filter(id=user_id).first()
try:
user = await User.get(id=user_id)
except DoesNotExist:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
@ -219,7 +226,7 @@ async def refresh_token(refresh_token: str):
new_access = create_token(
claims(TokenType.access, user), ACCESS_TOKEN_EXPIRE_SECONDS
)
return AccessToken(token_type="bearer", access_token=access_token)
return AccessToken(token_type="bearer", access_token=new_access)
@router.get("/introspect", response_model=Dict[str, Any])

View file

@ -0,0 +1,17 @@
import base64
import hashlib
import hmac
import settings
SECRET_KEY = getattr(settings, "SECRET_KEY", "OhMyAPI Secret Key")
def hmac_hash(data: str) -> str:
digest = hmac.new(
SECRET_KEY.encode("UTF-8"),
data.encode("utf-8"),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8")

View file

@ -165,6 +165,13 @@ def createsuperuser(root: str = "."):
)
user.set_email(email)
user.set_password(password1)
asyncio.run(project.init_orm())
asyncio.run(user.save())
asyncio.run(project.close_orm())
async def _run():
await project.init_orm()
user = ohmyapi_auth.models.User(username=username, is_staff=True, is_admin=True)
user.set_email(email)
user.set_password(password1)
await user.save()
await project.close_orm()
asyncio.run(_run())

View file

@ -342,9 +342,9 @@ class App:
except ModuleNotFoundError:
return
getter = getattr(mod, "get", None)
if getter is not None:
for middleware in getter():
installer = getattr(mod, "install", None)
if installer is not None:
for middleware in installer():
self._middlewares.append(middleware)
def __serialize_route(self, route):
@ -404,10 +404,15 @@ class App:
"""
Convenience method for serializing the runtime data.
"""
# An app may come without any models
models = []
if f"{self.name}.models" in self._models:
models = [
f"{self.name}.{m.__name__}"
for m in self._models[f"{self.name}.models"]
]
return {
"models": [
f"{self.name}.{m.__name__}" for m in self._models[f"{self.name}.models"]
],
"models": models,
"middlewares": self.__serialize_middleware(),
"routes": self.__serialize_router(),
}

View file

@ -15,12 +15,12 @@ CORS_CONFIG: Dict[str, Any] = getattr(settings, "MIDDLEWARE_CORS", {})
if not isinstance(CORS_CONFIG, dict):
raise ValueError("MIDDLEWARE_CORS must be of type dict")
middleware = [
(CORSMiddleware, {
middleware = (
CORSMiddleware,
{
"allow_origins": CORS_CONFIG.get("ALLOW_ORIGINS", DEFAULT_ORIGINS),
"allow_credentials": CORS_CONFIG.get("ALLOW_CREDENTIALS", DEFAULT_CREDENTIALS),
"allow_methods": CORS_CONFIG.get("ALLOW_METHODS", DEFAULT_METHODS),
"allow_headers": CORS_CONFIG.get("ALLOW_HEADERS", DEFAULT_HEADERS),
}),
]
}
)