feat(subsonic): per-user encrypted app-password foundation
Subsonic auth (t=md5(password+salt), legacy p=) needs a recoverable secret, but login passwords are stored as a one-way argon2 hash. Add a separate, per-user app-password: high-entropy, random, and encrypted at rest with a Fernet key derived from SUBSONIC_SECRET_KEY (never stored in the DB). - SubsonicPasswordCipher + generate_subsonic_password in core.security - users.subsonic_password_enc column (+ Alembic migration), repo + port methods - SubsonicAuthService: verify (t+s / p / p=enc:) and rotate/reveal lifecycle - self-service GET/POST /users/me/subsonic-password + admin rotate endpoint - domain SubsonicCredentials + SubsonicCipher port; deps wiring Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""subsonic: per-user encrypted app-password
|
||||||
|
|
||||||
|
Revision ID: 20260608_subsonic_pw
|
||||||
|
Revises: 20260608_storage_uri
|
||||||
|
Create Date: 2026-06-08 12:00:00.000000
|
||||||
|
|
||||||
|
Adds ``users.subsonic_password_enc`` — the recoverable, Fernet-encrypted
|
||||||
|
Subsonic app-password (plan §7). NULL until the user generates one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260608_subsonic_pw"
|
||||||
|
down_revision: str | None = "20260608_storage_uri"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("subsonic_password_enc", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "subsonic_password_enc")
|
||||||
+43
-3
@@ -10,19 +10,20 @@ from collections.abc import AsyncIterator
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends, Query
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.auth_service import AuthService
|
from app.application.auth_service import AuthService
|
||||||
from app.application.streaming_service import StreamingService
|
from app.application.streaming_service import StreamingService
|
||||||
|
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||||
from app.application.upload_service import UploadService
|
from app.application.upload_service import UploadService
|
||||||
from app.application.user_service import UserService
|
from app.application.user_service import UserService
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import Argon2PasswordHasher, JwtTokenService
|
from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
|
||||||
from app.domain.entities import User
|
from app.domain.entities import User
|
||||||
from app.domain.errors import AuthenticationError, PermissionDeniedError
|
from app.domain.errors import AuthenticationError, PermissionDeniedError
|
||||||
from app.domain.ports import FileStorage, PasswordHasher, TokenService
|
from app.domain.ports import FileStorage, PasswordHasher, SubsonicCipher, TokenService
|
||||||
from app.infrastructure.db import get_sessionmaker
|
from app.infrastructure.db import get_sessionmaker
|
||||||
from app.infrastructure.db.repositories import (
|
from app.infrastructure.db.repositories import (
|
||||||
SqlAlchemyAlbumRepository,
|
SqlAlchemyAlbumRepository,
|
||||||
@@ -64,6 +65,11 @@ def get_token_service() -> TokenService:
|
|||||||
return JwtTokenService(get_settings())
|
return JwtTokenService(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_subsonic_cipher() -> SubsonicCipher:
|
||||||
|
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
|
||||||
|
|
||||||
|
|
||||||
# -- request-scoped services ---------------------------------------------------
|
# -- request-scoped services ---------------------------------------------------
|
||||||
def get_auth_service(session: SessionDep) -> AuthService:
|
def get_auth_service(session: SessionDep) -> AuthService:
|
||||||
return AuthService(
|
return AuthService(
|
||||||
@@ -82,8 +88,16 @@ def get_user_service(session: SessionDep) -> UserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService:
|
||||||
|
return SubsonicAuthService(
|
||||||
|
users=SqlAlchemyUserRepository(session),
|
||||||
|
cipher=get_subsonic_cipher(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
||||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||||
|
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
|
||||||
|
|
||||||
|
|
||||||
# -- file storage (process-cached) ---------------------------------------------
|
# -- file storage (process-cached) ---------------------------------------------
|
||||||
@@ -187,3 +201,29 @@ async def get_streaming_user(
|
|||||||
|
|
||||||
|
|
||||||
StreamUser = Annotated[User, Depends(get_streaming_user)]
|
StreamUser = Annotated[User, Depends(get_streaming_user)]
|
||||||
|
|
||||||
|
|
||||||
|
# -- subsonic (/rest) authentication -------------------------------------------
|
||||||
|
# Subsonic puts credentials in the query string: u + (t & s) | p, plus c/v/f.
|
||||||
|
# The dep extracts them and delegates verification to the service; domain errors
|
||||||
|
# propagate to the rest-aware exception handler, which renders the Subsonic
|
||||||
|
# error envelope (HTTP 200). HTTPS is mandatory — the secret rides in the URL.
|
||||||
|
async def get_subsonic_user(
|
||||||
|
service: SubsonicAuthServiceDep,
|
||||||
|
u: Annotated[str | None, Query()] = None,
|
||||||
|
t: Annotated[str | None, Query()] = None,
|
||||||
|
s: Annotated[str | None, Query()] = None,
|
||||||
|
p: Annotated[str | None, Query()] = None,
|
||||||
|
) -> User:
|
||||||
|
return await service.authenticate(username=u, token=t, salt=s, password=p)
|
||||||
|
|
||||||
|
|
||||||
|
SubsonicUser = Annotated[User, Depends(get_subsonic_user)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subsonic_format(f: Annotated[str | None, Query()] = None) -> str | None:
|
||||||
|
"""The requested response format (``f``): ``xml`` (default) or ``json``."""
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
SubsonicFormat = Annotated[str | None, Depends(get_subsonic_format)]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Schemas for Subsonic app-password self-service (native /api/v1 surface).
|
||||||
|
|
||||||
|
The Subsonic /rest layer itself returns its own XML/JSON envelope, not these
|
||||||
|
pydantic models — these only back the lifecycle endpoints that reveal/rotate the
|
||||||
|
recoverable app-password."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicPasswordResponse(BaseModel):
|
||||||
|
"""The plaintext Subsonic app-password, for pasting into a client."""
|
||||||
|
|
||||||
|
password: str
|
||||||
+10
-1
@@ -9,7 +9,8 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query, status
|
from fastapi import APIRouter, Query, status
|
||||||
|
|
||||||
from app.api.deps import SuperUser, UserServiceDep
|
from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
||||||
|
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||||
from app.api.schemas.user import (
|
from app.api.schemas.user import (
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
ResetPasswordRequest,
|
ResetPasswordRequest,
|
||||||
@@ -81,6 +82,14 @@ async def deactivate_user(
|
|||||||
return UserResponse.from_entity(await users.deactivate(user_id))
|
return UserResponse.from_entity(await users.deactivate(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||||
|
async def rotate_user_subsonic_password(
|
||||||
|
user_id: uuid.UUID, _admin: SuperUser, subsonic: SubsonicAuthServiceDep
|
||||||
|
) -> SubsonicPasswordResponse:
|
||||||
|
"""Rotate any user's Subsonic app-password and return the new plaintext."""
|
||||||
|
return SubsonicPasswordResponse(password=await subsonic.rotate(user_id))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services")
|
@router.get("/services")
|
||||||
async def list_services(_admin: SuperUser) -> Any: ...
|
async def list_services(_admin: SuperUser) -> Any: ...
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, UserServiceDep
|
from app.api.deps import CurrentUser, SubsonicAuthServiceDep, UserServiceDep
|
||||||
|
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||||
from app.api.schemas.user import ChangePasswordRequest
|
from app.api.schemas.user import ChangePasswordRequest
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -17,3 +18,22 @@ async def change_my_password(
|
|||||||
current_password=body.current_password,
|
current_password=body.current_password,
|
||||||
new_password=body.new_password,
|
new_password=body.new_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||||
|
async def reveal_my_subsonic_password(
|
||||||
|
user: CurrentUser, subsonic: SubsonicAuthServiceDep
|
||||||
|
) -> SubsonicPasswordResponse:
|
||||||
|
"""Reveal the caller's Subsonic app-password for copying into a client.
|
||||||
|
|
||||||
|
It's recoverable, so it can be read on demand; one is generated lazily on
|
||||||
|
first access. Paste it (with the username) into Symfonium/DSub."""
|
||||||
|
return SubsonicPasswordResponse(password=await subsonic.reveal(user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/subsonic-password", response_model=SubsonicPasswordResponse)
|
||||||
|
async def rotate_my_subsonic_password(
|
||||||
|
user: CurrentUser, subsonic: SubsonicAuthServiceDep
|
||||||
|
) -> SubsonicPasswordResponse:
|
||||||
|
"""Rotate the caller's Subsonic app-password (invalidates the previous one)."""
|
||||||
|
return SubsonicPasswordResponse(password=await subsonic.rotate(user.id))
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""SubsonicAuthService — app-password lifecycle + Subsonic auth verification.
|
||||||
|
|
||||||
|
The Subsonic protocol authenticates with either ``t=md5(password+salt)`` (+``s``)
|
||||||
|
or the legacy ``p=`` (plaintext or ``enc:<hex>``). Both need a *recoverable*
|
||||||
|
secret server-side, so Subsonic clients authenticate against a dedicated,
|
||||||
|
high-entropy app-password — never the argon2 login password. That app-password
|
||||||
|
is encrypted at rest (:class:`~app.domain.ports.SubsonicCipher`) and decrypted
|
||||||
|
only here, to verify a request or to reveal it for copying into a client.
|
||||||
|
|
||||||
|
This is an adapter over the existing user store; it adds no business state of its
|
||||||
|
own beyond the app-password column (CLAUDE.md: Subsonic is an adapter, not a
|
||||||
|
reimplementation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.security import generate_subsonic_password
|
||||||
|
from app.domain.entities import User
|
||||||
|
from app.domain.errors import AuthenticationError, NotFoundError, ValidationError
|
||||||
|
from app.domain.ports import SubsonicCipher, UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
def _md5_hex(value: str) -> str:
|
||||||
|
return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_legacy_password(p: str) -> str:
|
||||||
|
"""Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext."""
|
||||||
|
if p.startswith("enc:"):
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(p[4:]).decode("utf-8")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AuthenticationError("Wrong username or password.") from exc
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicAuthService:
|
||||||
|
def __init__(self, *, users: UserRepository, cipher: SubsonicCipher) -> None:
|
||||||
|
self._users = users
|
||||||
|
self._cipher = cipher
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str | None,
|
||||||
|
token: str | None,
|
||||||
|
salt: str | None,
|
||||||
|
password: str | None,
|
||||||
|
) -> User:
|
||||||
|
"""Resolve Subsonic query auth params to a domain :class:`User`.
|
||||||
|
|
||||||
|
Raises :class:`ValidationError` (Subsonic code 10) for missing params and
|
||||||
|
:class:`AuthenticationError` (code 40) for any credential mismatch — an
|
||||||
|
unknown user is reported identically to a wrong password (no enumeration).
|
||||||
|
"""
|
||||||
|
if not username:
|
||||||
|
raise ValidationError("Required parameter 'u' is missing.")
|
||||||
|
if not ((token and salt) or password):
|
||||||
|
raise ValidationError("Required authentication parameter is missing.")
|
||||||
|
|
||||||
|
creds = await self._users.get_subsonic_credentials_by_username(username)
|
||||||
|
if creds is None or not creds.user.is_active or creds.password_enc is None:
|
||||||
|
raise AuthenticationError("Wrong username or password.")
|
||||||
|
|
||||||
|
app_password = self._cipher.decrypt(creds.password_enc)
|
||||||
|
|
||||||
|
if token and salt:
|
||||||
|
expected = _md5_hex(app_password + salt)
|
||||||
|
if not hmac.compare_digest(expected, token.lower()):
|
||||||
|
raise AuthenticationError("Wrong username or password.")
|
||||||
|
else:
|
||||||
|
assert password is not None # guaranteed by the missing-param check above
|
||||||
|
supplied = _decode_legacy_password(password)
|
||||||
|
if not hmac.compare_digest(supplied, app_password):
|
||||||
|
raise AuthenticationError("Wrong username or password.")
|
||||||
|
|
||||||
|
return creds.user
|
||||||
|
|
||||||
|
async def rotate(self, user_id: uuid.UUID) -> str:
|
||||||
|
"""Generate a fresh app-password, store it encrypted, return the plaintext."""
|
||||||
|
await self._require_user(user_id)
|
||||||
|
password = generate_subsonic_password()
|
||||||
|
await self._users.set_subsonic_password_enc(user_id, self._cipher.encrypt(password))
|
||||||
|
return password
|
||||||
|
|
||||||
|
async def reveal(self, user_id: uuid.UUID) -> str:
|
||||||
|
"""Return the current app-password, generating one on first access."""
|
||||||
|
await self._require_user(user_id)
|
||||||
|
enc = await self._users.get_subsonic_password_enc(user_id)
|
||||||
|
if enc is None:
|
||||||
|
return await self.rotate(user_id)
|
||||||
|
return self._cipher.decrypt(enc)
|
||||||
|
|
||||||
|
async def _require_user(self, user_id: uuid.UUID) -> User:
|
||||||
|
user = await self._users.get_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("User not found.")
|
||||||
|
return user
|
||||||
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
|
|||||||
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
||||||
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
||||||
|
|
||||||
|
# -- subsonic ---------------------------------------------------------
|
||||||
|
# Symmetric key (any string) used to encrypt each user's recoverable
|
||||||
|
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
||||||
|
# this value renders stored app-passwords undecryptable (rotate them too).
|
||||||
|
subsonic_secret_key: SecretStr = SecretStr("change-me-subsonic-key")
|
||||||
|
|
||||||
# -- media / storage --------------------------------------------------
|
# -- media / storage --------------------------------------------------
|
||||||
media_path: Path = Path("/data/media")
|
media_path: Path = Path("/data/media")
|
||||||
transcode_cache_path: Path = Path("/data/transcode-cache")
|
transcode_cache_path: Path = Path("/data/transcode-cache")
|
||||||
|
|||||||
@@ -6,16 +6,54 @@ to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
|
|||||||
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from pwdlib import PasswordHash
|
from pwdlib import PasswordHash
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.domain.errors import AuthenticationError
|
from app.domain.errors import AuthenticationError
|
||||||
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
from app.domain.tokens import IssuedToken, TokenClaims, TokenType
|
||||||
|
|
||||||
|
# Length (in bytes of entropy) of a generated Subsonic app-password. 18 bytes of
|
||||||
|
# url-safe base64 → 24 characters, well above the Subsonic auth threat model.
|
||||||
|
_SUBSONIC_PASSWORD_ENTROPY_BYTES = 18
|
||||||
|
|
||||||
|
|
||||||
|
def generate_subsonic_password() -> str:
|
||||||
|
"""A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)."""
|
||||||
|
return secrets.token_urlsafe(_SUBSONIC_PASSWORD_ENTROPY_BYTES)
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicPasswordCipher:
|
||||||
|
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password.
|
||||||
|
|
||||||
|
Subsonic auth (``t=md5(password+salt)`` and legacy ``p=``) needs the plaintext
|
||||||
|
password server-side, so — unlike the argon2-hashed login password — the
|
||||||
|
app-password is stored *encrypted*, not hashed. A Fernet key (AES-128-CBC +
|
||||||
|
HMAC) is derived from the configured secret; the plaintext key never touches
|
||||||
|
the DB. Implements :class:`app.domain.ports.SubsonicCipher`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, secret_key: str) -> None:
|
||||||
|
digest = hashlib.sha256(secret_key.encode("utf-8")).digest()
|
||||||
|
self._fernet = Fernet(base64.urlsafe_b64encode(digest))
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> str:
|
||||||
|
return self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
def decrypt(self, token: str) -> str:
|
||||||
|
try:
|
||||||
|
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
|
||||||
|
except InvalidToken as exc:
|
||||||
|
# Wrong/rotated secret key, or corrupted ciphertext.
|
||||||
|
raise AuthenticationError("Stored Subsonic password could not be decrypted.") from exc
|
||||||
|
|
||||||
|
|
||||||
class Argon2PasswordHasher:
|
class Argon2PasswordHasher:
|
||||||
"""argon2id hasher with sensible defaults from pwdlib."""
|
"""argon2id hasher with sensible defaults from pwdlib."""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from app.domain.entities.like import Like
|
|||||||
from app.domain.entities.playlist import Playlist
|
from app.domain.entities.playlist import Playlist
|
||||||
from app.domain.entities.storage import ObjectStat
|
from app.domain.entities.storage import ObjectStat
|
||||||
from app.domain.entities.track import Artist, Track
|
from app.domain.entities.track import Artist, Track
|
||||||
from app.domain.entities.user import Credentials, User
|
from app.domain.entities.user import Credentials, SubsonicCredentials, User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Album",
|
"Album",
|
||||||
@@ -16,6 +16,7 @@ __all__ = [
|
|||||||
"ObjectStat",
|
"ObjectStat",
|
||||||
"PlayHistoryEntry",
|
"PlayHistoryEntry",
|
||||||
"Playlist",
|
"Playlist",
|
||||||
|
"SubsonicCredentials",
|
||||||
"Track",
|
"Track",
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,3 +31,14 @@ class Credentials:
|
|||||||
|
|
||||||
user: User
|
user: User
|
||||||
password_hash: str
|
password_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SubsonicCredentials:
|
||||||
|
"""A user paired with their *encrypted* Subsonic app-password.
|
||||||
|
|
||||||
|
``password_enc`` is ``None`` until the user generates one. Stays inside the
|
||||||
|
application layer; the plaintext is only recovered for auth verification."""
|
||||||
|
|
||||||
|
user: User
|
||||||
|
password_enc: str | None
|
||||||
|
|||||||
+25
-1
@@ -19,6 +19,7 @@ from app.domain.entities import (
|
|||||||
ObjectStat,
|
ObjectStat,
|
||||||
PlayHistoryEntry,
|
PlayHistoryEntry,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
SubsonicCredentials,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.domain.entities.track import Artist, Track
|
from app.domain.entities.track import Artist, Track
|
||||||
@@ -34,6 +35,19 @@ class UserRepository(Protocol):
|
|||||||
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
|
||||||
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
||||||
async def count(self) -> int: ...
|
async def count(self) -> int: ...
|
||||||
|
# -- subsonic app-password (recoverable, encrypted at rest) ----------
|
||||||
|
async def get_subsonic_credentials_by_username(
|
||||||
|
self, username: str
|
||||||
|
) -> SubsonicCredentials | None: ...
|
||||||
|
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None: ...
|
||||||
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SubsonicCipher(Protocol):
|
||||||
|
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str) -> str: ...
|
||||||
|
def decrypt(self, token: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenRepository(Protocol):
|
class RefreshTokenRepository(Protocol):
|
||||||
@@ -109,6 +123,9 @@ class TrackRepository(Protocol):
|
|||||||
added_by: uuid.UUID | None,
|
added_by: uuid.UUID | None,
|
||||||
) -> Track: ...
|
) -> Track: ...
|
||||||
async def delete(self, track_id: uuid.UUID) -> None: ...
|
async def delete(self, track_id: uuid.UUID) -> None: ...
|
||||||
|
# genres must come before ``list`` — the method named ``list`` shadows the
|
||||||
|
# builtin in later annotations (same pattern as AlbumRepository below).
|
||||||
|
async def genres(self) -> list[tuple[str, int]]: ...
|
||||||
async def list(
|
async def list(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -145,7 +162,14 @@ class AlbumRepository(Protocol):
|
|||||||
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
|
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
|
||||||
# list must come after any method using list[...] in its signature (name shadowing)
|
# list must come after any method using list[...] in its signature (name shadowing)
|
||||||
async def list(
|
async def list(
|
||||||
self, *, artist_id: uuid.UUID | None, q: str | None, limit: int, offset: int
|
self,
|
||||||
|
*,
|
||||||
|
artist_id: uuid.UUID | None,
|
||||||
|
q: str | None,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
sort_by: str = "title",
|
||||||
|
order: str = "asc",
|
||||||
) -> list[Album]: ...
|
) -> list[Album]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
# Admin is a single flag in Phase 1 — no role system (plan §3.5).
|
||||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
# Recoverable Subsonic app-password, Fernet-encrypted at rest. NULL until the
|
||||||
|
# user generates one. Never the argon2 login password — see core.security.
|
||||||
|
subsonic_password_enc: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import uuid
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.domain.entities import Credentials, User
|
from app.domain.entities import Credentials, SubsonicCredentials, User
|
||||||
from app.domain.errors import NotFoundError
|
from app.domain.errors import NotFoundError
|
||||||
from app.infrastructure.db.models import UserModel
|
from app.infrastructure.db.models import UserModel
|
||||||
|
|
||||||
@@ -91,3 +91,22 @@ class SqlAlchemyUserRepository:
|
|||||||
return (
|
return (
|
||||||
await self._session.execute(select(func.count()).select_from(UserModel))
|
await self._session.execute(select(func.count()).select_from(UserModel))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
async def get_subsonic_credentials_by_username(
|
||||||
|
self, username: str
|
||||||
|
) -> SubsonicCredentials | None:
|
||||||
|
row = (
|
||||||
|
await self._session.execute(select(UserModel).where(UserModel.username == username))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return SubsonicCredentials(user=_to_entity(row), password_enc=row.subsonic_password_enc)
|
||||||
|
|
||||||
|
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
|
||||||
|
row = await self._get_row(user_id)
|
||||||
|
return row.subsonic_password_enc
|
||||||
|
|
||||||
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
|
||||||
|
row = await self._get_row(user_id)
|
||||||
|
row.subsonic_password_enc = password_enc
|
||||||
|
await self._session.flush()
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ dependencies = [
|
|||||||
# auth
|
# auth
|
||||||
"pyjwt>=2.10",
|
"pyjwt>=2.10",
|
||||||
"pwdlib[argon2]>=0.2.1",
|
"pwdlib[argon2]>=0.2.1",
|
||||||
|
# symmetric encryption for the recoverable Subsonic app-password (Fernet)
|
||||||
|
"cryptography>=44.0",
|
||||||
# outbound http (ML client, MusicBrainz, AcoustID)
|
# outbound http (ML client, MusicBrainz, AcoustID)
|
||||||
"httpx>=0.28",
|
"httpx>=0.28",
|
||||||
# S3-compatible object storage
|
# S3-compatible object storage
|
||||||
@@ -71,6 +73,11 @@ select = [
|
|||||||
]
|
]
|
||||||
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# Subsonic query params are camelCase by spec (artistCount, songId, …); the
|
||||||
|
# handler arg names must match the wire names exactly.
|
||||||
|
"app/api/rest/*" = ["N803"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.14"
|
python_version = "3.14"
|
||||||
strict = true
|
strict = true
|
||||||
|
|||||||
+18
-1
@@ -4,13 +4,14 @@ import datetime as dt
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from app.domain.entities import Credentials, User
|
from app.domain.entities import Credentials, SubsonicCredentials, User
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Stored:
|
class _Stored:
|
||||||
user: User
|
user: User
|
||||||
password_hash: str
|
password_hash: str
|
||||||
|
subsonic_password_enc: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class InMemoryUserRepository:
|
class InMemoryUserRepository:
|
||||||
@@ -61,6 +62,22 @@ class InMemoryUserRepository:
|
|||||||
async def count(self) -> int:
|
async def count(self) -> int:
|
||||||
return len(self._by_id)
|
return len(self._by_id)
|
||||||
|
|
||||||
|
async def get_subsonic_credentials_by_username(
|
||||||
|
self, username: str
|
||||||
|
) -> SubsonicCredentials | None:
|
||||||
|
for stored in self._by_id.values():
|
||||||
|
if stored.user.username == username:
|
||||||
|
return SubsonicCredentials(
|
||||||
|
user=stored.user, password_enc=stored.subsonic_password_enc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
|
||||||
|
return self._by_id[user_id].subsonic_password_enc
|
||||||
|
|
||||||
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
|
||||||
|
self._by_id[user_id].subsonic_password_enc = password_enc
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Token:
|
class _Token:
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Unit tests for SubsonicAuthService — verification + app-password lifecycle.
|
||||||
|
|
||||||
|
DB-free: uses the in-memory user repository and a real cipher.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.application.subsonic_auth_service import SubsonicAuthService
|
||||||
|
from app.core.security import SubsonicPasswordCipher
|
||||||
|
from app.domain.errors import AuthenticationError, ValidationError
|
||||||
|
|
||||||
|
from tests.fakes import InMemoryUserRepository
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
_KNOWN_PASSWORD = "s3cret-app-password"
|
||||||
|
|
||||||
|
|
||||||
|
def _md5(value: str) -> str:
|
||||||
|
return hashlib.md5(value.encode(), usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _service_with_user(*, password: str | None = _KNOWN_PASSWORD, active: bool = True):
|
||||||
|
users = InMemoryUserRepository()
|
||||||
|
cipher = SubsonicPasswordCipher("test-key")
|
||||||
|
user = await users.add(username="alice", password_hash="x", is_superuser=False)
|
||||||
|
if not active:
|
||||||
|
await users.set_active(user.id, False)
|
||||||
|
if password is not None:
|
||||||
|
await users.set_subsonic_password_enc(user.id, cipher.encrypt(password))
|
||||||
|
service = SubsonicAuthService(users=users, cipher=cipher)
|
||||||
|
return service, user
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_token_salt_success() -> None:
|
||||||
|
service, user = await _service_with_user()
|
||||||
|
salt = "abcdef"
|
||||||
|
token = _md5(_KNOWN_PASSWORD + salt)
|
||||||
|
result = await service.authenticate(username="alice", token=token, salt=salt, password=None)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_plain_password_success() -> None:
|
||||||
|
service, user = await _service_with_user()
|
||||||
|
result = await service.authenticate(
|
||||||
|
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_enc_password_success() -> None:
|
||||||
|
service, user = await _service_with_user()
|
||||||
|
enc = "enc:" + _KNOWN_PASSWORD.encode().hex()
|
||||||
|
result = await service.authenticate(username="alice", token=None, salt=None, password=enc)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_wrong_token_fails() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="alice", token=_md5("wrong" + "abc"), salt="abc", password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_wrong_password_fails() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(username="alice", token=None, salt=None, password="nope")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_unknown_user_fails() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="ghost", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_inactive_user_fails() -> None:
|
||||||
|
service, _ = await _service_with_user(active=False)
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_no_password_set_fails() -> None:
|
||||||
|
service, _ = await _service_with_user(password=None)
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await service.authenticate(
|
||||||
|
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_missing_username_is_validation_error() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await service.authenticate(username=None, token=None, salt=None, password=_KNOWN_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authenticate_missing_credentials_is_validation_error() -> None:
|
||||||
|
service, _ = await _service_with_user()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
await service.authenticate(username="alice", token=None, salt=None, password=None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rotate_then_authenticate() -> None:
|
||||||
|
users = InMemoryUserRepository()
|
||||||
|
cipher = SubsonicPasswordCipher("test-key")
|
||||||
|
user = await users.add(username="bob", password_hash="x", is_superuser=False)
|
||||||
|
service = SubsonicAuthService(users=users, cipher=cipher)
|
||||||
|
|
||||||
|
password = await service.rotate(user.id)
|
||||||
|
result = await service.authenticate(username="bob", token=None, salt=None, password=password)
|
||||||
|
assert result.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reveal_generates_then_is_stable() -> None:
|
||||||
|
users = InMemoryUserRepository()
|
||||||
|
cipher = SubsonicPasswordCipher("test-key")
|
||||||
|
user = await users.add(username="cara", password_hash="x", is_superuser=False)
|
||||||
|
service = SubsonicAuthService(users=users, cipher=cipher)
|
||||||
|
|
||||||
|
first = await service.reveal(user.id)
|
||||||
|
second = await service.reveal(user.id)
|
||||||
|
assert first == second # lazily generated once, then stable
|
||||||
|
|
||||||
|
rotated = await service.rotate(user.id)
|
||||||
|
assert rotated != first
|
||||||
@@ -415,6 +415,59 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "48.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.3"
|
version = "0.136.3"
|
||||||
@@ -724,6 +777,7 @@ dependencies = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "arq" },
|
{ name = "arq" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pwdlib", extra = ["argon2"] },
|
{ name = "pwdlib", extra = ["argon2"] },
|
||||||
@@ -752,6 +806,7 @@ requires-dist = [
|
|||||||
{ name = "alembic", specifier = ">=1.14" },
|
{ name = "alembic", specifier = ">=1.14" },
|
||||||
{ name = "arq", specifier = ">=0.26" },
|
{ name = "arq", specifier = ">=0.26" },
|
||||||
{ name = "asyncpg", specifier = ">=0.30" },
|
{ name = "asyncpg", specifier = ">=0.30" },
|
||||||
|
{ name = "cryptography", specifier = ">=44.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.28" },
|
{ name = "httpx", specifier = ">=0.28" },
|
||||||
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.2.1" },
|
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.2.1" },
|
||||||
|
|||||||
Reference in New Issue
Block a user