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
@@ -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()