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:
Senko-san
2026-06-08 18:23:19 +03:00
parent 4ade6939b6
commit 7a17e3babd
17 changed files with 535 additions and 9 deletions
+43 -3
View File
@@ -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)]