Hotfix: add logged-out password reset flow
This commit is contained in:
@@ -49,12 +49,21 @@ from ..services.user_cache import (
|
||||
match_jellyseerr_user_id,
|
||||
save_jellyfin_users_cache,
|
||||
)
|
||||
from ..services.invite_email import send_templated_email
|
||||
from ..services.invite_email import send_templated_email, smtp_email_config_ready
|
||||
from ..services.password_reset import (
|
||||
PasswordResetUnavailableError,
|
||||
apply_password_reset,
|
||||
request_password_reset,
|
||||
verify_password_reset_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
STREAM_TOKEN_TTL_SECONDS = 120
|
||||
PASSWORD_RESET_GENERIC_MESSAGE = (
|
||||
"If an account exists for that username or email, a password reset link has been sent."
|
||||
)
|
||||
|
||||
_LOGIN_RATE_LOCK = Lock()
|
||||
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
|
||||
@@ -223,6 +232,11 @@ def _extract_http_error_detail(exc: Exception) -> str:
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _requested_user_agent(request: Request) -> str:
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
return user_agent[:512]
|
||||
|
||||
|
||||
async def _refresh_jellyfin_user_cache(client: JellyfinClient) -> None:
|
||||
try:
|
||||
users = await client.get_users()
|
||||
@@ -880,6 +894,100 @@ async def signup(payload: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
async def forgot_password(payload: dict, request: Request) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
|
||||
if not isinstance(identifier, str) or not identifier.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username or email is required.")
|
||||
|
||||
ready, detail = smtp_email_config_ready()
|
||||
if not ready:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Password reset email is unavailable: {detail}",
|
||||
)
|
||||
|
||||
client_ip = _auth_client_ip(request)
|
||||
safe_identifier = identifier.strip().lower()[:256]
|
||||
logger.info("password reset requested identifier=%s client=%s", safe_identifier, client_ip)
|
||||
try:
|
||||
reset_result = await request_password_reset(
|
||||
identifier,
|
||||
requested_by_ip=client_ip,
|
||||
requested_user_agent=_requested_user_agent(request),
|
||||
)
|
||||
if reset_result.get("issued"):
|
||||
logger.info(
|
||||
"password reset issued username=%s provider=%s recipient=%s client=%s",
|
||||
reset_result.get("username"),
|
||||
reset_result.get("auth_provider"),
|
||||
reset_result.get("recipient_email"),
|
||||
client_ip,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"password reset request completed with no eligible account identifier=%s client=%s",
|
||||
safe_identifier,
|
||||
client_ip,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"password reset email dispatch failed identifier=%s client=%s detail=%s",
|
||||
safe_identifier,
|
||||
client_ip,
|
||||
str(exc),
|
||||
)
|
||||
return {"status": "ok", "message": PASSWORD_RESET_GENERIC_MESSAGE}
|
||||
|
||||
|
||||
@router.get("/password/reset/verify")
|
||||
async def password_reset_verify(token: str) -> dict:
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
|
||||
try:
|
||||
return verify_password_reset_token(token.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/password/reset")
|
||||
async def password_reset(payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
token = payload.get("token")
|
||||
new_password = payload.get("new_password")
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reset token is required.")
|
||||
if not isinstance(new_password, str) or len(new_password.strip()) < 8:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password must be at least 8 characters.",
|
||||
)
|
||||
|
||||
try:
|
||||
result = await apply_password_reset(token.strip(), new_password.strip())
|
||||
except PasswordResetUnavailableError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
detail = _extract_http_error_detail(exc)
|
||||
logger.warning("password reset failed token_present=%s detail=%s", bool(token), detail)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Password reset failed: {detail}",
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
"password reset completed username=%s provider=%s",
|
||||
result.get("username"),
|
||||
result.get("provider"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
username = current_user.get("username") or ""
|
||||
|
||||
Reference in New Issue
Block a user