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