Implement admin dashboard

Fixes #23.
This commit is contained in:
Jonas Schäfer
2021-01-16 21:29:06 +01:00
parent 16bc3c6990
commit e476d9b7c2
19 changed files with 1186 additions and 178 deletions

View File

@@ -1,3 +1,5 @@
import dataclasses
import enum
import functools
import hashlib
import logging
@@ -5,6 +7,8 @@ import secrets
import types
import typing
from datetime import datetime
import aiohttp
import xml.etree.ElementTree as ET
@@ -19,9 +23,71 @@ from . import xmpputil
from .xmpputil import split_jid
SCOPE_DEFAULT = "prosody:scope:default"
SCOPE_ADMIN = "prosody:scope:admin"
T = typing.TypeVar("T")
@dataclasses.dataclass(frozen=True)
class TokenInfo:
token: str
scopes: typing.Collection[str]
@dataclasses.dataclass(frozen=True)
class AdminUserInfo:
localpart: str
display_name: typing.Optional[str]
email: typing.Optional[str]
phone: typing.Optional[str]
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminUserInfo":
return cls(
localpart=data["username"],
display_name=data.get("display_name") or None,
email=data.get("email") or None,
phone=data.get("phone") or None,
)
class InviteType(enum.Enum):
REGISTER = "register"
@dataclasses.dataclass(frozen=True)
class AdminInviteInfo:
id_: str
type_: InviteType
jid: typing.Optional[str]
token: str
xmpp_uri: typing.Optional[str]
landing_page: typing.Optional[str]
created_at: datetime
expires: datetime
@classmethod
def from_api_response(
cls,
data: typing.Mapping[str, typing.Any],
) -> "AdminInviteInfo":
return cls(
id_=data["id"],
created_at=datetime.utcfromtimestamp(data["created_at"]),
expires=datetime.utcfromtimestamp(data["expires"]),
type_=InviteType(data["type"]),
jid=data["jid"],
token=data["id"],
xmpp_uri=data.get("xmpp_uri"),
landing_page=data.get("landing_page"),
)
class HTTPSessionManager:
def __init__(self, app_context_attribute: str):
self._app_context_attribute = app_context_attribute
@@ -119,6 +185,7 @@ class ProsodyClient:
CTX_AUTH_SESSION = "_ProsodyClient__auth_session"
CONFIG_ENDPOINT = "PROSODY_ENDPOINT"
SESSION_TOKEN = "prosody_access_token"
SESSION_CACHED_SCOPE = "prosody_scope_cache"
SESSION_ADDRESS = "prosody_jid"
def __init__(self, app: typing.Optional[quart.Quart] = None):
@@ -158,19 +225,26 @@ class ProsodyClient:
def _rest_endpoint(self) -> str:
return "{}/rest".format(self._endpoint_base)
def _admin_v1_endpoint(self, subpath: str) -> str:
return "{}/admin_api{}".format(self._endpoint_base, subpath)
async def _oauth2_bearer_token(self,
session: aiohttp.ClientSession,
jid: str,
password: str) -> None:
password: str) -> TokenInfo:
request = aiohttp.FormData()
request.add_field("grant_type", "password")
request.add_field("username", jid)
request.add_field("password", password)
request.add_field(
"scope",
" ".join([SCOPE_DEFAULT, SCOPE_ADMIN])
)
self.logger.debug("sending OAuth2 request (payload omitted)")
async with session.post(self._login_endpoint, data=request) as resp:
auth_status = resp.status
auth_info = (await resp.json())
auth_info: typing.Mapping[str, str] = (await resp.json())
if auth_status in [400, 401]:
self.logger.debug("oauth2 error: %r", auth_info)
@@ -189,7 +263,10 @@ class ProsodyClient:
auth_info["token_type"]
)
)
return auth_info["access_token"]
return TokenInfo(
token=auth_info["access_token"],
scopes=auth_info["scope"].split(),
)
raise RuntimeError(
"unexpected authentication reply: ({}) {!r}".format(
@@ -199,10 +276,13 @@ class ProsodyClient:
async def login(self, jid: str, password: str) -> bool:
async with self._plain_session as session:
token = await self._oauth2_bearer_token(session, jid, password)
token_info = await self._oauth2_bearer_token(
session, jid, password,
)
http_session[self.SESSION_TOKEN] = token
http_session[self.SESSION_TOKEN] = token_info.token
http_session[self.SESSION_ADDRESS] = jid
http_session[self.SESSION_CACHED_SCOPE] = " ".join(token_info.scopes)
return True
@property
@@ -252,6 +332,30 @@ class ProsodyClient:
return wrapped
return decorator
def require_admin_session(
self,
redirect_to: typing.Optional[str] = None,
) -> typing.Callable[
[typing.Callable[..., typing.Awaitable[T]]],
typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]]:
def decorator(
f: typing.Callable[..., typing.Awaitable[T]],
) -> typing.Callable[..., typing.Awaitable[
typing.Union[T, quart.Response]]]:
@functools.wraps(f)
@self.require_session(redirect_to=redirect_to)
async def wrapped(
*args: typing.Any,
**kwargs: typing.Any,
) -> typing.Union[T, quart.Response]:
if not self.is_admin_session:
raise abort(403, "This is not for you.")
return await f(*args, **kwargs)
return wrapped
return decorator
async def _xml_iq_call(
self,
session: aiohttp.ClientSession,
@@ -309,6 +413,7 @@ class ProsodyClient:
"nickname": nickname,
"display_name": nickname or localpart,
"avatar_hash": avatar_hash,
"is_admin": self.is_admin_session,
}
@autosession
@@ -450,12 +555,115 @@ class ProsodyClient:
# server to expire/revoke all tokens on password change.
http_session[self.SESSION_TOKEN] = token
def _raise_error_from_response(
self,
resp: aiohttp.ClientResponse,
) -> None:
if resp.status in [401, 403]:
abort(403, "request rejected by backend")
if resp.status == 400:
abort(500, "request rejected by backend")
if not 200 <= resp.status < 300:
abort(resp.status)
@autosession
async def list_users(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Collection[AdminUserInfo]:
result = []
async with session.get(self._admin_v1_endpoint("/users")) as resp:
self._raise_error_from_response(resp)
for user in await resp.json():
result.append(AdminUserInfo.from_api_response(user))
return result
@autosession
async def get_user_by_localpart(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> AdminUserInfo:
async with session.get(
self._admin_v1_endpoint("/users/{}".format(localpart)),
) as resp:
self._raise_error_from_response(resp)
return AdminUserInfo.from_api_response(await resp.json())
@autosession
async def delete_user_by_localpart(
self,
localpart: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint("/users/{}".format(localpart)),
) as resp:
self._raise_error_from_response(resp)
@autosession
async def list_invites(
self,
*,
session: aiohttp.ClientSession,
) -> typing.Collection[AdminInviteInfo]:
async with session.get(self._admin_v1_endpoint("/invites")) as resp:
self._raise_error_from_response(resp)
return list(map(AdminInviteInfo.from_api_response,
await resp.json()))
@autosession
async def get_invite_by_id(
self,
id_: str,
*,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
async with session.get(
self._admin_v1_endpoint("/invites/{}".format(id_)),
) as resp:
self._raise_error_from_response(resp)
return AdminInviteInfo.from_api_response(await resp.json())
@autosession
async def delete_invite(
self,
id_: str,
*,
session: aiohttp.ClientSession,
) -> None:
async with session.delete(
self._admin_v1_endpoint("/invites/{}".format(id_)),
) as resp:
self._raise_error_from_response(resp)
@autosession
async def create_invite(
self,
*,
session: aiohttp.ClientSession,
) -> AdminInviteInfo:
async with session.put(self._admin_v1_endpoint("/invites")) as resp:
self._raise_error_from_response(resp)
return AdminInviteInfo.from_api_response(await resp.json())
async def logout(self) -> None:
# this currently only kills the cookie stuff, we may want to invalidate
# the token on the server side, toos
# See-Also: https://issues.prosody.im/1503
http_session.pop(self.SESSION_TOKEN, None)
http_session.pop(self.SESSION_ADDRESS, None)
http_session.pop(self.SESSION_CACHED_SCOPE, None)
@property
def is_admin_session(self) -> bool:
if not self.has_session:
return False
scopes = http_session[self.SESSION_CACHED_SCOPE].split()
return SCOPE_ADMIN in scopes
client = ProsodyClient()