From 1001e0f7f977088a15979f6fa56799c7cb90c876 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 15:18:31 +0700 Subject: [PATCH 01/14] Add auth settings --- src/app/core/config.py | 10 ++++++++++ src/app/core/setup.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/app/core/config.py b/src/app/core/config.py index c031243..6f3023f 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -141,6 +141,15 @@ class CORSSettings(BaseSettings): CORS_HEADERS: list[str] = ["*"] +class AuthSettings(BaseSettings): + ENABLE_LOCAL_AUTH: bool = True + GOOGLE_CLIENT_ID: str | None = None + GOOGLE_CLIENT_SECRET: str | None = None + MICROSOFT_CLIENT_ID: str | None = None + MICROSOFT_CLIENT_SECRET: str | None = None + MICROSOFT_TENANT: str | None = None + + class Settings( AppSettings, PostgresSettings, @@ -155,6 +164,7 @@ class Settings( CRUDAdminSettings, EnvironmentSettings, CORSSettings, + AuthSettings, ): model_config = SettingsConfigDict( env_file=os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", ".env"), diff --git a/src/app/core/setup.py b/src/app/core/setup.py index b2cdcbf..766eae6 100644 --- a/src/app/core/setup.py +++ b/src/app/core/setup.py @@ -18,6 +18,7 @@ from ..models import * # noqa: F403 from .config import ( AppSettings, + AuthSettings, ClientSideCacheSettings, CORSSettings, DatabaseSettings, @@ -86,6 +87,7 @@ def lifespan_factory( | RedisQueueSettings | RedisRateLimiterSettings | EnvironmentSettings + | AuthSettings ), create_tables_on_start: bool = True, ) -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]: @@ -142,6 +144,7 @@ def create_application( | RedisQueueSettings | RedisRateLimiterSettings | EnvironmentSettings + | AuthSettings ), create_tables_on_start: bool = True, lifespan: Callable[[FastAPI], _AsyncGeneratorContextManager[Any]] | None = None, From e5946f9b111f433e4c2e95137b3d3f6d3d4bc606 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 16:07:46 +0700 Subject: [PATCH 02/14] Make password auth optional based on environment variable --- src/app/api/v1/login.py | 42 +++++++++++++++++++---------------------- src/app/core/config.py | 2 +- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/app/api/v1/login.py b/src/app/api/v1/login.py index e784731..5303463 100644 --- a/src/app/api/v1/login.py +++ b/src/app/api/v1/login.py @@ -1,4 +1,3 @@ -from datetime import timedelta from typing import Annotated from fastapi import APIRouter, Depends, Request, Response @@ -10,7 +9,6 @@ from ...core.exceptions.http_exceptions import UnauthorizedException from ...core.schemas import Token from ...core.security import ( - ACCESS_TOKEN_EXPIRE_MINUTES, TokenType, authenticate_user, create_access_token, @@ -21,27 +19,25 @@ router = APIRouter(tags=["login"]) -@router.post("/login", response_model=Token) -async def login_for_access_token( - response: Response, - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - db: Annotated[AsyncSession, Depends(async_get_db)], -) -> dict[str, str]: - user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db) - if not user: - raise UnauthorizedException("Wrong username, email or password.") - - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = await create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires) - - refresh_token = await create_refresh_token(data={"sub": user["username"]}) - max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 - - response.set_cookie( - key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age - ) - - return {"access_token": access_token, "token_type": "bearer"} +if settings.ENABLE_PASSWORD_AUTH: + + @router.post("/login", response_model=Token) + async def login_with_password( + response: Response, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[AsyncSession, Depends(async_get_db)], + ) -> dict[str, str]: + user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db) + if not user: + raise UnauthorizedException("Wrong username, email or password.") + + access_token = await create_access_token(data={"sub": user["username"]}) + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age + ) + return {"access_token": access_token, "token_type": "bearer"} @router.post("/refresh") diff --git a/src/app/core/config.py b/src/app/core/config.py index 6f3023f..2bca7c7 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -142,7 +142,7 @@ class CORSSettings(BaseSettings): class AuthSettings(BaseSettings): - ENABLE_LOCAL_AUTH: bool = True + ENABLE_PASSWORD_AUTH: bool = True GOOGLE_CLIENT_ID: str | None = None GOOGLE_CLIENT_SECRET: str | None = None MICROSOFT_CLIENT_ID: str | None = None From 6a043eee49b6e0878dfc80d031d8ff3de8c793e5 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 16:10:47 +0700 Subject: [PATCH 03/14] Add environment variables to the .env file example --- scripts/local_with_uvicorn/.env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/local_with_uvicorn/.env.example b/scripts/local_with_uvicorn/.env.example index 9f3e5f4..b913cfa 100644 --- a/scripts/local_with_uvicorn/.env.example +++ b/scripts/local_with_uvicorn/.env.example @@ -72,3 +72,11 @@ ENVIRONMENT="local" # ------------- first tier ------------- TIER_NAME="free" + +# ------------- auth settings ------------- +# ENABLE_PASSWORD_AUTH=true +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# MICROSOFT_CLIENT_ID= +# MICROSOFT_CLIENT_SECRET= +# MICROSOFT_TENANT= From 3b3421366fd39152d1c5f16779e0af871e5573ed Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 21:08:36 +0700 Subject: [PATCH 04/14] Add oauth for Google and Microsoft --- src/app/api/v1/__init__.py | 6 +- src/app/api/v1/oauth.py | 126 +++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/oauth.py diff --git a/src/app/api/v1/__init__.py b/src/app/api/v1/__init__.py index 7575848..823fa14 100644 --- a/src/app/api/v1/__init__.py +++ b/src/app/api/v1/__init__.py @@ -3,6 +3,7 @@ from .health import router as health_router from .login import router as login_router from .logout import router as logout_router +from .oauth import router as oauth_router from .posts import router as posts_router from .rate_limits import router as rate_limits_router from .tasks import router as tasks_router @@ -13,8 +14,9 @@ router.include_router(health_router) router.include_router(login_router) router.include_router(logout_router) -router.include_router(users_router) +router.include_router(oauth_router) router.include_router(posts_router) +router.include_router(rate_limits_router) router.include_router(tasks_router) router.include_router(tiers_router) -router.include_router(rate_limits_router) +router.include_router(users_router) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py new file mode 100644 index 0000000..36d6e20 --- /dev/null +++ b/src/app/api/v1/oauth.py @@ -0,0 +1,126 @@ +import secrets +from abc import ABC +from typing import Any + +from fastapi import APIRouter, Depends, Request, Response +from fastapi_sso.sso.base import OpenID, SSOBase +from fastapi_sso.sso.google import GoogleSSO +from fastapi_sso.sso.microsoft import MicrosoftSSO +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.config import settings +from ...core.db.database import async_get_db +from ...core.exceptions.http_exceptions import UnauthorizedException +from ...core.security import ( + create_access_token, + create_refresh_token, +) +from ...crud.crud_users import crud_users +from ...schemas.user import UserCreate, UserRead +from .users import write_user + +router = APIRouter(tags=["login", "oauth"]) + + +class BaseOAuthProvider(ABC): + provider_config: dict[str, Any] + sso_provider: type[SSOBase] + + def __init__(self, router: Any): + self.router = router + self.provider_name: str = self.sso_provider.provider + if self.is_enabled: + self.sso = self.sso_provider(redirect_uri=self.redirect_uri, **self.provider_config) + tag = f"{self.sso_provider.provider.title()} OAuth" + self.router.add_api_route( + f"/login/{self.provider_name}", + self._login_handler, + methods=["GET"], + tags=[tag], + summary=f"Login with {self.provider_name.title()} OAuth", + ) + self.router.add_api_route( + f"/callback/{self.provider_name}", + self._callback_handler, + methods=["GET"], + tags=[tag], + summary=f"Callback for {self.provider_name.title()} OAuth", + ) + + @property + def redirect_uri(self) -> str: + return f"{settings.APP_BACKEND_HOST}/api/v1/callback/{self.provider_name}" + + @property + def is_enabled(self) -> bool: + return all(self.provider_config.values()) + + async def _create_and_set_token(self, response: Response, user: dict[str, Any]) -> str: + access_token = await create_access_token(data={"sub": user["username"]}) + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age + ) + return access_token + + async def _login_handler(self): + async with self.sso: + return await self.sso.get_login_redirect() + + async def _callback_handler(self, request: Request, response: Response, db: AsyncSession = Depends(async_get_db)): + async with self.sso: + oauth_user: OpenID | None = await self.sso.verify_and_process(request) + if not oauth_user or not oauth_user.email: + raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") + + db_user = await crud_users.get(db=db, email=oauth_user.email, is_deleted=False, schema_to_select=UserRead) + if not db_user: + user_create = await self._get_user_details(oauth_user) + db_user = await write_user(request=request, user=user_create, db=db) + + access_token = await self._create_and_set_token(response, db_user) + return {"access_token": access_token, "token_type": "bearer"} + + async def _get_user_details(self, oauth_user: OpenID) -> UserCreate: + """Get user details from the OAuth provider response. + + The exact details exposed by the OpenID class can be found here: + https://github.com/tomasvotava/fastapi-sso/blob/master/fastapi_sso/sso/base.py#L64 + """ + if not oauth_user.email: + raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") + username = oauth_user.email.split("@")[0] + name = oauth_user.display_name or username + + # Create a random password for OAuth users. + # It can still be changed if the user requests login with password. + random_password = secrets.token_urlsafe(32) + return UserCreate( + email=oauth_user.email, + name=name, + password=random_password, + username=username, + ) + + +class GoogleOAuthProvider(BaseOAuthProvider): + sso_provider = GoogleSSO + provider_config = { + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + } + + +# TODO: There is a bug in fastapi-sso, it does not return the email address +class MicrosoftOAuthProvider(BaseOAuthProvider): + sso_provider = MicrosoftSSO + provider_config = { + "client_id": settings.MICROSOFT_CLIENT_ID, + "client_secret": settings.MICROSOFT_CLIENT_SECRET, + "tenant": settings.MICROSOFT_TENANT, + } + + +GoogleOAuthProvider(router) +MicrosoftOAuthProvider(router) From 7c98abfc3bdd38de162a1d7fd179e0ddfa6cdbdf Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 23:40:46 +0700 Subject: [PATCH 05/14] Add microsoft and github oauth --- scripts/local_with_uvicorn/.env.example | 2 ++ src/app/api/v1/oauth.py | 11 ++++++++++- src/app/core/config.py | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/local_with_uvicorn/.env.example b/scripts/local_with_uvicorn/.env.example index b913cfa..b3be8b3 100644 --- a/scripts/local_with_uvicorn/.env.example +++ b/scripts/local_with_uvicorn/.env.example @@ -80,3 +80,5 @@ TIER_NAME="free" # MICROSOFT_CLIENT_ID= # MICROSOFT_CLIENT_SECRET= # MICROSOFT_TENANT= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 36d6e20..cbf58c4 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Request, Response from fastapi_sso.sso.base import OpenID, SSOBase +from fastapi_sso.sso.github import GithubSSO from fastapi_sso.sso.google import GoogleSSO from fastapi_sso.sso.microsoft import MicrosoftSSO from sqlalchemy.ext.asyncio import AsyncSession @@ -112,7 +113,6 @@ class GoogleOAuthProvider(BaseOAuthProvider): } -# TODO: There is a bug in fastapi-sso, it does not return the email address class MicrosoftOAuthProvider(BaseOAuthProvider): sso_provider = MicrosoftSSO provider_config = { @@ -122,5 +122,14 @@ class MicrosoftOAuthProvider(BaseOAuthProvider): } +class GithubSSOProvider(BaseOAuthProvider): + sso_provider = GithubSSO + provider_config = { + "client_id": settings.GITHUB_CLIENT_ID, + "client_secret": settings.GITHUB_CLIENT_SECRET, + } + + GoogleOAuthProvider(router) MicrosoftOAuthProvider(router) +GithubSSOProvider(router) diff --git a/src/app/core/config.py b/src/app/core/config.py index 2bca7c7..5693c52 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -148,6 +148,8 @@ class AuthSettings(BaseSettings): MICROSOFT_CLIENT_ID: str | None = None MICROSOFT_CLIENT_SECRET: str | None = None MICROSOFT_TENANT: str | None = None + GITHUB_CLIENT_ID: str | None = None + GITHUB_CLIENT_SECRET: str | None = None class Settings( From db727a320bfe112f57a7980c80b26839cbfdbe51 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Sun, 23 Nov 2025 10:46:44 +0700 Subject: [PATCH 06/14] Add warning message about mixing password auth with oauth --- src/app/api/v1/oauth.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index cbf58c4..0c9ed87 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -1,3 +1,4 @@ +import logging import secrets from abc import ABC from typing import Any @@ -21,6 +22,7 @@ from .users import write_user router = APIRouter(tags=["login", "oauth"]) +logger = logging.getLogger(__name__) class BaseOAuthProvider(ABC): @@ -54,7 +56,14 @@ def redirect_uri(self) -> str: @property def is_enabled(self) -> bool: - return all(self.provider_config.values()) + is_enabled = all(self.provider_config.values()) + if settings.ENABLE_PASSWORD_AUTH and is_enabled: + logger.warning( + f"Both password authentication and {self.provider_name} OAuth are enabled. " + "For enterprise or B2B deployments, it is recommended to disable password authentication " + "by setting ENABLE_PASSWORD_AUTH=false and relying solely on OAuth." + ) + return is_enabled async def _create_and_set_token(self, response: Response, user: dict[str, Any]) -> str: access_token = await create_access_token(data={"sub": user["username"]}) From 325e89a7822647f0883092642adfa73a7f8a9963 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Mon, 24 Nov 2025 18:23:47 +0800 Subject: [PATCH 07/14] Allow Oauth user to have a null password and deny password auth when user password i null/None --- src/app/api/v1/oauth.py | 18 +++++++----------- src/app/api/v1/users.py | 21 +++++++++++++++------ src/app/core/security.py | 2 +- src/app/models/user.py | 2 +- src/app/schemas/user.py | 4 +++- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 0c9ed87..c711820 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -1,5 +1,4 @@ import logging -import secrets from abc import ABC from typing import Any @@ -18,8 +17,8 @@ create_refresh_token, ) from ...crud.crud_users import crud_users -from ...schemas.user import UserCreate, UserRead -from .users import write_user +from ...schemas.user import UserCreateInternal, UserRead +from .users import write_user_internal router = APIRouter(tags=["login", "oauth"]) logger = logging.getLogger(__name__) @@ -86,13 +85,13 @@ async def _callback_handler(self, request: Request, response: Response, db: Asyn db_user = await crud_users.get(db=db, email=oauth_user.email, is_deleted=False, schema_to_select=UserRead) if not db_user: - user_create = await self._get_user_details(oauth_user) - db_user = await write_user(request=request, user=user_create, db=db) + user = await self._get_user_details(oauth_user) + db_user = await write_user_internal(user=user, db=db) access_token = await self._create_and_set_token(response, db_user) return {"access_token": access_token, "token_type": "bearer"} - async def _get_user_details(self, oauth_user: OpenID) -> UserCreate: + async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal: """Get user details from the OAuth provider response. The exact details exposed by the OpenID class can be found here: @@ -103,14 +102,11 @@ async def _get_user_details(self, oauth_user: OpenID) -> UserCreate: username = oauth_user.email.split("@")[0] name = oauth_user.display_name or username - # Create a random password for OAuth users. - # It can still be changed if the user requests login with password. - random_password = secrets.token_urlsafe(32) - return UserCreate( + return UserCreateInternal( email=oauth_user.email, name=name, - password=random_password, username=username, + hashed_password=None, # No password since OAuth is used ) diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 60264cc..300c018 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...api.dependencies import get_current_superuser, get_current_user +from ...core.config import settings from ...core.db.database import async_get_db from ...core.exceptions.http_exceptions import DuplicateValueException, ForbiddenException, NotFoundException from ...core.security import blacklist_token, get_password_hash, oauth2_scheme @@ -17,10 +18,17 @@ router = APIRouter(tags=["users"]) -@router.post("/user", response_model=UserRead, status_code=201) -async def write_user( - request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)] -) -> dict[str, Any]: +if settings.ENABLE_PASSWORD_AUTH: + + @router.post("/user", response_model=UserRead, status_code=201) + async def write_user( + request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)] + ) -> dict[str, Any]: + created_user = await write_user_internal(user=user, db=db) + return created_user + + +async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSession) -> dict[str, Any]: email_row = await crud_users.exists(db=db, email=user.email) if email_row: raise DuplicateValueException("Email is already registered") @@ -30,8 +38,9 @@ async def write_user( raise DuplicateValueException("Username not available") user_internal_dict = user.model_dump() - user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) - del user_internal_dict["password"] + if isinstance(user, UserCreate): + user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) + del user_internal_dict["password"] user_internal = UserCreateInternal(**user_internal_dict) created_user = await crud_users.create(db=db, object=user_internal, schema_to_select=UserRead) diff --git a/src/app/core/security.py b/src/app/core/security.py index d589078..d77f13f 100644 --- a/src/app/core/security.py +++ b/src/app/core/security.py @@ -45,7 +45,7 @@ async def authenticate_user(username_or_email: str, password: str, db: AsyncSess if not db_user: return False - if not await verify_password(password, db_user["hashed_password"]): + if db_user["hashed_password"] is None or not await verify_password(password, db_user["hashed_password"]): return False return db_user diff --git a/src/app/models/user.py b/src/app/models/user.py index 07cca2d..d8f1170 100644 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -17,7 +17,7 @@ class User(Base): name: Mapped[str] = mapped_column(String(30)) username: Mapped[str] = mapped_column(String(20), unique=True, index=True) email: Mapped[str] = mapped_column(String(50), unique=True, index=True) - hashed_password: Mapped[str] = mapped_column(String) + hashed_password: Mapped[str | None] = mapped_column(String, nullable=True) profile_image_url: Mapped[str] = mapped_column(String, default="https://profileimageurl.com") uuid: Mapped[uuid_pkg.UUID] = mapped_column(UUID(as_uuid=True), default_factory=uuid7, unique=True) diff --git a/src/app/schemas/user.py b/src/app/schemas/user.py index c33a94e..6303168 100644 --- a/src/app/schemas/user.py +++ b/src/app/schemas/user.py @@ -36,7 +36,9 @@ class UserCreate(UserBase): class UserCreateInternal(UserBase): - hashed_password: str + model_config = ConfigDict(extra="forbid") + + hashed_password: str | None class UserUpdate(BaseModel): From 300ec9f80f13df3e3745a2d359c1927ca7fb2278 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 15:54:12 +0800 Subject: [PATCH 08/14] Solve conflicts and sync uv.lock file --- pyproject.toml | 1 + uv.lock | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5b2692..24ac3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "gunicorn>=23.0.0", "ruff>=0.11.13", "mypy>=1.16.0", + "fastapi-sso>=0.18.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 5dda7a2..1dd702a 100644 --- a/uv.lock +++ b/uv.lock @@ -387,6 +387,7 @@ dependencies = [ { name = "bcrypt" }, { name = "crudadmin" }, { name = "fastapi" }, + { name = "fastapi-sso" }, { name = "fastcrud" }, { name = "greenlet" }, { name = "gunicorn" }, @@ -435,6 +436,7 @@ requires-dist = [ { name = "crudadmin", specifier = ">=0.4.2" }, { name = "faker", marker = "extra == 'dev'", specifier = ">=26.0.0" }, { name = "fastapi", specifier = ">=0.109.1" }, + { name = "fastapi-sso", specifier = ">=0.18.0" }, { name = "fastcrud", specifier = ">=0.19.2" }, { name = "greenlet", specifier = ">=2.0.2" }, { name = "gunicorn", specifier = ">=23.0.0" }, @@ -470,6 +472,22 @@ dev = [ { name = "watchfiles", specifier = ">=1.1.1" }, ] +[[package]] +name = "fastapi-sso" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "oauthlib" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/57/cc971c018af5d09eb5f8d1cd12abdd99ab4c59ea5c0b0b1b96349ffe117d/fastapi_sso-0.18.0.tar.gz", hash = "sha256:d8df5a686af7a6a7be248817544b405cf77f7e9ffcd5d0d7d2a196fd071964bc", size = 16811, upload-time = "2025-03-20T17:09:09.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/03/70ca13994f5569d343a9f99dba2930c8ae3471171f161b8887d44b6c526f/fastapi_sso-0.18.0-py3-none-any.whl", hash = "sha256:727754ad770b70690f1471f7b0a9e17c6dfd8ebd6e477616d3bde1eaf62e53dc", size = 26103, upload-time = "2025-03-20T17:09:08.656Z" }, +] + [[package]] name = "fastcrud" version = "0.19.2" @@ -816,6 +834,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1039,11 +1066,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.9.0" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825, upload-time = "2024-08-01T15:01:08.445Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344, upload-time = "2024-08-01T15:01:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [[package]] @@ -1175,15 +1202,15 @@ wheels = [ [[package]] name = "redis" -version = "5.3.0" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, { name = "pyjwt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/dd/2b37032f4119dff2a2f9bbcaade03221b100ba26051bb96e275de3e5db7a/redis-5.3.0.tar.gz", hash = "sha256:8d69d2dde11a12dc85d0dbf5c45577a5af048e2456f7077d87ad35c1c81c310e", size = 4626288, upload-time = "2025-04-30T14:54:40.634Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/b0/aa601efe12180ba492b02e270554877e68467e66bda5d73e51eaa8ecc78a/redis-5.3.0-py3-none-any.whl", hash = "sha256:f1deeca1ea2ef25c1e4e46b07f4ea1275140526b1feea4c6459c0ec27a10ef83", size = 272836, upload-time = "2025-04-30T14:54:30.744Z" }, + { url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" }, ] [package.optional-dependencies] From 36794747e816bba6ce5c8cfc2ba836d53cb4791b Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 16:20:09 +0800 Subject: [PATCH 09/14] Add comments for clarity --- src/app/api/v1/users.py | 9 ++++----- src/app/core/security.py | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 300c018..49acb96 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -18,7 +18,7 @@ router = APIRouter(tags=["users"]) -if settings.ENABLE_PASSWORD_AUTH: +if settings.ENABLE_PASSWORD_AUTH: # If password auth is not enable there should be no way to create users via the API @router.post("/user", response_model=UserRead, status_code=201) async def write_user( @@ -37,14 +37,13 @@ async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSe if username_row: raise DuplicateValueException("Username not available") - user_internal_dict = user.model_dump() if isinstance(user, UserCreate): + user_internal_dict = user.model_dump() user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) del user_internal_dict["password"] + user = UserCreateInternal(**user_internal_dict) - user_internal = UserCreateInternal(**user_internal_dict) - created_user = await crud_users.create(db=db, object=user_internal, schema_to_select=UserRead) - + created_user = await crud_users.create(db=db, object=user, schema_to_select=UserRead) if created_user is None: raise NotFoundException("Failed to create user") diff --git a/src/app/core/security.py b/src/app/core/security.py index d77f13f..d6f18ca 100644 --- a/src/app/core/security.py +++ b/src/app/core/security.py @@ -45,6 +45,7 @@ async def authenticate_user(username_or_email: str, password: str, db: AsyncSess if not db_user: return False + # If the user has no password set (e.g. OAuth2 only accounts), reject authentication if db_user["hashed_password"] is None or not await verify_password(password, db_user["hashed_password"]): return False From 3fe8eda23cff2cb21502297ab6b408e6f50a9a93 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 18:51:41 +0800 Subject: [PATCH 10/14] Add python fire as dependency to better manage command line arguments --- pyproject.toml | 1 + uv.lock | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 24ac3e3..805c4eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "ruff>=0.11.13", "mypy>=1.16.0", "fastapi-sso>=0.18.0", + "fire>=0.7.1", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 1dd702a..1fc3ab3 100644 --- a/uv.lock +++ b/uv.lock @@ -389,6 +389,7 @@ dependencies = [ { name = "fastapi" }, { name = "fastapi-sso" }, { name = "fastcrud" }, + { name = "fire" }, { name = "greenlet" }, { name = "gunicorn" }, { name = "httptools" }, @@ -438,6 +439,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.109.1" }, { name = "fastapi-sso", specifier = ">=0.18.0" }, { name = "fastcrud", specifier = ">=0.19.2" }, + { name = "fire", specifier = ">=0.7.1" }, { name = "greenlet", specifier = ">=2.0.2" }, { name = "gunicorn", specifier = ">=23.0.0" }, { name = "httptools", specifier = ">=0.6.1" }, @@ -512,6 +514,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] +[[package]] +name = "fire" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/00/f8d10588d2019d6d6452653def1ee807353b21983db48550318424b5ff18/fire-0.7.1.tar.gz", hash = "sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf", size = 88720, upload-time = "2025-08-16T20:20:24.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945, upload-time = "2025-08-16T20:20:22.87Z" }, +] + [[package]] name = "greenlet" version = "3.2.3" @@ -1334,6 +1348,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/0f/64baf7a06492e8c12f5c4b49db286787a7255195df496fc21f5fd9eecffa/starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4", size = 73303, upload-time = "2024-10-15T06:52:32.486Z" }, ] +[[package]] +name = "termcolor" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/56/ab275c2b56a5e2342568838f0d5e3e66a32354adcc159b495e374cda43f5/termcolor-3.2.0.tar.gz", hash = "sha256:610e6456feec42c4bcd28934a8c87a06c3fa28b01561d46aa09a9881b8622c58", size = 14423, upload-time = "2025-10-25T19:11:42.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/d5/141f53d7c1eb2a80e6d3e9a390228c3222c27705cbe7f048d3623053f3ca/termcolor-3.2.0-py3-none-any.whl", hash = "sha256:a10343879eba4da819353c55cb8049b0933890c2ebf9ad5d3ecd2bb32ea96ea6", size = 7698, upload-time = "2025-10-25T19:11:41.536Z" }, +] + [[package]] name = "types-cffi" version = "1.17.0.20250523" From dfe3ccbc495bf645bda9bd9a094b9f85fdee716a Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 19:04:09 +0800 Subject: [PATCH 11/14] Update create super user script to be more robust and use already defined functionality --- scripts/local_with_uvicorn/docker-compose.yml | 1 + src/app/schemas/user.py | 1 + src/scripts/create_default_superuser.py | 97 +++++++++++++++++++ src/scripts/create_first_superuser.py | 78 --------------- 4 files changed, 99 insertions(+), 78 deletions(-) create mode 100644 src/scripts/create_default_superuser.py delete mode 100644 src/scripts/create_first_superuser.py diff --git a/scripts/local_with_uvicorn/docker-compose.yml b/scripts/local_with_uvicorn/docker-compose.yml index e41c2c9..a741918 100644 --- a/scripts/local_with_uvicorn/docker-compose.yml +++ b/scripts/local_with_uvicorn/docker-compose.yml @@ -13,6 +13,7 @@ services: - redis volumes: - ./src/app:/code/app + - ./src/scripts:/code/scripts - .env:/code/.env worker: diff --git a/src/app/schemas/user.py b/src/app/schemas/user.py index 6303168..40e8e80 100644 --- a/src/app/schemas/user.py +++ b/src/app/schemas/user.py @@ -39,6 +39,7 @@ class UserCreateInternal(UserBase): model_config = ConfigDict(extra="forbid") hashed_password: str | None + is_superuser: bool = False class UserUpdate(BaseModel): diff --git a/src/scripts/create_default_superuser.py b/src/scripts/create_default_superuser.py new file mode 100644 index 0000000..54fc7c5 --- /dev/null +++ b/src/scripts/create_default_superuser.py @@ -0,0 +1,97 @@ +"""This script creates the default super user and should only be used in certain scenarios, such as: + + - Initial setup of the application where no super user exists. + - Recovery of super user access to the application when all super user accounts have been deleted or compromised. + +Once human super users have been created through the application's standard user management processes, +it is recommended to delete or disable the default super user. + +Do not change the default values for DEFAULT_USERNAME or DEFAULT_EMAIL or make them assignable via command-line +arguments. This will ensure there is a single known default super user account created by this script that can be +monitored and controlled. + +Please set a strong password via the command-line argument when running this script. +""" + +import asyncio +import json +import logging +import os + +import fire +from app.api.v1.users import write_user_internal +from app.core.db.database import local_session +from app.core.security import get_password_hash +from app.schemas.user import UserCreateInternal +from fastcrud.exceptions.http_exceptions import DuplicateValueException + +logger = logging.getLogger(os.path.basename(__file__)) + +# Do not change these default values, read the file docstrings for context +DEFAULT_NAME = "Default Superuser" +DEFAULT_USERNAME = "defaultsuperuser" +DEFAULT_EMAIL = "default.superuser@superuser.com" + + +async def async_main(password: str): + logger.info(f"Running script {os.path.basename(__file__)}") + logger.debug("Creating hashed password") + hashed_password = get_password_hash(password) + logger.debug("Preparing superuser data") + superuser = UserCreateInternal( + name=DEFAULT_NAME, + username=DEFAULT_USERNAME, + email=DEFAULT_EMAIL, + hashed_password=hashed_password, + is_superuser=True, + ) + logger.debug("Creating database session") + async with local_session() as db: + try: + logger.info("Writing default superuser to database.") + result = await write_user_internal(user=superuser, db=db) + except DuplicateValueException: + user_details = { + "name": superuser.name, + "username": superuser.username, + "email": superuser.email, + } + logger.warning( + "Default superuser already exists with details:\n%s", json.dumps(user_details, default=str, indent=2) + ) + else: + user_details = { + "id": result["id"], + "name": result["name"], + "username": result["username"], + "email": result["email"], + "profile_image_url": result["profile_image_url"], + "uuid": result["uuid"], + "created_at": result["created_at"], + "updated_at": result["updated_at"], + "deleted_at": result["deleted_at"], + "is_deleted": result["is_deleted"], + "is_superuser": result["is_superuser"], + "tier_id": result["tier_id"], + } + logger.info("User created with details:\n%s", json.dumps(user_details, default=str, indent=2)) + + +def main(password: str): + """CLI entrypoint to create the default super user with a custom password. + + The default superuser details are: + Name: Default Superuser + Username: defaultsuperuser + Email: default.superuser@superuser.com + + Please set a strong password via the command-line argument when running this script. + + Args: + password (str): Password for the default super user. + """ + asyncio.run(async_main(password=password)) + + +if __name__ == "__main__": + fire.Fire(main) diff --git a/src/scripts/create_first_superuser.py b/src/scripts/create_first_superuser.py deleted file mode 100644 index baf58af..0000000 --- a/src/scripts/create_first_superuser.py +++ /dev/null @@ -1,78 +0,0 @@ -import asyncio -import logging -from datetime import UTC, datetime - -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, MetaData, String, Table, insert, select -from sqlalchemy.dialects.postgresql import UUID -from uuid6 import uuid7 # 126 - -from ..app.core.config import settings -from ..app.core.db.database import AsyncSession, async_engine, local_session -from ..app.core.security import get_password_hash -from ..app.models.user import User - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -async def create_first_user(session: AsyncSession) -> None: - try: - name = settings.ADMIN_NAME - email = settings.ADMIN_EMAIL - username = settings.ADMIN_USERNAME - hashed_password = get_password_hash(settings.ADMIN_PASSWORD) - - query = select(User).filter_by(email=email) - result = await session.execute(query) - user = result.scalar_one_or_none() - - if user is None: - metadata = MetaData() - user_table = Table( - "user", - metadata, - Column("id", Integer, primary_key=True, autoincrement=True, nullable=False), - Column("name", String(30), nullable=False), - Column("username", String(20), nullable=False, unique=True, index=True), - Column("email", String(50), nullable=False, unique=True, index=True), - Column("hashed_password", String, nullable=False), - Column("profile_image_url", String, default="https://profileimageurl.com"), - Column("uuid", UUID(as_uuid=True), default=uuid7, unique=True), - Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False), - Column("updated_at", DateTime), - Column("deleted_at", DateTime), - Column("is_deleted", Boolean, default=False, index=True), - Column("is_superuser", Boolean, default=False), - Column("tier_id", Integer, ForeignKey("tier.id"), index=True), - ) - - data = { - "name": name, - "email": email, - "username": username, - "hashed_password": hashed_password, - "is_superuser": True, - } - - stmt = insert(user_table).values(data) - async with async_engine.connect() as conn: - await conn.execute(stmt) - await conn.commit() - - logger.info(f"Admin user {username} created successfully.") - - else: - logger.info(f"Admin user {username} already exists.") - - except Exception as e: - logger.error(f"Error creating admin user: {e}") - - -async def main(): - async with local_session() as session: - await create_first_user(session) - - -if __name__ == "__main__": - loop = asyncio.get_event_loop() - loop.run_until_complete(main()) From 9dd290b18c455fd02b07bfcbb70b52a9bf59d261 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 19:32:26 +0800 Subject: [PATCH 12/14] Remove create superuser service --- .../docker-compose.yml | 14 -------------- scripts/local_with_uvicorn/docker-compose.yml | 14 -------------- scripts/production_with_nginx/docker-compose.yml | 14 -------------- 3 files changed, 42 deletions(-) diff --git a/scripts/gunicorn_managing_uvicorn_workers/docker-compose.yml b/scripts/gunicorn_managing_uvicorn_workers/docker-compose.yml index 8ec38e8..8f1e2d0 100644 --- a/scripts/gunicorn_managing_uvicorn_workers/docker-compose.yml +++ b/scripts/gunicorn_managing_uvicorn_workers/docker-compose.yml @@ -62,20 +62,6 @@ services: # depends_on: # - web - #-------- uncomment to create first superuser -------- - create_superuser: - build: - context: . - dockerfile: Dockerfile - env_file: - - .env - depends_on: - - db - - web - command: python -m src.scripts.create_first_superuser - volumes: - - ./src:/code/src - #-------- uncomment to run tests -------- # pytest: # build: diff --git a/scripts/local_with_uvicorn/docker-compose.yml b/scripts/local_with_uvicorn/docker-compose.yml index a741918..84d1234 100644 --- a/scripts/local_with_uvicorn/docker-compose.yml +++ b/scripts/local_with_uvicorn/docker-compose.yml @@ -56,20 +56,6 @@ services: # depends_on: # - web - #-------- uncomment to create first superuser -------- - create_superuser: - build: - context: . - dockerfile: Dockerfile - env_file: - - .env - depends_on: - - db - - web - command: python -m src.scripts.create_first_superuser - volumes: - - ./src:/code/src - #-------- uncomment to run tests -------- pytest: build: diff --git a/scripts/production_with_nginx/docker-compose.yml b/scripts/production_with_nginx/docker-compose.yml index 9ed7f9a..c11bd7e 100644 --- a/scripts/production_with_nginx/docker-compose.yml +++ b/scripts/production_with_nginx/docker-compose.yml @@ -62,20 +62,6 @@ services: depends_on: - web - #-------- uncomment to create first superuser -------- - # create_superuser: - # build: - # context: . - # dockerfile: Dockerfile - # env_file: - # - .env - # depends_on: - # - db - # - web - # command: python -m src.scripts.create_first_superuser - # volumes: - # - ./src:/code/src - #-------- uncomment to run tests -------- # pytest: # build: From d08ac4a2e5bd685deb4c28032b69ec8b5b227e77 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 19:33:00 +0800 Subject: [PATCH 13/14] Add extra security measure for super user script --- src/scripts/__init__.py | 3 +++ src/scripts/create_default_superuser.py | 12 +++++++++++- src/scripts/utils.py | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/scripts/utils.py diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py index e69de29..23aa1aa 100644 --- a/src/scripts/__init__.py +++ b/src/scripts/__init__.py @@ -0,0 +1,3 @@ +"""To generate the SHA256 checksum run shasum -a 256 script.py.""" + +CREATE_DEFAULT_SUPERUSER_CHECKSUM = "051463fc44db46b9a1a791530a8ab05ee2cdd6882613fddab839ceeaa20e1c98" diff --git a/src/scripts/create_default_superuser.py b/src/scripts/create_default_superuser.py index 54fc7c5..12c9943 100644 --- a/src/scripts/create_default_superuser.py +++ b/src/scripts/create_default_superuser.py @@ -17,6 +17,7 @@ import json import logging import os +import sys import fire from app.api.v1.users import write_user_internal @@ -25,6 +26,9 @@ from app.schemas.user import UserCreateInternal from fastcrud.exceptions.http_exceptions import DuplicateValueException +from . import CREATE_DEFAULT_SUPERUSER_CHECKSUM as EXPECTED_CHECKSUM +from .utils import ScriptIntegrityError, verify_script_integrity + logger = logging.getLogger(os.path.basename(__file__)) # Do not change these default values, read the file docstrings for context @@ -94,4 +98,10 @@ def main(password: str): if __name__ == "__main__": - fire.Fire(main) + try: + verify_script_integrity(os.path.abspath(__file__), EXPECTED_CHECKSUM) + except ScriptIntegrityError as e: + logger.error(e) + sys.exit(1) # Exit with failure code + else: + fire.Fire(main) diff --git a/src/scripts/utils.py b/src/scripts/utils.py new file mode 100644 index 0000000..748f520 --- /dev/null +++ b/src/scripts/utils.py @@ -0,0 +1,21 @@ +import hashlib + + +class ScriptIntegrityError(Exception): + """Raised when the script integrity check fails.""" + + +def verify_script_integrity(script_path: str, expected_checksum: str): + """Verifies the SHA256 checksum of the given script file. + + Raises ScriptIntegrityError if the checksum does not match. + """ + with open(script_path, "rb") as f: + file_bytes = f.read() + checksum = hashlib.sha256(file_bytes).hexdigest() + if checksum != expected_checksum: + raise ScriptIntegrityError( + f"Script integrity check failed for '{script_path}'. " + "The file may have been modified or tampered with. " + "If this change was intentional, update the expected SHA256 checksum of the script." + ) From ac9fefdf12157386a912d4365025dcf841237d25 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 20:01:40 +0800 Subject: [PATCH 14/14] Add audit logging at the start of the script --- src/scripts/__init__.py | 2 +- src/scripts/create_default_superuser.py | 12 ++++++++---- src/scripts/utils.py | 26 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py index 23aa1aa..7eee1aa 100644 --- a/src/scripts/__init__.py +++ b/src/scripts/__init__.py @@ -1,3 +1,3 @@ """To generate the SHA256 checksum run shasum -a 256 script.py.""" -CREATE_DEFAULT_SUPERUSER_CHECKSUM = "051463fc44db46b9a1a791530a8ab05ee2cdd6882613fddab839ceeaa20e1c98" +CREATE_DEFAULT_SUPERUSER_CHECKSUM = "f9ede74987c9a774d3d836b2088b6a7fe6d9b2a845b8a7e54db97b0eb2c6dc74" diff --git a/src/scripts/create_default_superuser.py b/src/scripts/create_default_superuser.py index 12c9943..e26d811 100644 --- a/src/scripts/create_default_superuser.py +++ b/src/scripts/create_default_superuser.py @@ -27,9 +27,13 @@ from fastcrud.exceptions.http_exceptions import DuplicateValueException from . import CREATE_DEFAULT_SUPERUSER_CHECKSUM as EXPECTED_CHECKSUM -from .utils import ScriptIntegrityError, verify_script_integrity +from .utils import ScriptIntegrityError, get_audit_info, verify_script_integrity -logger = logging.getLogger(os.path.basename(__file__)) +SCRIPT_PATH = os.path.abspath(__file__) +SCRIPT_NAME = os.path.basename(__file__) +logger = logging.getLogger(SCRIPT_NAME) +audit_info = get_audit_info(SCRIPT_PATH) +logger.warning(f"Script being run by: {json.dumps(audit_info, default=str, indent=2)}") # Do not change these default values, read the file docstrings for context DEFAULT_NAME = "Default Superuser" @@ -38,7 +42,7 @@ async def async_main(password: str): - logger.info(f"Running script {os.path.basename(__file__)}") + logger.info(f"Running script {SCRIPT_NAME}") logger.debug("Creating hashed password") hashed_password = get_password_hash(password) logger.debug("Preparing superuser data") @@ -99,7 +103,7 @@ def main(password: str): if __name__ == "__main__": try: - verify_script_integrity(os.path.abspath(__file__), EXPECTED_CHECKSUM) + verify_script_integrity(SCRIPT_PATH, EXPECTED_CHECKSUM) except ScriptIntegrityError as e: logger.error(e) sys.exit(1) # Exit with failure code diff --git a/src/scripts/utils.py b/src/scripts/utils.py index 748f520..f2befa0 100644 --- a/src/scripts/utils.py +++ b/src/scripts/utils.py @@ -1,3 +1,29 @@ +import datetime +import getpass +import os +import socket + + +def get_audit_info(script_name: str) -> dict: + """Returns a dictionary with audit details about the script execution environment.""" + user = getpass.getuser() + hostname = socket.gethostname() + try: + ip = socket.gethostbyname(hostname) + except Exception: + ip = None + cwd = os.getcwd() + now = datetime.datetime.now().isoformat() + return { + "user": user, + "hostname": hostname, + "ip": ip, + "cwd": cwd, + "script": os.path.abspath(script_name), + "time": now, + } + + import hashlib