diff --git a/tests/test_invite.py b/tests/test_invite.py index 7c6bf2fb762c447d38d8fc5f0088ceca51c470df..ecbd51862b9ef2cd61ae72144c6ef20b8209345d 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -524,7 +524,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) @@ -534,7 +534,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) @@ -543,7 +543,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) @@ -565,6 +565,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) @@ -573,7 +574,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() @@ -590,9 +591,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) @@ -601,9 +603,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) @@ -616,9 +619,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) @@ -636,8 +640,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), @@ -657,7 +662,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) @@ -665,7 +670,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) @@ -673,7 +678,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) @@ -683,7 +688,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) @@ -694,7 +699,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) @@ -704,14 +709,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) @@ -723,14 +729,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) @@ -742,8 +749,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') @@ -753,8 +761,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') @@ -764,8 +773,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') @@ -775,8 +785,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') @@ -786,8 +797,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') @@ -797,13 +809,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 d31f23d467126fb7aaa3ef1171ba57d5b32181a7..f8dc18a7eb96af7bbcc7cae5f81931e82d069c81 100644 --- a/uffd/invite/templates/invite/use.html +++ b/uffd/invite/templates/invite/use.html @@ -30,17 +30,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 d93dd892bddd3f65ec1041215cba5cd841a4e8b1..e6e6e8da0746dade02369744d81d942389944ba1 100644 --- a/uffd/invite/views.py +++ b/uffd/invite/views.py @@ -1,7 +1,8 @@ import datetime import functools +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 @@ -112,19 +113,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() @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() @@ -138,12 +154,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('/') @@ -152,12 +173,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']): @@ -166,9 +189,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'])