diff --git a/requirements.txt b/requirements.txt index da94390..45ca00a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ aiohttp~=3.6 quart~=0.11 +flask-wtf~=0.14 diff --git a/snikket_web/__init__.py b/snikket_web/__init__.py index f0e9b6a..116b175 100644 --- a/snikket_web/__init__.py +++ b/snikket_web/__init__.py @@ -1,32 +1,38 @@ +import quart.flask_patch + from quart import Quart, session, request, render_template, redirect, url_for -from . import prosodyclient +from .prosodyclient import client app = Quart(__name__) app.config.from_envvar("SNIKKET_WEB_CONFIG") -client = prosodyclient.ProsodyClient(app) +client.init_app(app) client.default_login_redirect = "login" -@app.route("/", methods=["GET", "POST"]) +@app.route("/login", methods=["GET", "POST"]) async def login(): if client.has_session: - return redirect(url_for('home')) + return redirect(url_for('user.index')) if request.method == "POST": form = await request.form jid = form["address"] password = form["password"] await client.login(jid, password) - return redirect(url_for('home')) + return redirect(url_for('user.index')) return await render_template("login.html") -@app.route('/home') -@client.require_session() +@app.route("/") async def home(): - user_info = await client.get_user_info() + if client.has_session: + return redirect(url_for('user.index')) - return await render_template("home.html", user_info=user_info) + return redirect(url_for('login')) + + +from .user import user_bp +app.register_blueprint(user_bp) diff --git a/snikket_web/prosodyclient.py b/snikket_web/prosodyclient.py index 1fb4545..c4616b9 100644 --- a/snikket_web/prosodyclient.py +++ b/snikket_web/prosodyclient.py @@ -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() diff --git a/snikket_web/templates/home.html b/snikket_web/templates/user_home.html similarity index 52% rename from snikket_web/templates/home.html rename to snikket_web/templates/user_home.html index 16c40d8..f1bfbbb 100644 --- a/snikket_web/templates/home.html +++ b/snikket_web/templates/user_home.html @@ -6,5 +6,9 @@
Welcome home, {{ user_info.username }}.
+