You've already forked snikket-web-portal
Implement support for password change and logout
Note the hack.
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
aiohttp~=3.6
|
||||
quart~=0.11
|
||||
flask-wtf~=0.14
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
9
snikket_web/templates/user_logout.html
Normal file
9
snikket_web/templates/user_logout.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<form method="POST">
|
||||
{{ form.csrf_token }}
|
||||
<input type="submit" value="Logout">
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
19
snikket_web/templates/user_passwd.html
Normal file
19
snikket_web/templates/user_passwd.html
Normal 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>
|
||||
66
snikket_web/user/__init__.py
Normal file
66
snikket_web/user/__init__.py
Normal 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)
|
||||
Reference in New Issue
Block a user