diff --git a/tests/test_invite.py b/tests/test_invite.py index f3d652ac7f5dc7ab66ab126a9947e0eded1bad20..94f7967d7da3939faa1a6ad90b8f6fac977789ef 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -521,7 +521,7 @@ class TestInviteUseViews(UffdTestCase): db.session.add(invite) db.session.commit() token = invite.token - r = self.client.get(path=url_for('invite.use', token=token), follow_redirects=True) + r = self.client.get(path=url_for('invite.use', invite_id=invite.id, token=token), follow_redirects=True) dump('invite_use', r) self.assertEqual(r.status_code, 200) @@ -531,7 +531,7 @@ class TestInviteUseViews(UffdTestCase): db.session.add(invite) db.session.commit() token = invite.token - r = self.client.get(path=url_for('invite.use', token=token), follow_redirects=True) + r = self.client.get(path=url_for('invite.use', invite_id=invite.id, token=token), follow_redirects=True) dump('invite_use_loggedin', r) self.assertEqual(r.status_code, 200) @@ -540,7 +540,7 @@ class TestInviteUseViews(UffdTestCase): db.session.add(invite) db.session.commit() token = invite.token - r = self.client.get(path=url_for('invite.use', token=token), follow_redirects=True) + r = self.client.get(path=url_for('invite.use', invite_id=invite.id, token=token), follow_redirects=True) dump('invite_use_inactive', r) self.assertEqual(r.status_code, 200) @@ -562,6 +562,7 @@ class TestInviteUseViews(UffdTestCase): db.session.add(invite) db.session.commit() ldap.session.commit() + invite_id = invite.id token = invite.token self.assertIn(role0, user.roles) self.assertNotIn(role1, user.roles) @@ -570,7 +571,7 @@ class TestInviteUseViews(UffdTestCase): self.assertNotIn(group1, user.groups) self.assertFalse(invite.used) self.login_as('user') - r = self.client.post(path=url_for('invite.grant', token=token), follow_redirects=True) + r = self.client.post(path=url_for('invite.grant', invite_id=invite_id, token=token), follow_redirects=True) dump('invite_grant', r) self.assertEqual(r.status_code, 200) db_flush() @@ -587,9 +588,10 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), disabled=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token self.login_as('user') - r = self.client.post(path=url_for('invite.grant', token=token), follow_redirects=True) + r = self.client.post(path=url_for('invite.grant', invite_id=invite_id, token=token), follow_redirects=True) dump('invite_grant_invalid_invite', r) self.assertEqual(r.status_code, 200) self.assertFalse(Invite.query.filter_by(token=token).first().used) @@ -598,9 +600,10 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60)) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token self.login_as('user') - r = self.client.post(path=url_for('invite.grant', token=token), follow_redirects=True) + r = self.client.post(path=url_for('invite.grant', invite_id=invite_id, token=token), follow_redirects=True) dump('invite_grant_no_roles', r) self.assertEqual(r.status_code, 200) self.assertFalse(Invite.query.filter_by(token=token).first().used) @@ -613,9 +616,10 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), roles=[role]) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token self.login_as('user') - r = self.client.post(path=url_for('invite.grant', token=token), follow_redirects=True) + r = self.client.post(path=url_for('invite.grant', invite_id=invite_id, token=token), follow_redirects=True) dump('invite_grant_no_new_roles', r) self.assertEqual(r.status_code, 200) self.assertFalse(Invite.query.filter_by(token=token).first().used) @@ -633,8 +637,9 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), roles=[role1, role2], allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token - r = self.client.get(path=url_for('invite.signup_start', token=token), follow_redirects=True) + r = self.client.get(path=url_for('invite.signup_start', invite_id=invite_id, token=token), follow_redirects=True) dump('invite_signup_start', r) self.assertEqual(r.status_code, 200) r = self.client.post(path=url_for('invite.signup_submit', token=token), @@ -654,7 +659,7 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True, disabled=True) db.session.add(invite) db.session.commit() - r = self.client.get(path=url_for('invite.signup_start', token=invite.token), follow_redirects=True) + r = self.client.get(path=url_for('invite.signup_start', invite_id=invite.id, token=invite.token), follow_redirects=True) dump('invite_signup_invalid_invite', r) self.assertEqual(r.status_code, 200) @@ -662,7 +667,7 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=False) db.session.add(invite) db.session.commit() - r = self.client.get(path=url_for('invite.signup_start', token=invite.token), follow_redirects=True) + r = self.client.get(path=url_for('invite.signup_start', invite_id=invite.id, token=invite.token), follow_redirects=True) dump('invite_signup_nosignup', r) self.assertEqual(r.status_code, 200) @@ -670,7 +675,7 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() - r = self.client.post(path=url_for('invite.signup_submit', token=invite.token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite.id, token=invite.token), data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', 'password1': 'notsecret', 'password2': 'notthesame'}, follow_redirects=True) dump('invite_signup_wrongpassword', r) @@ -680,7 +685,7 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() - r = self.client.post(path=url_for('invite.signup_submit', token=invite.token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite.id, token=invite.token), data={'loginname': '', 'displayname': 'New User', 'mail': 'test@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}, follow_redirects=True) dump('invite_signup_invalid', r) @@ -691,7 +696,7 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() - r = self.client.post(path=url_for('invite.signup_submit', token=invite.token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite.id, token=invite.token), data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}, follow_redirects=True) dump('invite_signup_mailerror', r) @@ -701,14 +706,15 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token for i in range(20): - r = self.client.post(path=url_for('invite.signup_submit', token=token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite_id, token=token), data={'loginname': 'newuser%d'%i, 'displayname': 'New User', 'mail': 'test%d@example.com'%i, 'password1': 'notsecret', 'password2': 'notsecret'}, follow_redirects=True) self.assertEqual(r.status_code, 200) self.app.last_mail = None - r = self.client.post(path=url_for('invite.signup_submit', token=token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite_id, token=token), data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}, follow_redirects=True) dump('invite_signup_hostlimit', r) @@ -720,14 +726,15 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token for i in range(3): - r = self.client.post(path=url_for('invite.signup_submit', token=token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite_id, token=token), data={'loginname': 'newuser%d'%i, 'displayname': 'New User', 'mail': 'test@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}, follow_redirects=True) self.assertEqual(r.status_code, 200) self.app.last_mail = None - r = self.client.post(path=url_for('invite.signup_submit', token=token), + r = self.client.post(path=url_for('invite.signup_submit', invite_id=invite_id, token=token), data={'loginname': 'newuser', 'displayname': 'New User', 'mail': 'test@example.com', 'password1': 'notsecret', 'password2': 'notsecret'}, follow_redirects=True) dump('invite_signup_maillimit', r) @@ -739,8 +746,9 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': 'newuser'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.content_type, 'application/json') @@ -750,8 +758,9 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': ''}) self.assertEqual(r.status_code, 200) self.assertEqual(r.content_type, 'application/json') @@ -761,8 +770,9 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': 'testuser'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.content_type, 'application/json') @@ -772,8 +782,9 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=False) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': 'testuser'}) self.assertEqual(r.status_code, 403) self.assertEqual(r.content_type, 'application/json') @@ -783,8 +794,9 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True, disabled=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': 'testuser'}) self.assertEqual(r.status_code, 403) self.assertEqual(r.content_type, 'application/json') @@ -794,13 +806,14 @@ class TestInviteUseViews(UffdTestCase): invite = Invite(valid_until=datetime.datetime.now() + datetime.timedelta(seconds=60), allow_signup=True) db.session.add(invite) db.session.commit() + invite_id = invite.id token = invite.token for i in range(20): - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': 'testuser'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.content_type, 'application/json') - r = self.client.post(path=url_for('invite.signup_check', token=token), follow_redirects=True, + r = self.client.post(path=url_for('invite.signup_check', invite_id=invite_id, token=token), follow_redirects=True, data={'loginname': 'testuser'}) self.assertEqual(r.status_code, 200) self.assertEqual(r.content_type, 'application/json') diff --git a/uffd/invite/templates/invite/list.html b/uffd/invite/templates/invite/list.html index 2eb543541918806337096048903d4de87509c98b..a527fd45e4477ed4fa5fb85313f7126f356cff94 100644 --- a/uffd/invite/templates/invite/list.html +++ b/uffd/invite/templates/invite/list.html @@ -22,8 +22,8 @@ <tr> <td> {% if invite.creator == request.user and invite.active %} - <a href="{{ url_for('invite.use', token=invite.token) }}"><code>{{ invite.short_token }}</code></a> - <button type="button" class="btn btn-link btn-sm p-0 copy-clipboard" data-copy="{{ url_for('invite.use', token=invite.token, _external=True) }}" title="{{_('Copy link to clipboard')}}"><i class="fas fa-clipboard"></i></button> + <a href="{{ url_for('invite.use', invite_id=invite.id, token=invite.token) }}"><code>{{ invite.short_token }}</code></a> + <button type="button" class="btn btn-link btn-sm p-0 copy-clipboard" data-copy="{{ url_for('invite.use', invite_id=invite.id, token=invite.token, _external=True) }}" title="{{_('Copy link to clipboard')}}"><i class="fas fa-clipboard"></i></button> <button type="button" class="btn btn-link btn-sm p-0" data-toggle="modal" data-target="#modal-{{ invite.id }}-qrcode" title="{{_('Show link as QR code')}}"><i class="fas fa-qrcode"></i></button> {% else %} <code>{{ invite.short_token }}</code> @@ -143,7 +143,7 @@ </button> </div> <div class="modal-body"> - {{ url_for('invite.use', token=invite.token, _external=True)|qrcode_svg(width='100%', height='100%') }} + {{ url_for('invite.use', invite_id=invite.id, token=invite.token, _external=True)|qrcode_svg(width='100%', height='100%') }} </div> </div> </div> diff --git a/uffd/invite/templates/invite/use.html b/uffd/invite/templates/invite/use.html index 687153f9ab969b34507f8c4f55c91d24fbbe2388..f8bb7278e6bb95fd11a31665153e3ca6e10065c1 100644 --- a/uffd/invite/templates/invite/use.html +++ b/uffd/invite/templates/invite/use.html @@ -24,17 +24,17 @@ {% endif %} {% if request.user %} {% if invite.roles %} - <form method="POST" action="{{ url_for("invite.grant", token=invite.token) }}" class="mb-2"> + <form method="POST" action="{{ url_for("invite.grant", invite_id=invite.id, token=invite.token) }}" class="mb-2"> <button type="submit" class="btn btn-primary btn-block">{{_('Add the roles to your account now')}}</button> </form> <a href="{{ url_for("session.logout", ref=url_for("session.login", ref=request.full_path)) }}" class="btn btn-secondary btn-block">{{_('Logout and switch to a different account')}}</a> {% endif %} {% if invite.allow_signup %} - <a href="{{ url_for("session.logout", ref=url_for("invite.signup_start", token=invite.token)) }}" class="btn btn-secondary btn-block">{{_('Logout to register a new account')}}</a> + <a href="{{ url_for("session.logout", ref=url_for("invite.signup_start", invite_id=invite.id, token=invite.token)) }}" class="btn btn-secondary btn-block">{{_('Logout to register a new account')}}</a> {% endif %} {% else %} {% if invite.allow_signup %} - <a href="{{ url_for("invite.signup_start", token=invite.token) }}" class="btn btn-primary btn-block">{{_('Register a new account')}}</a> + <a href="{{ url_for("invite.signup_start", invite_id=invite.id, token=invite.token) }}" class="btn btn-primary btn-block">{{_('Register a new account')}}</a> {% endif %} {% if invite.roles %} <a href="{{ url_for("session.login", ref=request.full_path) }}" class="btn btn-primary btn-block">{{_('Login and add the roles to your account')}}</a> diff --git a/uffd/invite/views.py b/uffd/invite/views.py index 2c0b7f2754f7af93849fdf86542f82eaaaa348f3..185d1a224044a239596c747d6da2a2ec7c911cc6 100644 --- a/uffd/invite/views.py +++ b/uffd/invite/views.py @@ -1,6 +1,7 @@ import datetime +import secrets -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, abort from flask_babel import gettext as _, lazy_gettext import sqlalchemy @@ -102,19 +103,34 @@ def reset(invite_id): db.session.commit() return redirect(url_for('.index')) +# Deprecated @bp.route('/<token>') -def use(token): - invite = Invite.query.filter_by(token=token).first_or_404() +def use_legacy(token): + matching_invite = None + for invite in Invite.query.filter(Invite.valid_until > datetime.datetime.now().replace(second=0, microsecond=0)): + if secrets.compare_digest(invite.token, token): + matching_invite = invite + if not matching_invite: + abort(404) + return redirect(url_for('invite.use', invite_id=matching_invite.id, token=token)) + +@bp.route('/<int:invite_id>/<token>') +def use(invite_id, token): + invite = Invite.query.get(invite_id) + if not invite or not secrets.compare_digest(invite.token, token): + abort(404) if not invite.active: flash(_('Invalid invite link')) return redirect('/') return render_template('invite/use.html', invite=invite) -@bp.route('/<token>/grant', methods=['POST']) +@bp.route('/<int:invite_id>/<token>/grant', methods=['POST']) @login_required(selfservice_acl_check) @csrf_protect(blueprint=bp) -def grant(token): - invite = Invite.query.filter_by(token=token).first_or_404() +def grant(invite_id, token): + invite = Invite.query.get(invite_id) + if not invite or not secrets.compare_digest(invite.token, token): + abort(404) invite_grant = InviteGrant(invite=invite, user=request.user) db.session.add(invite_grant) success, msg = invite_grant.apply() @@ -128,12 +144,17 @@ def grant(token): @bp.url_defaults def inject_invite_token(endpoint, values): - if endpoint in ['invite.signup_submit', 'invite.signup_check'] and 'token' in request.view_args: - values['token'] = request.view_args['token'] - -@bp.route('/<token>/signup') -def signup_start(token): - invite = Invite.query.filter_by(token=token).first_or_404() + if endpoint in ['invite.signup_submit', 'invite.signup_check']: + if 'invite_id' in request.view_args: + values['invite_id'] = request.view_args['invite_id'] + if 'token' in request.view_args: + values['token'] = request.view_args['token'] + +@bp.route('/<int:invite_id>/<token>/signup') +def signup_start(invite_id, token): + invite = Invite.query.get(invite_id) + if not invite or not secrets.compare_digest(invite.token, token): + abort(404) if not invite.active: flash(_('Invalid invite link')) return redirect('/') @@ -142,12 +163,14 @@ def signup_start(token): return redirect('/') return render_template('signup/start.html') -@bp.route('/<token>/signupcheck', methods=['POST']) -def signup_check(token): +@bp.route('/<int:invite_id>/<token>/signupcheck', methods=['POST']) +def signup_check(invite_id, token): if host_ratelimit.get_delay(): return jsonify({'status': 'ratelimited'}) host_ratelimit.log() - invite = Invite.query.filter_by(token=token).first_or_404() + invite = Invite.query.get(invite_id) + if not invite or not secrets.compare_digest(invite.token, token): + abort(404) if not invite.active or not invite.allow_signup: return jsonify({'status': 'error'}), 403 if not User().set_loginname(request.form['loginname']): @@ -156,9 +179,11 @@ def signup_check(token): return jsonify({'status': 'exists'}) return jsonify({'status': 'ok'}) -@bp.route('/<token>/signup', methods=['POST']) -def signup_submit(token): - invite = Invite.query.filter_by(token=token).first_or_404() +@bp.route('/<int:invite_id>/<token>/signup', methods=['POST']) +def signup_submit(invite_id, token): + invite = Invite.query.get(invite_id) + if not invite or not secrets.compare_digest(invite.token, token): + abort(404) if request.form['password1'] != request.form['password2']: return render_template('signup/start.html', error=_('Passwords do not match')) signup_delay = signup_ratelimit.get_delay(request.form['mail'])