added password hashing and ensured admin user exists #8

Merged
brian merged 3 commits from add-password-hashing into main 2025-11-08 00:04:44 +01:00
6 changed files with 181 additions and 61 deletions
Showing only changes of commit 7b4e597e6f - Show all commits
+2 -2
View File
@@ -13,8 +13,8 @@ services:
environment: environment:
- ENVIRONMENT=development - ENVIRONMENT=development
# Optional: Override admin credentials for development # Optional: Override admin credentials for development
# - ADMIN_USERNAME=admin - ADMIN_USERNAME=admin
# - ADMIN_PASSWORD=devpassword - ADMIN_PASSWORD=password
# Optional: Use Redis for token storage (uncomment redis service below) # Optional: Use Redis for token storage (uncomment redis service below)
# - REDIS_URI=redis://redis:6379/0 # - REDIS_URI=redis://redis:6379/0
command: ["--host", "0.0.0.0", "--port", "8000", "--reload"] command: ["--host", "0.0.0.0", "--port", "8000", "--reload"]
+1
View File
@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"bcrypt>=4.0.0",
"fastapi>=0.121.0", "fastapi>=0.121.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"uvicorn>=0.38.0", "uvicorn>=0.38.0",
@@ -30,6 +30,50 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base: "type[DeclarativeBase]" = declarative_base() Base: "type[DeclarativeBase]" = declarative_base()
def _ensure_admin_user() -> None:
"""Create or update admin user from environment variables."""
from baby_monitor.models.db.user import User
from baby_monitor.utils.password import hash_password
admin_username = os.getenv("ADMIN_USERNAME")
admin_password = os.getenv("ADMIN_PASSWORD")
if not admin_username:
raise RuntimeError(
"ADMIN_USERNAME environment variable is required but not set"
)
if not admin_password:
raise RuntimeError(
"ADMIN_PASSWORD environment variable is required but not set"
)
db = SessionLocal()
try:
# Check if admin user exists
admin_user = db.query(User).filter(
User.username == admin_username
).first()
hashed_pw = hash_password(admin_password)
if admin_user:
# Update existing admin user's password and ensure admin flag
admin_user.hashed_password = hashed_pw # type: ignore[assignment]
admin_user.is_admin = True # type: ignore[assignment]
db.commit()
else:
# Create new admin user
new_admin = User(
username=admin_username,
hashed_password=hashed_pw,
is_admin=True,
)
db.add(new_admin)
db.commit()
finally:
db.close()
def init_db() -> None: def init_db() -> None:
"""Initialize the database by creating all tables.""" """Initialize the database by creating all tables."""
# Import models to register them with Base.metadata # Import models to register them with Base.metadata
@@ -41,6 +85,9 @@ def init_db() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Create admin user if it doesn't exist
_ensure_admin_user()
def get_database() -> Generator[Session, None, None]: def get_database() -> Generator[Session, None, None]:
""" """
+30 -59
View File
@@ -12,7 +12,6 @@ from baby_monitor.models.auth import (
) )
from baby_monitor.repositories import ( from baby_monitor.repositories import (
get_token_repository, get_token_repository,
get_credentials_repository,
get_user_repository, get_user_repository,
) )
from baby_monitor.repositories.dependencies.get_invitation_repository import ( from baby_monitor.repositories.dependencies.get_invitation_repository import (
@@ -20,10 +19,10 @@ from baby_monitor.repositories.dependencies.get_invitation_repository import (
) )
from baby_monitor.repositories.interfaces import ( from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface, TokenRepositoryInterface,
CredentialsRepositoryInterface,
UserRepositoryInterface, UserRepositoryInterface,
InvitationRepositoryInterface, InvitationRepositoryInterface,
) )
from baby_monitor.utils.password import hash_password, verify_password
router = APIRouter(prefix="/api", tags=["authentication"]) router = APIRouter(prefix="/api", tags=["authentication"])
@@ -55,22 +54,15 @@ def verify_admin(
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
) -> int: ) -> int:
"""Verify the user is an admin and return user_id.""" """Verify the user is an admin and return user_id."""
# First try to get user from database
user = user_repo.get_by_id(user_id) user = user_repo.get_by_id(user_id)
if user: if not user:
# Database user - check is_admin field raise HTTPException(status_code=401, detail="User not found")
if not user.get("is_admin", False):
raise HTTPException(status_code=403, detail="Admin access required")
return user_id
# If not in database but has valid token with user_id=1, if not user.get("is_admin", False):
# it's the environment-based admin (only assigned during env admin login) raise HTTPException(status_code=403, detail="Admin access required")
if user_id == 1:
return user_id
# User not found and not environment admin return user_id
raise HTTPException(status_code=401, detail="User not found")
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
@@ -78,9 +70,6 @@ def login(
credentials: LoginRequest, credentials: LoginRequest,
token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)], token_repo: Annotated[TokenRepositoryInterface, Depends(get_token_repository)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository)
],
) -> LoginResponse: ) -> LoginResponse:
""" """
API endpoint for user authentication. API endpoint for user authentication.
@@ -88,38 +77,29 @@ def login(
Returns user info and access token on successful login. Returns user info and access token on successful login.
Raises 401 on invalid credentials. Raises 401 on invalid credentials.
""" """
# First check if it's a database user # Check if user exists in database
user = user_repo.get_by_username(credentials.username) user = user_repo.get_by_username(credentials.username)
if user: if not user:
# TODO: Use proper password hashing (bcrypt/argon2) raise HTTPException(
# For now, compare plain text (matches registration) status_code=401, detail="Invalid username or password"
if user["hashed_password"] == credentials.password:
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600)
return LoginResponse(
message="Login successful",
username=credentials.username,
access_token=access_token,
is_admin=user.get("is_admin", False),
)
# Fallback to admin credentials from environment
if creds_repo.verify_admin_credentials(credentials.username, credentials.password):
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
# Store token with user_id (hardcoded 1 for admin)
token_repo.store(access_token, user_id=1, ttl=3600)
return LoginResponse(
message="Login successful",
username=credentials.username,
access_token=access_token,
is_admin=True,
) )
raise HTTPException(status_code=401, detail="Invalid username or password") # Verify password using bcrypt
if not verify_password(credentials.password, user["hashed_password"]):
raise HTTPException(
status_code=401, detail="Invalid username or password"
)
# Generate a secure random token
access_token = secrets.token_urlsafe(32)
token_repo.store(access_token, user_id=user["id"], ttl=3600)
return LoginResponse(
message="Login successful",
username=credentials.username,
access_token=access_token,
is_admin=user.get("is_admin", False),
)
@router.post("/logout") @router.post("/logout")
@@ -177,11 +157,11 @@ def register(
detail="Username already exists", detail="Username already exists",
) )
# Create the new user # Create the new user with hashed password
# TODO: Hash password before storing (currently plain text) hashed_pw = hash_password(request.password)
user = user_repo.create( user = user_repo.create(
username=request.username, username=request.username,
hashed_password=request.password, hashed_password=hashed_pw,
) )
# Consume the invitation token # Consume the invitation token
@@ -203,21 +183,12 @@ def register(
def get_current_user( def get_current_user(
user_id: Annotated[int, Depends(verify_token)], user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
creds_repo: Annotated[
CredentialsRepositoryInterface, Depends(get_credentials_repository)
],
) -> dict: ) -> dict:
"""Get current user info including admin status.""" """Get current user info including admin status."""
user = user_repo.get_by_id(user_id) user = user_repo.get_by_id(user_id)
# If user not found in DB, might be env-based admin
if not user: if not user:
# Return admin info from environment raise HTTPException(status_code=404, detail="User not found")
return {
"id": user_id,
"username": creds_repo.get_admin_username(),
"is_admin": True, # Env-based admin is always admin
}
return { return {
"id": user["id"], "id": user["id"],
+33
View File
@@ -0,0 +1,33 @@
"""Password hashing utilities using bcrypt."""
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password to hash
Returns:
Hashed password as a string
"""
salt = bcrypt.gensalt()
hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(password: str, hashed_password: str) -> bool:
"""Verify a password against a hashed password.
Args:
password: Plain text password to verify
hashed_password: Previously hashed password
Returns:
True if password matches, False otherwise
"""
result: bool = bcrypt.checkpw(
password.encode("utf-8"), hashed_password.encode("utf-8")
)
return result
Generated
+68
View File
@@ -39,6 +39,7 @@ name = "baby-monitor"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn" }, { name = "uvicorn" },
@@ -67,6 +68,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bcrypt", specifier = ">=4.0.0" },
{ name = "fastapi", specifier = ">=0.121.0" }, { name = "fastapi", specifier = ">=0.121.0" },
{ name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.0" }, { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9.0" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" }, { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" },
@@ -88,6 +90,72 @@ dev = [
{ name = "types-psycopg2", specifier = ">=2.9.21.20251012" }, { name = "types-psycopg2", specifier = ">=2.9.21.20251012" },
] ]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "http://10.0.0.2:5001/index/" }
sdist = { url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" }
wheels = [
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" },
{ url = "http://10.0.0.2:5001/index/bcrypt/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.10.5" version = "2025.10.5"