You've already forked snikket-web-portal
Implement support for password change and logout
Note the hack.
This commit is contained in:
@@ -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 doesn’t 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()
|
||||
|
||||
Reference in New Issue
Block a user