Finalize diagnostics, logging controls, and email test support
This commit is contained in:
@@ -115,6 +115,7 @@ def _record_login_failure(request: Request, username: str) -> None:
|
||||
_prune_attempts(user_bucket, now, window)
|
||||
ip_bucket.append(now)
|
||||
user_bucket.append(now)
|
||||
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
|
||||
|
||||
|
||||
def _clear_login_failures(request: Request, username: str) -> None:
|
||||
@@ -148,6 +149,12 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
|
||||
if retry_candidates:
|
||||
retry_after = max(retry_candidates)
|
||||
if exceeded:
|
||||
logger.warning(
|
||||
"login rate limit exceeded username=%s client=%s retry_after=%s",
|
||||
user_key,
|
||||
ip_key,
|
||||
retry_after,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts. Try again shortly.",
|
||||
@@ -474,6 +481,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
||||
@router.post("/login")
|
||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=local username=%s client=%s",
|
||||
_login_rate_key_user(form_data.username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
# Provider placeholder passwords must never be accepted by the local-login endpoint.
|
||||
if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
|
||||
_record_login_failure(request, form_data.username)
|
||||
@@ -483,6 +495,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
||||
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
|
||||
)
|
||||
if has_external_match:
|
||||
logger.warning(
|
||||
"login rejected provider=local username=%s reason=external-account client=%s",
|
||||
_login_rate_key_user(form_data.username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This account uses external sign-in. Use the external sign-in option.",
|
||||
@@ -492,6 +509,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if user.get("auth_provider") != "local":
|
||||
logger.warning(
|
||||
"login rejected provider=local username=%s reason=wrong-provider client=%s",
|
||||
_login_rate_key_user(form_data.username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This account uses external sign-in. Use the external sign-in option.",
|
||||
@@ -500,6 +522,12 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
||||
token = create_access_token(user["username"], user["role"])
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(user["username"])
|
||||
logger.info(
|
||||
"login success provider=local username=%s role=%s client=%s",
|
||||
user["username"],
|
||||
user["role"],
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -510,6 +538,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
|
||||
@router.post("/jellyfin/login")
|
||||
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=jellyfin username=%s client=%s",
|
||||
_login_rate_key_user(form_data.username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
@@ -527,6 +560,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
token = create_access_token(canonical_username, "user")
|
||||
_clear_login_failures(request, username)
|
||||
set_last_login(canonical_username)
|
||||
logger.info(
|
||||
"login success provider=jellyfin username=%s source=cache client=%s",
|
||||
canonical_username,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -535,6 +573,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
try:
|
||||
response = await client.authenticate_by_name(username, password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=jellyfin username=%s client=%s",
|
||||
_login_rate_key_user(username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict) or not response.get("User"):
|
||||
_record_login_failure(request, username)
|
||||
@@ -564,6 +607,12 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
token = create_access_token(canonical_username, "user")
|
||||
_clear_login_failures(request, username)
|
||||
set_last_login(canonical_username)
|
||||
logger.info(
|
||||
"login success provider=jellyfin username=%s linked_seerr_id=%s client=%s",
|
||||
canonical_username,
|
||||
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -575,6 +624,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
||||
@router.post("/jellyseerr/login")
|
||||
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
_enforce_login_rate_limit(request, form_data.username)
|
||||
logger.info(
|
||||
"login attempt provider=seerr username=%s client=%s",
|
||||
_login_rate_key_user(form_data.username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
@@ -582,6 +636,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
try:
|
||||
response = await client.login_local(form_data.username, form_data.password)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"login upstream error provider=seerr username=%s client=%s",
|
||||
_login_rate_key_user(form_data.username),
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict):
|
||||
_record_login_failure(request, form_data.username)
|
||||
@@ -605,6 +664,12 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
|
||||
token = create_access_token(canonical_username, "user")
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(canonical_username)
|
||||
logger.info(
|
||||
"login success provider=seerr username=%s seerr_user_id=%s client=%s",
|
||||
canonical_username,
|
||||
jellyseerr_user_id,
|
||||
_auth_client_ip(request),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -663,6 +728,11 @@ async def signup(payload: dict) -> dict:
|
||||
)
|
||||
if get_user_by_username(username):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
|
||||
logger.info(
|
||||
"signup attempt username=%s invite_code=%s",
|
||||
username,
|
||||
invite_code,
|
||||
)
|
||||
|
||||
invite = get_signup_invite_by_code(invite_code)
|
||||
if not invite:
|
||||
@@ -709,6 +779,7 @@ async def signup(payload: dict) -> dict:
|
||||
|
||||
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if jellyfin_client.configured():
|
||||
logger.info("signup provisioning jellyfin username=%s", username)
|
||||
auth_provider = "jellyfin"
|
||||
local_password_value = "jellyfin-user"
|
||||
try:
|
||||
@@ -788,6 +859,14 @@ async def signup(payload: dict) -> dict:
|
||||
_assert_user_can_login(created_user)
|
||||
token = create_access_token(username, role)
|
||||
set_last_login(username)
|
||||
logger.info(
|
||||
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
|
||||
username,
|
||||
role,
|
||||
created_user.get("auth_provider") if created_user else auth_provider,
|
||||
created_user.get("profile_id") if created_user else None,
|
||||
invite.get("code"),
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
|
||||
Reference in New Issue
Block a user