From e84b50688c2581304e9025ec8ec9bb55ad33754f Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Tue, 15 Feb 2022 15:58:41 +0100 Subject: [PATCH] Cleanup CLI command to delete expired objects The command replaces all existing mechanisms for deleting expired objects. It should run at least daily. The Debian package includes a corresponding cron job. Ratelimit events now use UTC timestamps instead of localtime. On upgrade all past ratelimit events are cleared. --- debian/cron.d | 3 +- tests/test_ratelimit.py | 16 ---- tests/test_selfservice.py | 17 ++-- tests/test_tasks.py | 36 +++++++ uffd/__init__.py | 3 + ...dd_expires_attribute_to_ratelimit_event.py | 45 +++++++++ uffd/oauth2/models.py | 22 ++++- uffd/oauth2/views.py | 12 +-- uffd/ratelimit.py | 30 +++--- uffd/selfservice/models.py | 16 ++++ uffd/selfservice/views.py | 25 ++--- uffd/session/models.py | 4 + uffd/signup/models.py | 22 +++-- uffd/tasks.py | 29 ++++++ uffd/translations/de/LC_MESSAGES/messages.mo | Bin 32143 -> 32125 bytes uffd/translations/de/LC_MESSAGES/messages.po | 90 +++++++++--------- 16 files changed, 257 insertions(+), 113 deletions(-) create mode 100644 tests/test_tasks.py create mode 100644 uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py create mode 100644 uffd/tasks.py diff --git a/debian/cron.d b/debian/cron.d index 4b9e06d0..94262431 100644 --- a/debian/cron.d +++ b/debian/cron.d @@ -1,3 +1,4 @@ # Cronjobs for uffd -@daily uffd [ -f /usr/bin/uffd-admin ] && flock -n /var/run/uffd/cron.roles-update-all.lock /usr/bin/uffd-admin roles-update-all --check-only 2> /dev/null +@daily uffd flock -n /var/run/uffd/cron.roles-update-all.lock /usr/bin/uffd-admin roles-update-all --check-only 2> /dev/null +@daily uffd flock -n /var/run/uffd/cron.cleanup.lock /usr/bin/uffd-admin cleanup > /dev/null diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index cf7501fa..f0bf393c 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -48,19 +48,3 @@ class TestRatelimit(UffdTestCase): self.assertIsInstance(format_delay(120), str) self.assertIsInstance(format_delay(3600), str) self.assertIsInstance(format_delay(4000), str) - - def test_cleanup(self): - ratelimit = Ratelimit('test', 1, 1) - ratelimit.log('') - ratelimit.log('1') - ratelimit.log('2') - ratelimit.log('3') - ratelimit.log('4') - time.sleep(1) - ratelimit.log('5') - self.assertEqual(RatelimitEvent.query.filter(RatelimitEvent.name == 'test').count(), 6) - ratelimit.cleanup() - self.assertEqual(RatelimitEvent.query.filter(RatelimitEvent.name == 'test').count(), 1) - time.sleep(1) - ratelimit.cleanup() - self.assertEqual(RatelimitEvent.query.filter(RatelimitEvent.name == 'test').count(), 0) diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py index cd9988be..c6724ef4 100644 --- a/tests/test_selfservice.py +++ b/tests/test_selfservice.py @@ -200,7 +200,8 @@ class TestSelfservice(UffdTestCase): _user = request.user self.assertEqual(_user.mail, old_mail) tokens = MailToken.query.filter(MailToken.user == _user).all() - self.assertEqual(len(tokens), 0) + self.assertEqual(len(tokens), 1) + self.assertTrue(tokens[0].expired) def test_forgot_password(self): user = self.get_user() @@ -251,6 +252,7 @@ class TestSelfservice(UffdTestCase): token = PasswordToken(user=user) db.session.add(token) db.session.commit() + self.assertFalse(token.expired) r = self.client.get(path=url_for('selfservice.token_password', token_id=token.id, token=token.token), follow_redirects=True) dump('token_password', r) self.assertEqual(r.status_code, 200) @@ -265,12 +267,12 @@ class TestSelfservice(UffdTestCase): r = self.client.get(path=url_for('selfservice.token_password', token_id=1, token='A'*128), follow_redirects=True) dump('token_password_emptydb', r) self.assertEqual(r.status_code, 200) - self.assertIn(b'Token expired, please try again', r.data) + self.assertIn(b'Link invalid or expired', r.data) r = self.client.post(path=url_for('selfservice.token_password', token_id=1, token='A'*128), data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True) dump('token_password_emptydb_submit', r) self.assertEqual(r.status_code, 200) - self.assertIn(b'Token expired, please try again', r.data) + self.assertIn(b'Link invalid or expired', r.data) self.assertTrue(self.get_user().password.verify('userpassword')) def test_token_password_invalid(self): @@ -281,12 +283,12 @@ class TestSelfservice(UffdTestCase): r = self.client.get(path=url_for('selfservice.token_password', token_id=token.id, token='A'*128), follow_redirects=True) dump('token_password_invalid', r) self.assertEqual(r.status_code, 200) - self.assertIn(b'Token expired, please try again', r.data) + self.assertIn(b'Link invalid or expired', r.data) r = self.client.post(path=url_for('selfservice.token_password', token_id=token.id, token='A'*128), data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True) dump('token_password_invalid_submit', r) self.assertEqual(r.status_code, 200) - self.assertIn(b'Token expired, please try again', r.data) + self.assertIn(b'Link invalid or expired', r.data) self.assertTrue(self.get_user().password.verify('userpassword')) def test_token_password_expired(self): @@ -294,15 +296,16 @@ class TestSelfservice(UffdTestCase): token = PasswordToken(user=user, created=(datetime.datetime.now() - datetime.timedelta(days=10))) db.session.add(token) db.session.commit() + self.assertTrue(token.expired) r = self.client.get(path=url_for('selfservice.token_password', token_id=token.id, token=token.token), follow_redirects=True) dump('token_password_invalid_expired', r) self.assertEqual(r.status_code, 200) - self.assertIn(b'Token expired, please try again', r.data) + self.assertIn(b'Link invalid or expired', r.data) r = self.client.post(path=url_for('selfservice.token_password', token_id=token.id, token=token.token), data={'password1': 'newpassword', 'password2': 'newpassword'}, follow_redirects=True) dump('token_password_invalid_expired_submit', r) self.assertEqual(r.status_code, 200) - self.assertIn(b'Token expired, please try again', r.data) + self.assertIn(b'Link invalid or expired', r.data) self.assertTrue(self.get_user().password.verify('userpassword')) def test_token_password_different_passwords(self): diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 00000000..8f146d5a --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,36 @@ +import unittest + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +from uffd.tasks import CleanupTask + +class TestCleanupTask(unittest.TestCase): + def test(self): + app = Flask(__name__) + app.testing = True + app.debug = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + db = SQLAlchemy(app) + cleanup_task = CleanupTask(app, db) + + @cleanup_task.delete_by_attribute('delete_me') + class TestModel(db.Model): + id = db.Column(db.Integer(), primary_key=True, autoincrement=True) + delete_me = db.Column(db.Boolean(), default=False, nullable=False) + + with app.test_request_context(): + db.create_all() + db.session.add(TestModel(delete_me=True)) + db.session.add(TestModel(delete_me=True)) + db.session.add(TestModel(delete_me=True)) + db.session.add(TestModel(delete_me=False)) + db.session.add(TestModel(delete_me=False)) + db.session.commit() + db.session.expire_all() + self.assertEqual(TestModel.query.count(), 5) + + app.test_cli_runner().invoke(args=['cleanup']) + + with app.test_request_context(): + self.assertEqual(TestModel.query.count(), 2) diff --git a/uffd/__init__.py b/uffd/__init__.py index 611f107c..03ba6d42 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -14,6 +14,7 @@ from werkzeug.exceptions import InternalServerError, Forbidden from flask_migrate import Migrate from uffd.database import db, SQLAlchemyJSON, customize_db_engine +from uffd.tasks import cleanup_task from uffd.template_helper import register_template_helper from uffd.navbar import setup_navbar from uffd.secure_redirect import secure_local_redirect @@ -82,6 +83,8 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta with app.app_context(): customize_db_engine(db.engine) + cleanup_task.init_app(app, db) + for module in [user, selfservice, role, mail, session, csrf, mfa, oauth2, services, rolemod, api, signup, invite]: for bp in module.bp: app.register_blueprint(bp) diff --git a/uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py b/uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py new file mode 100644 index 00000000..70db4f46 --- /dev/null +++ b/uffd/migrations/versions/09d2edcaf0cc_add_expires_attribute_to_ratelimit_event.py @@ -0,0 +1,45 @@ +"""add expires attribute to ratelimit_event + +Revision ID: 09d2edcaf0cc +Revises: af07cea65391 +Create Date: 2022-02-15 14:16:19.318253 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '09d2edcaf0cc' +down_revision = 'af07cea65391' +branch_labels = None +depends_on = None + +def upgrade(): + meta = sa.MetaData(bind=op.get_bind()) + ratelimit_event = sa.Table('ratelimit_event', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.Column('name', sa.String(length=128), nullable=True), + sa.Column('key', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_ratelimit_event')) + ) + op.execute(ratelimit_event.delete()) + with op.batch_alter_table('ratelimit_event', copy_from=ratelimit_event) as batch_op: + batch_op.add_column(sa.Column('expires', sa.DateTime(), nullable=False)) + batch_op.alter_column('name', existing_type=sa.VARCHAR(length=128), nullable=False) + batch_op.alter_column('timestamp', existing_type=sa.DATETIME(), nullable=False) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + ratelimit_event = sa.Table('ratelimit_event', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('expires', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('key', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_ratelimit_event')) + ) + op.execute(ratelimit_event.delete()) + with op.batch_alter_table('ratelimit_event', schema=None) as batch_op: + batch_op.alter_column('timestamp', existing_type=sa.DATETIME(), nullable=True) + batch_op.alter_column('name', existing_type=sa.VARCHAR(length=128), nullable=True) + batch_op.drop_column('expires') diff --git a/uffd/oauth2/models.py b/uffd/oauth2/models.py index 211f2c22..37470751 100644 --- a/uffd/oauth2/models.py +++ b/uffd/oauth2/models.py @@ -1,9 +1,13 @@ +import datetime + from flask import current_app from flask_babel import get_locale, gettext as _ from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey from sqlalchemy.orm import relationship +from sqlalchemy.ext.hybrid import hybrid_property from uffd.database import db +from uffd.tasks import cleanup_task from uffd.session.models import DeviceLoginInitiation, DeviceLoginType class OAuth2Client: @@ -39,6 +43,7 @@ class OAuth2Client: def access_allowed(self, user): return user.has_permission(self.required_group) +@cleanup_task.delete_by_attribute('expired') class OAuth2Grant(db.Model): __tablename__ = 'oauth2grant' id = Column(Integer, primary_key=True, autoincrement=True) @@ -58,7 +63,7 @@ class OAuth2Grant(db.Model): code = Column(String(255), index=True, nullable=False) redirect_uri = Column(String(255), nullable=False) - expires = Column(DateTime, nullable=False) + expires = Column(DateTime, nullable=False, default=lambda: datetime.datetime.utcnow() + datetime.timedelta(seconds=100)) _scopes = Column(Text, nullable=False, default='') @property @@ -72,6 +77,13 @@ class OAuth2Grant(db.Model): db.session.commit() return self + @hybrid_property + def expired(self): + if self.expires is None: + return False + return self.expires < datetime.datetime.utcnow() + +@cleanup_task.delete_by_attribute('expired') class OAuth2Token(db.Model): __tablename__ = 'oauth2token' id = Column(Integer, primary_key=True, autoincrement=True) @@ -107,6 +119,14 @@ class OAuth2Token(db.Model): db.session.commit() return self + @hybrid_property + def expired(self): + return self.expires < datetime.datetime.utcnow() + + def set_expires_in_seconds(self, seconds): + self.expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) + expires_in_seconds = property(fset=set_expires_in_seconds) + class OAuth2DeviceLoginInitiation(DeviceLoginInitiation): __mapper_args__ = { 'polymorphic_identity': DeviceLoginType.OAUTH2 diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py index 4477e0ed..db5b6978 100644 --- a/uffd/oauth2/views.py +++ b/uffd/oauth2/views.py @@ -1,4 +1,3 @@ -import datetime import functools import secrets import urllib.parse @@ -66,9 +65,8 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): return set(scopes).issubset({'profile'}) def save_authorization_code(self, client_id, code, oauthreq, *args, **kwargs): - expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=100) grant = OAuth2Grant(user=oauthreq.user, client_id=client_id, code=code['code'], - redirect_uri=oauthreq.redirect_uri, expires=expires, _scopes=' '.join(oauthreq.scopes)) + redirect_uri=oauthreq.redirect_uri, _scopes=' '.join(oauthreq.scopes)) db.session.add(grant) db.session.commit() # Oauthlib does not really provide a way to customize grant code generation. @@ -86,7 +84,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): return False if not secrets.compare_digest(oauthreq.grant.code, grant_code): return False - if datetime.datetime.utcnow() > oauthreq.grant.expires: + if oauthreq.grant.expired: return False oauthreq.user = oauthreq.grant.user oauthreq.scopes = oauthreq.grant.scopes @@ -98,15 +96,13 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): def save_bearer_token(self, token_data, oauthreq, *args, **kwargs): OAuth2Token.query.filter_by(client_id=oauthreq.client.client_id, user=oauthreq.user).delete() - expires_in = token_data.get('expires_in') - expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in) tok = OAuth2Token( user=oauthreq.user, client_id=oauthreq.client.client_id, token_type=token_data['token_type'], access_token=token_data['access_token'], refresh_token=token_data['refresh_token'], - expires=expires, + expires_in_seconds=token_data['expires_in'], _scopes=' '.join(oauthreq.scopes) ) db.session.add(tok) @@ -133,7 +129,7 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): tok = OAuth2Token.query.get(tok_id) if not tok or not secrets.compare_digest(tok.access_token, tok_secret): return False - if datetime.datetime.utcnow() > tok.expires: + if tok.expired: oauthreq.error_message = 'Token expired' return False if not set(scopes).issubset(tok.scopes): diff --git a/uffd/ratelimit.py b/uffd/ratelimit.py index 46c97e42..5604299d 100644 --- a/uffd/ratelimit.py +++ b/uffd/ratelimit.py @@ -5,16 +5,24 @@ import math from flask import request from flask_babel import gettext as _ from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.ext.hybrid import hybrid_property from uffd.database import db +from uffd.tasks import cleanup_task +@cleanup_task.delete_by_attribute('expired') class RatelimitEvent(db.Model): __tablename__ = 'ratelimit_event' id = Column(Integer(), primary_key=True, autoincrement=True) - timestamp = Column(DateTime(), default=datetime.datetime.now) - name = Column(String(128)) + timestamp = Column(DateTime(), default=datetime.datetime.utcnow, nullable=False) + expires = Column(DateTime(), nullable=False) + name = Column(String(128), nullable=False) key = Column(String(128)) + @hybrid_property + def expired(self): + return self.expires < datetime.datetime.utcnow() + class Ratelimit: def __init__(self, name, interval, limit): self.name = name @@ -22,25 +30,23 @@ class Ratelimit: self.limit = limit self.base = interval**(1/limit) - def cleanup(self): - limit = datetime.datetime.now() - datetime.timedelta(seconds=self.interval) - RatelimitEvent.query.filter(RatelimitEvent.name == self.name, RatelimitEvent.timestamp <= limit).delete() - db.session.commit() - def log(self, key=None): - db.session.add(RatelimitEvent(name=self.name, key=key)) + db.session.add(RatelimitEvent(name=self.name, key=key, expires=datetime.datetime.utcnow() + datetime.timedelta(seconds=self.interval))) db.session.commit() def get_delay(self, key=None): - self.cleanup() - events = RatelimitEvent.query.filter(RatelimitEvent.name == self.name, RatelimitEvent.key == key).all() + events = RatelimitEvent.query\ + .filter(db.not_(RatelimitEvent.expired))\ + .filter_by(name=self.name, key=key)\ + .order_by(RatelimitEvent.timestamp)\ + .all() if not events: return 0 delay = math.ceil(self.base**len(events)) if delay < 5: delay = 0 - delay = min(delay, 365*24*60*60) # prevent overflow of datetime objetcs - remaining = events[0].timestamp + datetime.timedelta(seconds=delay) - datetime.datetime.now() + delay = min(delay, 365*24*60*60) # prevent overflow of datetime objects + remaining = events[0].timestamp + datetime.timedelta(seconds=delay) - datetime.datetime.utcnow() return max(0, math.ceil(remaining.total_seconds())) def get_addrkey(addr=None): diff --git a/uffd/selfservice/models.py b/uffd/selfservice/models.py index b0103f70..4ac327bd 100644 --- a/uffd/selfservice/models.py +++ b/uffd/selfservice/models.py @@ -2,10 +2,13 @@ import datetime from sqlalchemy import Column, String, DateTime, Integer, ForeignKey from sqlalchemy.orm import relationship +from sqlalchemy.ext.hybrid import hybrid_property from uffd.database import db +from uffd.tasks import cleanup_task from uffd.utils import token_urlfriendly +@cleanup_task.delete_by_attribute('expired') class PasswordToken(db.Model): __tablename__ = 'passwordToken' id = Column(Integer(), primary_key=True, autoincrement=True) @@ -14,6 +17,13 @@ class PasswordToken(db.Model): user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) user = relationship('User') + @hybrid_property + def expired(self): + if self.created is None: + return False + return self.created < datetime.datetime.now() - datetime.timedelta(days=2) + +@cleanup_task.delete_by_attribute('expired') class MailToken(db.Model): __tablename__ = 'mailToken' id = Column(Integer(), primary_key=True, autoincrement=True) @@ -22,3 +32,9 @@ class MailToken(db.Model): user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False) user = relationship('User') newmail = Column(String(255)) + + @hybrid_property + def expired(self): + if self.created is None: + return False + return self.created < datetime.datetime.now() - datetime.timedelta(days=2) diff --git a/uffd/selfservice/views.py b/uffd/selfservice/views.py index 8760499a..3103c118 100644 --- a/uffd/selfservice/views.py +++ b/uffd/selfservice/views.py @@ -1,4 +1,3 @@ -import datetime import secrets from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort @@ -83,11 +82,8 @@ def forgot_password(): def token_password(token_id, token): dbtoken = PasswordToken.query.get(token_id) if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \ - dbtoken.created < (datetime.datetime.now() - datetime.timedelta(days=2)): - flash(_('Token expired, please try again.')) - if dbtoken: - db.session.delete(dbtoken) - db.session.commit() + dbtoken.expired: + flash(_('Link invalid or expired')) return redirect(url_for('session.login')) if request.method == 'GET': return render_template('selfservice/set_password.html', token=dbtoken) @@ -103,8 +99,8 @@ def token_password(token_id, token): flash(_('Password ist not valid, please try again.')) return render_template('selfservice/set_password.html', token=dbtoken) db.session.delete(dbtoken) - flash(_('New password set')) db.session.commit() + flash(_('New password set')) return redirect(url_for('session.login')) @bp.route("/token/mail_verification/<int:token_id>/<token>") @@ -112,18 +108,15 @@ def token_password(token_id, token): def token_mail(token_id, token): dbtoken = MailToken.query.get(token_id) if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \ - dbtoken.created < (datetime.datetime.now() - datetime.timedelta(days=2)): - flash(_('Token expired, please try again.')) - if dbtoken: - db.session.delete(dbtoken) - db.session.commit() + dbtoken.expired: + flash(_('Link invalid or expired')) return redirect(url_for('selfservice.index')) if dbtoken.user != request.user: abort(403, description=_('This link was generated for another user. Login as the correct user to continue.')) dbtoken.user.set_mail(dbtoken.newmail) - flash(_('New mail set')) db.session.delete(dbtoken) db.session.commit() + flash(_('New mail set')) return redirect(url_for('selfservice.index')) @bp.route("/leaverole/<int:roleid>", methods=(['POST'])) @@ -138,8 +131,7 @@ def leave_role(roleid): return redirect(url_for('selfservice.index')) def send_mail_verification(user, newmail): - MailToken.query.filter(db.or_(MailToken.created < (datetime.datetime.now() - datetime.timedelta(days=2)), - MailToken.user == user)).delete() + MailToken.query.filter(MailToken.user == user).delete() token = MailToken(user=user, newmail=newmail) db.session.add(token) db.session.commit() @@ -148,8 +140,7 @@ def send_mail_verification(user, newmail): flash(_('Mail to "%(mail_address)s" could not be sent!', mail_address=newmail)) def send_passwordreset(user, new=False): - PasswordToken.query.filter(db.or_(PasswordToken.created < (datetime.datetime.now() - datetime.timedelta(days=2)), - PasswordToken.user == user)).delete() + PasswordToken.query.filter(PasswordToken.user == user).delete() token = PasswordToken(user=user) db.session.add(token) db.session.commit() diff --git a/uffd/session/models.py b/uffd/session/models.py index a64dd5cd..4b2099e8 100644 --- a/uffd/session/models.py +++ b/uffd/session/models.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.ext.hybrid import hybrid_property from uffd.database import db +from uffd.tasks import cleanup_task from uffd.utils import token_typeable # Device login provides a convenient and secure way to log into SSO-enabled @@ -55,6 +56,7 @@ from uffd.utils import token_typeable class DeviceLoginType(enum.Enum): OAUTH2 = 0 +@cleanup_task.delete_by_attribute('expired') class DeviceLoginInitiation(db.Model): '''Abstract initiation code class @@ -92,6 +94,8 @@ class DeviceLoginInitiation(db.Model): @hybrid_property def expired(self): + if self.created is None: + return False return self.created < datetime.datetime.now() - datetime.timedelta(minutes=30) @property diff --git a/uffd/signup/models.py b/uffd/signup/models.py index af19bd5d..ab9920ae 100644 --- a/uffd/signup/models.py +++ b/uffd/signup/models.py @@ -2,12 +2,15 @@ import datetime from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey from sqlalchemy.orm import relationship, backref +from sqlalchemy.ext.hybrid import hybrid_property from uffd.database import db +from uffd.tasks import cleanup_task from uffd.user.models import User from uffd.utils import token_urlfriendly from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash +@cleanup_task.delete_by_attribute('expired_and_not_completed') class Signup(db.Model): '''Model that represents a self-signup request @@ -22,8 +25,8 @@ class Signup(db.Model): address does not allow a third party to complete the signup procedure and set a new password with the (also mail-based) password reset functionality. - As long as they are not completed, signup requests have no effect each other - or different parts of the application.''' + As long as they are not completed, signup requests have no effect on each + other or different parts of the application.''' __tablename__ = 'signup' id = Column(Integer(), primary_key=True, autoincrement=True) token = Column(String(128), default=token_urlfriendly, nullable=False) @@ -48,13 +51,20 @@ class Signup(db.Model): self.password = value return True - @property + @hybrid_property def expired(self): - return self.created is not None and datetime.datetime.now() >= self.created + datetime.timedelta(hours=48) + if self.created is None: + return False + return self.created < datetime.datetime.now() - datetime.timedelta(hours=48) - @property + @hybrid_property def completed(self): - return self.user is not None + # pylint: disable=singleton-comparison + return self.user_id != None + + @hybrid_property + def expired_and_not_completed(self): + return db.and_(self.expired, db.not_(self.completed)) def validate(self): # pylint: disable=too-many-return-statements '''Return whether the signup request is valid and Signup.finish is likely to succeed diff --git a/uffd/tasks.py b/uffd/tasks.py new file mode 100644 index 00000000..4ae6d380 --- /dev/null +++ b/uffd/tasks.py @@ -0,0 +1,29 @@ +class CleanupTask: + def __init__(self, *init_args): + self.handlers = [] + if init_args: + self.init_app(*init_args) + + def init_app(self, app, db): + @app.cli.command('cleanup', help='Cleanup expired data') + def cleanup(): #pylint: disable=unused-variable + self.run() + db.session.commit() + + def handler(self, func): + self.handlers.append(func) + return func + + def delete_by_attribute(self, attribute): + def decorator(cls): + @self.handler + def handler(): + cls.query.filter(getattr(cls, attribute)).delete() + return cls + return decorator + + def run(self): + for handler in self.handlers: + handler() + +cleanup_task = CleanupTask() diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 7178b9b68894ad12809ce2d9fae534e339fbd7e1..fae15547dea316a68d4e4a766cc5cbe185c27655 100644 GIT binary patch delta 4148 zcmeDG&G`2h;|3O%dT|B@1}0?&1~~=>1}S9*1{Vef21{iI26YAohB{>i24w~YhGohO z46F<c45ySC7`PZ17%o8NZ!0q}s4_4xFsm>y$TKi7XsR$UNHQ=mc&ackurV+&B&#qm zXfQA^WT`MPC@?TEOjcoFU}0cj*rLL~z{9}6uv3Mho`Ii%f#H}61A{071H&~H1_oUQ z28K6K4N|HM415d>4A!a)4D1XH3|^`b2L!1yfc3>f`MIhL4BQM13>B&j44e!M3@xe* z4EhWV41KB)3-_rqFoZHNFdT-emsDe5U}j)oP*JOgxI{w@LYt~FFbFU(FgU3(FbFX) zFa)bXLLf~IVsMul#HUl#7#LJQK2c*}Xk=huxT(g#Ai}`F5UtL@Fol7Ep+KF1VIu<r z1FHrD0|x^G!*(crKm%g_Neu=DVFm_<TlE?Y44MoK4DU1;7{nMD7$h_ye0@!bk0Lc8 z4olT!V6bOkU?|dLU`S_RVAuqem(qfSjG7k2Ks_x;h&gF7Fyu2ZFa&BbFtjl+FdWok zVBlq7VDQj}I4DdTVqbl{HbkOKn}NX=6m;5<xI3f`3F4F55TD=Fh8X-<8<JK&X)`cr zF)%O)>OkaebRg!qLHQv%kTesg!@yw4z`&5A!@wZLz`!t12a+as>VTuBp5c)W#KPw~ z3=9#VkkEnn++CM}K>`#7x{wga(S-zYr7pw=eYy|_O^3=a(`8_o!N9<<87l9i2XRoS z9wf+<^&t97^dR<i>VeWyJp;o8JxE;7)`JAeIz0vk9|i`7qk0Ss6$}gvLi!Mgb?Y-Q zOk!YQc%lz6pxgiw^mPUd42BF03~dGs46O_d3_A=M7$z_<FxVMFa>IE;NYuPEWMEj# zz`*d=kbxn9fq`M65d(ukJp%*7CnHFlD;q<C%Ga2I!JUDDq1_m2fH5TdyfkKDaAaU$ z;4)!gaA06y@G*fT&R!F6P%>;Zfdu(B6G(2_W5U4jmVtrck_iKYECU0>0aHk-zh?>w zkw>Ns4E3Pw_1P5SBPKHjhCBua22L{u1`kknGGk!y0wo?Zh);RU85nFB7#Kv%AyE=& z4zV!K91>+I<_rvqpww>;ao8ktNLtxu4l)0#ImAPEp!{#<4E5kt{nwm<A&P;4!PbI- zA)0}KVFr}`VgU*A080jjR0aly$CeBXW}pJbih&`9fq|jHih;qEfq~(!6$67d0|SGy zH3P#j1_lOyYluh0Y#12a7#J8@Z5S9z7#JAN*f1~{GcYhX+CtJ!sck(ZNLy?f7?eN- zku3v*8Uq8vOIt`95U^ulFk)a}h_!=QG{Fv1;$5?2V31~DVEALlz)%Xxw)PARppvxD z9+JA3*+UAl^Y#o3k)SxYXJANVU|<M$fY3V~Am&KdJ2EgF0lCPLfnh2G0|T2AB-PGw zf&|qnCrHp9ae`QQ!3h%AZ=4{x;G+{L6*DmWa$;a$0u?yUkf`E-(gMzqY%Jl-z|hOc zz@XvGz#z!Lz_85)k|^s>xIhfL;{plNr!J7h;p7UjIM@~9pm<k^MY*n!+*08RiTiF> zNMf7l3MmhkyFz??$(4a2j)8$e#|@J0y4@HUk{K8n7Pv7mtYKhaU~vaWO+CX#cLs(c z1_p*7?vOG&+k=52n1O*|wg<$=?>!(EaC<^5R`g_GFlAt1F!6*qD9aO40F`<|d|vMf z@j$yL*aC(bo{+?PAFBS1C!{3(?g=Ww>lql#y&!QP1EuS|ASKlzFGx`B^@60{^InjU zxCWJf;sx>9e=kU467h!6%H9wM8F@oO*2Wv+AXje&1{nqhhA?l4xy9ZL3_PIx-|7u9 zXc9<(fq`K;l-}YE34w#&kn-TJH^hgkJ`e+ReHa)F7#JAreIQX)0HteuAO%^64+Dc9 z0|Uc+A4vW`=L4yn-uOT&u|{78hI&vfw9psg@~yrM3_1)93`cw+x#FWQBr1&kAaUL9 z$G}j_z`)S!2MKy^e@M`Z`9mD0><>v)+WwHJviFB1z94@{!IR_<$z5swklfMh&rr`0 z!oa|=+aKa1fdGiXN&yg!x&aUaZ37q>EI_qc0K}rM07#Hd3SeM(#lXO@IDmnn5>(#@ zGBB)WU|`?~f)vSHf*>LDEC^C=d<ud%Ofwi_pG|N*B(D5|Aw_INFayIO1_p-8V2A^3 zLLl-!A&?*p3xOoYk`PE5=?Z~3WN8S*;B6t05Ih+IX+eDpfkcs9C?tgap>$j*M1O95 zD8z>yp^$=RPAH^uI3EgefI=9=2L@q~>e(g?;-IQ9NIB3F21#U{VUR>TDGXxqRw(~& z7$jFQghR5UK{x}09|HqJe>lX$_4mRd2K*0)Si}(l$zCE6ki_E^0r62~1SABOML^Ua zfy$qafLQP+0^&oRNC<5m2}!(Ok&slM7RkV1#lXPO90`g0eUacOsAqT+35oN6P?|Fe zEWscW1xcl9Q4p89ML|L;J&J+Bnt_3#Itmg3+oK=_*PbXy6i7xx5}Qsm#32UJkamQ9 zG(<ik8d6keM}vy$dIpB(Xh<qv6Ada57#Ma&LtJ<-nt`E?fq{W129lk+V;C4#GB7YK zieX?l0IGgtAr9w_gM^4g9K-^}IEZ?aI7kS3$3aqkRUD++UKj^)*rPZG27XZf{}>0U zZW-etwUk#pBvI{;hZH;)p#1Cc5SPD*XJF`IU|?WKU|?7W%8m(;5VJ{yc*HLelDMK1 zAr_S-GB7xSYQaQE+BuO3DQ_+%GBDJGDud^V5EuVUgjAyfNsxNpCJExh$|OkIXo2!4 zBtaZDI|&jMi;^I5d@qTC!GwW<;dc_mB7<ZIZI#Txkjucp;F1h6XHzny_S=^XZT~+> zhQ!gAWJvzzO@RcZXbL13XrwSOtYTnbut<Rv!H-iQKKPgd(Z`kwsV(JGA=%S16%r!; zsSt}2Qz22?o(f4TYf>2)W`P<yY4wm2ZAluW@u-vz@!9Tlh(ivgLsI{#bOwf*3=9n4 z(;*F#i5ZaWrjiK>V)sl)xe=BLX?9CyL3&c1S&*jPxhw_-XHX@V#lR56z`$UW4arUY z_1O>$)@Cy>c!N3w*$fPx3=9lvIgs+8AP16~7w14y`|=!!57*={Fo-ZRFdWJOrD6t# z>$wnhp!OT6t;doFQKys#NfQ=%3=G)}3=FP$3=DP*3=A9dAokRM$b-bORXzhlAV@(z zBxtASLsIpcd`O&a&4>8pU_K;Ozs`r`Z>a)^1!e`1Z0cM9F)yWn0o;hqFMw3<+Y1;N zG8q^cv<e}u>M4Z`3>z32>KS;8ASKhjB1nBLTg<>v1?n&qLmYI!7}BsXEMZ{y#K6E1 zQvzvn`Ikc4j7Liu7&;gj7!=ANiEm{YB<}B(LF)MrWst;aP!92cZ#e^lF9QQZc{!*~ zsApg}S`KmPpK?gPcd3BHO;H6T&L>nre5zFmsTJKSAqI6+LVUKV5>ol7Rzdjls~{E% zRWmSrW?*2@uV!G_3~HR#Kpb|x29iBr)-W*efV$f(wUB1AU@at$4Qe4lZVIIxq2j)^ zkSGbNg(S}CT1cu--n>#cieD@<FIyoquPiYqGesf4NFlYNAhRenWizAlZT`)Fjiw85 z7@8^=8Cw|{Z=U09!npaOi<pp-4_H%ii9%^!`r$n}C7J08`6;PI3W-VSsX2+IX{mXe OpM`Y^Z=Rj?MF;>a6dPy& delta 4141 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 G69NDP-4*-* diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 217cba4f..aac46a19 100644 --- a/uffd/translations/de/LC_MESSAGES/messages.po +++ b/uffd/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-02-03 16:51+0100\n" +"POT-Creation-Date: 2022-02-15 23:23+0100\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -18,31 +18,31 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.1\n" -#: uffd/ratelimit.py:70 +#: uffd/ratelimit.py:76 msgid "a few seconds" msgstr "ein paar Sekunden" -#: uffd/ratelimit.py:72 +#: uffd/ratelimit.py:78 msgid "30 seconds" msgstr "30 Sekunden" -#: uffd/ratelimit.py:74 +#: uffd/ratelimit.py:80 msgid "one minute" msgstr "eine Minute" -#: uffd/ratelimit.py:76 +#: uffd/ratelimit.py:82 #, python-format msgid "%(minutes)d minutes" msgstr "%(minutes)d Minuten" -#: uffd/ratelimit.py:78 +#: uffd/ratelimit.py:84 msgid "one hour" msgstr "eine Stunde" -#: uffd/ratelimit.py:79 +#: uffd/ratelimit.py:85 #, python-format msgid "%(hours)d hours" -msgstr "%(hours)d Stunden\"" +msgstr "%(hours)d Stunden" #: uffd/invite/views.py:46 msgid "Invites" @@ -74,7 +74,7 @@ msgstr "Rollen erfolgreich geändert" msgid "Invite link does not allow signup" msgstr "Einladungslink erlaubt keine Account-Registrierung" -#: uffd/invite/views.py:175 uffd/selfservice/views.py:50 +#: uffd/invite/views.py:175 uffd/selfservice/views.py:49 #: uffd/signup/views.py:49 msgid "Passwords do not match" msgstr "Die Passwörter stimmen nicht überein" @@ -86,12 +86,12 @@ msgstr "" "Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte " "%(delay)s." -#: uffd/invite/views.py:182 uffd/signup/views.py:56 uffd/signup/views.py:92 +#: uffd/invite/views.py:182 uffd/signup/views.py:56 uffd/signup/views.py:96 #, python-format msgid "Too many requests! Please wait %(delay)s." msgstr "Zu viele Anfragen! Bitte warte %(delay)s." -#: uffd/invite/views.py:195 uffd/signup/views.py:68 +#: uffd/invite/views.py:195 uffd/signup/views.py:72 msgid "Cound not send mail" msgstr "Mailversand fehlgeschlagen" @@ -721,11 +721,11 @@ msgstr "Sekunden" msgid "Verify and complete setup" msgstr "Verifiziere und beende das Setup" -#: uffd/oauth2/models.py:29 +#: uffd/oauth2/models.py:33 msgid "You need to login to access this service" msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" -#: uffd/oauth2/views.py:173 uffd/selfservice/views.py:72 +#: uffd/oauth2/views.py:169 uffd/selfservice/views.py:71 #: uffd/session/views.py:73 #, python-format msgid "" @@ -735,15 +735,15 @@ msgstr "" "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem " "Netzwerk empfangen! Bitte warte mindestens %(delay)s." -#: uffd/oauth2/views.py:181 +#: uffd/oauth2/views.py:177 msgid "Device login is currently not available. Try again later!" msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!" -#: uffd/oauth2/views.py:194 +#: uffd/oauth2/views.py:190 msgid "Device login failed" msgstr "Gerätelogin fehlgeschlagen" -#: uffd/oauth2/views.py:207 +#: uffd/oauth2/views.py:203 #, python-format msgid "" "You don't have the permission to access the service " @@ -929,31 +929,31 @@ msgstr "Mitglieder:" msgid "Remove" msgstr "Entfernen" -#: uffd/selfservice/views.py:25 +#: uffd/selfservice/views.py:24 msgid "Selfservice" msgstr "Selfservice" -#: uffd/selfservice/views.py:36 +#: uffd/selfservice/views.py:35 msgid "Display name changed." msgstr "Anzeigename geändert." -#: uffd/selfservice/views.py:38 +#: uffd/selfservice/views.py:37 msgid "Display name is not valid." msgstr "Anzeigename ist nicht valide." -#: uffd/selfservice/views.py:41 +#: uffd/selfservice/views.py:40 msgid "We sent you an email, please verify your mail address." msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse." -#: uffd/selfservice/views.py:53 +#: uffd/selfservice/views.py:52 msgid "Password changed" msgstr "Passwort geändert" -#: uffd/selfservice/views.py:55 +#: uffd/selfservice/views.py:54 msgid "Invalid password" msgstr "Passwort ungültig" -#: uffd/selfservice/views.py:70 +#: uffd/selfservice/views.py:69 #, python-format msgid "" "We received too many password reset requests for this user! Please wait " @@ -962,7 +962,7 @@ msgstr "" "Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! " "Bitte warte mindestens %(delay)s." -#: uffd/selfservice/views.py:76 +#: uffd/selfservice/views.py:75 msgid "" "We sent a mail to this user's mail address if you entered the correct " "mail and login name combination" @@ -970,27 +970,27 @@ msgstr "" "Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an " "die Adresse gesendet." -#: uffd/selfservice/views.py:87 uffd/selfservice/views.py:116 -msgid "Token expired, please try again." -msgstr "Link abgelaufen, bitte versuche es erneut." +#: uffd/selfservice/views.py:86 uffd/selfservice/views.py:112 +msgid "Link invalid or expired" +msgstr "Link ist ungültig oder abgelaufen" -#: uffd/selfservice/views.py:95 +#: uffd/selfservice/views.py:91 msgid "You need to set a password, please try again." msgstr "Password fehlt, bitte versuche es erneut." -#: uffd/selfservice/views.py:98 +#: uffd/selfservice/views.py:94 msgid "Passwords do not match, please try again." msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut" -#: uffd/selfservice/views.py:103 +#: uffd/selfservice/views.py:99 msgid "Password ist not valid, please try again." msgstr "Ungültiges Passwort, bitte versuche es erneut" -#: uffd/selfservice/views.py:106 +#: uffd/selfservice/views.py:103 msgid "New password set" msgstr "Passwort geändert" -#: uffd/selfservice/views.py:122 +#: uffd/selfservice/views.py:115 msgid "" "This link was generated for another user. Login as the correct user to " "continue." @@ -998,16 +998,16 @@ msgstr "" "Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem " "richtigen Account an um Fortzufahren." -#: uffd/selfservice/views.py:124 +#: uffd/selfservice/views.py:119 msgid "New mail set" msgstr "E-Mail-Adresse geändert" -#: uffd/selfservice/views.py:137 +#: uffd/selfservice/views.py:130 #, python-format msgid "You left role %(role_name)s" msgstr "Rolle %(role_name)s verlassen" -#: uffd/selfservice/views.py:148 uffd/selfservice/views.py:165 +#: uffd/selfservice/views.py:140 uffd/selfservice/views.py:156 #, python-format msgid "Mail to \"%(mail_address)s\" could not be sent!" msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!" @@ -1209,23 +1209,23 @@ msgstr "" msgid "Login name or password is wrong" msgstr "Der Anmeldename oder das Passwort ist falsch" -#: uffd/session/views.py:82 +#: uffd/session/views.py:85 msgid "You do not have access to this service" msgstr "Du hast keinen Zugriff auf diesen Service" -#: uffd/session/views.py:94 uffd/session/views.py:105 +#: uffd/session/views.py:97 uffd/session/views.py:108 msgid "You need to login first" msgstr "Du musst dich erst anmelden" -#: uffd/session/views.py:126 uffd/session/views.py:136 +#: uffd/session/views.py:129 uffd/session/views.py:139 msgid "Initiation code is no longer valid" msgstr "Startcode ist nicht mehr gültig" -#: uffd/session/views.py:140 +#: uffd/session/views.py:143 msgid "Invalid confirmation code" msgstr "Ungültiger Bestätigungscode" -#: uffd/session/views.py:152 uffd/session/views.py:163 +#: uffd/session/views.py:155 uffd/session/views.py:166 msgid "Invalid initiation code" msgstr "Ungültiger Startcode" @@ -1335,20 +1335,20 @@ msgstr "Passwort vergessen?" msgid "Singup not enabled" msgstr "Account-Registrierung ist deaktiviert" -#: uffd/signup/views.py:77 uffd/signup/views.py:85 +#: uffd/signup/views.py:81 uffd/signup/views.py:89 msgid "Invalid signup link" msgstr "Ungültiger Account-Registrierungs-Link" -#: uffd/signup/views.py:90 +#: uffd/signup/views.py:94 #, python-format msgid "Too many failed attempts! Please wait %(delay)s." msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s." -#: uffd/signup/views.py:96 +#: uffd/signup/views.py:100 msgid "Wrong password" msgstr "Falsches Passwort" -#: uffd/signup/views.py:102 +#: uffd/signup/views.py:106 msgid "Your account was successfully created" msgstr "Account erfolgreich erstellt" @@ -1445,7 +1445,7 @@ msgstr "Ändern" msgid "About uffd" msgstr "Über uffd" -#: uffd/user/models.py:77 +#: uffd/user/models.py:48 #, python-format msgid "" "At least %(minlen)d and at most %(maxlen)d characters. Only letters, " -- GitLab