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

@@ -1,2 +1,3 @@
aiohttp~=3.6
quart~=0.11
flask-wtf~=0.14

View File

@@ -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)

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()

View File

@@ -6,5 +6,9 @@
<body>
<h1>Welcome!</h1>
<p>Welcome home, {{ user_info.username }}.</p>
<ul>
<li><a href="{{ url_for('user.change_pw') }}">Change password</a></li>
<li><a href="{{ url_for('user.logout') }}">Log out</a></li>
</ul>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<body>
<form method="POST">
{{ form.csrf_token }}
<input type="submit" value="Logout">
</form>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<body>
<form method="POST">
{{ form.csrf_token }}
{{ form.current_password }}
{{ form.new_password }}
{{ form.new_password_confirm }}
<ul>
{% for field, errors in form.errors.items() %}
{% for error in errors %}
<li>{{ field }}: {{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
<input type="submit" value="Change password">
</form>
</body>
</html>

View File

@@ -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)