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!

Welcome home, {{ user_info.username }}.

+ diff --git a/snikket_web/templates/user_logout.html b/snikket_web/templates/user_logout.html new file mode 100644 index 0000000..6dbf915 --- /dev/null +++ b/snikket_web/templates/user_logout.html @@ -0,0 +1,9 @@ + + + +
+ {{ form.csrf_token }} + +
+ + diff --git a/snikket_web/templates/user_passwd.html b/snikket_web/templates/user_passwd.html new file mode 100644 index 0000000..e113828 --- /dev/null +++ b/snikket_web/templates/user_passwd.html @@ -0,0 +1,19 @@ + + + +
+ {{ form.csrf_token }} + {{ form.current_password }} + {{ form.new_password }} + {{ form.new_password_confirm }} + + +
+ + diff --git a/snikket_web/user/__init__.py b/snikket_web/user/__init__.py new file mode 100644 index 0000000..1b8922e --- /dev/null +++ b/snikket_web/user/__init__.py @@ -0,0 +1,66 @@ +import quart.flask_patch + +from quart import Blueprint, render_template, request, redirect, url_for +import quart.exceptions +from flask_wtf import FlaskForm +import wtforms + +from snikket_web.prosodyclient import client + +user_bp = Blueprint('user', __name__, url_prefix="/user") + + +class ChangePasswordForm(FlaskForm): + current_password = wtforms.PasswordField( + validators=[wtforms.validators.InputRequired()] + ) + + new_password = wtforms.PasswordField( + validators=[wtforms.validators.InputRequired()] + ) + + new_password_confirm = wtforms.PasswordField( + validators=[wtforms.validators.InputRequired(), + wtforms.validators.EqualTo("new_password")] + ) + + +class LogoutForm(FlaskForm): + pass + + +@user_bp.route("/") +async def index(): + user_info = await client.get_user_info() + return await render_template("user_home.html", user_info=user_info) + + +@user_bp.route('/passwd', methods=["GET", "POST"]) +async def change_pw(): + form = ChangePasswordForm() + if form.validate_on_submit(): + try: + await client.change_password( + form.current_password.data, + form.new_password.data, + ) + except quart.exceptions.Unauthorized: + # server refused current password, set an appropriate error + form.errors.setdefault(form.current_password.name, []).append( + # TODO(i18n) + "Incorrect password", + ) + else: + return redirect(url_for("user.change_pw")) + + return await render_template("user_passwd.html", form=form) + + +@user_bp.route("/logout", methods=["GET", "POST"]) +async def logout(): + form = LogoutForm() + if form.validate_on_submit(): + await client.logout() + return redirect(url_for("home")) + + return await render_template("user_logout.html", form=form)