6f728cf853
- Raise ValueError on missing request body in auth_logout - Add username check in verify_authentication to prevent credential reuse - Replace N+1 queries in list_users with single JOIN query
588 lines
16 KiB
Python
588 lines
16 KiB
Python
"""Authentication daemon handlers.
|
|
|
|
Handles login, logout, token refresh, session management, password change,
|
|
user CRUD, and WebAuthn operations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from daemon.iface import (
|
|
DELETE_AUTH_USER,
|
|
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
|
GET_AUTH_SESSION,
|
|
GET_AUTH_USERS,
|
|
GET_AUTH_WEBAUTHN_CAPABLE,
|
|
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
|
|
GET_AUTH_WEBAUTHN_CREDENTIALS,
|
|
POST_AUTH_LOGIN,
|
|
POST_AUTH_LOGOUT,
|
|
POST_AUTH_PASSWORD,
|
|
POST_AUTH_REFRESH,
|
|
POST_AUTH_USER_CREATE,
|
|
POST_AUTH_USER_UPDATE,
|
|
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
|
|
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
|
|
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
|
|
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
|
|
)
|
|
from daemon.server import ConflictError, NotFoundError, registry
|
|
from lib.auth import (
|
|
blacklist_active_refresh_token,
|
|
blacklist_token,
|
|
check_login_rate,
|
|
check_webauthn_rate,
|
|
clear_active_refresh_token,
|
|
generate_tokens,
|
|
get_access_ttl,
|
|
record_login_failure,
|
|
record_login_success,
|
|
validate_token,
|
|
)
|
|
from lib.auth_users import (
|
|
ALL_SUBSYSTEMS,
|
|
create_user,
|
|
delete_user,
|
|
get_user,
|
|
list_users,
|
|
update_password,
|
|
update_permissions,
|
|
verify_user_password,
|
|
)
|
|
from lib.webauthn import (
|
|
create_authentication_options,
|
|
create_registration_options,
|
|
get_management_domains,
|
|
get_rp_name,
|
|
list_credentials,
|
|
remove_credential,
|
|
verify_authentication,
|
|
verify_registration,
|
|
)
|
|
from lib.webauthn import (
|
|
is_enabled as webauthn_is_enabled,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ALL_RW = {sub: "rw" for sub in ALL_SUBSYSTEMS}
|
|
|
|
|
|
def _clear_refresh_token_after_rotation(username: str) -> None:
|
|
"""Remove the user's entry from refresh_tokens after a successful refresh rotation."""
|
|
clear_active_refresh_token(username)
|
|
|
|
|
|
@registry.register(POST_AUTH_LOGIN)
|
|
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Handle user login.
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Dict with ``username`` and ``password``.
|
|
|
|
Returns:
|
|
Dict with ``tokens``, ``user``, and ``permissions``.
|
|
|
|
Raises:
|
|
ValueError: If credentials are invalid.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
username = body.get("username")
|
|
password = body.get("password")
|
|
if not username or not password:
|
|
raise ValueError("username and password are required")
|
|
|
|
client_ip = body.get("client_ip")
|
|
if not check_login_rate(username, client_ip):
|
|
raise ValueError("Too many login attempts. Please try again later.")
|
|
|
|
user = verify_user_password(username, password)
|
|
if user is None:
|
|
record_login_failure(username, client_ip)
|
|
raise ValueError("Invalid credentials")
|
|
|
|
record_login_success(username, client_ip)
|
|
|
|
permissions = user["permissions"]
|
|
tokens = generate_tokens(username, permissions)
|
|
|
|
return {
|
|
"tokens": tokens,
|
|
"access_ttl": get_access_ttl(),
|
|
"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
},
|
|
"permissions": permissions,
|
|
}
|
|
|
|
|
|
@registry.register(POST_AUTH_LOGOUT)
|
|
def auth_logout(request: Any, body: Any) -> dict[str, Any]:
|
|
"""Handle user logout by blacklisting the access and refresh tokens.
|
|
|
|
Args:
|
|
request: The aiohttp request.
|
|
body: Dict with ``jti``, ``username`` from Flask user context, and
|
|
``refresh_token`` from the client.
|
|
|
|
Returns:
|
|
Success response.
|
|
"""
|
|
if not body:
|
|
raise ValueError("Request body is required")
|
|
|
|
jti = body.get("jti")
|
|
if jti:
|
|
blacklist_token(jti)
|
|
|
|
username = body.get("username")
|
|
if username:
|
|
blacklist_active_refresh_token(username)
|
|
|
|
return {}
|
|
|
|
|
|
@registry.register(POST_AUTH_REFRESH)
|
|
def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Handle token refresh.
|
|
|
|
Validates the refresh token, blacklists it, and issues a new access token.
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Dict with ``refresh_token``.
|
|
|
|
Returns:
|
|
Dict with new access token, refresh token, user, and permissions.
|
|
|
|
Raises:
|
|
ValueError: If refresh token is invalid.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
refresh_token = body.get("refresh_token")
|
|
if not refresh_token:
|
|
raise ValueError("refresh_token is required")
|
|
|
|
payload = validate_token(refresh_token, token_type="refresh")
|
|
if payload is None:
|
|
raise ValueError("Invalid or expired refresh token")
|
|
|
|
username = payload["sub"]
|
|
user = get_user(username)
|
|
if user is None:
|
|
raise ValueError("User not found")
|
|
|
|
jti = payload.get("jti")
|
|
if jti:
|
|
blacklist_token(jti, token_type="refresh")
|
|
if username:
|
|
_clear_refresh_token_after_rotation(username)
|
|
permissions = user["permissions"]
|
|
tokens = generate_tokens(username, permissions)
|
|
|
|
return {
|
|
"tokens": tokens,
|
|
"access_ttl": get_access_ttl(),
|
|
"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
},
|
|
"permissions": permissions,
|
|
}
|
|
|
|
|
|
@registry.register(GET_AUTH_SESSION)
|
|
def auth_session(request: Any, body: Any) -> dict[str, Any]:
|
|
"""Return current user session info.
|
|
|
|
Args:
|
|
request: The aiohttp request.
|
|
body: Dict with ``username`` from Flask user context.
|
|
|
|
Returns:
|
|
Dict with user info and permissions.
|
|
"""
|
|
username = body.get("username") if body else None
|
|
if username is None:
|
|
raise ValueError("No active session")
|
|
|
|
user = get_user(username)
|
|
if user is None:
|
|
raise ValueError("User not found")
|
|
|
|
return {
|
|
"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
},
|
|
"permissions": user["permissions"],
|
|
}
|
|
|
|
|
|
@registry.register(POST_AUTH_PASSWORD)
|
|
def auth_change_password(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Change user password.
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Dict with ``username``, ``oldPassword``, ``newPassword``.
|
|
|
|
Returns:
|
|
Success response.
|
|
|
|
Raises:
|
|
ValueError: If password change fails.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
username = body.get("username")
|
|
old_password = body.get("oldPassword")
|
|
new_password = body.get("newPassword")
|
|
|
|
if not username or not old_password or not new_password:
|
|
raise ValueError("username, oldPassword, and newPassword are required")
|
|
|
|
if len(new_password) < 8:
|
|
raise ValueError("New password must be at least 8 characters")
|
|
|
|
update_password(username, old_password, new_password)
|
|
logger.info("Password changed for user %r", username)
|
|
return {"ok": True}
|
|
|
|
|
|
@registry.register(GET_AUTH_USERS)
|
|
def auth_list_users(_request: Any, body: Any) -> list[dict[str, Any]]:
|
|
"""List all users.
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Unused.
|
|
|
|
Returns:
|
|
List of user summary dicts.
|
|
"""
|
|
return list_users()
|
|
|
|
|
|
@registry.register(POST_AUTH_USER_CREATE)
|
|
def auth_create_user(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Create a new user.
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Dict with ``username``, ``password``, ``permissions``.
|
|
|
|
Returns:
|
|
Created user dict.
|
|
|
|
Raises:
|
|
ConflictError: If user already exists.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
username = body.get("username")
|
|
password = body.get("password")
|
|
permissions = body.get("permissions", {})
|
|
|
|
if not username or not password:
|
|
raise ValueError("username and password are required")
|
|
|
|
if len(password) < 8:
|
|
raise ValueError("Password must be at least 8 characters")
|
|
|
|
try:
|
|
user = create_user(username, password, permissions)
|
|
return {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"permissions": user["permissions"],
|
|
}
|
|
except ValueError as e:
|
|
raise ConflictError(str(e)) from e
|
|
|
|
|
|
@registry.register(POST_AUTH_USER_UPDATE)
|
|
def auth_update_user(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Update a user's permissions.
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Dict with optional ``permissions``, ``password`` keys.
|
|
Path param ``username`` is merged into body by the daemon.
|
|
|
|
Returns:
|
|
Updated user dict.
|
|
|
|
Raises:
|
|
NotFoundError: If user not found.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
username = body.get("username")
|
|
if not username:
|
|
raise ValueError("username is required")
|
|
|
|
existing = get_user(username)
|
|
if existing is None:
|
|
raise NotFoundError(f"User {username!r} not found")
|
|
|
|
if "permissions" in body:
|
|
update_permissions(username, body["permissions"])
|
|
|
|
user = get_user(username)
|
|
if user is None:
|
|
raise NotFoundError(f"User {username!r} not found")
|
|
|
|
return {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"permissions": user["permissions"],
|
|
}
|
|
|
|
|
|
@registry.register(DELETE_AUTH_USER)
|
|
def auth_delete_user(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Delete a user.
|
|
|
|
Self-deletion is blocked by Flask middleware (auth blueprint).
|
|
|
|
Args:
|
|
_request: Unused.
|
|
body: Path param ``username`` merged by the daemon.
|
|
|
|
Returns:
|
|
Success response.
|
|
|
|
Raises:
|
|
NotFoundError: If user not found.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
username = body.get("username")
|
|
if not username:
|
|
raise ValueError("username is required")
|
|
|
|
try:
|
|
delete_user(username)
|
|
logger.info("User %r deleted", username)
|
|
return {"ok": True}
|
|
except ValueError as e:
|
|
if "not found" in str(e):
|
|
raise NotFoundError(str(e)) from e
|
|
raise
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebAuthn handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _check_webauthn_domain(body: Any) -> tuple[str, str]:
|
|
"""Validate that the request domain is eligible for WebAuthn.
|
|
|
|
Returns (origin, rp_id) if valid. Raises ValueError otherwise.
|
|
"""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
if not webauthn_is_enabled():
|
|
raise ValueError("WebAuthn is disabled")
|
|
|
|
rp_id = body.get("webauthn_rp_id")
|
|
origin = body.get("webauthn_origin")
|
|
if not rp_id or not origin:
|
|
raise ValueError("Missing WebAuthn domain configuration")
|
|
|
|
if rp_id not in get_management_domains():
|
|
raise ValueError("WebAuthn is not available on this domain")
|
|
|
|
return origin, rp_id
|
|
|
|
|
|
@registry.register(GET_AUTH_WEBAUTHN_CAPABLE)
|
|
def webauthn_capable(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Check if WebAuthn is available for the current request domain."""
|
|
rp_id = body.get("webauthn_rp_id") if body else None
|
|
origin = body.get("webauthn_origin") if body else None
|
|
|
|
if not webauthn_is_enabled():
|
|
return {"enabled": False, "reason": "WebAuthn is disabled in config"}
|
|
|
|
if not rp_id or not origin:
|
|
return {"enabled": False, "reason": "Domain information unavailable"}
|
|
|
|
mgmt_domains = get_management_domains()
|
|
if rp_id not in mgmt_domains:
|
|
return {"enabled": False, "reason": "Not a management domain"}
|
|
|
|
return {
|
|
"enabled": True,
|
|
"rp_id": rp_id,
|
|
"rp_name": get_rp_name(),
|
|
"origin": origin,
|
|
}
|
|
|
|
|
|
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_BEGIN)
|
|
def webauthn_register_begin(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Begin WebAuthn registration — return options for ``credentials.create()``."""
|
|
origin, rp_id = _check_webauthn_domain(body)
|
|
|
|
username = body.get("username")
|
|
if not username:
|
|
raise ValueError("username is required")
|
|
|
|
options = create_registration_options(
|
|
username,
|
|
origin=origin,
|
|
rp_id=rp_id,
|
|
)
|
|
return options
|
|
|
|
|
|
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_FINISH)
|
|
def webauthn_register_finish(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Finish WebAuthn registration — verify credential and persist."""
|
|
origin, rp_id = _check_webauthn_domain(body)
|
|
|
|
username = body.get("username")
|
|
credential_response = body.get("credential_response")
|
|
registration_options = body.get("registration_options")
|
|
credential_name = body.get("name", "")
|
|
|
|
if not username or not credential_response or not registration_options:
|
|
raise ValueError(
|
|
"username, credential_response, and registration_options are required"
|
|
)
|
|
|
|
cred = verify_registration(
|
|
username,
|
|
credential_response,
|
|
registration_options,
|
|
credential_name,
|
|
origin=origin,
|
|
rp_id=rp_id,
|
|
)
|
|
return {"ok": True, "credential": cred}
|
|
|
|
|
|
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN)
|
|
def webauthn_authenticate_begin(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Begin WebAuthn authentication — return options for ``credentials.get()``.
|
|
|
|
Public endpoint — no JWT required. Returns ``{"noWebAuthn": true}`` if the
|
|
user has no registered credentials (so the frontend can fall back to password).
|
|
"""
|
|
_, rp_id = _check_webauthn_domain(body)
|
|
|
|
username = body.get("username")
|
|
if not username:
|
|
raise ValueError("username is required")
|
|
|
|
options = create_authentication_options(
|
|
username,
|
|
rp_id=rp_id,
|
|
)
|
|
if options is None:
|
|
return {"no_webauthn": True}
|
|
return options
|
|
|
|
|
|
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH)
|
|
def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
|
|
"""Finish WebAuthn authentication — verify assertion, issue tokens.
|
|
|
|
Public endpoint — no JWT required.
|
|
"""
|
|
origin, rp_id = _check_webauthn_domain(body)
|
|
|
|
username = body.get("username")
|
|
assertion_response = body.get("assertion_response")
|
|
auth_options = body.get("auth_options")
|
|
client_ip = body.get("client_ip")
|
|
|
|
if not username or not assertion_response or not auth_options:
|
|
raise ValueError("username, assertion_response, and auth_options are required")
|
|
|
|
if not check_webauthn_rate(username, client_ip):
|
|
raise ValueError("Too many WebAuthn attempts. Please try again later.")
|
|
|
|
verify_authentication(
|
|
username,
|
|
assertion_response,
|
|
auth_options,
|
|
origin=origin,
|
|
rp_id=rp_id,
|
|
)
|
|
|
|
user = get_user(username)
|
|
if user is None:
|
|
raise ValueError("User not found")
|
|
|
|
permissions = user["permissions"]
|
|
tokens = generate_tokens(username, permissions)
|
|
|
|
return {
|
|
"tokens": tokens,
|
|
"access_ttl": get_access_ttl(),
|
|
"user": {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
},
|
|
"permissions": permissions,
|
|
}
|
|
|
|
|
|
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIALS)
|
|
def webauthn_credentials(request: Any, body: Any) -> list[dict[str, Any]]:
|
|
"""List WebAuthn credentials for the authenticated user."""
|
|
username = body.get("username") if body else None
|
|
if not username:
|
|
raise ValueError("No active session")
|
|
|
|
return list_credentials(username)
|
|
|
|
|
|
@registry.register(DELETE_AUTH_WEBAUTHN_CREDENTIAL)
|
|
def webauthn_remove_credential(request: Any, body: Any) -> dict[str, Any]:
|
|
"""Remove a WebAuthn credential."""
|
|
if not body or not isinstance(body, dict):
|
|
raise ValueError("Request body is required")
|
|
|
|
username = body.get("username")
|
|
if not username:
|
|
raise ValueError("No active session")
|
|
|
|
credential_id = body.get("credential_id")
|
|
if not credential_id:
|
|
raise ValueError("credential_id is required")
|
|
|
|
try:
|
|
remove_credential(username, credential_id)
|
|
logger.info("WebAuthn credential removed for %s", username)
|
|
return {"ok": True}
|
|
except ValueError as e:
|
|
if "not found" in str(e):
|
|
raise NotFoundError(str(e)) from e
|
|
raise
|
|
|
|
|
|
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS)
|
|
def webauthn_credential_counts(_request: Any, body: Any) -> dict[str, int]:
|
|
"""Return credential counts for all users (admin endpoint)."""
|
|
from lib.webauthn import get_all_credential_counts
|
|
|
|
return get_all_credential_counts()
|