diff --git a/debian/cron.d b/debian/cron.d index 4b9e06d09c17096332b5af489dedd7e8fc7e3d7f..942624310ed045c10429141a760468ab2c50dae3 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 cf7501fa99e752d3d549f528191a501a997eb8b4..f0bf393c10ceb169a30984c2dc220d29a1868671 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 cd9988be88d3e582d500cdd7fa24454674decef1..c6724ef4016919fb3d03680a4fed5514d699220e 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 0000000000000000000000000000000000000000..8f146d5ac8aa3e8c95283657478bc71396328c5b --- /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 611f107c378286358dbd8506a93cc9d18e0da7d1..03ba6d42569c7f5f311b35889d8197472891772a 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 0000000000000000000000000000000000000000..70db4f4618ba302f95727f5d052bf172a6d9ab51 --- /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 211f2c22ee26adde4d35e470d5166af699ff7f14..374707514f18c1b5780bab44d53bb0e5420d9992 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 4477e0edae16803f2790ea7305d8dec935bb7439..db5b69783f6dbc6fd4351d575ff0dfff7b5f2197 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 46c97e424fd6da84a99c39d3bad269719cc422a5..5604299d983a0e076e107dc2f9e1f46e5f14cfb3 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 b0103f70379a21639715267add4085def91bf057..4ac327bd597230040c78cc3c020f990853fa7819 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 8760499ac099aed9dc070c630b8710d658c621d2..3103c118acaf64ac723b2e9234e08d810bc6a708 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 a64dd5cdb99a06aad06c5ebfbe32bbaad867534d..4b2099e8a5c88780778146b686ab1b40f068026e 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 af19bd5d537a2f5de83d85a13dbf4394b7a66d0a..ab9920ae297fb7cf915f8d9baa00e9905cfcdf92 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 0000000000000000000000000000000000000000..4ae6d380f9d221b54d8c57ce2ade602c4e065eb8 --- /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 Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 217cba4fc9b5ceef6127d5492f3c6b232f6c2d42..aac46a197d92b30b2b7f1834a1f40315ff96e8a4 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, "