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:
+43
-3
@@ -10,19 +10,20 @@ from collections.abc import AsyncIterator
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.auth_service import AuthService
|
||||
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.user_service import UserService
|
||||
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.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.repositories import (
|
||||
SqlAlchemyAlbumRepository,
|
||||
@@ -64,6 +65,11 @@ def get_token_service() -> TokenService:
|
||||
return JwtTokenService(get_settings())
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_subsonic_cipher() -> SubsonicCipher:
|
||||
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
|
||||
|
||||
|
||||
# -- request-scoped services ---------------------------------------------------
|
||||
def get_auth_service(session: SessionDep) -> 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)]
|
||||
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
|
||||
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
|
||||
|
||||
|
||||
# -- file storage (process-cached) ---------------------------------------------
|
||||
@@ -187,3 +201,29 @@ async def 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 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 (
|
||||
CreateUserRequest,
|
||||
ResetPasswordRequest,
|
||||
@@ -81,6 +82,14 @@ async def deactivate_user(
|
||||
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")
|
||||
async def list_services(_admin: SuperUser) -> Any: ...
|
||||
|
||||
|
||||
+21
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
@@ -17,3 +18,22 @@ async def change_my_password(
|
||||
current_password=body.current_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
|
||||
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_path: Path = Path("/data/media")
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.errors import AuthenticationError
|
||||
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:
|
||||
"""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.storage import ObjectStat
|
||||
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__ = [
|
||||
"Album",
|
||||
@@ -16,6 +16,7 @@ __all__ = [
|
||||
"ObjectStat",
|
||||
"PlayHistoryEntry",
|
||||
"Playlist",
|
||||
"SubsonicCredentials",
|
||||
"Track",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -31,3 +31,14 @@ class Credentials:
|
||||
|
||||
user: User
|
||||
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,
|
||||
PlayHistoryEntry,
|
||||
Playlist,
|
||||
SubsonicCredentials,
|
||||
User,
|
||||
)
|
||||
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_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
|
||||
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):
|
||||
@@ -109,6 +123,9 @@ class TrackRepository(Protocol):
|
||||
added_by: uuid.UUID | None,
|
||||
) -> Track: ...
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -145,7 +162,14 @@ class AlbumRepository(Protocol):
|
||||
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)
|
||||
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]: ...
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
# 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_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):
|
||||
|
||||
@@ -10,7 +10,7 @@ import uuid
|
||||
from sqlalchemy import func, select
|
||||
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.infrastructure.db.models import UserModel
|
||||
|
||||
@@ -91,3 +91,22 @@ class SqlAlchemyUserRepository:
|
||||
return (
|
||||
await self._session.execute(select(func.count()).select_from(UserModel))
|
||||
).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()
|
||||
|
||||
Reference in New Issue
Block a user