From ac731bf438e0a701da08dcbae9836c91486b2be2 Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Mon, 15 Aug 2022 22:01:39 +0200 Subject: [PATCH] Restructure source tree Move all models, views, cli commands and templates into corresponding top-level folders. Detailed changes: - uffd/<NAME>/models.py -> uffd/models/<NAME>.py - uffd/<NAME>/cli.py -> uffd/commands/<NAME>.py - uffd/<NAME>/views.py -> uffd/views/<NAME>.py - uffd/<NAME>/templates/* -> uffd/templates/ - uffd/ratelimit.py -> uffd/models/ratelimit.py (it contains models) - gendevcert from uffd/__init__.py -> uffd/commands/gendevcert.py - profile from uffd/__init__.py -> uffd/commands/profile.py - cleanup from uffd/tasks.py -> uffd/commands/cleanup.py - roles-update-all from uffd/role/views.py -> uffd/commands/... - Views from uffd/__init__.py -> uffd/views/__init__.py - All models can/should be imported from uffd.models - flask shell auto-imports all models instead of only a few The old structure was meant to keep the code modular and related code/resources close to each other. However, the modules turned out to be heavily interdependent and not very modular. Also importing was fragile due to ordering issues. With the new structure the dependency tree is much simpler: Infrastructure code (top-level *.py files) has no internal dependencies. Models only depend on infrastructure and other models. Views and cli commands depend on infrastructure, models and other views/commands. Going forward there is still some restructuring to do, e.g.: - Move mfa setup views to selfservice views - Move mfa auth views to session views - Move utility code from views to infrastructure (e.g. login_required) - In most cases views should not need to import from other views - Reorganize infrastructure code --- check_migrations.py | 20 +++--- tests/test_api.py | 6 +- tests/test_csrf.py | 2 +- tests/test_invite.py | 9 +-- tests/test_mail.py | 5 +- tests/test_mfa.py | 10 +-- tests/test_oauth2.py | 10 +-- tests/test_ratelimit.py | 2 +- tests/test_role.py | 8 +-- tests/test_rolemod.py | 3 +- tests/test_selfservice.py | 7 +- tests/test_services.py | 3 - tests/test_session.py | 12 +--- tests/test_signup.py | 9 +-- tests/test_tasks.py | 7 +- tests/test_user.py | 7 +- tests/utils.py | 3 +- uffd/__init__.py | 70 ++++--------------- uffd/api/__init__.py | 3 - uffd/commands/__init__.py | 16 +++++ uffd/commands/cleanup.py | 11 +++ uffd/commands/gendevcert.py | 13 ++++ uffd/{user/cli_group.py => commands/group.py} | 21 +++--- uffd/commands/profile.py | 19 +++++ uffd/{role/cli.py => commands/role.py} | 23 +++--- uffd/commands/roles_update_all.py | 30 ++++++++ uffd/{user/cli_user.py => commands/user.py} | 23 +++--- uffd/{csrf => }/csrf.py | 0 uffd/csrf/__init__.py | 3 - uffd/{mfa => }/fido2_compat.py | 0 uffd/invite/__init__.py | 3 - uffd/mail/__init__.py | 3 - uffd/mfa/__init__.py | 3 - uffd/models/__init__.py | 27 +++++++ uffd/{api/models.py => models/api.py} | 0 uffd/{invite/models.py => models/invite.py} | 4 +- uffd/{mail/models.py => models/mail.py} | 0 uffd/{mfa/models.py => models/mfa.py} | 4 +- uffd/{oauth2/models.py => models/oauth2.py} | 2 +- uffd/{ => models}/ratelimit.py | 2 +- uffd/{role/models.py => models/role.py} | 2 +- .../models.py => models/selfservice.py} | 2 +- uffd/{service/models.py => models/service.py} | 0 uffd/{session/models.py => models/session.py} | 2 +- uffd/{signup/models.py => models/signup.py} | 4 +- uffd/{user/models.py => models/user.py} | 2 +- uffd/oauth2/__init__.py | 3 - uffd/role/__init__.py | 4 -- uffd/rolemod/__init__.py | 3 - uffd/selfservice/__init__.py | 3 - uffd/service/__init__.py | 3 - uffd/session/__init__.py | 3 - uffd/signup/__init__.py | 3 - uffd/tasks.py | 10 +-- uffd/{user => }/templates/group/list.html | 0 uffd/{user => }/templates/group/show.html | 0 uffd/{invite => }/templates/invite/list.html | 0 uffd/{invite => }/templates/invite/new.html | 0 uffd/{invite => }/templates/invite/use.html | 0 uffd/{mail => }/templates/mail/list.html | 0 uffd/{mail => }/templates/mail/show.html | 0 uffd/{mfa => }/templates/mfa/auth.html | 0 uffd/{mfa => }/templates/mfa/disable.html | 0 uffd/{mfa => }/templates/mfa/setup.html | 0 .../templates/mfa/setup_recovery.html | 0 uffd/{mfa => }/templates/mfa/setup_totp.html | 0 uffd/{oauth2 => }/templates/oauth2/error.html | 0 .../{oauth2 => }/templates/oauth2/logout.html | 0 uffd/{role => }/templates/role/list.html | 0 uffd/{role => }/templates/role/show.html | 0 .../{rolemod => }/templates/rolemod/list.html | 0 .../{rolemod => }/templates/rolemod/show.html | 0 .../selfservice/forgot_password.html | 0 .../selfservice/mailverification.mail.txt | 0 .../templates/selfservice/newuser.mail.txt | 0 .../selfservice/passwordreset.mail.txt | 0 .../templates/selfservice/self.html | 0 .../templates/selfservice/set_password.html | 0 uffd/{service => }/templates/service/api.html | 0 .../templates/service/index.html | 0 .../templates/service/oauth2.html | 0 .../templates/service/overview.html | 0 .../{service => }/templates/service/show.html | 0 .../templates/session/deviceauth.html | 0 .../templates/session/devicelogin.html | 0 .../templates/session/login.html | 0 .../templates/signup/confirm.html | 0 uffd/{signup => }/templates/signup/mail.txt | 0 uffd/{signup => }/templates/signup/start.html | 0 .../templates/signup/submitted.html | 0 uffd/{user => }/templates/user/list.html | 0 uffd/{user => }/templates/user/show.html | 0 uffd/user/__init__.py | 6 -- uffd/views/__init__.py | 38 ++++++++++ uffd/{api/views.py => views/api.py} | 6 +- uffd/{user/views_group.py => views/group.py} | 5 +- uffd/{invite/views.py => views/invite.py} | 13 ++-- uffd/{mail/views.py => views/mail.py} | 5 +- uffd/{mfa/views.py => views/mfa.py} | 10 ++- uffd/{oauth2/views.py => views/oauth2.py} | 6 +- uffd/{role/views.py => views/role.py} | 31 +------- uffd/{rolemod/views.py => views/rolemod.py} | 5 +- .../views.py => views/selfservice.py} | 7 +- uffd/{service/views.py => views/service.py} | 8 +-- uffd/{session/views.py => views/session.py} | 4 +- uffd/{signup/views.py => views/signup.py} | 8 +-- uffd/{user/views_user.py => views/user.py} | 8 +-- 107 files changed, 275 insertions(+), 332 deletions(-) delete mode 100644 uffd/api/__init__.py create mode 100644 uffd/commands/__init__.py create mode 100644 uffd/commands/cleanup.py create mode 100644 uffd/commands/gendevcert.py rename uffd/{user/cli_group.py => commands/group.py} (78%) create mode 100644 uffd/commands/profile.py rename uffd/{role/cli.py => commands/role.py} (92%) create mode 100644 uffd/commands/roles_update_all.py rename uffd/{user/cli_user.py => commands/user.py} (90%) rename uffd/{csrf => }/csrf.py (100%) delete mode 100644 uffd/csrf/__init__.py rename uffd/{mfa => }/fido2_compat.py (100%) delete mode 100644 uffd/invite/__init__.py delete mode 100644 uffd/mail/__init__.py delete mode 100644 uffd/mfa/__init__.py create mode 100644 uffd/models/__init__.py rename uffd/{api/models.py => models/api.py} (100%) rename uffd/{invite/models.py => models/invite.py} (99%) rename uffd/{mail/models.py => models/mail.py} (100%) rename uffd/{mfa/models.py => models/mfa.py} (97%) rename uffd/{oauth2/models.py => models/oauth2.py} (98%) rename uffd/{ => models}/ratelimit.py (100%) rename uffd/{role/models.py => models/role.py} (99%) rename uffd/{selfservice/models.py => models/selfservice.py} (100%) rename uffd/{service/models.py => models/service.py} (100%) rename uffd/{session/models.py => models/session.py} (100%) rename uffd/{signup/models.py => models/signup.py} (99%) rename uffd/{user/models.py => models/user.py} (99%) delete mode 100644 uffd/oauth2/__init__.py delete mode 100644 uffd/role/__init__.py delete mode 100644 uffd/rolemod/__init__.py delete mode 100644 uffd/selfservice/__init__.py delete mode 100644 uffd/service/__init__.py delete mode 100644 uffd/session/__init__.py delete mode 100644 uffd/signup/__init__.py rename uffd/{user => }/templates/group/list.html (100%) rename uffd/{user => }/templates/group/show.html (100%) rename uffd/{invite => }/templates/invite/list.html (100%) rename uffd/{invite => }/templates/invite/new.html (100%) rename uffd/{invite => }/templates/invite/use.html (100%) rename uffd/{mail => }/templates/mail/list.html (100%) rename uffd/{mail => }/templates/mail/show.html (100%) rename uffd/{mfa => }/templates/mfa/auth.html (100%) rename uffd/{mfa => }/templates/mfa/disable.html (100%) rename uffd/{mfa => }/templates/mfa/setup.html (100%) rename uffd/{mfa => }/templates/mfa/setup_recovery.html (100%) rename uffd/{mfa => }/templates/mfa/setup_totp.html (100%) rename uffd/{oauth2 => }/templates/oauth2/error.html (100%) rename uffd/{oauth2 => }/templates/oauth2/logout.html (100%) rename uffd/{role => }/templates/role/list.html (100%) rename uffd/{role => }/templates/role/show.html (100%) rename uffd/{rolemod => }/templates/rolemod/list.html (100%) rename uffd/{rolemod => }/templates/rolemod/show.html (100%) rename uffd/{selfservice => }/templates/selfservice/forgot_password.html (100%) rename uffd/{selfservice => }/templates/selfservice/mailverification.mail.txt (100%) rename uffd/{selfservice => }/templates/selfservice/newuser.mail.txt (100%) rename uffd/{selfservice => }/templates/selfservice/passwordreset.mail.txt (100%) rename uffd/{selfservice => }/templates/selfservice/self.html (100%) rename uffd/{selfservice => }/templates/selfservice/set_password.html (100%) rename uffd/{service => }/templates/service/api.html (100%) rename uffd/{service => }/templates/service/index.html (100%) rename uffd/{service => }/templates/service/oauth2.html (100%) rename uffd/{service => }/templates/service/overview.html (100%) rename uffd/{service => }/templates/service/show.html (100%) rename uffd/{session => }/templates/session/deviceauth.html (100%) rename uffd/{session => }/templates/session/devicelogin.html (100%) rename uffd/{session => }/templates/session/login.html (100%) rename uffd/{signup => }/templates/signup/confirm.html (100%) rename uffd/{signup => }/templates/signup/mail.txt (100%) rename uffd/{signup => }/templates/signup/start.html (100%) rename uffd/{signup => }/templates/signup/submitted.html (100%) rename uffd/{user => }/templates/user/list.html (100%) rename uffd/{user => }/templates/user/show.html (100%) delete mode 100644 uffd/user/__init__.py create mode 100644 uffd/views/__init__.py rename uffd/{api/views.py => views/api.py} (95%) rename uffd/{user/views_group.py => views/group.py} (96%) rename uffd/{invite/views.py => views/invite.py} (95%) rename uffd/{mail/views.py => views/mail.py} (96%) rename uffd/{mfa/views.py => views/mfa.py} (96%) rename uffd/{oauth2/views.py => views/oauth2.py} (98%) rename uffd/{role/views.py => views/role.py} (76%) rename uffd/{rolemod/views.py => views/rolemod.py} (94%) rename uffd/{selfservice/views.py => views/selfservice.py} (96%) rename uffd/{service/views.py => views/service.py} (96%) rename uffd/{session/views.py => views/session.py} (97%) rename uffd/{signup/views.py => views/signup.py} (96%) rename uffd/{user/views_user.py => views/user.py} (96%) diff --git a/check_migrations.py b/check_migrations.py index 643f30fb..1a9773ea 100755 --- a/check_migrations.py +++ b/check_migrations.py @@ -7,15 +7,17 @@ import datetime import flask_migrate from uffd import create_app, db -from uffd.user.models import User, Group -from uffd.mfa.models import RecoveryCodeMethod, TOTPMethod, WebauthnMethod -from uffd.role.models import Role, RoleGroup -from uffd.signup.models import Signup -from uffd.invite.models import Invite, InviteGrant, InviteSignup -from uffd.session.models import DeviceLoginConfirmation -from uffd.service.models import Service -from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation -from uffd.selfservice.models import PasswordToken, MailToken +from uffd.models import ( + User, Group, + RecoveryCodeMethod, TOTPMethod, WebauthnMethod, + Role, RoleGroup, + Signup, + Invite, InviteGrant, InviteSignup, + DeviceLoginConfirmation, + Service, + OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, + PasswordToken, MailToken, +) def run_test(dburi, revision): config = { diff --git a/tests/test_api.py b/tests/test_api.py index 7e86379a..46c6fefb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,12 +2,10 @@ import base64 from flask import url_for -from uffd.api.views import apikey_required -from uffd.api.models import APIClient -from uffd.service.models import Service -from uffd.user.models import User, remailer +from uffd.models import APIClient, Service, User, remailer from uffd.password_hash import PlaintextPasswordHash from uffd.database import db +from uffd.views.api import apikey_required from utils import UffdTestCase, db_flush def basic_auth(username, password): diff --git a/tests/test_csrf.py b/tests/test_csrf.py index 1486beb7..6475d969 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -2,7 +2,7 @@ import unittest from flask import Flask, Blueprint, session, url_for -from uffd.csrf import csrf_bp, csrf_protect +from uffd.csrf import bp as csrf_bp, csrf_protect uid_counter = 0 diff --git a/tests/test_invite.py b/tests/test_invite.py index 0408ca6d..29510320 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -4,14 +4,9 @@ import time from flask import url_for, session, current_app -# These imports are required, because otherwise we get circular imports?! -from uffd import user - from uffd import create_app, db -from uffd.invite.models import Invite, InviteGrant, InviteSignup -from uffd.user.models import User, Group -from uffd.role.models import Role, RoleGroup -from uffd.session.views import login_get_user +from uffd.models import Invite, InviteGrant, InviteSignup, User, Group, Role, RoleGroup +from uffd.views.session import login_get_user from utils import dump, UffdTestCase, db_flush diff --git a/tests/test_mail.py b/tests/test_mail.py index ab8abe5b..f794486b 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -4,11 +4,8 @@ import unittest from flask import url_for, session -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.mail.models import Mail from uffd import create_app, db +from uffd.models import Mail from utils import dump, UffdTestCase, db_flush diff --git a/tests/test_mfa.py b/tests/test_mfa.py index 05a61351..ff32a105 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -4,13 +4,9 @@ import time from flask import url_for, session, request -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.user.models import User -from uffd.role.models import Role, RoleGroup -from uffd.mfa.models import MFAMethod, MFAType, RecoveryCodeMethod, TOTPMethod, WebauthnMethod, _hotp from uffd import create_app, db +from uffd.models import User, Role, RoleGroup, MFAMethod, MFAType, RecoveryCodeMethod, TOTPMethod, WebauthnMethod +from uffd.models.mfa import _hotp from utils import dump, UffdTestCase, db_flush @@ -26,7 +22,7 @@ class TestMfaPrimitives(unittest.TestCase): def get_fido2_test_cred(self): try: - from uffd.mfa.fido2_compat import AttestedCredentialData + from uffd.fido2_compat import AttestedCredentialData except ImportError: self.skipTest('fido2 could not be imported') # Example public key from webauthn spec 6.5.1.1 diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py index 9021240c..cd612b4a 100644 --- a/tests/test_oauth2.py +++ b/tests/test_oauth2.py @@ -3,15 +3,9 @@ from urllib.parse import urlparse, parse_qs from flask import url_for, session -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.user.models import User, remailer -from uffd.password_hash import PlaintextPasswordHash -from uffd.session.models import DeviceLoginConfirmation -from uffd.service.models import Service -from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation from uffd import create_app, db +from uffd.password_hash import PlaintextPasswordHash +from uffd.models import User, remailer, DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation from utils import dump, UffdTestCase diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py index f0bf393c..a10903f4 100644 --- a/tests/test_ratelimit.py +++ b/tests/test_ratelimit.py @@ -2,7 +2,7 @@ import time from flask import Flask, Blueprint, session, url_for -from uffd.ratelimit import get_addrkey, format_delay, Ratelimit, RatelimitEvent +from uffd.models.ratelimit import get_addrkey, format_delay, Ratelimit, RatelimitEvent from utils import UffdTestCase diff --git a/tests/test_role.py b/tests/test_role.py index 6fe4125a..7061d393 100644 --- a/tests/test_role.py +++ b/tests/test_role.py @@ -4,13 +4,9 @@ import unittest from flask import url_for, session -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.user.models import User, Group -from uffd.role.models import flatten_recursive, Role, RoleGroup -from uffd.mfa.models import TOTPMethod from uffd import create_app, db +from uffd.models import User, Group, Role, RoleGroup, TOTPMethod +from uffd.models.role import flatten_recursive from utils import dump, UffdTestCase diff --git a/tests/test_rolemod.py b/tests/test_rolemod.py index beb47219..2950c83c 100644 --- a/tests/test_rolemod.py +++ b/tests/test_rolemod.py @@ -1,8 +1,7 @@ from flask import url_for -from uffd.user.models import User, Group -from uffd.role.models import Role, RoleGroup from uffd.database import db +from uffd.models import User, Group, Role, RoleGroup from utils import dump, UffdTestCase diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py index c6724ef4..cc2b0a86 100644 --- a/tests/test_selfservice.py +++ b/tests/test_selfservice.py @@ -3,13 +3,8 @@ import unittest from flask import url_for, request -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.selfservice.models import MailToken, PasswordToken -from uffd.user.models import User -from uffd.role.models import Role, RoleGroup from uffd import create_app, db +from uffd.models import MailToken, PasswordToken, User, Role, RoleGroup from utils import dump, UffdTestCase diff --git a/tests/test_services.py b/tests/test_services.py index efd71898..09cc802e 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -3,9 +3,6 @@ import unittest from flask import url_for -# These imports are required, because otherwise we get circular imports?! -from uffd import user - from utils import dump, UffdTestCase class TestServices(UffdTestCase): diff --git a/tests/test_session.py b/tests/test_session.py index 173adfd1..bad3b887 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3,16 +3,10 @@ import unittest from flask import url_for, request -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.session.views import login_required -from uffd.session.models import DeviceLoginConfirmation -from uffd.service.models import Service -from uffd.oauth2.models import OAuth2Client, OAuth2DeviceLoginInitiation -from uffd.user.models import User -from uffd.password_hash import PlaintextPasswordHash from uffd import create_app, db +from uffd.password_hash import PlaintextPasswordHash +from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, User +from uffd.views.session import login_required from utils import dump, UffdTestCase, db_flush diff --git a/tests/test_signup.py b/tests/test_signup.py index c202a501..5979da3b 100644 --- a/tests/test_signup.py +++ b/tests/test_signup.py @@ -4,14 +4,9 @@ import time from flask import url_for, session, request -# These imports are required, because otherwise we get circular imports?! -from uffd import user - from uffd import create_app, db -from uffd.signup.models import Signup -from uffd.user.models import User -from uffd.role.models import Role, RoleGroup -from uffd.session.views import login_get_user +from uffd.models import Signup, User, Role, RoleGroup +from uffd.views.session import login_get_user from utils import dump, UffdTestCase, db_flush diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 8f146d5a..6c297b1b 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -12,7 +12,7 @@ class TestCleanupTask(unittest.TestCase): app.debug = True app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' db = SQLAlchemy(app) - cleanup_task = CleanupTask(app, db) + cleanup_task = CleanupTask() @cleanup_task.delete_by_attribute('delete_me') class TestModel(db.Model): @@ -30,7 +30,10 @@ class TestCleanupTask(unittest.TestCase): db.session.expire_all() self.assertEqual(TestModel.query.count(), 5) - app.test_cli_runner().invoke(args=['cleanup']) + with app.test_request_context(): + cleanup_task.run() + db.session.commit() + db.session.expire_all() with app.test_request_context(): self.assertEqual(TestModel.query.count(), 2) diff --git a/tests/test_user.py b/tests/test_user.py index 0990a74f..0152a058 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -4,13 +4,8 @@ import unittest from flask import url_for, session import sqlalchemy -# These imports are required, because otherwise we get circular imports?! -from uffd import user - -from uffd.user.models import User, remailer, RemailerAddress, Group -from uffd.role.models import Role, RoleGroup -from uffd.service.models import Service from uffd import create_app, db +from uffd.models import User, remailer, RemailerAddress, Group, Role, RoleGroup, Service from utils import dump, UffdTestCase diff --git a/tests/utils.py b/tests/utils.py index 6a2e2cb8..c4017b22 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,8 +6,7 @@ import unittest from flask import request, url_for from uffd import create_app, db -from uffd.user.models import User, Group -from uffd.mail.models import Mail +from uffd.models import User, Group, Mail def dump(basename, resp): basename = basename.replace('.', '_').replace('/', '_') diff --git a/uffd/__init__.py b/uffd/__init__.py index f9a0df33..6f48d7a0 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -4,24 +4,14 @@ import sys from flask import Flask, redirect, url_for, request, render_template from flask_babel import Babel -from werkzeug.routing import IntegerConverter -from werkzeug.serving import make_ssl_devcert -try: - from werkzeug.middleware.profiler import ProfilerMiddleware -except ImportError: - from werkzeug.contrib.profiler import ProfilerMiddleware -from werkzeug.exceptions import InternalServerError, Forbidden +from werkzeug.exceptions import 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 -from uffd import user, selfservice, role, mail, session, csrf, mfa, oauth2, service, signup, rolemod, invite, api -from uffd.user.models import User, Group -from uffd.role.models import Role, RoleGroup -from uffd.mail.models import Mail +from .database import db, SQLAlchemyJSON, customize_db_engine +from .template_helper import register_template_helper +from .navbar import setup_navbar +from .csrf import bp as csrf_bp +from . import models, views, commands def load_config_file(app, path, silent=False): if not os.path.exists(path): @@ -75,6 +65,11 @@ def create_app(test_config=None): # pylint: disable=too-many-locals,too-many-sta positions += ["rolemod", "invite", "user", "group", "role", "mail"] setup_navbar(app, positions) + app.register_blueprint(csrf_bp) + + views.init_app(app) + commands.init_app(app) + # We never want to fail here, but at a file access that doesn't work. # We might only have read access to app.instance_path try: @@ -87,51 +82,10 @@ 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, service, rolemod, api, signup, invite]: - for bp in module.bp: - app.register_blueprint(bp) - @app.shell_context_processor def push_request_context(): #pylint: disable=unused-variable app.test_request_context().push() # LDAP ORM requires request context - return {'db': db, 'User': User, 'Group': Group, 'Role': Role, 'Mail': Mail} - - @app.errorhandler(403) - def handle_403(error): - return render_template('403.html', description=error.description if error.description != Forbidden.description else None), 403 - - @app.route("/") - def index(): #pylint: disable=unused-variable - if app.config['DEFAULT_PAGE_SERVICES']: - return redirect(url_for('service.overview')) - return redirect(url_for('selfservice.index')) - - @app.route('/lang', methods=['POST']) - def setlang(): #pylint: disable=unused-variable - resp = secure_local_redirect(request.values.get('ref', '/')) - if 'lang' in request.values: - resp.set_cookie('language', request.values['lang']) - return resp - - @app.cli.command("gendevcert", help='Generates a self-signed TLS certificate for development') - def gendevcert(): #pylint: disable=unused-variable - if os.path.exists('devcert.crt') or os.path.exists('devcert.key'): - print('Refusing to overwrite existing "devcert.crt"/"devcert.key" file!') - return - make_ssl_devcert('devcert') - print('Certificate written to "devcert.crt", private key to "devcert.key".') - print('Run `flask run --cert devcert.crt --key devcert.key` to use it.') - - @app.cli.command("profile", help='Runs app with profiler') - def profile(): #pylint: disable=unused-variable - # app.run() is silently ignored if executed from commands. We really want - # to do this, so we overwrite the check by overwriting the environment - # variable. - os.environ['FLASK_RUN_FROM_CLI'] = 'false' - app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) - app.run(debug=True) + return {'db': db} | {name: getattr(models, name) for name in models.__all__} babel = Babel(app) diff --git a/uffd/api/__init__.py b/uffd/api/__init__.py deleted file mode 100644 index 65639004..00000000 --- a/uffd/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as _bp - -bp = [_bp] diff --git a/uffd/commands/__init__.py b/uffd/commands/__init__.py new file mode 100644 index 00000000..e3ad7436 --- /dev/null +++ b/uffd/commands/__init__.py @@ -0,0 +1,16 @@ +from .user import user_command +from .group import group_command +from .role import role_command +from .profile import profile_command +from .gendevcert import gendevcert_command +from .cleanup import cleanup_command +from .roles_update_all import roles_update_all_command + +def init_app(app): + app.cli.add_command(user_command) + app.cli.add_command(group_command) + app.cli.add_command(role_command) + app.cli.add_command(gendevcert_command) + app.cli.add_command(profile_command) + app.cli.add_command(cleanup_command) + app.cli.add_command(roles_update_all_command) diff --git a/uffd/commands/cleanup.py b/uffd/commands/cleanup.py new file mode 100644 index 00000000..925dda27 --- /dev/null +++ b/uffd/commands/cleanup.py @@ -0,0 +1,11 @@ +import click +from flask.cli import with_appcontext + +from uffd.tasks import cleanup_task +from uffd.database import db + +@click.command('cleanup', help='Cleanup expired data') +@with_appcontext +def cleanup_command(): + cleanup_task.run() + db.session.commit() diff --git a/uffd/commands/gendevcert.py b/uffd/commands/gendevcert.py new file mode 100644 index 00000000..a536768f --- /dev/null +++ b/uffd/commands/gendevcert.py @@ -0,0 +1,13 @@ +import os + +import click +from werkzeug.serving import make_ssl_devcert + +@click.command("gendevcert", help='Generates a self-signed TLS certificate for development') +def gendevcert_command(): #pylint: disable=unused-variable + if os.path.exists('devcert.crt') or os.path.exists('devcert.key'): + print('Refusing to overwrite existing "devcert.crt"/"devcert.key" file!') + return + make_ssl_devcert('devcert') + print('Certificate written to "devcert.crt", private key to "devcert.key".') + print('Run `flask run --cert devcert.crt --key devcert.key` to use it.') diff --git a/uffd/user/cli_group.py b/uffd/commands/group.py similarity index 78% rename from uffd/user/cli_group.py rename to uffd/commands/group.py index 0dd80e53..a98e23b3 100644 --- a/uffd/user/cli_group.py +++ b/uffd/commands/group.py @@ -1,26 +1,21 @@ -from flask import Blueprint, current_app +from flask import current_app from flask.cli import AppGroup from sqlalchemy.exc import IntegrityError import click from uffd.database import db -from .models import Group +from uffd.models import Group -bp = Blueprint('group_cli', __name__) -group_cli = AppGroup('group', help='Manage groups') +group_command = AppGroup('group', help='Manage groups') -@bp.record -def add_cli_commands(state): - state.app.cli.add_command(group_cli) - -@group_cli.command(help='List names of all groups') +@group_command.command(help='List names of all groups') def list(): with current_app.test_request_context(): for group in Group.query: click.echo(group.name) -@group_cli.command(help='Show details of group') +@group_command.command(help='Show details of group') @click.argument('name') def show(name): with current_app.test_request_context(): @@ -32,7 +27,7 @@ def show(name): click.echo(f'Description: {group.description}') click.echo(f'Members: {", ".join([user.loginname for user in group.members])}') -@group_cli.command(help='Create new group') +@group_command.command(help='Create new group') @click.argument('name') @click.option('--description', default='', help='Set description text. Empty per default.') def create(name, description): @@ -46,7 +41,7 @@ def create(name, description): except IntegrityError as ex: raise click.ClickException(f'Group creation failed: {ex}') -@group_cli.command(help='Update group attributes') +@group_command.command(help='Update group attributes') @click.argument('name') @click.option('--description', help='Set description text.') def update(name, description): @@ -58,7 +53,7 @@ def update(name, description): group.description = description db.session.commit() -@group_cli.command(help='Delete group') +@group_command.command(help='Delete group') @click.argument('name') def delete(name): with current_app.test_request_context(): diff --git a/uffd/commands/profile.py b/uffd/commands/profile.py new file mode 100644 index 00000000..38d8de55 --- /dev/null +++ b/uffd/commands/profile.py @@ -0,0 +1,19 @@ +import os + +from flask import current_app +from flask.cli import with_appcontext +import click +try: + from werkzeug.middleware.profiler import ProfilerMiddleware +except ImportError: + from werkzeug.contrib.profiler import ProfilerMiddleware + +@click.command("profile", help='Runs app with profiler') +@with_appcontext +def profile_command(): #pylint: disable=unused-variable + # app.run() is silently ignored if executed from commands. We really want + # to do this, so we overwrite the check by overwriting the environment + # variable. + os.environ['FLASK_RUN_FROM_CLI'] = 'false' + current_app.wsgi_app = ProfilerMiddleware(current_app.wsgi_app, restrictions=[30]) + current_app.run(debug=True) diff --git a/uffd/role/cli.py b/uffd/commands/role.py similarity index 92% rename from uffd/role/cli.py rename to uffd/commands/role.py index 6e94f5f1..584aaa0d 100644 --- a/uffd/role/cli.py +++ b/uffd/commands/role.py @@ -1,19 +1,12 @@ -from flask import Blueprint, current_app +from flask import current_app from flask.cli import AppGroup from sqlalchemy.exc import IntegrityError import click from uffd.database import db -from uffd.user.models import Group +from uffd.models import Group, Role, RoleGroup -from .models import Role, RoleGroup - -bp = Blueprint('role_cli', __name__) -role_cli = AppGroup('role', help='Manage roles') - -@bp.record -def add_cli_commands(state): - state.app.cli.add_command(role_cli) +role_command = AppGroup('role', help='Manage roles') # pylint: disable=too-many-arguments,too-many-locals @@ -57,13 +50,13 @@ def update_attrs(role, description=None, default=None, raise click.ClickException(f'Role {role_name} not found') role.included_roles.remove(_role) -@role_cli.command(help='List names of all roles') +@role_command.command(help='List names of all roles') def list(): with current_app.test_request_context(): for role in Role.query: click.echo(role.name) -@role_cli.command(help='Show details of group') +@role_command.command(help='Show details of group') @click.argument('name') def show(name): with current_app.test_request_context(): @@ -80,7 +73,7 @@ def show(name): click.echo(f'Direct members: {", ".join(sorted([user.loginname for user in role.members]))}') click.echo(f'Effective members: {", ".join(sorted([user.loginname for user in role.members_effective]))}') -@role_cli.command(help='Create new role') +@role_command.command(help='Create new role') @click.argument('name') @click.option('--description', default='', help='Set description text.') @click.option('--default/--no-default', default=False, help='Mark role as default or not. Non-service users are auto-added to default roles.') @@ -99,7 +92,7 @@ def create(name, description, default, moderator_group, add_group, add_role): except IntegrityError as ex: raise click.ClickException(f'Role creation failed: {ex}') -@role_cli.command(help='Update role attributes') +@role_command.command(help='Update role attributes') @click.argument('name') @click.option('--description', help='Set description text.') @click.option('--default/--no-default', default=None, help='Mark role as default or not. Non-service users are auto-added to default roles.') @@ -126,7 +119,7 @@ def update(name, description, default, moderator_group, no_moderator_group, role.update_member_groups() db.session.commit() -@role_cli.command(help='Delete role') +@role_command.command(help='Delete role') @click.argument('name') def delete(name): with current_app.test_request_context(): diff --git a/uffd/commands/roles_update_all.py b/uffd/commands/roles_update_all.py new file mode 100644 index 00000000..3ad676e1 --- /dev/null +++ b/uffd/commands/roles_update_all.py @@ -0,0 +1,30 @@ +import sys + +from flask import current_app +from flask.cli import with_appcontext +import click + +from uffd.database import db +from uffd.models import User + +@click.command('roles-update-all', help='Update group memberships for all users based on their roles') +@click.option('--check-only', is_flag=True) +@with_appcontext +def roles_update_all_command(check_only): #pylint: disable=unused-variable + consistent = True + with current_app.test_request_context(): + for user in User.query.all(): + groups_added, groups_removed = user.update_groups() + if groups_added: + consistent = False + print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.loginname)) + if groups_removed: + consistent = False + print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.loginname)) + if not check_only: + db.session.commit() + if check_only and not consistent: + print('No changes were made because --check-only is set') + print() + print('Error: Groups are not consistent with roles in database') + sys.exit(1) diff --git a/uffd/user/cli_user.py b/uffd/commands/user.py similarity index 90% rename from uffd/user/cli_user.py rename to uffd/commands/user.py index 490af1cf..5b82eec6 100644 --- a/uffd/user/cli_user.py +++ b/uffd/commands/user.py @@ -1,19 +1,12 @@ -from flask import Blueprint, current_app +from flask import current_app from flask.cli import AppGroup from sqlalchemy.exc import IntegrityError import click -from uffd.role.models import Role from uffd.database import db +from uffd.models import User, Role -from .models import User - -bp = Blueprint('user_cli', __name__) -user_cli = AppGroup('user', help='Manage users') - -@bp.record -def add_cli_commands(state): - state.app.cli.add_command(user_cli) +user_command = AppGroup('user', help='Manage users') # pylint: disable=too-many-arguments @@ -42,13 +35,13 @@ def update_attrs(user, mail=None, displayname=None, password=None, role.members.remove(user) user.update_groups() -@user_cli.command(help='List login names of all users') +@user_command.command(help='List login names of all users') def list(): with current_app.test_request_context(): for user in User.query: click.echo(user.loginname) -@user_cli.command(help='Show details of user') +@user_command.command(help='Show details of user') @click.argument('loginname') def show(loginname): with current_app.test_request_context(): @@ -62,7 +55,7 @@ def show(loginname): click.echo(f'Roles: {", ".join([role.name for role in user.roles])}') click.echo(f'Groups: {", ".join([group.name for group in user.groups])}') -@user_cli.command(help='Create new user') +@user_command.command(help='Create new user') @click.argument('loginname') @click.option('--mail', required=True, metavar='EMAIL_ADDRESS', help='E-Mail address') @click.option('--displayname', help='Set display name. Defaults to login name.') @@ -86,7 +79,7 @@ def create(loginname, mail, displayname, service, password, prompt_password, add except IntegrityError as ex: raise click.ClickException(f'User creation failed: {ex}') -@user_cli.command(help='Update user attributes and roles') +@user_command.command(help='Update user attributes and roles') @click.argument('loginname') @click.option('--mail', metavar='EMAIL_ADDRESS', help='Set e-mail address.') @click.option('--displayname', help='Set display name.') @@ -103,7 +96,7 @@ def update(loginname, mail, displayname, password, prompt_password, clear_roles, update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role) db.session.commit() -@user_cli.command(help='Delete user') +@user_command.command(help='Delete user') @click.argument('loginname') def delete(loginname): with current_app.test_request_context(): diff --git a/uffd/csrf/csrf.py b/uffd/csrf.py similarity index 100% rename from uffd/csrf/csrf.py rename to uffd/csrf.py diff --git a/uffd/csrf/__init__.py b/uffd/csrf/__init__.py deleted file mode 100644 index 601eb5f1..00000000 --- a/uffd/csrf/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .csrf import bp as csrf_bp, csrf_protect - -bp = [csrf_bp] diff --git a/uffd/mfa/fido2_compat.py b/uffd/fido2_compat.py similarity index 100% rename from uffd/mfa/fido2_compat.py rename to uffd/fido2_compat.py diff --git a/uffd/invite/__init__.py b/uffd/invite/__init__.py deleted file mode 100644 index 65639004..00000000 --- a/uffd/invite/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as _bp - -bp = [_bp] diff --git a/uffd/mail/__init__.py b/uffd/mail/__init__.py deleted file mode 100644 index 67157866..00000000 --- a/uffd/mail/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as bp_ui - -bp = [bp_ui] diff --git a/uffd/mfa/__init__.py b/uffd/mfa/__init__.py deleted file mode 100644 index 65639004..00000000 --- a/uffd/mfa/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as _bp - -bp = [_bp] diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py new file mode 100644 index 00000000..2734866e --- /dev/null +++ b/uffd/models/__init__.py @@ -0,0 +1,27 @@ +from .api import APIClient +from .invite import Invite, InviteGrant, InviteSignup +from .mail import Mail, MailReceiveAddress, MailDestinationAddress +from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod +from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation +from .role import Role, RoleGroup, RoleGroupMap +from .selfservice import PasswordToken, MailToken +from .service import Service, get_services +from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation +from .signup import Signup +from .user import User, Group, RemailerAddress, remailer +from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay + +__all__ = [ + 'APIClient', + 'Invite', 'InviteGrant', 'InviteSignup', + 'Mail', 'MailReceiveAddress', 'MailDestinationAddress', + 'MFAType', 'MFAMethod', 'RecoveryCodeMethod', 'TOTPMethod', 'WebauthnMethod', + 'OAuth2Client', 'OAuth2RedirectURI', 'OAuth2LogoutURI', 'OAuth2Grant', 'OAuth2Token', 'OAuth2DeviceLoginInitiation', + 'Role', 'RoleGroup', 'RoleGroupMap', + 'PasswordToken', 'MailToken', + 'Service', 'get_services', + 'DeviceLoginType', 'DeviceLoginInitiation', 'DeviceLoginConfirmation', + 'Signup', + 'User', 'Group', 'RemailerAddress', 'remailer', + 'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay', +] diff --git a/uffd/api/models.py b/uffd/models/api.py similarity index 100% rename from uffd/api/models.py rename to uffd/models/api.py diff --git a/uffd/invite/models.py b/uffd/models/invite.py similarity index 99% rename from uffd/invite/models.py rename to uffd/models/invite.py index f3c97004..12018d35 100644 --- a/uffd/invite/models.py +++ b/uffd/models/invite.py @@ -5,9 +5,9 @@ from flask import current_app from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship -from uffd.database import db -from uffd.signup.models import Signup from uffd.utils import token_urlfriendly +from uffd.database import db +from .signup import Signup invite_roles = db.Table('invite_roles', Column('invite_id', Integer(), ForeignKey('invite.id', onupdate='CASCADE', ondelete='CASCADE'), primary_key=True), diff --git a/uffd/mail/models.py b/uffd/models/mail.py similarity index 100% rename from uffd/mail/models.py rename to uffd/models/mail.py diff --git a/uffd/mfa/models.py b/uffd/models/mfa.py similarity index 97% rename from uffd/mfa/models.py rename to uffd/models/mfa.py index 1e117ac6..7db21b5c 100644 --- a/uffd/mfa/models.py +++ b/uffd/models/mfa.py @@ -16,7 +16,7 @@ from sqlalchemy import Column, Integer, Enum, String, DateTime, Text, ForeignKey from sqlalchemy.orm import relationship, backref from uffd.database import db -from uffd.user.models import User +from .user import User User.mfa_enabled = property(lambda user: bool(user.mfa_totp_methods or user.mfa_webauthn_methods)) @@ -141,7 +141,7 @@ class WebauthnMethod(MFAMethod): @property def cred(self): - from .fido2_compat import AttestedCredentialData #pylint: disable=import-outside-toplevel + from uffd.fido2_compat import AttestedCredentialData #pylint: disable=import-outside-toplevel return AttestedCredentialData(base64.b64decode(self._cred)) @cred.setter diff --git a/uffd/oauth2/models.py b/uffd/models/oauth2.py similarity index 98% rename from uffd/oauth2/models.py rename to uffd/models/oauth2.py index 6ed91097..dd0df00f 100644 --- a/uffd/oauth2/models.py +++ b/uffd/models/oauth2.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.associationproxy import association_proxy from uffd.database import db, CommaSeparatedList from uffd.tasks import cleanup_task from uffd.password_hash import PasswordHashAttribute, HighEntropyPasswordHash -from uffd.session.models import DeviceLoginInitiation, DeviceLoginType +from .session import DeviceLoginInitiation, DeviceLoginType class OAuth2Client(db.Model): __tablename__ = 'oauth2client' diff --git a/uffd/ratelimit.py b/uffd/models/ratelimit.py similarity index 100% rename from uffd/ratelimit.py rename to uffd/models/ratelimit.py index 5604299d..cd370956 100644 --- a/uffd/ratelimit.py +++ b/uffd/models/ratelimit.py @@ -7,8 +7,8 @@ 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 +from uffd.database import db @cleanup_task.delete_by_attribute('expired') class RatelimitEvent(db.Model): diff --git a/uffd/role/models.py b/uffd/models/role.py similarity index 99% rename from uffd/role/models.py rename to uffd/models/role.py index 8bd6fcfc..27111b8c 100644 --- a/uffd/role/models.py +++ b/uffd/models/role.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import relationship from sqlalchemy.orm.collections import MappedCollection, collection from uffd.database import db -from uffd.user.models import User +from .user import User class RoleGroup(db.Model): __tablename__ = 'role_groups' diff --git a/uffd/selfservice/models.py b/uffd/models/selfservice.py similarity index 100% rename from uffd/selfservice/models.py rename to uffd/models/selfservice.py index 4ac327bd..bb148457 100644 --- a/uffd/selfservice/models.py +++ b/uffd/models/selfservice.py @@ -5,8 +5,8 @@ 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 +from uffd.tasks import cleanup_task @cleanup_task.delete_by_attribute('expired') class PasswordToken(db.Model): diff --git a/uffd/service/models.py b/uffd/models/service.py similarity index 100% rename from uffd/service/models.py rename to uffd/models/service.py diff --git a/uffd/session/models.py b/uffd/models/session.py similarity index 100% rename from uffd/session/models.py rename to uffd/models/session.py index 4b2099e8..6b72912b 100644 --- a/uffd/session/models.py +++ b/uffd/models/session.py @@ -7,8 +7,8 @@ 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 +from uffd.tasks import cleanup_task # Device login provides a convenient and secure way to log into SSO-enabled # services on a secondary device without entering the user password or diff --git a/uffd/signup/models.py b/uffd/models/signup.py similarity index 99% rename from uffd/signup/models.py rename to uffd/models/signup.py index 073d5eab..204edbc4 100644 --- a/uffd/signup/models.py +++ b/uffd/models/signup.py @@ -5,11 +5,11 @@ 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 +from uffd.database import db +from .user import User @cleanup_task.delete_by_attribute('expired_and_not_completed') class Signup(db.Model): diff --git a/uffd/user/models.py b/uffd/models/user.py similarity index 99% rename from uffd/user/models.py rename to uffd/models/user.py index 8ff69508..a91c9fe7 100644 --- a/uffd/user/models.py +++ b/uffd/models/user.py @@ -172,7 +172,7 @@ class Remailer: def parse_address(self, address): # With a top-level import we get circular import problems # pylint: disable=import-outside-toplevel - from uffd.service.models import Service + from .service import Service if '@' not in address: return None local_part, domain = address.rsplit('@', 1) diff --git a/uffd/oauth2/__init__.py b/uffd/oauth2/__init__.py deleted file mode 100644 index 65639004..00000000 --- a/uffd/oauth2/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as _bp - -bp = [_bp] diff --git a/uffd/role/__init__.py b/uffd/role/__init__.py deleted file mode 100644 index 550874c0..00000000 --- a/uffd/role/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .views import bp as bp_ui -from .cli import bp as bp_cli - -bp = [bp_ui, bp_cli] diff --git a/uffd/rolemod/__init__.py b/uffd/rolemod/__init__.py deleted file mode 100644 index 67157866..00000000 --- a/uffd/rolemod/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as bp_ui - -bp = [bp_ui] diff --git a/uffd/selfservice/__init__.py b/uffd/selfservice/__init__.py deleted file mode 100644 index 69a1df97..00000000 --- a/uffd/selfservice/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as bp_ui, send_passwordreset - -bp = [bp_ui] diff --git a/uffd/service/__init__.py b/uffd/service/__init__.py deleted file mode 100644 index 65639004..00000000 --- a/uffd/service/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as _bp - -bp = [_bp] diff --git a/uffd/session/__init__.py b/uffd/session/__init__.py deleted file mode 100644 index 0e571f3a..00000000 --- a/uffd/session/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as bp_ui, login_required, set_session - -bp = [bp_ui] diff --git a/uffd/signup/__init__.py b/uffd/signup/__init__.py deleted file mode 100644 index 65639004..00000000 --- a/uffd/signup/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .views import bp as _bp - -bp = [_bp] diff --git a/uffd/tasks.py b/uffd/tasks.py index 4ae6d380..85f7b412 100644 --- a/uffd/tasks.py +++ b/uffd/tasks.py @@ -1,14 +1,6 @@ class CleanupTask: - def __init__(self, *init_args): + def __init__(self): 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) diff --git a/uffd/user/templates/group/list.html b/uffd/templates/group/list.html similarity index 100% rename from uffd/user/templates/group/list.html rename to uffd/templates/group/list.html diff --git a/uffd/user/templates/group/show.html b/uffd/templates/group/show.html similarity index 100% rename from uffd/user/templates/group/show.html rename to uffd/templates/group/show.html diff --git a/uffd/invite/templates/invite/list.html b/uffd/templates/invite/list.html similarity index 100% rename from uffd/invite/templates/invite/list.html rename to uffd/templates/invite/list.html diff --git a/uffd/invite/templates/invite/new.html b/uffd/templates/invite/new.html similarity index 100% rename from uffd/invite/templates/invite/new.html rename to uffd/templates/invite/new.html diff --git a/uffd/invite/templates/invite/use.html b/uffd/templates/invite/use.html similarity index 100% rename from uffd/invite/templates/invite/use.html rename to uffd/templates/invite/use.html diff --git a/uffd/mail/templates/mail/list.html b/uffd/templates/mail/list.html similarity index 100% rename from uffd/mail/templates/mail/list.html rename to uffd/templates/mail/list.html diff --git a/uffd/mail/templates/mail/show.html b/uffd/templates/mail/show.html similarity index 100% rename from uffd/mail/templates/mail/show.html rename to uffd/templates/mail/show.html diff --git a/uffd/mfa/templates/mfa/auth.html b/uffd/templates/mfa/auth.html similarity index 100% rename from uffd/mfa/templates/mfa/auth.html rename to uffd/templates/mfa/auth.html diff --git a/uffd/mfa/templates/mfa/disable.html b/uffd/templates/mfa/disable.html similarity index 100% rename from uffd/mfa/templates/mfa/disable.html rename to uffd/templates/mfa/disable.html diff --git a/uffd/mfa/templates/mfa/setup.html b/uffd/templates/mfa/setup.html similarity index 100% rename from uffd/mfa/templates/mfa/setup.html rename to uffd/templates/mfa/setup.html diff --git a/uffd/mfa/templates/mfa/setup_recovery.html b/uffd/templates/mfa/setup_recovery.html similarity index 100% rename from uffd/mfa/templates/mfa/setup_recovery.html rename to uffd/templates/mfa/setup_recovery.html diff --git a/uffd/mfa/templates/mfa/setup_totp.html b/uffd/templates/mfa/setup_totp.html similarity index 100% rename from uffd/mfa/templates/mfa/setup_totp.html rename to uffd/templates/mfa/setup_totp.html diff --git a/uffd/oauth2/templates/oauth2/error.html b/uffd/templates/oauth2/error.html similarity index 100% rename from uffd/oauth2/templates/oauth2/error.html rename to uffd/templates/oauth2/error.html diff --git a/uffd/oauth2/templates/oauth2/logout.html b/uffd/templates/oauth2/logout.html similarity index 100% rename from uffd/oauth2/templates/oauth2/logout.html rename to uffd/templates/oauth2/logout.html diff --git a/uffd/role/templates/role/list.html b/uffd/templates/role/list.html similarity index 100% rename from uffd/role/templates/role/list.html rename to uffd/templates/role/list.html diff --git a/uffd/role/templates/role/show.html b/uffd/templates/role/show.html similarity index 100% rename from uffd/role/templates/role/show.html rename to uffd/templates/role/show.html diff --git a/uffd/rolemod/templates/rolemod/list.html b/uffd/templates/rolemod/list.html similarity index 100% rename from uffd/rolemod/templates/rolemod/list.html rename to uffd/templates/rolemod/list.html diff --git a/uffd/rolemod/templates/rolemod/show.html b/uffd/templates/rolemod/show.html similarity index 100% rename from uffd/rolemod/templates/rolemod/show.html rename to uffd/templates/rolemod/show.html diff --git a/uffd/selfservice/templates/selfservice/forgot_password.html b/uffd/templates/selfservice/forgot_password.html similarity index 100% rename from uffd/selfservice/templates/selfservice/forgot_password.html rename to uffd/templates/selfservice/forgot_password.html diff --git a/uffd/selfservice/templates/selfservice/mailverification.mail.txt b/uffd/templates/selfservice/mailverification.mail.txt similarity index 100% rename from uffd/selfservice/templates/selfservice/mailverification.mail.txt rename to uffd/templates/selfservice/mailverification.mail.txt diff --git a/uffd/selfservice/templates/selfservice/newuser.mail.txt b/uffd/templates/selfservice/newuser.mail.txt similarity index 100% rename from uffd/selfservice/templates/selfservice/newuser.mail.txt rename to uffd/templates/selfservice/newuser.mail.txt diff --git a/uffd/selfservice/templates/selfservice/passwordreset.mail.txt b/uffd/templates/selfservice/passwordreset.mail.txt similarity index 100% rename from uffd/selfservice/templates/selfservice/passwordreset.mail.txt rename to uffd/templates/selfservice/passwordreset.mail.txt diff --git a/uffd/selfservice/templates/selfservice/self.html b/uffd/templates/selfservice/self.html similarity index 100% rename from uffd/selfservice/templates/selfservice/self.html rename to uffd/templates/selfservice/self.html diff --git a/uffd/selfservice/templates/selfservice/set_password.html b/uffd/templates/selfservice/set_password.html similarity index 100% rename from uffd/selfservice/templates/selfservice/set_password.html rename to uffd/templates/selfservice/set_password.html diff --git a/uffd/service/templates/service/api.html b/uffd/templates/service/api.html similarity index 100% rename from uffd/service/templates/service/api.html rename to uffd/templates/service/api.html diff --git a/uffd/service/templates/service/index.html b/uffd/templates/service/index.html similarity index 100% rename from uffd/service/templates/service/index.html rename to uffd/templates/service/index.html diff --git a/uffd/service/templates/service/oauth2.html b/uffd/templates/service/oauth2.html similarity index 100% rename from uffd/service/templates/service/oauth2.html rename to uffd/templates/service/oauth2.html diff --git a/uffd/service/templates/service/overview.html b/uffd/templates/service/overview.html similarity index 100% rename from uffd/service/templates/service/overview.html rename to uffd/templates/service/overview.html diff --git a/uffd/service/templates/service/show.html b/uffd/templates/service/show.html similarity index 100% rename from uffd/service/templates/service/show.html rename to uffd/templates/service/show.html diff --git a/uffd/session/templates/session/deviceauth.html b/uffd/templates/session/deviceauth.html similarity index 100% rename from uffd/session/templates/session/deviceauth.html rename to uffd/templates/session/deviceauth.html diff --git a/uffd/session/templates/session/devicelogin.html b/uffd/templates/session/devicelogin.html similarity index 100% rename from uffd/session/templates/session/devicelogin.html rename to uffd/templates/session/devicelogin.html diff --git a/uffd/session/templates/session/login.html b/uffd/templates/session/login.html similarity index 100% rename from uffd/session/templates/session/login.html rename to uffd/templates/session/login.html diff --git a/uffd/signup/templates/signup/confirm.html b/uffd/templates/signup/confirm.html similarity index 100% rename from uffd/signup/templates/signup/confirm.html rename to uffd/templates/signup/confirm.html diff --git a/uffd/signup/templates/signup/mail.txt b/uffd/templates/signup/mail.txt similarity index 100% rename from uffd/signup/templates/signup/mail.txt rename to uffd/templates/signup/mail.txt diff --git a/uffd/signup/templates/signup/start.html b/uffd/templates/signup/start.html similarity index 100% rename from uffd/signup/templates/signup/start.html rename to uffd/templates/signup/start.html diff --git a/uffd/signup/templates/signup/submitted.html b/uffd/templates/signup/submitted.html similarity index 100% rename from uffd/signup/templates/signup/submitted.html rename to uffd/templates/signup/submitted.html diff --git a/uffd/user/templates/user/list.html b/uffd/templates/user/list.html similarity index 100% rename from uffd/user/templates/user/list.html rename to uffd/templates/user/list.html diff --git a/uffd/user/templates/user/show.html b/uffd/templates/user/show.html similarity index 100% rename from uffd/user/templates/user/show.html rename to uffd/templates/user/show.html diff --git a/uffd/user/__init__.py b/uffd/user/__init__.py deleted file mode 100644 index 17a20ec5..00000000 --- a/uffd/user/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .views_user import bp as bp_user -from .cli_user import bp as bp_cli_user -from .views_group import bp as bp_group -from .cli_group import bp as bp_cli_group - -bp = [bp_user, bp_group, bp_cli_user, bp_cli_group] diff --git a/uffd/views/__init__.py b/uffd/views/__init__.py new file mode 100644 index 00000000..4f5bdb08 --- /dev/null +++ b/uffd/views/__init__.py @@ -0,0 +1,38 @@ +from flask import redirect, url_for, request, render_template +from werkzeug.exceptions import Forbidden + +from uffd.secure_redirect import secure_local_redirect + +from . import session, selfservice, signup, mfa, oauth2, user, group, service, role, invite, api, mail, rolemod + +def init_app(app): + @app.errorhandler(403) + def handle_403(error): + return render_template('403.html', description=error.description if error.description != Forbidden.description else None), 403 + + @app.route("/") + def index(): #pylint: disable=unused-variable + if app.config['DEFAULT_PAGE_SERVICES']: + return redirect(url_for('service.overview')) + return redirect(url_for('selfservice.index')) + + @app.route('/lang', methods=['POST']) + def setlang(): #pylint: disable=unused-variable + resp = secure_local_redirect(request.values.get('ref', '/')) + if 'lang' in request.values: + resp.set_cookie('language', request.values['lang']) + return resp + + app.register_blueprint(session.bp) + app.register_blueprint(selfservice.bp) + app.register_blueprint(signup.bp) + app.register_blueprint(mfa.bp) + app.register_blueprint(oauth2.bp) + app.register_blueprint(user.bp) + app.register_blueprint(group.bp) + app.register_blueprint(service.bp) + app.register_blueprint(role.bp) + app.register_blueprint(invite.bp) + app.register_blueprint(api.bp) + app.register_blueprint(mail.bp) + app.register_blueprint(rolemod.bp) diff --git a/uffd/api/views.py b/uffd/views/api.py similarity index 95% rename from uffd/api/views.py rename to uffd/views/api.py index 33e7122a..11d59db4 100644 --- a/uffd/api/views.py +++ b/uffd/views/api.py @@ -2,11 +2,9 @@ import functools from flask import Blueprint, jsonify, request, abort -from uffd.user.models import User, remailer, Group -from uffd.mail.models import Mail, MailReceiveAddress, MailDestinationAddress -from uffd.api.models import APIClient -from uffd.session.views import login_get_user, login_ratelimit from uffd.database import db +from uffd.models import User, remailer, Group, Mail, MailReceiveAddress, MailDestinationAddress, APIClient +from .session import login_get_user, login_ratelimit bp = Blueprint('api', __name__, template_folder='templates', url_prefix='/api/v1/') diff --git a/uffd/user/views_group.py b/uffd/views/group.py similarity index 96% rename from uffd/user/views_group.py rename to uffd/views/group.py index 63d281f4..d70751ef 100644 --- a/uffd/user/views_group.py +++ b/uffd/views/group.py @@ -4,10 +4,9 @@ import sqlalchemy from uffd.navbar import register_navbar from uffd.csrf import csrf_protect -from uffd.session import login_required from uffd.database import db - -from .models import Group +from uffd.models import Group +from .session import login_required bp = Blueprint("group", __name__, template_folder='templates', url_prefix='/group/') diff --git a/uffd/invite/views.py b/uffd/views/invite.py similarity index 95% rename from uffd/invite/views.py rename to uffd/views/invite.py index 7e8074c7..99b0675e 100644 --- a/uffd/invite/views.py +++ b/uffd/views/invite.py @@ -6,16 +6,13 @@ from flask_babel import gettext as _, lazy_gettext import sqlalchemy from uffd.csrf import csrf_protect -from uffd.database import db -from uffd.session import login_required -from uffd.role.models import Role -from uffd.invite.models import Invite, InviteSignup, InviteGrant -from uffd.user.models import User, Group from uffd.sendmail import sendmail from uffd.navbar import register_navbar -from uffd.ratelimit import host_ratelimit, format_delay -from uffd.signup.views import signup_ratelimit -from uffd.selfservice.views import selfservice_acl_check +from uffd.database import db +from uffd.models import Role, User, Group, Invite, InviteSignup, InviteGrant, host_ratelimit, format_delay +from .session import login_required +from .signup import signup_ratelimit +from .selfservice import selfservice_acl_check bp = Blueprint('invite', __name__, template_folder='templates', url_prefix='/invite/') diff --git a/uffd/mail/views.py b/uffd/views/mail.py similarity index 96% rename from uffd/mail/views.py rename to uffd/views/mail.py index 30981efa..7bd25049 100644 --- a/uffd/mail/views.py +++ b/uffd/views/mail.py @@ -4,9 +4,8 @@ from flask_babel import gettext as _, lazy_gettext from uffd.navbar import register_navbar from uffd.csrf import csrf_protect from uffd.database import db -from uffd.session import login_required - -from uffd.mail.models import Mail +from uffd.models import Mail +from .session import login_required bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/') diff --git a/uffd/mfa/views.py b/uffd/views/mfa.py similarity index 96% rename from uffd/mfa/views.py rename to uffd/views/mfa.py index bae24649..7471bef2 100644 --- a/uffd/mfa/views.py +++ b/uffd/views/mfa.py @@ -4,13 +4,11 @@ import urllib.parse from flask import Blueprint, render_template, session, request, redirect, url_for, flash, current_app, abort from flask_babel import gettext as _ -from uffd.database import db -from uffd.mfa.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod -from uffd.session.views import login_required, login_required_pre_mfa, set_request_user -from uffd.user.models import User from uffd.csrf import csrf_protect from uffd.secure_redirect import secure_local_redirect -from uffd.ratelimit import Ratelimit, format_delay +from uffd.database import db +from uffd.models import MFAMethod, TOTPMethod, WebauthnMethod, RecoveryCodeMethod, User, Ratelimit, format_delay +from .session import login_required, login_required_pre_mfa, set_request_user bp = Blueprint('mfa', __name__, template_folder='templates', url_prefix='/mfa/') @@ -101,7 +99,7 @@ def delete_totp(id): #pylint: disable=redefined-builtin # WebAuthn support is optional because fido2 has a pretty unstable # interface and might be difficult to install with the correct version try: - from .fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import + from uffd.fido2_compat import * # pylint: disable=wildcard-import,unused-wildcard-import WEBAUTHN_SUPPORTED = True except ImportError as err: warn(_('2FA WebAuthn support disabled because import of the fido2 module failed (%s)')%err) diff --git a/uffd/oauth2/views.py b/uffd/views/oauth2.py similarity index 98% rename from uffd/oauth2/views.py rename to uffd/views/oauth2.py index e53bf7cc..d9219cab 100644 --- a/uffd/oauth2/views.py +++ b/uffd/views/oauth2.py @@ -7,11 +7,9 @@ import oauthlib.oauth2 from flask_babel import gettext as _ from sqlalchemy.exc import IntegrityError -from uffd.ratelimit import host_ratelimit, format_delay -from uffd.database import db from uffd.secure_redirect import secure_local_redirect -from uffd.session.models import DeviceLoginConfirmation -from .models import OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation +from uffd.database import db +from uffd.models import DeviceLoginConfirmation, OAuth2Client, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation, host_ratelimit, format_delay class UffdRequestValidator(oauthlib.oauth2.RequestValidator): # Argument "oauthreq" is named "request" in superclass but this clashes with flask's "request" object diff --git a/uffd/role/views.py b/uffd/views/role.py similarity index 76% rename from uffd/role/views.py rename to uffd/views/role.py index ce99dafc..153c2cda 100644 --- a/uffd/role/views.py +++ b/uffd/views/role.py @@ -1,41 +1,14 @@ -import sys - from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app from flask_babel import gettext as _, lazy_gettext -import click from uffd.navbar import register_navbar from uffd.csrf import csrf_protect -from uffd.role.models import Role, RoleGroup -from uffd.user.models import User, Group -from uffd.session import login_required from uffd.database import db +from uffd.models import Role, RoleGroup, Group +from .session import login_required bp = Blueprint("role", __name__, template_folder='templates', url_prefix='/role/') -@bp.record -def add_cli_commands(state): - @state.app.cli.command('roles-update-all', help='Update group memberships for all users based on their roles') - @click.option('--check-only', is_flag=True) - def roles_update_all(check_only): #pylint: disable=unused-variable - consistent = True - with current_app.test_request_context(): - for user in User.query.all(): - groups_added, groups_removed = user.update_groups() - if groups_added: - consistent = False - print('Adding groups [%s] to user %s'%(', '.join([group.name for group in groups_added]), user.loginname)) - if groups_removed: - consistent = False - print('Removing groups [%s] from user %s'%(', '.join([group.name for group in groups_removed]), user.loginname)) - if not check_only: - db.session.commit() - if check_only and not consistent: - print('No changes were made because --check-only is set') - print() - print('Error: LDAP groups are not consistent with roles in database') - sys.exit(1) - def role_acl_check(): return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP']) diff --git a/uffd/rolemod/views.py b/uffd/views/rolemod.py similarity index 94% rename from uffd/rolemod/views.py rename to uffd/views/rolemod.py index d1c520b7..61dcdbff 100644 --- a/uffd/rolemod/views.py +++ b/uffd/views/rolemod.py @@ -3,10 +3,9 @@ from flask_babel import gettext as _, lazy_gettext from uffd.navbar import register_navbar from uffd.csrf import csrf_protect -from uffd.role.models import Role -from uffd.user.models import User, Group -from uffd.session import login_required from uffd.database import db +from uffd.models import Role, User, Group +from .session import login_required bp = Blueprint('rolemod', __name__, template_folder='templates', url_prefix='/rolemod/') diff --git a/uffd/selfservice/views.py b/uffd/views/selfservice.py similarity index 96% rename from uffd/selfservice/views.py rename to uffd/views/selfservice.py index 3103c118..fee71b8d 100644 --- a/uffd/selfservice/views.py +++ b/uffd/views/selfservice.py @@ -5,13 +5,10 @@ from flask_babel import gettext as _, lazy_gettext from uffd.navbar import register_navbar from uffd.csrf import csrf_protect -from uffd.user.models import User -from uffd.session import login_required -from uffd.selfservice.models import PasswordToken, MailToken from uffd.sendmail import sendmail -from uffd.role.models import Role from uffd.database import db -from uffd.ratelimit import host_ratelimit, Ratelimit, format_delay +from uffd.models import User, PasswordToken, MailToken, Role, host_ratelimit, Ratelimit, format_delay +from .session import login_required bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/') diff --git a/uffd/service/views.py b/uffd/views/service.py similarity index 96% rename from uffd/service/views.py rename to uffd/views/service.py index edcb1c32..dbedf510 100644 --- a/uffd/service/views.py +++ b/uffd/views/service.py @@ -5,12 +5,10 @@ from flask_babel import lazy_gettext from uffd.navbar import register_navbar from uffd.csrf import csrf_protect -from uffd.session import login_required -from uffd.service.models import Service, get_services -from uffd.user.models import Group -from uffd.oauth2.models import OAuth2Client, OAuth2LogoutURI -from uffd.api.models import APIClient from uffd.database import db +from uffd.models import Service, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient + +from .session import login_required bp = Blueprint('service', __name__, template_folder='templates') diff --git a/uffd/session/views.py b/uffd/views/session.py similarity index 97% rename from uffd/session/views.py rename to uffd/views/session.py index 690397bc..3f6a6187 100644 --- a/uffd/session/views.py +++ b/uffd/views/session.py @@ -8,9 +8,7 @@ from flask_babel import gettext as _ from uffd.database import db from uffd.csrf import csrf_protect from uffd.secure_redirect import secure_local_redirect -from uffd.user.models import User -from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay -from uffd.session.models import DeviceLoginInitiation, DeviceLoginConfirmation +from uffd.models import User, DeviceLoginInitiation, DeviceLoginConfirmation, Ratelimit, host_ratelimit, format_delay bp = Blueprint("session", __name__, template_folder='templates', url_prefix='/') diff --git a/uffd/signup/views.py b/uffd/views/signup.py similarity index 96% rename from uffd/signup/views.py rename to uffd/views/signup.py index 752cc587..edbd3dcb 100644 --- a/uffd/signup/views.py +++ b/uffd/views/signup.py @@ -4,12 +4,10 @@ import secrets from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify from flask_babel import gettext as _ -from uffd.database import db -from uffd.session import set_session -from uffd.user.models import User from uffd.sendmail import sendmail -from uffd.signup.models import Signup -from uffd.ratelimit import Ratelimit, host_ratelimit, format_delay +from uffd.database import db +from uffd.models import User, Signup, Ratelimit, host_ratelimit, format_delay +from .session import set_session bp = Blueprint('signup', __name__, template_folder='templates', url_prefix='/signup/') diff --git a/uffd/user/views_user.py b/uffd/views/user.py similarity index 96% rename from uffd/user/views_user.py rename to uffd/views/user.py index d9a8800b..3ecf45d0 100644 --- a/uffd/user/views_user.py +++ b/uffd/views/user.py @@ -7,12 +7,10 @@ from sqlalchemy.exc import IntegrityError from uffd.navbar import register_navbar from uffd.csrf import csrf_protect -from uffd.selfservice import send_passwordreset -from uffd.session import login_required -from uffd.role.models import Role from uffd.database import db - -from .models import User, remailer +from uffd.models import User, remailer, Role +from .selfservice import send_passwordreset +from .session import login_required bp = Blueprint("user", __name__, template_folder='templates', url_prefix='/user/') -- GitLab