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))
|
||||
|
||||
Reference in New Issue
Block a user