Implement support for password change and logout

Note the hack.
This commit is contained in:
Jonas Schäfer
2020-02-29 13:41:08 +01:00
parent 90273960d3
commit 9318b0d152
7 changed files with 198 additions and 32 deletions

View File

@@ -3,6 +3,8 @@ import functools
import aiohttp
import xml.etree.ElementTree as ET
from quart import (
current_app, _app_ctx_stack, session as http_session, abort, redirect,
url_for,
@@ -20,6 +22,18 @@ def split_jid(s):
return localpart, domain, resource
def _mk_change_password_request(jid, password):
username, domain, _ = split_jid(jid)
# XXX: this is due to a problem with mod_rest / mod_register in prosody:
# it doesnt recognize the password change stanza unless we send it to
# the account JID.
req = ET.Element("iq", to="{}@{}".format(username, domain), type="set")
q = ET.SubElement(req, "query", xmlns="jabber:iq:register")
ET.SubElement(q, "username").text = username
ET.SubElement(q, "password").text = password
return ET.tostring(req)
class HTTPSessionManager:
def __init__(self, app_context_attribute):
self._app_context_attribute = app_context_attribute
@@ -120,34 +134,39 @@ class ProsodyClient:
def _rest_endpoint(self):
return "{}/rest".format(self._endpoint_base)
async def login(self, jid: str, password: str):
async def _oauth2_bearer_token(self,
session: aiohttp.ClientSession,
jid: str,
password: str):
request = aiohttp.FormData()
request.add_field("grant_type", "password")
request.add_field("username", jid)
request.add_field("password", password)
async with self._plain_session as session:
async with session.post(self._login_endpoint, data=request) as resp:
auth_status = resp.status
auth_info = (await resp.json())
if auth_status == 401:
raise ValueError("Invalid credentials")
elif auth_status == 200:
token_type = auth_info["token_type"]
if token_type != "bearer":
raise NotImplementedError(
"unsupported token type: {!r}".format(
auth_info["token_type"]
)
async with session.post(self._login_endpoint, data=request) as resp:
auth_status = resp.status
auth_info = (await resp.json())
if auth_status == 401:
raise abort(401, "Invalid credentials")
elif auth_status == 200:
token_type = auth_info["token_type"]
if token_type != "bearer":
raise NotImplementedError(
"unsupported token type: {!r}".format(
auth_info["token_type"]
)
http_session[self.SESSION_TOKEN] = auth_info["access_token"]
http_session[self.SESSION_ADDRESS] = jid
return True
else:
raise RuntimeError(
"unexpected backend response: {!r}".format(auth_status)
)
return auth_info["access_token"]
else:
raise abort(500, "Unexpected backend response status: {!r}".format(auth_status, auth_info))
async def login(self, jid: str, password: str):
async with self._plain_session as session:
token = await self._oauth2_bearer_token(session, jid, password)
http_session[self.SESSION_TOKEN] = token
http_session[self.SESSION_ADDRESS] = jid
return True
@property
def session_token(self):
@@ -184,12 +203,23 @@ class ProsodyClient:
return wrapped
return decorator
async def _xml_iq_call(self, session, payload, *, headers=None):
headers = headers or {}
headers.update({
"Content-Type": "application/xmpp+xml"
})
async with session.post(self._rest_endpoint,
headers=headers,
data=payload) as resp:
reply_payload = await resp.read()
return ET.fromstring(reply_payload)
async def get_user_info(self):
localpart, domain, _ = split_jid(self.session_address)
request = {
"kind": "iq",
"to": domain,
"to": self.session_address,
"type": "get",
"ping": True
}
@@ -199,7 +229,38 @@ class ProsodyClient:
json=request) as resp:
if resp.status != 200:
raise abort(resp.status)
return {
"username": localpart,
}
async def change_password(self, current_password, new_password):
# we play it safe here and do not use the existing auth session;
# instead, we do a login on the plain session and use the token we
# got there, replacing the current session token on the way.
async with self._plain_session as session:
token = await self._oauth2_bearer_token(
session,
self.session_address,
current_password,
)
reply = await self._xml_iq_call(
session,
_mk_change_password_request(self.session_address, new_password),
headers={
"Authorization": "Bearer {}".format(token),
}
)
# TODO: error handling
# TODO: obtain a new token using the new password to allow the
# server to expire/revoke all tokens on password change.
http_session[self.SESSION_TOKEN] = token
async def logout(self):
# this currently only kills the cookie stuff, we may want to invalidate
# the token on th server side, toos
http_session.pop(self.SESSION_TOKEN, None)
http_session.pop(self.SESSION_ADDRESS, None)
client = ProsodyClient()