diff --git a/.env.example b/.env.example index 5812b5c..bca9cd3 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,16 @@ ENVIRONMENT="local" FIRST_SUPERUSER="admin@example.com" FIRST_SUPERUSER_PASSWORD="changethis" FRONTEND_HOST="http://localhost:5173" +# Comma-separated Host header allowlist enforced by TrustedHostMiddleware in +# production (e.g. "api.example.com,example.com"). Ignored outside production, +# where all hosts are allowed for local dev. The frontend host is added +# automatically. +ALLOWED_HOSTS="" + +# Host address Docker binds the DB/Redis ports to. Defaults to loopback +# (127.0.0.1) so they are not exposed on the host's network interfaces. Set to +# 0.0.0.0 or a specific IP only if you need external access. +DOCKER_BIND_HOST="127.0.0.1" # Database Settings POSTGRES_SERVER="localhost" diff --git a/README.md b/README.md index 27347cf..7421249 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,11 @@ ENVIRONMENT="local" FIRST_SUPERUSER="admin@example.com" FIRST_SUPERUSER_PASSWORD="changethis" FRONTEND_HOST="http://localhost:5173" +# Comma-separated Host header allowlist enforced in production only. +ALLOWED_HOSTS="" + +# Host address Docker binds the DB/Redis ports to (loopback by default). +DOCKER_BIND_HOST="127.0.0.1" # Database Settings POSTGRES_SERVER="localhost" diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index b821557..5a61473 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -4,7 +4,7 @@ from fastapi.security import OAuth2PasswordRequestForm from app.api.decorators import audit_unexpected_failure -from app.api.deps import CurrentActiveUser, SessionDep +from app.api.deps import CurrentActiveUser, SessionDep, reusable_oauth2 from app.core.config import settings from app.core.messages.error_message import ErrorMessages from app.core.messages.success_message import SuccessMessages @@ -18,7 +18,7 @@ ForgotPassword, NewPassword, UpdatePassword, - UserCreate, + UserRegister, VerifyEmail, ) from app.schemas.user_activity import ActivityType, ResourceType @@ -36,10 +36,11 @@ router = APIRouter() -# The refresh cookie is limited to the refresh endpoint only so it is never -# sent on unrelated requests. This path must match for both set_cookie and -# delete_cookie calls or the cookie cannot be cleared. -_REFRESH_COOKIE_PATH = f"{settings.API_V1_STR}/auth/refresh" +# The refresh cookie is scoped to the auth router so it is sent on the auth +# endpoints that must revoke it — /refresh (rotation), /logout, and +# /change-password — but not on the rest of the API. This path must match for +# both set_cookie and delete_cookie calls or the cookie cannot be cleared. +_REFRESH_COOKIE_PATH = f"{settings.API_V1_STR}/auth" _COOKIE_SECURE = settings.ENVIRONMENT != "local" @@ -128,6 +129,7 @@ async def refresh_token( request=request, session=session, refresh_token=refresh_token_cookie ) _set_access_cookie(response, result.access_token) + _set_refresh_cookie(response, result.refresh_token) return CookieRefreshResponse(message=result.message) @@ -138,11 +140,15 @@ async def refresh_token( endpoint="/logout", ) async def logout(request: Request, response: Response, session: SessionDep) -> Message: - """Clear refresh token cookie and invalidate token in the blacklist.""" + """Clear the auth cookies and revoke both tokens in the blacklist.""" refresh_token_cookie = request.cookies.get("refresh_token") - if refresh_token_cookie: + access_token_cookie = request.cookies.get("access_token") + if refresh_token_cookie or access_token_cookie: await logout_service( - request=request, session=session, refresh_token=refresh_token_cookie + request=request, + session=session, + refresh_token=refresh_token_cookie, + access_token=access_token_cookie, ) _clear_auth_cookies(response) return Message(success=True, message=SuccessMessages.LOGOUT_SUCCESS) @@ -156,10 +162,10 @@ async def logout(request: Request, response: Response, session: SessionDep) -> M endpoint="/register", ) async def register_user( - request: Request, session: SessionDep, user_in: UserCreate + request: Request, session: SessionDep, user_in: UserRegister ) -> Message: """Register a new user.""" - await register_service(request=request, session=session, user_create=user_in) + await register_service(request=request, session=session, user_register=user_in) return Message(success=True, message=SuccessMessages.REGISTER_SUCCESS) @@ -243,14 +249,23 @@ async def resend_verification( ) async def change_password( request: Request, + response: Response, session: SessionDep, current_user: CurrentActiveUser, body: UpdatePassword, + bearer_token: Annotated[str | None, Depends(reusable_oauth2)] = None, ) -> Message: - """Change user password while logged in.""" - return await change_password_service( + """Change user password while logged in, then end the current session.""" + # Resolve the access token the same way ``get_current_user`` does so the + # session is revoked for Bearer clients too, not only cookie-based ones. + access_token = request.cookies.get("access_token") or bearer_token + result = await change_password_service( request=request, session=session, current_user=current_user, update_password=body, + access_token=access_token, + refresh_token=request.cookies.get("refresh_token"), ) + _clear_auth_cookies(response) + return result diff --git a/app/core/config.py b/app/core/config.py index e51b189..911e770 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -29,11 +29,18 @@ class Settings(BaseSettings): ) API_V1_STR: str = "/api/v1" SECRET_KEY: str = secrets.token_urlsafe(32) - # 8 days — long enough to avoid surprise logouts, short enough to limit - # blast radius of a stolen token when combined with refresh rotation. - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + # 60 minutes — short-lived access token limits the blast radius of a + # stolen token. Clients transparently renew via the refresh-token flow. + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 + + # Account lockout: after MAX failed logins within the WINDOW, the account is + # locked for LOCKOUT seconds. Closes distributed brute-force that the + # per-IP rate limit cannot (attempts keyed by email, not source IP). + LOGIN_MAX_FAILED_ATTEMPTS: int = 5 + LOGIN_FAILED_ATTEMPT_WINDOW_SECONDS: int = 15 * 60 + LOGIN_LOCKOUT_SECONDS: int = 15 * 60 FRONTEND_HOST: str = "http://localhost:5173" ENVIRONMENT: Literal["local", "staging", "production"] = "local" DEFAULT_LANGUAGE: Literal["en", "tr"] = "en" @@ -50,6 +57,33 @@ def all_cors_origins(self) -> list[str]: origins.append(str(self.FRONTEND_HOST).rstrip("/")) return origins + # Host header allowlist enforced by TrustedHostMiddleware. Comma-separated + # in the environment (e.g. ``api.example.com,example.com``). + ALLOWED_HOSTS: Annotated[list[str] | str, BeforeValidator(parse_cors)] = [] + + @computed_field # type: ignore[prop-decorator] + @property + def trusted_hosts(self) -> list[str]: + """Hosts accepted by ``TrustedHostMiddleware``. + + Outside production we return ``["*"]`` so local dev and the test client + (which sends ``Host: testserver``) are never blocked. Production + enforces the explicit ``ALLOWED_HOSTS`` list plus the frontend host, + falling back to ``["*"]`` only if nothing was configured so a missing + value can never brick the deployment. + """ + if self.ENVIRONMENT != "production": + return ["*"] + + from urllib.parse import urlparse + + hosts = [h.strip() for h in self.ALLOWED_HOSTS if h.strip()] + if self.FRONTEND_HOST: + hostname = urlparse(str(self.FRONTEND_HOST)).hostname + if hostname: + hosts.append(hostname) + return hosts or ["*"] + PROJECT_NAME: str SENTRY_DSN: str | None = None @@ -120,10 +154,12 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: FIRST_SUPERUSER_PASSWORD: str def _check_default_secret(self, var_name: str, value: str | None) -> None: - if value == "changethis": + is_blank = not (value or "").strip() + if value == "changethis" or is_blank: + reason = "empty" if is_blank else 'the insecure default "changethis"' message = ( - f'The value of {var_name} is "changethis", ' - "for security, please change it, at least for deployments." + f"The value of {var_name} is {reason}; " + "for security, please set it, at least for deployments." ) if self.ENVIRONMENT == "local": warnings.warn(message, stacklevel=1) diff --git a/app/core/messages/error_message.py b/app/core/messages/error_message.py index 0b8719b..82f42cc 100644 --- a/app/core/messages/error_message.py +++ b/app/core/messages/error_message.py @@ -14,6 +14,7 @@ class ErrorMessages: EMAIL_ALREADY_EXISTS = "error.user.email_exists" INVALID_CREDENTIALS = "error.user.invalid_credentials" INVALID_PASSWORD = "error.user.invalid_password" + WEAK_PASSWORD = "error.user.password_weak" USER_INACTIVE = "error.user.user_inactive" EMAIL_NOT_VERIFIED = "error.user.email_not_verified" INVALID_VERIFICATION_TOKEN = "error.user.invalid_verification_token" @@ -32,6 +33,9 @@ class ErrorMessages: ACCOUNT_ALREADY_SUSPENDED = "error.account.already_suspended" ACCOUNT_NOT_SUSPENDED = "error.account.not_suspended" + # Account lockout (automatic, temporary — too many failed logins) + ACCOUNT_LOCKED = "error.account.locked" + # Admin ADMIN_CANNOT_MODIFY_SELF = "error.admin.cannot_modify_self" ADMIN_CANNOT_DELETE_SELF = "error.admin.cannot_delete_self" diff --git a/app/core/middleware.py b/app/core/middleware.py index eab92b9..3fb5138 100644 --- a/app/core/middleware.py +++ b/app/core/middleware.py @@ -9,13 +9,33 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from starlette.middleware.cors import CORSMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware from app.core.config import settings from app.core.messages.error_message import ErrorMessages def register_middleware(app: FastAPI) -> None: - """Wire the origin check and (when configured) the CORS middleware.""" + """Wire host validation, security headers, the origin check and CORS.""" + + @app.middleware("http") + async def security_headers_middleware(request: Request, call_next): + """Attach baseline security headers to every response. + + Uses ``frame-ancestors 'none'`` rather than a full ``default-src`` + policy so the bundled Swagger UI keeps loading its assets. HSTS is + only sent outside local since it requires HTTPS to be meaningful. + """ + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = "frame-ancestors 'none'" + if settings.ENVIRONMENT != "local": + response.headers["Strict-Transport-Security"] = ( + "max-age=63072000; includeSubDomains" + ) + return response @app.middleware("http") async def origin_check_middleware(request: Request, call_next): @@ -65,6 +85,17 @@ async def origin_check_middleware(request: Request, call_next): CORSMiddleware, allow_origins=settings.all_cors_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=[ + "Authorization", + "Content-Type", + "Accept", + "Accept-Language", + "X-Requested-With", + ], ) + + # Reject requests whose Host header is not in the allowlist. In production + # this blocks Host-header injection / cache-poisoning; elsewhere the list + # resolves to ``["*"]`` so it is a no-op for local dev and tests. + app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts) diff --git a/app/models/user_activity.py b/app/models/user_activity.py index b32c32d..dc0a82f 100644 --- a/app/models/user_activity.py +++ b/app/models/user_activity.py @@ -15,6 +15,14 @@ from app.schemas.user_activity import ActivityStatus from app.utils import utc_now +# Length caps for client-supplied audit fields, enforced at write time by the +# logger (see ``app/use_cases/log_activity.py``). ``ip_address`` fits the +# longest IPv6 textual form; ``user_agent`` is attacker-controlled so it is +# bounded to stop oversized headers from bloating the audit log. Kept as a +# write-time cap rather than a DB constraint to avoid a heavy column rewrite. +IP_ADDRESS_MAX_LENGTH = 45 +USER_AGENT_MAX_LENGTH = 500 + class UserActivity(Base): """ diff --git a/app/repositories/admin/activity.py b/app/repositories/admin/activity.py index 8ecec47..e35a1c3 100644 --- a/app/repositories/admin/activity.py +++ b/app/repositories/admin/activity.py @@ -10,6 +10,7 @@ from app.models.user import User from app.models.user_activity import UserActivity from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType +from app.utils.db_search import LIKE_ESCAPE_CHAR, ilike_contains def _filtered_activities_stmt( @@ -30,12 +31,12 @@ def _filtered_activities_stmt( if user_search: # Match the acting user by name/email. ILIKE on raw columns so the # pg_trgm indexes on email/first_name/last_name can serve the query. - like = f"%{user_search}%" + like = ilike_contains(user_search) stmt = stmt.join(User, UserActivity.user_id == User.id).where( or_( - User.email.ilike(like), - User.first_name.ilike(like), - User.last_name.ilike(like), + User.email.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.first_name.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.last_name.ilike(like, escape=LIKE_ESCAPE_CHAR), ) ) if activity_type is not None: diff --git a/app/repositories/admin/file.py b/app/repositories/admin/file.py index b945837..eda10ca 100644 --- a/app/repositories/admin/file.py +++ b/app/repositories/admin/file.py @@ -8,6 +8,7 @@ from app.models.file import File from app.models.user import User +from app.utils.db_search import LIKE_ESCAPE_CHAR, ilike_contains def _filtered_files_stmt( @@ -23,12 +24,12 @@ def _filtered_files_stmt( # Inner-join the uploader and match on name or email. Files without an # uploader (uploaded_by_id IS NULL) are naturally excluded, which is # correct: they cannot match a person's name. - pattern = f"%{uploader}%" + pattern = ilike_contains(uploader) stmt = stmt.join(User, File.uploaded_by_id == User.id).where( or_( - User.first_name.ilike(pattern), - User.last_name.ilike(pattern), - User.email.ilike(pattern), + User.first_name.ilike(pattern, escape=LIKE_ESCAPE_CHAR), + User.last_name.ilike(pattern, escape=LIKE_ESCAPE_CHAR), + User.email.ilike(pattern, escape=LIKE_ESCAPE_CHAR), ) ) return stmt diff --git a/app/repositories/admin/support.py b/app/repositories/admin/support.py index 06d05a0..6dc0e97 100644 --- a/app/repositories/admin/support.py +++ b/app/repositories/admin/support.py @@ -8,6 +8,7 @@ from app.models.support import SupportTicket from app.models.user import User +from app.utils.db_search import LIKE_ESCAPE_CHAR, ilike_contains def _filtered_tickets_stmt( @@ -24,13 +25,13 @@ def _filtered_tickets_stmt( """ stmt = select(SupportTicket) if search: - like = f"%{search}%" + like = ilike_contains(search) stmt = stmt.join(User, SupportTicket.user_id == User.id).where( or_( - SupportTicket.subject.ilike(like), - User.email.ilike(like), - User.first_name.ilike(like), - User.last_name.ilike(like), + SupportTicket.subject.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.email.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.first_name.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.last_name.ilike(like, escape=LIKE_ESCAPE_CHAR), ) ) if status is not None: diff --git a/app/repositories/admin/user.py b/app/repositories/admin/user.py index b288e93..c396b3f 100644 --- a/app/repositories/admin/user.py +++ b/app/repositories/admin/user.py @@ -7,6 +7,7 @@ from app.models.user import User from app.schemas.user import SystemRole +from app.utils.db_search import LIKE_ESCAPE_CHAR, ilike_contains def _filtered_users_stmt( @@ -22,12 +23,12 @@ def _filtered_users_stmt( # ``ILIKE`` on the raw columns so the ``pg_trgm`` GIN indexes on # email/first_name/last_name can actually serve the query. Wrapping # with ``func.lower(...)`` would defeat the index. - like = f"%{search}%" + like = ilike_contains(search) stmt = stmt.where( or_( - User.email.ilike(like), - User.first_name.ilike(like), - User.last_name.ilike(like), + User.email.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.first_name.ilike(like, escape=LIKE_ESCAPE_CHAR), + User.last_name.ilike(like, escape=LIKE_ESCAPE_CHAR), ) ) if role is not None: diff --git a/app/repositories/login_attempts.py b/app/repositories/login_attempts.py new file mode 100644 index 0000000..20ad950 --- /dev/null +++ b/app/repositories/login_attempts.py @@ -0,0 +1,65 @@ +"""Redis-backed account-lockout counters for the login flow. + +Failed logins are counted per email (hashed so raw addresses are never stored) +inside a rolling window. Once the threshold is hit the account is locked for a +fixed period via a separate key that Redis evicts automatically. This stops +distributed brute-force that the per-IP rate limit cannot, since the counter +follows the targeted account rather than the source address. +""" + +import hashlib + +from app.core.config import settings +from app.core.redis import get_redis + +_FAIL_PREFIX = "login_fail:" +_LOCK_PREFIX = "login_lock:" + + +def _hash(email: str) -> str: + """Hash a normalised email so plaintext addresses are never persisted.""" + return hashlib.sha256(email.strip().lower().encode()).hexdigest() + + +def _fail_key(email: str) -> str: + """Redis key holding the failed-attempt counter for an email.""" + return f"{_FAIL_PREFIX}{_hash(email)}" + + +def _lock_key(email: str) -> str: + """Redis key holding the active lock flag for an email.""" + return f"{_LOCK_PREFIX}{_hash(email)}" + + +async def is_login_locked(email: str) -> int: + """Return the remaining lock TTL in seconds, or ``0`` if not locked.""" + ttl = await get_redis().ttl(_lock_key(email)) + return ttl if ttl and ttl > 0 else 0 + + +async def register_failed_login(email: str) -> bool: + """Record a failed login; lock the account when the threshold is reached. + + Returns ``True`` only on the attempt that *transitions* the account into a + locked state, so the caller can fire a one-off notification. The counter is + reset when the lock is set, so subsequent attempts are short-circuited by + ``is_login_locked`` rather than re-triggering this path. + """ + redis = get_redis() + fail_key = _fail_key(email) + + count = await redis.incr(fail_key) + if count == 1: + await redis.expire(fail_key, settings.LOGIN_FAILED_ATTEMPT_WINDOW_SECONDS) + + if count >= settings.LOGIN_MAX_FAILED_ATTEMPTS: + await redis.set(_lock_key(email), "1", ex=settings.LOGIN_LOCKOUT_SECONDS) + await redis.delete(fail_key) + return True + + return False + + +async def clear_login_attempts(email: str) -> None: + """Drop the counter and any lock for an email (called on a successful login).""" + await get_redis().delete(_fail_key(email), _lock_key(email)) diff --git a/app/repositories/root_transfer.py b/app/repositories/root_transfer.py index 90f0f15..ab99cfd 100644 --- a/app/repositories/root_transfer.py +++ b/app/repositories/root_transfer.py @@ -6,6 +6,7 @@ """ import hashlib +import hmac import json import uuid @@ -54,7 +55,9 @@ async def verify_root_transfer_otp(root_id: uuid.UUID, code: str) -> uuid.UUID | if not raw: return None data = json.loads(raw) - if data.get("code_hash") == _hash_code(code): + # Constant-time compare: both sides are SHA-256 hex digests, so the timing + # risk is already minimal, but ``compare_digest`` removes it entirely. + if hmac.compare_digest(str(data.get("code_hash", "")), _hash_code(code)): return uuid.UUID(data["target_id"]) attempts = int(data.get("attempts", 0)) + 1 diff --git a/app/schemas/token.py b/app/schemas/token.py index 1e30a39..5251ce1 100644 --- a/app/schemas/token.py +++ b/app/schemas/token.py @@ -16,6 +16,12 @@ class AuthTokens(Token): message: str | None = None +class RefreshedTokens(Token): + """Rotated credentials returned by the refresh flow: new access + refresh.""" + + refresh_token: str + + class CookieLoginResponse(BaseModel): """Login response without access_token in body (token is in HttpOnly cookie).""" diff --git a/app/schemas/user.py b/app/schemas/user.py index c4e5693..f1ad870 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,8 +1,11 @@ +import re import uuid from datetime import datetime from enum import StrEnum +from typing import Annotated from pydantic import ( + AfterValidator, BaseModel, ConfigDict, EmailStr, @@ -16,6 +19,31 @@ from app.schemas.admin_permission import Permission from app.schemas.file import FilePublic +# Character classes a new password must contain, mirroring the frontend rule: +# at least one uppercase, one lowercase, one digit, and one special character. +_PASSWORD_CLASSES = ( + re.compile(r"[A-Z]"), + re.compile(r"[a-z]"), + re.compile(r"[0-9]"), + re.compile(r'[!@#$%^&*(),.?":{}|<>]'), +) + + +def _validate_password_strength(value: str) -> str: + """Reject new passwords that miss any required character class.""" + if not all(pattern.search(value) for pattern in _PASSWORD_CLASSES): + raise ValueError(ErrorMessages.WEAK_PASSWORD) + return value + + +# Applied to *new* passwords only (not current-password confirmations). Length is +# enforced by ``Field``; the validator adds the complexity requirement. +StrongPassword = Annotated[ + str, + Field(min_length=8, max_length=40), + AfterValidator(_validate_password_strength), +] + class SystemRole(StrEnum): SUPERADMIN = "superadmin" @@ -43,7 +71,7 @@ class UserBase(BaseModel): # Properties to receive via API on creation class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=40) + password: StrongPassword role: SystemRole = SystemRole.USER lang: Language = Language.EN @@ -57,16 +85,17 @@ def validate_role(cls, v: str) -> str: class UserRegister(BaseModel): email: EmailStr = Field(max_length=255) - password: str = Field(min_length=8, max_length=40) + password: StrongPassword first_name: str | None = Field(default=None, max_length=100) last_name: str | None = Field(default=None, max_length=100) title: str | None = Field(default=None, max_length=100) + lang: Language = Language.EN # Properties to receive via API on update, all are optional class UserUpdate(UserBase): email: EmailStr | None = Field(default=None, max_length=255) # type: ignore - password: str | None = Field(default=None, min_length=8, max_length=40) + password: StrongPassword | None = None role: SystemRole | None = None @field_validator("role") @@ -87,7 +116,7 @@ class UserUpdateMe(BaseModel): class UpdatePassword(BaseModel): current_password: str = Field(min_length=8, max_length=40) - new_password: str = Field(min_length=8, max_length=40) + new_password: StrongPassword class DeleteAccount(BaseModel): @@ -141,7 +170,7 @@ class UsersPublic(BaseModel): class NewPassword(BaseModel): token: str - new_password: str = Field(min_length=8, max_length=40) + new_password: StrongPassword lang: Language = Language.EN diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 86ee1b9..f08ebd7 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -20,29 +20,59 @@ verify_refresh_token, ) from app.models.user import User +from app.repositories.login_attempts import ( + clear_login_attempts, + is_login_locked, + register_failed_login, +) from app.repositories.token_blacklist import ( add_token_to_blacklist, is_token_blacklisted, ) from app.repositories.user import get_user_by_email, get_user_by_id, update_user from app.schemas.msg import Message -from app.schemas.token import AuthTokens, Token -from app.schemas.user import Language, UpdatePassword, UserCreate, UserPublic +from app.schemas.token import AuthTokens, RefreshedTokens +from app.schemas.user import ( + Language, + SystemRole, + UpdatePassword, + UserCreate, + UserPublic, + UserRegister, +) from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType from app.services.user_service import create_user_service from app.use_cases.log_activity import log_activity from app.utils.email_templates import ( + generate_account_locked_email, generate_email_verification_email, generate_password_reset_email, ) async def register_service( - request: Request, session: AsyncSession, user_create: UserCreate + request: Request, session: AsyncSession, user_register: UserRegister ) -> UserPublic: - """Register a user, audit the event, and send the verification email.""" + """Register a user, audit the event, and send the verification email. + + Builds the user from a restricted ``UserRegister`` payload and forces the + privileged fields (``role``, ``is_active``, ``is_verified``) to safe values + so a self-service registrant can never escalate to admin/superadmin or + self-verify. Privileged user creation goes through the admin flow instead. + """ + safe_user = UserCreate( + email=user_register.email, + password=user_register.password, + first_name=user_register.first_name, + last_name=user_register.last_name, + title=user_register.title, + lang=user_register.lang, + role=SystemRole.USER, + is_active=True, + is_verified=False, + ) user = await create_user_service( - request=request, session=session, user_create=user_create, current_user=None + request=request, session=session, user_create=safe_user, current_user=None ) await log_activity( session=session, @@ -62,7 +92,7 @@ async def register_service( email_data = generate_email_verification_email( verify_link=verify_url, project_name=settings.PROJECT_NAME, - lang=user_create.lang, + lang=user_register.lang, ) await send_email( @@ -83,6 +113,24 @@ async def register_service( _DUMMY_PASSWORD_HASH = get_password_hash("unused-timing-safe-placeholder") +async def _notify_account_locked(user: User) -> None: + """Email the user that their account was just locked (best-effort).""" + lock_minutes = max(1, settings.LOGIN_LOCKOUT_SECONDS // 60) + email_data = generate_account_locked_email( + project_name=settings.PROJECT_NAME, + lock_minutes=lock_minutes, + lang=settings.DEFAULT_LANGUAGE, + ) + await send_email( + to=user.email, + subject=email_data["subject"], + body=email_data["html"], + plain_text=email_data["plain_text"], + user_id=str(user.id), + is_html=True, + ) + + async def authenticate( request: Request | None, session: AsyncSession, email: str, password: str ) -> User: @@ -91,6 +139,16 @@ async def authenticate( Returns the user object if successful, raises 401 otherwise. Combined check for security (timing attacks). """ + # Account lockout guard: reject early (423) while a temporary lock is active + # so even a correct password cannot be used during the cooldown. + lock_ttl = await is_login_locked(email) + if lock_ttl > 0: + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail=ErrorMessages.ACCOUNT_LOCKED, + headers={"Retry-After": str(lock_ttl)}, + ) + user = await get_user_by_email(session, email=email) # Always run verify_password so the response time does not leak whether @@ -110,11 +168,38 @@ async def authenticate( details={"reason": "invalid_password", "email": email}, request=request, ) + + # Count the failure; the threshold-crossing attempt locks the account. + locked_now = await register_failed_login(email) + if locked_now: + if user: + await _notify_account_locked(user) + if request: + await log_activity( + session=session, + user_id=user.id, + activity_type=ActivityType.LOGIN, + resource_type=ResourceType.AUTH, + status=ActivityStatus.FAILURE, + status_code=status.HTTP_423_LOCKED, + details={"reason": "account_locked", "email": email}, + request=request, + ) + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail=ErrorMessages.ACCOUNT_LOCKED, + headers={"Retry-After": str(settings.LOGIN_LOCKOUT_SECONDS)}, + ) + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorMessages.INVALID_CREDENTIALS, ) + # Correct password: reset the failed-attempt counter so a legitimate login + # never accumulates toward a lockout. + await clear_login_attempts(email) + # Admin-suspended accounts are permanently locked out. This guard must run # before the grace-window fall-through below, otherwise a suspended user # would still receive tokens. @@ -178,10 +263,13 @@ async def login_service( async def refresh_token_service( request: Request | None, session: AsyncSession, refresh_token: str -) -> Token: +) -> RefreshedTokens: """ - Validate refresh token and return a new access token. - Checks if token is blacklisted and the user is still active. + Validate the refresh token and rotate the session credentials. + + On success the presented refresh token is revoked and a fresh access + + refresh pair is issued. Replaying the old (now-blacklisted) refresh token + is rejected by the blacklist guard below, so a leaked token is single-use. """ user_id = verify_refresh_token(refresh_token) if not user_id: @@ -255,22 +343,43 @@ async def refresh_token_service( detail=ErrorMessages.ACCOUNT_SUSPENDED, ) - return Token( - access_token=create_access_token(user_id), message=SuccessMessages.LOGIN_SUCCESS + # Rotate: revoke the presented refresh token and mint a fresh pair so the + # old token can never be reused (replay is caught by the guard above). + await _revoke_token(refresh_token) + + return RefreshedTokens( + access_token=create_access_token(user_id), + refresh_token=create_refresh_token(user_id), + message=SuccessMessages.LOGIN_SUCCESS, ) +async def _revoke_token(token: str | None) -> None: + """Blacklist a token if present and not already revoked. + + The guard avoids redundant writes when the same token is revoked twice + (e.g. a double logout) without raising. + """ + if token and not await is_token_blacklisted(token): + await add_token_to_blacklist(token) + + async def logout_service( - request: Request | None, session: AsyncSession, refresh_token: str | None + request: Request | None, + session: AsyncSession, + refresh_token: str | None, + access_token: str | None = None, ) -> None: """ - Invalidates a refresh token by adding it to the blacklist. + Invalidate the session by blacklisting both the refresh and access tokens. + + Revoking the access token too closes the window where a stolen access + token would otherwise stay valid until its own expiry after logout. """ + await _revoke_token(access_token) + if refresh_token: - # Check if it was already blacklisted to avoid unique constraint errors - is_blacklisted = await is_token_blacklisted(refresh_token) - if not is_blacklisted: - await add_token_to_blacklist(refresh_token) + await _revoke_token(refresh_token) # Log success if possible user_id = verify_refresh_token(refresh_token) @@ -462,9 +571,15 @@ async def change_password_service( session: AsyncSession, current_user: User, update_password: UpdatePassword, + access_token: str | None = None, + refresh_token: str | None = None, ) -> Message: """ Change user password after verifying current password. + + On success the current session's access and refresh tokens are revoked so + a password change forces re-authentication and immediately invalidates any + token that may have been stolen before the change. """ if not await averify_password( update_password.current_password, current_user.hashed_password @@ -487,6 +602,11 @@ async def change_password_service( hashed_password = await aget_password_hash(update_password.new_password) await update_user(session, current_user, {"hashed_password": hashed_password}) + # Revoke the current session so the change forces a fresh login and any + # token captured before the change stops working immediately. + await _revoke_token(access_token) + await _revoke_token(refresh_token) + await log_activity( session=session, user_id=current_user.id, diff --git a/app/tests/admin/conftest.py b/app/tests/admin/conftest.py index 9722b84..c429e79 100644 --- a/app/tests/admin/conftest.py +++ b/app/tests/admin/conftest.py @@ -20,7 +20,7 @@ async def register_and_verify( client: AsyncClient, email: str, - password: str = "password123", + password: str = "Password123!", ) -> None: """Register a user and mark them verified so they can log in.""" await client.post( @@ -79,7 +79,9 @@ async def grant_all_permissions(email: str) -> None: await grant_permissions(email, list(Permission)) -async def login(client: AsyncClient, email: str, password: str = "password123") -> None: +async def login( + client: AsyncClient, email: str, password: str = "Password123!" +) -> None: """Log in and let httpx store the returned auth cookies on the client.""" response = await client.post( "/auth/login", diff --git a/app/tests/admin/test_admins.py b/app/tests/admin/test_admins.py index d55781b..7a6a817 100644 --- a/app/tests/admin/test_admins.py +++ b/app/tests/admin/test_admins.py @@ -48,7 +48,7 @@ async def test_create_admin_account_with_permissions(superadmin_client: AsyncCli "email": "newadmin@test.com", "first_name": "New", "last_name": "Admin", - "password": "password123", + "password": "Password123!", "permissions": [Permission.USERS_READ.value, Permission.USERS_WRITE.value], }, ) @@ -71,7 +71,7 @@ async def test_create_admin_duplicate_email_conflicts(superadmin_client: AsyncCl response = await superadmin_client.post( "/admin/admins", - json={"email": "taken@test.com", "password": "password123", "permissions": []}, + json={"email": "taken@test.com", "password": "Password123!", "permissions": []}, ) assert response.status_code == 409 assert response.json()["error"] == ErrorMessages.EMAIL_ALREADY_EXISTS diff --git a/app/tests/admin/test_rbac.py b/app/tests/admin/test_rbac.py index e555a94..a8c625f 100644 --- a/app/tests/admin/test_rbac.py +++ b/app/tests/admin/test_rbac.py @@ -167,14 +167,14 @@ async def test_is_last_active_superadmin_repository(): async with TestingSessionLocal() as session: only_super = User( email="solo-super@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), role=SystemRole.SUPERADMIN.value, is_active=True, is_verified=True, ) other_super = User( email="second-super@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), role=SystemRole.SUPERADMIN.value, is_active=True, is_verified=True, diff --git a/app/tests/admin/test_stats.py b/app/tests/admin/test_stats.py index f851aeb..fe6a910 100644 --- a/app/tests/admin/test_stats.py +++ b/app/tests/admin/test_stats.py @@ -29,7 +29,7 @@ async def test_stats_returns_expected_counts(admin_client: AsyncClient): "/auth/register", json={ "email": "unverified@test.com", - "password": "password123", + "password": "Password123!", "first_name": "U", "last_name": "V", "title": "T", diff --git a/app/tests/admin/test_users.py b/app/tests/admin/test_users.py index 1221a55..3c0e72e 100644 --- a/app/tests/admin/test_users.py +++ b/app/tests/admin/test_users.py @@ -288,21 +288,21 @@ async def test_is_last_active_admin_repository(): async with TestingSessionLocal() as session: only_admin = User( email="solo-admin@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), role=SystemRole.ADMIN.value, is_active=True, is_verified=True, ) inactive_admin = User( email="inactive-admin@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), role=SystemRole.ADMIN.value, is_active=False, is_verified=True, ) regular = User( email="plain@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), role=SystemRole.USER.value, is_active=True, is_verified=True, @@ -315,7 +315,7 @@ async def test_is_last_active_admin_repository(): second_admin = User( email="second-admin@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), role=SystemRole.ADMIN.value, is_active=True, is_verified=True, diff --git a/app/tests/test_account_lockout.py b/app/tests/test_account_lockout.py new file mode 100644 index 0000000..bd5c34e --- /dev/null +++ b/app/tests/test_account_lockout.py @@ -0,0 +1,99 @@ +"""Account-lockout tests. + +Exercised at the service/repository layer rather than over HTTP so the per-IP +``rate_limit_strict`` on ``/auth/login`` does not mask the lockout behaviour +(both share a threshold of 5, so a 6th HTTP call would 429 before we could +assert that a locked account rejects even a correct password). +""" + +import pytest +from fastapi import HTTPException +from httpx import AsyncClient +from sqlalchemy import update + +from app.core.config import settings +from app.core.messages.error_message import ErrorMessages +from app.models.user import User +from app.repositories.login_attempts import ( + clear_login_attempts, + is_login_locked, + register_failed_login, +) +from app.services.auth_service import authenticate +from app.tests.conftest import TestingSessionLocal + + +@pytest.mark.asyncio +async def test_failed_logins_lock_after_threshold(): + """The threshold-crossing failure flips the account into a locked state.""" + email = "lock-repo@test.com" + for _ in range(settings.LOGIN_MAX_FAILED_ATTEMPTS - 1): + assert await register_failed_login(email) is False + assert await is_login_locked(email) == 0 + + assert await register_failed_login(email) is True + assert await is_login_locked(email) > 0 + + +@pytest.mark.asyncio +async def test_clear_login_attempts_unlocks(): + """A successful login clears the counter and any active lock.""" + email = "lock-clear@test.com" + for _ in range(settings.LOGIN_MAX_FAILED_ATTEMPTS): + await register_failed_login(email) + assert await is_login_locked(email) > 0 + + await clear_login_attempts(email) + assert await is_login_locked(email) == 0 + + +@pytest.mark.asyncio +async def test_authenticate_locks_and_emails(client: AsyncClient, mock_email_send): + """Wrong passwords lock the account, email the owner, then block valid logins.""" + email = "lockflow@test.com" + password = "Password123!" + + await client.post( + "/auth/register", + json={ + "email": email, + "password": password, + "first_name": "F", + "last_name": "L", + }, + ) + async with TestingSessionLocal() as session: + await session.execute( + update(User).where(User.email == email).values(is_verified=True) + ) + await session.commit() + + # Drop the registration verification email so later assertions are precise. + mock_email_send.reset_mock() + + async with TestingSessionLocal() as session: + # Wrong password below the threshold -> 401, no lock, no email. + for _ in range(settings.LOGIN_MAX_FAILED_ATTEMPTS - 1): + with pytest.raises(HTTPException) as exc: + await authenticate( + request=None, session=session, email=email, password="wrong" + ) + assert exc.value.status_code == 401 + mock_email_send.assert_not_called() + + # Threshold-crossing attempt -> 423 + exactly one lock notification. + with pytest.raises(HTTPException) as exc: + await authenticate( + request=None, session=session, email=email, password="wrong" + ) + assert exc.value.status_code == 423 + assert exc.value.detail == ErrorMessages.ACCOUNT_LOCKED + assert exc.value.headers["Retry-After"] + mock_email_send.assert_awaited_once() + + # A correct password is still rejected while the lock is active. + with pytest.raises(HTTPException) as exc: + await authenticate( + request=None, session=session, email=email, password=password + ) + assert exc.value.status_code == 423 diff --git a/app/tests/test_auth.py b/app/tests/test_auth.py index ef57271..5d430a3 100644 --- a/app/tests/test_auth.py +++ b/app/tests/test_auth.py @@ -11,7 +11,7 @@ async def test_register(client: AsyncClient): "/auth/register", json={ "email": "test@test.com", - "password": "password123", + "password": "Password123!", "first_name": "Test", "last_name": "User", "title": "Tester", @@ -23,6 +23,22 @@ async def test_register(client: AsyncClient): assert data["message"] == SuccessMessages.REGISTER_SUCCESS +@pytest.mark.asyncio +async def test_register_rejects_weak_password(client: AsyncClient): + # "password123" has no uppercase and no special character -> rejected. + response = await client.post( + "/auth/register", + json={ + "email": "weakpw@test.com", + "password": "password123", + "first_name": "W", + "last_name": "P", + }, + ) + assert response.status_code == 422 + assert ErrorMessages.WEAK_PASSWORD in response.text + + @pytest.mark.asyncio async def test_login(client: AsyncClient): # Register first @@ -30,7 +46,7 @@ async def test_login(client: AsyncClient): "/auth/register", json={ "email": "test2@test.com", - "password": "password123", + "password": "Password123!", "first_name": "Test2", "last_name": "User2", }, @@ -38,7 +54,7 @@ async def test_login(client: AsyncClient): # Login should fail because of unverified email response = await client.post( - "/auth/login", data={"username": "test2@test.com", "password": "password123"} + "/auth/login", data={"username": "test2@test.com", "password": "Password123!"} ) assert response.status_code == 403 assert response.json()["error"] == ErrorMessages.EMAIL_NOT_VERIFIED @@ -59,7 +75,7 @@ async def test_refresh_token_and_logout(client: AsyncClient): "/auth/register", json={ "email": "test3@test.com", - "password": "password123", + "password": "Password123!", "first_name": "Test3", "last_name": "User3", }, @@ -73,7 +89,7 @@ async def test_refresh_token_and_logout(client: AsyncClient): await session.commit() login_response = await client.post( - "/auth/login", data={"username": "test3@test.com", "password": "password123"} + "/auth/login", data={"username": "test3@test.com", "password": "Password123!"} ) assert login_response.status_code == 200 @@ -101,6 +117,101 @@ async def test_refresh_token_and_logout(client: AsyncClient): assert failed_refresh_response.json()["error"] == ErrorMessages.INVALID_TOKEN +@pytest.mark.asyncio +async def test_logout_revokes_refresh_token_directly(client: AsyncClient): + """Logout blacklists the refresh token itself, not only via rotation. + + The refresh cookie is scoped to ``/auth`` so it now reaches ``/auth/logout``; + this guards against regressing that scope back to ``/auth/refresh`` (which + would silently make logout unable to revoke the refresh token). + """ + from sqlalchemy import update + + from app.models.user import User + from app.tests.conftest import TestingSessionLocal + + email = "logout_revoke@test.com" + await client.post( + "/auth/register", + json={ + "email": email, + "password": "Password123!", + "first_name": "L", + "last_name": "R", + }, + ) + async with TestingSessionLocal() as session: + await session.execute( + update(User).where(User.email == email).values(is_verified=True) + ) + await session.commit() + + login = await client.post( + "/auth/login", data={"username": email, "password": "Password123!"} + ) + refresh_cookie = login.cookies.get("refresh_token") + assert refresh_cookie is not None + + # Log out without refreshing first, so a rejection below can only come from + # logout revoking the refresh token — not from rotation on /auth/refresh. + logout = await client.post("/auth/logout") + assert logout.status_code == 200 + + client.cookies.set("refresh_token", refresh_cookie) + replay = await client.post("/auth/refresh") + assert replay.status_code == 401 + assert replay.json()["error"] == ErrorMessages.INVALID_TOKEN + + +@pytest.mark.asyncio +async def test_refresh_token_rotation(client: AsyncClient): + from sqlalchemy import update + + from app.models.user import User + from app.tests.conftest import TestingSessionLocal + + email = "rotate@test.com" + await client.post( + "/auth/register", + json={ + "email": email, + "password": "Password123!", + "first_name": "R", + "last_name": "T", + }, + ) + async with TestingSessionLocal() as session: + await session.execute( + update(User).where(User.email == email).values(is_verified=True) + ) + await session.commit() + + login = await client.post( + "/auth/login", data={"username": email, "password": "Password123!"} + ) + assert login.status_code == 200 + r1 = login.cookies.get("refresh_token") + assert r1 is not None + + # First refresh rotates the token: a new refresh cookie, different from r1. + client.cookies.set("refresh_token", r1) + first = await client.post("/auth/refresh") + assert first.status_code == 200 + r2 = first.cookies.get("refresh_token") + assert r2 is not None and r2 != r1 + + # Replaying the old refresh token is rejected (revoked on rotation). + client.cookies.set("refresh_token", r1) + replay = await client.post("/auth/refresh") + assert replay.status_code == 401 + assert replay.json()["error"] == ErrorMessages.INVALID_TOKEN + + # The rotated token works for a subsequent refresh. + client.cookies.set("refresh_token", r2) + second = await client.post("/auth/refresh") + assert second.status_code == 200 + + @pytest.mark.asyncio async def test_verify_email_flow(client: AsyncClient): from sqlalchemy import select @@ -116,7 +227,7 @@ async def test_verify_email_flow(client: AsyncClient): "/auth/register", json={ "email": email, - "password": "password123", + "password": "Password123!", "first_name": "Test4", "last_name": "User4", }, @@ -145,7 +256,7 @@ async def test_verify_email_flow(client: AsyncClient): # Login should now succeed login_response = await client.post( - "/auth/login", data={"username": email, "password": "password123"} + "/auth/login", data={"username": email, "password": "Password123!"} ) assert login_response.status_code == 200 data = login_response.json() @@ -158,8 +269,8 @@ async def test_forgot_and_reset_password_flow(client: AsyncClient): from app.core.security import create_password_reset_token email = "test5@test.com" - old_password = "password123" - new_password = "newPassword456" + old_password = "Password123!" + new_password = "NewPassword456!" # Register await client.post( @@ -213,7 +324,7 @@ async def test_token_reuse_protection(client: AsyncClient): ) email = "test_reuse@test.com" - password = "password123" + password = "Password123!" # 1. Register await client.post( @@ -240,14 +351,14 @@ async def test_token_reuse_protection(client: AsyncClient): reset_token = create_password_reset_token(email) response = await client.post( "/auth/reset-password", - json={"token": reset_token, "new_password": "newPassword123"}, + json={"token": reset_token, "new_password": "NewPassword123!"}, ) assert response.status_code == 200 # 5. Reset Password Again with SAME token (Should Fail) response = await client.post( "/auth/reset-password", - json={"token": reset_token, "new_password": "anotherPassword123"}, + json={"token": reset_token, "new_password": "AnotherPassword123!"}, ) assert response.status_code == 400 assert response.json()["error"] == ErrorMessages.INVALID_TOKEN @@ -261,8 +372,8 @@ async def test_change_password(client: AsyncClient): from app.tests.conftest import TestingSessionLocal email = "test_change@test.com" - old_password = "password123" - new_password = "newPassword456" + old_password = "Password123!" + new_password = "NewPassword456!" # 1. Register await client.post( @@ -287,12 +398,15 @@ async def test_change_password(client: AsyncClient): "/auth/login", data={"username": email, "password": old_password} ) assert login_response.status_code == 200 - assert login_response.cookies.get("access_token") is not None + old_access_token = login_response.cookies.get("access_token") + old_refresh_token = login_response.cookies.get("refresh_token") + assert old_access_token is not None + assert old_refresh_token is not None # 4. Test Change Password - Failure (Wrong current password) fail_response = await client.patch( "/auth/change-password", - json={"current_password": "wrongpassword", "new_password": "newPassword456"}, + json={"current_password": "wrongpassword", "new_password": "NewPassword456!"}, ) assert fail_response.status_code == 400 assert fail_response.json()["error"] == ErrorMessages.INVALID_CURRENT_PASSWORD @@ -306,6 +420,19 @@ async def test_change_password(client: AsyncClient): assert success_response.json()["success"] is True assert success_response.json()["message"] == SuccessMessages.PASSWORD_CHANGE_SUCCESS + # 5b. The session held before the change is revoked immediately: the old + # access token is rejected on a protected route and the old refresh token + # can no longer mint new access tokens. + client.cookies.set("access_token", old_access_token) + me_response = await client.get("/users/me") + assert me_response.status_code == 401 + + client.cookies.set("refresh_token", old_refresh_token) + refresh_response = await client.post("/auth/refresh") + assert refresh_response.status_code == 401 + + client.cookies.clear() + # 6. Verify Login works with NEW password new_login_response = await client.post( "/auth/login", data={"username": email, "password": new_password} @@ -330,7 +457,7 @@ async def test_suspended_user_login_returns_account_suspended(client: AsyncClien from app.utils import utc_now email = "suspended@test.com" - password = "password123" + password = "Password123!" await client.post( "/auth/register", json={"email": email, "password": password, "first_name": "Sus"}, diff --git a/app/tests/test_deletion_worker.py b/app/tests/test_deletion_worker.py index e344d47..69dfe0a 100644 --- a/app/tests/test_deletion_worker.py +++ b/app/tests/test_deletion_worker.py @@ -28,7 +28,7 @@ async def _make_user( async with TestingSessionLocal() as session: user = User( email=email, - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), is_active=scheduled_at is None, is_verified=True, deactivated_at=scheduled_at - timedelta(days=30) if scheduled_at else None, @@ -156,7 +156,7 @@ async def test_suspended_user_is_never_due_for_deletion(): async with TestingSessionLocal() as session: suspended = User( email="suspended-worker@test.com", - hashed_password=get_password_hash("password123"), + hashed_password=get_password_hash("Password123!"), is_active=False, is_verified=True, suspended_at=utc_now(), diff --git a/app/tests/test_files.py b/app/tests/test_files.py index 48ef902..9eed674 100644 --- a/app/tests/test_files.py +++ b/app/tests/test_files.py @@ -13,7 +13,7 @@ async def _register_verify_login( - client: AsyncClient, email: str, password: str = "password123" + client: AsyncClient, email: str, password: str = "Password123!" ) -> None: """Register a user, mark them verified, and log in on the client.""" await client.post( diff --git a/app/tests/test_users.py b/app/tests/test_users.py index d7a8b31..787999a 100644 --- a/app/tests/test_users.py +++ b/app/tests/test_users.py @@ -14,7 +14,7 @@ async def auth_client(client: AsyncClient) -> AsyncClient: "/auth/register", json={ "email": "user_test@test.com", - "password": "password123", + "password": "Password123!", "first_name": "Test", "last_name": "User", "title": "Tester", @@ -40,7 +40,7 @@ async def auth_client(client: AsyncClient) -> AsyncClient: "/auth/login", data={ "username": "user_test@test.com", - "password": "password123", + "password": "Password123!", }, ) assert response.status_code == 200 @@ -109,7 +109,7 @@ async def test_delete_user_me_schedules_deletion(auth_client: AsyncClient): response = await auth_client.request( "DELETE", url="/users/me", - json={"password": "password123"}, + json={"password": "Password123!"}, ) assert response.status_code == 200 @@ -132,7 +132,7 @@ async def test_delete_user_me_schedules_deletion(auth_client: AsyncClient): async def test_delete_user_me_twice_returns_400(auth_client: AsyncClient): """Second deactivate attempt while already pending must fail fast.""" first = await auth_client.request( - "DELETE", url="/users/me", json={"password": "password123"} + "DELETE", url="/users/me", json={"password": "Password123!"} ) assert first.status_code == 200 @@ -140,11 +140,11 @@ async def test_delete_user_me_twice_returns_400(auth_client: AsyncClient): # fresh credentials — deactivated users are allowed to log back in. await auth_client.post( "/auth/login", - data={"username": "user_test@test.com", "password": "password123"}, + data={"username": "user_test@test.com", "password": "Password123!"}, ) second = await auth_client.request( - "DELETE", url="/users/me", json={"password": "password123"} + "DELETE", url="/users/me", json={"password": "Password123!"} ) assert second.status_code == 400 @@ -153,12 +153,12 @@ async def test_delete_user_me_twice_returns_400(auth_client: AsyncClient): async def test_deactivated_user_blocked_from_update(auth_client: AsyncClient): """PATCH /users/me must reject deactivated callers.""" await auth_client.request( - "DELETE", url="/users/me", json={"password": "password123"} + "DELETE", url="/users/me", json={"password": "Password123!"} ) # Re-login so cookies are present again. await auth_client.post( "/auth/login", - data={"username": "user_test@test.com", "password": "password123"}, + data={"username": "user_test@test.com", "password": "Password123!"}, ) response = await auth_client.patch("/users/me", json={"first_name": "Hacked"}) @@ -174,11 +174,11 @@ async def test_reactivate_cancels_deletion(auth_client: AsyncClient): from app.tests.conftest import TestingSessionLocal await auth_client.request( - "DELETE", url="/users/me", json={"password": "password123"} + "DELETE", url="/users/me", json={"password": "Password123!"} ) await auth_client.post( "/auth/login", - data={"username": "user_test@test.com", "password": "password123"}, + data={"username": "user_test@test.com", "password": "Password123!"}, ) response = await auth_client.post("/users/me/reactivate") @@ -228,11 +228,11 @@ async def test_suspended_user_cannot_self_reactivate(auth_client: AsyncClient): async def test_me_exposes_deletion_schedule(auth_client: AsyncClient): """GET /users/me must include deletion_scheduled_at for deactivated users.""" await auth_client.request( - "DELETE", url="/users/me", json={"password": "password123"} + "DELETE", url="/users/me", json={"password": "Password123!"} ) await auth_client.post( "/auth/login", - data={"username": "user_test@test.com", "password": "password123"}, + data={"username": "user_test@test.com", "password": "Password123!"}, ) response = await auth_client.get("/users/me") diff --git a/app/use_cases/log_activity.py b/app/use_cases/log_activity.py index cb27416..ac7d66c 100644 --- a/app/use_cases/log_activity.py +++ b/app/use_cases/log_activity.py @@ -4,7 +4,11 @@ from fastapi import status as http_status from sqlalchemy.ext.asyncio import AsyncSession -from app.models.user_activity import UserActivity +from app.models.user_activity import ( + IP_ADDRESS_MAX_LENGTH, + USER_AGENT_MAX_LENGTH, + UserActivity, +) from app.repositories.user_activity import create_user_activity from app.schemas.common import ActivityDetails from app.schemas.user_activity import ( @@ -36,7 +40,14 @@ async def log_activity( status_code = http_status.HTTP_200_OK ip_address = request.client.host if request and request.client else None + if ip_address is not None: + ip_address = ip_address[:IP_ADDRESS_MAX_LENGTH] + user_agent = request.headers.get("user-agent") if request else None + # Truncate the client-supplied user-agent so an oversized header can never + # exceed the column length (which would raise on insert) or bloat the log. + if user_agent is not None: + user_agent = user_agent[:USER_AGENT_MAX_LENGTH] activity_data = UserActivityCreate( user_id=user_id, diff --git a/app/utils/db_search.py b/app/utils/db_search.py new file mode 100644 index 0000000..ced2bdd --- /dev/null +++ b/app/utils/db_search.py @@ -0,0 +1,21 @@ +"""Helpers for building safe SQL ``LIKE`` / ``ILIKE`` search patterns.""" + +# Escape character paired with every ``.ilike(pattern, escape=LIKE_ESCAPE_CHAR)`` +# call so the neutralised wildcards below are interpreted literally. +LIKE_ESCAPE_CHAR = "\\" + + +def ilike_contains(term: str) -> str: + """Return a ``%term%`` pattern with ``LIKE`` wildcards in ``term`` neutralised. + + Escapes the backslash escape character first, then ``%`` and ``_`` so a + user searching for a literal ``50%`` or ``a_b`` does not turn those into + wildcards — which would otherwise cause over-broad matches and needless + full scans. Callers MUST pass ``escape=LIKE_ESCAPE_CHAR`` to ``.ilike()``. + """ + escaped = ( + term.replace(LIKE_ESCAPE_CHAR, LIKE_ESCAPE_CHAR * 2) + .replace("%", LIKE_ESCAPE_CHAR + "%") + .replace("_", LIKE_ESCAPE_CHAR + "_") + ) + return f"%{escaped}%" diff --git a/app/utils/email_templates.py b/app/utils/email_templates.py index cbe4a00..e9469d9 100644 --- a/app/utils/email_templates.py +++ b/app/utils/email_templates.py @@ -550,3 +550,107 @@ def generate_root_transfer_otp_email( """ return {"subject": subject, "html": html, "plain_text": plain_text} + + +def generate_account_locked_email( + project_name: str, lock_minutes: int, lang: str = Language.EN +) -> dict[str, str]: + """Generate subject, HTML, and plain text for an account-lockout alert. + + Sent when an account is temporarily locked after too many failed logins. + Deliberately link-free: it only informs the user the lock is automatic and + time-limited, and prompts a password reset if the attempts were not theirs. + """ + if lang == Language.TR: + subject = "Hesabınız geçici olarak kilitlendi" + greeting = "Merhaba," + message = ( + "Çok sayıda başarısız giriş denemesi nedeniyle hesabınız güvenlik " + f"amacıyla {lock_minutes} dakika boyunca geçici olarak kilitlendi. " + "Bu süre sonunda otomatik olarak tekrar giriş yapabilirsiniz." + ) + disclaimer = ( + "Bu denemeler size ait değilse, giriş sayfasındaki 'Şifremi Unuttum' " + "bağlantısı ile şifrenizi sıfırlamanızı öneririz." + ) + footer_text = f"© {project_name}. Tüm hakları saklıdır." + plain_text = ( + "Çok sayıda başarısız giriş denemesi nedeniyle hesabınız " + f"{lock_minutes} dakika boyunca geçici olarak kilitlendi. Bu denemeler " + "size ait değilse, giriş sayfasındaki 'Şifremi Unuttum' bağlantısı ile " + "şifrenizi sıfırlayın." + ) + else: + subject = "Your account has been temporarily locked" + greeting = "Hi there," + message = ( + "For your security, your account has been temporarily locked for " + f"{lock_minutes} minutes after too many failed login attempts. You " + "will be able to sign in again automatically once this period ends." + ) + disclaimer = ( + "If these attempts weren't you, we recommend resetting your password " + "using the 'Forgot Password' link on the login page." + ) + footer_text = f"© {project_name}. All rights reserved." + plain_text = ( + "For your security, your account has been temporarily locked for " + f"{lock_minutes} minutes after too many failed login attempts. If these " + "attempts weren't you, reset your password using the 'Forgot Password' " + "link on the login page." + ) + + html = f""" + + + + + + {subject} + + + + + + + +
+ + + + + + + + + + +
+
+ 🔒 +
+

{project_name}

+
+

{greeting}

+

{message}

+
+

{disclaimer}

+
+
+

{project_name}

+

{footer_text}

+
+
+ +""" + + return {"subject": subject, "html": html, "plain_text": plain_text} diff --git a/docker-compose.yaml b/docker-compose.yaml index 627b103..cc61fdf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,7 +26,10 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_DB=${POSTGRES_DB?Variable not set} ports: - - "5432:5432" + # Bind address is configurable; defaults to loopback so the database is + # not exposed on the host's network interfaces. Inter-container access + # uses the compose network regardless of this binding. + - "${DOCKER_BIND_HOST:-127.0.0.1}:5432:5432" backend: build: @@ -114,7 +117,8 @@ services: timeout: 5s retries: 5 ports: - - "6379:6379" + # Same configurable loopback default as the database (see DOCKER_BIND_HOST). + - "${DOCKER_BIND_HOST:-127.0.0.1}:6379:6379" volumes: - app-redis-data:/data diff --git a/pyproject.toml b/pyproject.toml index e8b61e0..37f6f9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "bcrypt>=5.0.0", "cloudinary>=1.44.2", "dnspython>=2.8.0", - "fastapi[standard]>=0.129.2", + "fastapi[standard]>=0.130.0", "httpx>=0.28.1", "opentelemetry-api>=1.41.1", "opentelemetry-exporter-otlp-proto-http>=1.41.1", @@ -23,12 +23,13 @@ dependencies = [ "prometheus-fastapi-instrumentator>=7.1.0", "pydantic-settings>=2.13.1", "pydantic[email]>=2.12.5", - "pyjwt>=2.11.0", - "python-multipart>=0.0.22", + "pyjwt>=2.12.0", + "python-multipart>=0.0.26", "redis>=7.2.1", "sentry-sdk>=2.53.0", "slowapi>=0.1.9", "sqlalchemy>=2.0.0", + "starlette>=1.0.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 52bea33..df445be 100644 --- a/uv.lock +++ b/uv.lock @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.129.2" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -589,15 +589,16 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/cc/1b0d90ed759ff8c9dbc4800de7475d4e9256a81b97b45bd05a1affcb350a/fastapi-0.129.2.tar.gz", hash = "sha256:e2b3637a2b47856e704dbd9a3a09393f6df48e8b9cb6c7a3e26ba44d2053f9ab", size = 368211, upload-time = "2026-02-21T17:25:49.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/d0/a89a640308016c7fff8d2a47b86cc03ee7cca780b5079d0b69f466f9e1a9/fastapi-0.129.2-py3-none-any.whl", hash = "sha256:e21d9f6e8db376655187905ad0145edd6f6a4e5f2bff241c4efb8a0bffd6a540", size = 103227, upload-time = "2026-02-21T17:25:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [package.optional-dependencies] standard = [ { name = "email-validator" }, { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, { name = "httpx" }, { name = "jinja2" }, { name = "pydantic-extra-types" }, @@ -674,6 +675,7 @@ dependencies = [ { name = "sentry-sdk" }, { name = "slowapi" }, { name = "sqlalchemy" }, + { name = "starlette" }, ] [package.dev-dependencies] @@ -698,7 +700,7 @@ requires-dist = [ { name = "bcrypt", specifier = ">=5.0.0" }, { name = "cloudinary", specifier = ">=1.44.2" }, { name = "dnspython", specifier = ">=2.8.0" }, - { name = "fastapi", extras = ["standard"], specifier = ">=0.129.2" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.130.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "opentelemetry-api", specifier = ">=1.41.1" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.41.1" }, @@ -710,12 +712,13 @@ requires-dist = [ { name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, - { name = "pyjwt", specifier = ">=2.11.0" }, - { name = "python-multipart", specifier = ">=0.0.22" }, + { name = "pyjwt", specifier = ">=2.12.0" }, + { name = "python-multipart", specifier = ">=0.0.26" }, { name = "redis", specifier = ">=7.2.1" }, { name = "sentry-sdk", specifier = ">=2.53.0" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "starlette", specifier = ">=1.0.1" }, ] [package.metadata.requires-dev] @@ -734,70 +737,74 @@ dev = [ [[package]] name = "fastar" -version = "0.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f1/5b2ff898abac7f1a418284aad285e3a4f68d189c572ab2db0f6c9079dd16/fastar-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f10d2adfe40f47ff228f4efaa32d409d732ded98580e03ed37c9535b5fc923d", size = 706369, upload-time = "2025-11-26T02:34:37.783Z" }, - { url = "https://files.pythonhosted.org/packages/23/60/8046a386dca39154f80c927cbbeeb4b1c1267a3271bffe61552eb9995757/fastar-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b930da9d598e3bc69513d131f397e6d6be4643926ef3de5d33d1e826631eb036", size = 629097, upload-time = "2025-11-26T02:34:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/22/7e/1ae005addc789924a9268da2394d3bb5c6f96836f7e37b7e3d23c2362675/fastar-0.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d210da2de733ca801de83e931012349d209f38b92d9630ccaa94bd445bdc9b8", size = 868938, upload-time = "2025-11-26T02:33:51.119Z" }, - { url = "https://files.pythonhosted.org/packages/a6/77/290a892b073b84bf82e6b2259708dfe79c54f356e252c2dd40180b16fe07/fastar-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa02270721517078a5bd61a38719070ac2537a4aa6b6c48cf369cf2abc59174a", size = 765204, upload-time = "2025-11-26T02:32:47.02Z" }, - { url = "https://files.pythonhosted.org/packages/d0/00/c3155171b976003af3281f5258189f1935b15d1221bfc7467b478c631216/fastar-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c391e5b789a720e4d0029b9559f5d6dee3226693c5b39c0eab8eaece997e0f", size = 764717, upload-time = "2025-11-26T02:33:02.453Z" }, - { url = "https://files.pythonhosted.org/packages/b7/43/405b7ad76207b2c11b7b59335b70eac19e4a2653977f5588a1ac8fed54f4/fastar-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3258d7a78a72793cdd081545da61cabe85b1f37634a1d0b97ffee0ff11d105ef", size = 931502, upload-time = "2025-11-26T02:33:18.619Z" }, - { url = "https://files.pythonhosted.org/packages/da/8a/a3dde6d37cc3da4453f2845cdf16675b5686b73b164f37e2cc579b057c2c/fastar-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6eab95dd985cdb6a50666cbeb9e4814676e59cfe52039c880b69d67cfd44767", size = 821454, upload-time = "2025-11-26T02:33:33.427Z" }, - { url = "https://files.pythonhosted.org/packages/da/c1/904fe2468609c8990dce9fe654df3fbc7324a8d8e80d8240ae2c89757064/fastar-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:829b1854166141860887273c116c94e31357213fa8e9fe8baeb18bd6c38aa8d9", size = 821647, upload-time = "2025-11-26T02:34:07Z" }, - { url = "https://files.pythonhosted.org/packages/c8/73/a0642ab7a400bc07528091785e868ace598fde06fcd139b8f865ec1b6f3c/fastar-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1667eae13f9457a3c737f4376d68e8c3e548353538b28f7e4273a30cb3965cd", size = 986342, upload-time = "2025-11-26T02:34:53.371Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/60c1bfa6edab72366461a95f053d0f5f7ab1825fe65ca2ca367432cd8629/fastar-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b864a95229a7db0814cd9ef7987cb713fd43dce1b0d809dd17d9cd6f02fdde3e", size = 1040207, upload-time = "2025-11-26T02:35:10.65Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a0/0d624290dec622e7fa084b6881f456809f68777d54a314f5dde932714506/fastar-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c05fbc5618ce17675a42576fa49858d79734627f0a0c74c0875ab45ee8de340c", size = 1045031, upload-time = "2025-11-26T02:35:28.108Z" }, - { url = "https://files.pythonhosted.org/packages/a7/74/cf663af53c4706ba88e6b4af44a6b0c3bd7d7ca09f079dc40647a8f06585/fastar-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7f41c51ee96f338662ee3c3df4840511ba3f9969606840f1b10b7cb633a3c716", size = 994877, upload-time = "2025-11-26T02:35:45.797Z" }, - { url = "https://files.pythonhosted.org/packages/52/17/444c8be6e77206050e350da7c338102b6cab384be937fa0b1d6d1f9ede73/fastar-0.8.0-cp312-cp312-win32.whl", hash = "sha256:d949a1a2ea7968b734632c009df0571c94636a5e1622c87a6e2bf712a7334f47", size = 455996, upload-time = "2025-11-26T02:36:26.938Z" }, - { url = "https://files.pythonhosted.org/packages/dc/34/fc3b5e56d71a17b1904800003d9251716e8fd65f662e1b10a26881698a74/fastar-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc645994d5b927d769121094e8a649b09923b3c13a8b0b98696d8f853f23c532", size = 490429, upload-time = "2025-11-26T02:36:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/35/a8/5608cc837417107c594e2e7be850b9365bcb05e99645966a5d6a156285fe/fastar-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:d81ee82e8dc78a0adb81728383bd39611177d642a8fa2d601d4ad5ad59e5f3bd", size = 461297, upload-time = "2025-11-26T02:36:03.546Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" }, - { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" }, - { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" }, - { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" }, - { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" }, - { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" }, - { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" }, - { url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" }, - { url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" }, - { url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" }, - { url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" }, - { url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" }, - { url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" }, - { url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" }, - { url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" }, - { url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" }, - { url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" }, - { url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" }, - { url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" }, - { url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" }, - { url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" }, - { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/06/a5773706afc8bd496769786590bbc56d2d0ee419a299cc12ea3f5717fcf3/fastar-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3c51f1c2cdddbd1420d2897ace7738e36c65e17f6ae84e0bfe763f8d1068bb97", size = 708394, upload-time = "2026-04-13T17:09:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/d5e2a4e48495616440a21eed07558219ca90243ad00b0502586f95bd4833/fastar-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0d9d6b052baf5380baea866675dab6ccd04ec2460d12b1c46f10ce3f4ee6a820", size = 628417, upload-time = "2026-04-13T17:09:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/69/9816d69ac8265c9e50456637a487ccfb7a9c566efd9dbcd673df9c2558c2/fastar-0.11.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd2f05666d4df7e14885b5c38fefd92a785917387513d33d837ff42ec143a22f", size = 863950, upload-time = "2026-04-13T17:09:11.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0d/f88daad53aff2e754b6b5ff2a7113f72447a34f6ef17cc23ca99988117b7/fastar-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e6e74aba1ae77ca4aedcaf1697cd413319f4c88a5ccbe5b42c709517c5097e", size = 760737, upload-time = "2026-04-13T17:07:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/82ef4ecd969d50d92ed3ed9dbd8fe77faa24be5e5736f716edc9f4ce8d62/fastar-0.11.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38ef77fe940bbc9b37a98bd838727f844b11731cd39358a2640ff864fb385086", size = 757603, upload-time = "2026-04-13T17:08:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/50249f0d827251f8ac511495e2eacccebda80a00a0ad73e9615b8113b84f/fastar-0.11.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8955e61b32d6aff82c983217abf80933fd823b0e727586fc72f08043d996fd59", size = 923952, upload-time = "2026-04-13T17:08:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/faee41659e9c379d906d24eaee6d6833ac8cfef0a5df480e5c2a8d3efb33/fastar-0.11.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:483532442cdb08fbff0169510224eae0836f2f672cea6aacb52847d90fefdc46", size = 816574, upload-time = "2026-04-13T17:08:56.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/0448ea7992b997dad2bf004bfd98eca74b5858630eae080b50c7b17d9ddc/fastar-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef5a6071121e05d8287fc75bccb054bcbac8bb0501200a0c0a8feeace5303ea4", size = 819382, upload-time = "2026-04-13T17:09:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/0d63eb43586831b7a6f8b22c4d77125a7c594423af1f4f090fa9541b9b40/fastar-0.11.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e45e598af5afe8412197d4786efd6cf29be02e7d3d4f6a3461149eae5d7e94f1", size = 885254, upload-time = "2026-04-13T17:08:40.9Z" }, + { url = "https://files.pythonhosted.org/packages/01/25/edd584675d69e49a165052c3ee886df1c5d574f3e7d813c990306387c623/fastar-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e160919b1c47ddb8538e7e8eb4cd527281b40f0bf75110a75993838ef61f286", size = 971239, upload-time = "2026-04-13T17:10:12.997Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/e8bb24f506ba2b08fbaf36c5800e843bd4d542954e9331f00418e2d23349/fastar-0.11.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4bb4dc0fc8f7a6807febcebce8a2f3626ba4955a9263d81ecc630aad83be84c0", size = 1035185, upload-time = "2026-04-13T17:10:30.207Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/be753736296338149ee4cb3e92e2b5423d6ba17c7b951d15218fd7e99bbf/fastar-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4ec95af56aa173f6e320e1183001bf108ba59beaf13edd1fc8200648db203588", size = 1072191, upload-time = "2026-04-13T17:10:47.072Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/a81c1aaafb5a22ce57c98ae22f39c89413ed53e4ee6e1b1444b0bd666a6c/fastar-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:136cf342735464091c39dc3708168f9fdeb9ebea40b1ead937c61afaf46143d9", size = 1028054, upload-time = "2026-04-13T17:11:04.293Z" }, + { url = "https://files.pythonhosted.org/packages/ec/88/1ce4eed3d70627c95f49ca017f6bbbf2ddcc4b0c601d293259de7689bc20/fastar-0.11.0-cp312-cp312-win32.whl", hash = "sha256:35f23c11b556cc4d3704587faacbc0037f7bdf6c4525cd1d09c70bda4b1c6809", size = 454198, upload-time = "2026-04-13T17:11:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1d/26ce92f4331cd61a69840db9ca6115829805eec24f285481a854f578e917/fastar-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:920bc56c3c0b8a8ca492904941d1883c1c947c858cd93343356c29122a38f44c", size = 486697, upload-time = "2026-04-13T17:11:31.084Z" }, + { url = "https://files.pythonhosted.org/packages/ed/96/e6eda4480559c69b05d466e7b5ea9170e81fef3795a73e059959a3258319/fastar-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:395248faf89e8a6bd5dc1fd544c8465113b627cb6d7c8b296796b60ebea33593", size = 462591, upload-time = "2026-04-13T17:11:20.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d6/3be260037e86fb694e88d47f583bac3a0188c99cee1a6b257ac26cb6b53c/fastar-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:33f544b08b4541b678e53749b4552a44720d96761fb79c172b005b1089c443ed", size = 707975, upload-time = "2026-04-13T17:09:58.866Z" }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7867aefb1784662554a335f2952c75a50f0c70585ed0d2210d6cc15e5627/fastar-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c1c792447e4a642745f347ff9847c52af39633071c57ee67ed53c157fc3506", size = 628460, upload-time = "2026-04-13T17:09:43.776Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2b/d11d84bdd5e0e377771b955755771e3460b290da5809cb78c1b735ee2228/fastar-0.11.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:881247e6b6eaea59fc6569f9b61447aa6b9fc2ee864e048b4643d69c52745805", size = 863054, upload-time = "2026-04-13T17:09:13.048Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/d3f428b318fa940b1b6e785b8d54fc895dfb5d5b945ef8d5442ffa904fb2/fastar-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:863b7929845c9fec92ef6c8d59579cf46af5136655e5342f8df5cebe46cab06c", size = 760247, upload-time = "2026-04-13T17:07:57.396Z" }, + { url = "https://files.pythonhosted.org/packages/9e/04/03949aee82aabb8ede06ac5a4a5579ffaf98a8fe59ce958494508ff15513/fastar-0.11.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96b4a57df12bf3211662627a3ea29d62ecb314a2434a0d0843f9fc23e47536e5", size = 756512, upload-time = "2026-04-13T17:08:12.415Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/2ca1ae0a3828ca51047962d932b80daca2522db73e8cb9d040cb6ebe28d5/fastar-0.11.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceef1c2c4df7b7b8ebd3f5d718bbf457b9bbdf25ce0bd07870211ec4fbd9aff4", size = 922183, upload-time = "2026-04-13T17:08:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/7fe808b1f73a68e686f25434f538c6dc10ef4dfb3db0ace22cd861744bf8/fastar-0.11.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8e545918441910a779659d4759ad0eef349e935fbdb4668a666d3681567eb05", size = 816394, upload-time = "2026-04-13T17:08:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/07d086080f8a83b8d7966955e29bcdbd6a060f5bd949dc9d5abd3658cead/fastar-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28095bb8f821e85fc2764e1a55f03e5e2876dee2abe7cd0ee9420d929905d643", size = 818983, upload-time = "2026-04-13T17:09:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/2c4edf0910af2e814ff6d65b77a91196d472ca8a9fb2033bd983f6856caa/fastar-0.11.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0fafb95ecbe70f666a5e9b35dd63974ccdc9bb3d99ccdbd4014a823ec3e659b5", size = 884689, upload-time = "2026-04-13T17:08:42.763Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/04fdcbd6558e60de4ced3b55230fac47675d181252582b2fcec3c74608e5/fastar-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af48fed039b94016629dcdad1c95c90c486326dd068de2b0a4df419ee09b6821", size = 970677, upload-time = "2026-04-13T17:10:15.124Z" }, + { url = "https://files.pythonhosted.org/packages/df/b3/2b860a9658550167dbd5824c85e88d0b4b912bf493e42a6322544d6e483d/fastar-0.11.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:74cd96163f39b8638ab4e8d49708ca887959672a22871d8170d01f067319533b", size = 1034026, upload-time = "2026-04-13T17:10:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9b/fa42ea1188b144bac4b1b60753dfd449974a4d5eda132029ee7711569f94/fastar-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e8b993cb5613bab495ed482810bedc0986633fcb9a3b55c37ec88e0d6714f6a", size = 1071147, upload-time = "2026-04-13T17:10:48.833Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/d2e501556dca9f1fbc9246111a31792fb49ad908fa4927f34938a97a3604/fastar-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfe39d91fc28e37e06162d94afe01050220edb7df554acb5b702b5503e564816", size = 1028377, upload-time = "2026-04-13T17:11:06.374Z" }, + { url = "https://files.pythonhosted.org/packages/db/33/5f11f23eca0a569cd052507bc45dda2e5468697f8665728d25be44120f7d/fastar-0.11.0-cp313-cp313-win32.whl", hash = "sha256:c5f63d4d99ff4bfb37c659982ec413358bdee747005348756cc50a04d412d989", size = 454089, upload-time = "2026-04-13T17:11:46.821Z" }, + { url = "https://files.pythonhosted.org/packages/da/2f/35ff03c939cba7a255a9132367873fec6c355fd06a7f84fedcbaf4c8129f/fastar-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8690ed1928d31ded3ada308e1086525fb3871f5fa81e1b69601a3f7774004583", size = 486312, upload-time = "2026-04-13T17:11:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/ee9246cbfcbfd4144558f35e7e9a306ffe0a7564730a5188c45f21d2dab8/fastar-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:d977ded9d98a0719a305e0a4d5ee811f1d3e856d853a50acb8ae833c3cd6d5d2", size = 461975, upload-time = "2026-04-13T17:11:22.589Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, ] [[package]] @@ -1617,15 +1624,15 @@ wheels = [ [[package]] name = "prometheus-fastapi-instrumentator" -version = "7.1.0" +version = "8.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "prometheus-client" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/6d/24d53033cf93826aa7857699a4450c1c67e5b9c710e925b1ed2b320c04df/prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e", size = 20220, upload-time = "2025-03-19T19:35:05.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/7a/fe49b7060e585e2cfa28cc1d13ebfe9c2904dcd80cf11071d5de0f622ff2/prometheus_fastapi_instrumentator-8.0.0.tar.gz", hash = "sha256:c31ed192db077e75467b28b2fe5055362517f53369b798cb8dac9ff85f3f1c38", size = 20357, upload-time = "2026-05-29T13:04:43.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/72/0824c18f3bc75810f55dacc2dd933f6ec829771180245ae3cc976195dec0/prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9", size = 19296, upload-time = "2025-03-19T19:35:04.323Z" }, + { url = "https://files.pythonhosted.org/packages/73/ad/b14ed2fe73bb1d37bcc947e75afae018f795526415d5b849aeb99c403cd1/prometheus_fastapi_instrumentator-8.0.0-py3-none-any.whl", hash = "sha256:e3c967c402124dfe81cf5aad35208d9f96db5452c6cb752350d9358ca0a96198", size = 19411, upload-time = "2026-05-29T13:04:44.17Z" }, ] [[package]] @@ -1809,11 +1816,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [[package]] @@ -1895,11 +1902,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -2283,15 +2290,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]]