You've already forked snikket-web-portal
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user