7a17e3babd
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>
40 lines
1.5 KiB
Python
40 lines
1.5 KiB
Python
"""Self-service user endpoints (the authenticated caller acts on themselves)."""
|
|
|
|
from fastapi import APIRouter, status
|
|
|
|
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"])
|
|
|
|
|
|
@router.patch("/me/password", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def change_my_password(
|
|
body: ChangePasswordRequest, user: CurrentUser, users: UserServiceDep
|
|
) -> None:
|
|
await users.change_password(
|
|
user.id,
|
|
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))
|