From 6b2ee671395580a897a3084d07f78b76447a5bb4 Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@cccv.de> Date: Sun, 13 Nov 2022 16:10:51 +0100 Subject: [PATCH] Add user deactivation --- tests/commands/test_user.py | 17 ++ tests/models/test_invite.py | 3 + tests/views/test_api.py | 59 ++++++ tests/views/test_oauth2.py | 35 ++++ tests/views/test_selfservice.py | 13 ++ tests/views/test_services.py | 19 ++ tests/views/test_session.py | 27 ++- tests/views/test_signup.py | 9 +- tests/views/test_user.py | 19 ++ uffd/commands/user.py | 15 +- .../versions/23293f32b503_deactivate_users.py | 90 ++++++++ uffd/models/invite.py | 2 + uffd/models/service.py | 1 + uffd/models/user.py | 1 + uffd/templates/role/show.html | 4 +- uffd/templates/service/show.html | 7 + uffd/templates/user/list.html | 3 + uffd/templates/user/show.html | 18 +- uffd/translations/de/LC_MESSAGES/messages.mo | Bin 40611 -> 41508 bytes uffd/translations/de/LC_MESSAGES/messages.po | 194 +++++++++++------- uffd/views/api.py | 12 +- uffd/views/oauth2.py | 7 +- uffd/views/selfservice.py | 2 +- uffd/views/service.py | 1 + uffd/views/session.py | 17 +- uffd/views/user.py | 18 ++ 26 files changed, 481 insertions(+), 112 deletions(-) create mode 100644 uffd/migrations/versions/23293f32b503_deactivate_users.py diff --git a/tests/commands/test_user.py b/tests/commands/test_user.py index 3612fc3b..3fcf49e8 100644 --- a/tests/commands/test_user.py +++ b/tests/commands/test_user.py @@ -57,6 +57,13 @@ class TestUserCLI(UffdTestCase): self.assertTrue(user.password.verify('newpassword')) self.assertEqual(user.roles, Role.query.filter_by(name='admin').all()) self.assertIn(self.get_admin_group(), user.groups) + self.assertFalse(user.is_deactivated) + + result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser2', '--mail', 'newmail2@example.com', '--deactivate']) + self.assertEqual(result.exit_code, 0) + with self.app.test_request_context(): + user = User.query.filter_by(loginname='newuser2').first() + self.assertTrue(user.is_deactivated) def test_update(self): result = self.app.test_cli_runner().invoke(args=['user', 'update', 'doesnotexist', '--displayname', 'foo']) @@ -106,6 +113,16 @@ class TestUserCLI(UffdTestCase): user = User.query.filter_by(loginname='testuser').first() self.assertEqual(user.roles, Role.query.filter_by(name='admin').all()) self.assertIn(self.get_admin_group(), user.groups) + result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--deactivate']) + self.assertEqual(result.exit_code, 0) + with self.app.test_request_context(): + user = User.query.filter_by(loginname='testuser').first() + self.assertTrue(user.is_deactivated) + result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--activate']) + self.assertEqual(result.exit_code, 0) + with self.app.test_request_context(): + user = User.query.filter_by(loginname='testuser').first() + self.assertFalse(user.is_deactivated) def test_delete(self): with self.app.test_request_context(): diff --git a/tests/models/test_invite.py b/tests/models/test_invite.py index fbbf8ab7..31fb104a 100644 --- a/tests/models/test_invite.py +++ b/tests/models/test_invite.py @@ -38,6 +38,9 @@ class TestInviteModel(UffdTestCase): invite.creator = self.get_admin() self.assertTrue(invite.permitted) self.assertTrue(invite.active) + invite.creator.is_deactivated = True + self.assertFalse(invite.permitted) + self.assertFalse(invite.active) invite.creator = self.get_user() self.assertFalse(invite.permitted) self.assertFalse(invite.active) diff --git a/tests/views/test_api.py b/tests/views/test_api.py index fce8554b..996bb1be 100644 --- a/tests/views/test_api.py +++ b/tests/views/test_api.py @@ -109,6 +109,13 @@ class TestAPICheckPassword(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json, None) + def test_deactivated(self): + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.post(path=url_for('api.checkpassword'), data={'loginname': 'testuser', 'password': 'userpassword'}, headers=[basic_auth('test', 'test')]) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json, None) + class TestAPIGetusers(UffdTestCase): def setUpDB(self): db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_users=True)) @@ -201,6 +208,23 @@ class TestAPIGetusers(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json, []) + def test_deactivated(self): + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.get(path=url_for('api.getusers'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.fix_result(r.json), [ + {'displayname': 'Test User', 'email': 'test@example.com', 'id': 10000, 'loginname': 'testuser', 'groups': ['uffd_access', 'users']}, + {'displayname': 'Test Admin', 'email': 'admin@example.com', 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']} + ]) + Service.query.filter_by(name='test').first().hide_deactivated_users = True + db.session.commit() + r = self.client.get(path=url_for('api.getusers'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.fix_result(r.json), [ + {'displayname': 'Test Admin', 'email': 'admin@example.com', 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']} + ]) + class TestAPIGetgroups(UffdTestCase): def setUpDB(self): db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_users=True)) @@ -220,6 +244,26 @@ class TestAPIGetgroups(UffdTestCase): {'id': 20003, 'members': ['testadmin'], 'name': 'uffd_admin'} ]) + def test_all_deactivated_members(self): + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.get(path=url_for('api.getgroups'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.fix_result(r.json), [ + {'id': 20001, 'members': ['testadmin', 'testuser'], 'name': 'users'}, + {'id': 20002, 'members': ['testadmin', 'testuser'], 'name': 'uffd_access'}, + {'id': 20003, 'members': ['testadmin'], 'name': 'uffd_admin'} + ]) + Service.query.filter_by(name='test').first().hide_deactivated_users = True + db.session.commit() + r = self.client.get(path=url_for('api.getgroups'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.fix_result(r.json), [ + {'id': 20001, 'members': ['testadmin'], 'name': 'users'}, + {'id': 20002, 'members': ['testadmin'], 'name': 'uffd_access'}, + {'id': 20003, 'members': ['testadmin'], 'name': 'uffd_admin'} + ]) + def test_id(self): r = self.client.get(path=url_for('api.getgroups', id=20002), headers=[basic_auth('test', 'test')], follow_redirects=True) self.assertEqual(r.status_code, 200) @@ -257,6 +301,21 @@ class TestAPIGetgroups(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertEqual(r.json, []) + def test_member_deactivated(self): + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.get(path=url_for('api.getgroups', member='testuser'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.fix_result(r.json), [ + {'id': 20001, 'members': ['testadmin', 'testuser'], 'name': 'users'}, + {'id': 20002, 'members': ['testadmin', 'testuser'], 'name': 'uffd_access'}, + ]) + Service.query.filter_by(name='test').first().hide_deactivated_users = True + db.session.commit() + r = self.client.get(path=url_for('api.getgroups', member='testuser'), headers=[basic_auth('test', 'test')], follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.fix_result(r.json), []) + class TestAPIRemailerResolve(UffdTestCase): def setUpDB(self): db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_remailer=True)) diff --git a/tests/views/test_oauth2.py b/tests/views/test_oauth2.py index 72c9ff7f..6d26baf6 100644 --- a/tests/views/test_oauth2.py +++ b/tests/views/test_oauth2.py @@ -139,6 +139,23 @@ class TestViews(UffdTestCase): r = self.client.post(path=url_for('session.devicelogin_submit', ref=ref), data={'confirmation-code': code}, follow_redirects=False) self.assert_authorization(r) + def test_authorization_devicelogin_auth_deactivated(self): + with self.client.session_transaction() as _session: + initiation = OAuth2DeviceLoginInitiation(client=OAuth2Client.query.filter_by(client_id='test').one()) + db.session.add(initiation) + confirmation = DeviceLoginConfirmation(initiation=initiation, user=self.get_user()) + db.session.add(confirmation) + db.session.commit() + _session['devicelogin_id'] = initiation.id + _session['devicelogin_secret'] = initiation.secret + code = confirmation.code + self.client.get(path='/') + self.get_user().is_deactivated = True + db.session.commit() + ref = url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback') + r = self.client.post(path=url_for('session.devicelogin_submit', ref=ref), data={'confirmation-code': code}, follow_redirects=True) + self.assertIn(b'Device login failed', r.data) + def get_auth_code(self): self.login_as('user') r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False) @@ -195,7 +212,25 @@ class TestViews(UffdTestCase): self.assertEqual(r.content_type, 'application/json') self.assertEqual(r.json['error'], 'unsupported_grant_type') + def test_token_deactivated_user(self): + code = self.get_auth_code() + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.post(path=url_for('oauth2.token'), + data={'grant_type': 'authorization_code', 'code': code, 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True) + self.assertIn(r.status_code, [400, 401]) # oauthlib behaviour changed between v2.1.0 and v3.1.0 + self.assertEqual(r.content_type, 'application/json') + def test_userinfo_invalid_access_token(self): token = 'invalidtoken' r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True) self.assertEqual(r.status_code, 401) + + def test_userinfo_invalid_access_token(self): + r = self.client.post(path=url_for('oauth2.token'), + data={'grant_type': 'authorization_code', 'code': self.get_auth_code(), 'redirect_uri': 'http://localhost:5009/callback', 'client_id': 'test', 'client_secret': 'testsecret'}, follow_redirects=True) + token = r.json['access_token'] + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.get(path=url_for('oauth2.userinfo'), headers=[('Authorization', 'Bearer %s'%token)], follow_redirects=True) + self.assertEqual(r.status_code, 401) diff --git a/tests/views/test_selfservice.py b/tests/views/test_selfservice.py index 1b598aa9..b5b20241 100644 --- a/tests/views/test_selfservice.py +++ b/tests/views/test_selfservice.py @@ -396,6 +396,19 @@ class TestSelfservice(UffdTestCase): self.assertFalse(hasattr(self.app, 'last_mail')) self.assertEqual(len(PasswordToken.query.all()), 0) + def test_forgot_password_wrong_user(self): + user = self.get_user() + r = self.client.get(path=url_for('selfservice.forgot_password')) + self.assertEqual(r.status_code, 200) + user = self.get_user() + user.is_deactivated = True + db.session.commit() + r = self.client.post(path=url_for('selfservice.forgot_password'), + data={'loginname': user.loginname, 'mail': user.primary_email.address}, follow_redirects=True) + self.assertEqual(r.status_code, 200) + self.assertFalse(hasattr(self.app, 'last_mail')) + self.assertEqual(len(PasswordToken.query.all()), 0) + def test_token_password(self): user = self.get_user() token = PasswordToken(user=user) diff --git a/tests/views/test_services.py b/tests/views/test_services.py index a40e447f..51e296a4 100644 --- a/tests/views/test_services.py +++ b/tests/views/test_services.py @@ -172,6 +172,7 @@ class TestServiceAdminViews(UffdTestCase): self.assertEqual(service.access_group, None) self.assertEqual(service.remailer_mode, RemailerMode.DISABLED) self.assertEqual(service.enable_email_preferences, False) + self.assertEqual(service.hide_deactivated_users, False) def test_edit_access_all(self): self.login_as('admin') @@ -209,6 +210,24 @@ class TestServiceAdminViews(UffdTestCase): self.assertEqual(service.limit_access, True) self.assertEqual(service.access_group, self.get_users_group()) + def test_edit_hide_deactivated_users(self): + self.login_as('admin') + r = self.client.post( + path=url_for('service.edit_submit', id=self.service_id), + follow_redirects=True, + data={ + 'name': 'test1', + 'access-group': '', + 'remailer-mode': 'DISABLED', + 'remailer-overwrite-mode': 'ENABLED_V2', + 'remailer-overwrite-users': '', + 'hide_deactivated_users': '1', + }, + ) + self.assertEqual(r.status_code, 200) + service = Service.query.get(self.service_id) + self.assertEqual(service.hide_deactivated_users, True) + def test_edit_email_preferences(self): self.login_as('admin') r = self.client.post( diff --git a/tests/views/test_session.py b/tests/views/test_session.py index 0cb5c340..1780ac87 100644 --- a/tests/views/test_session.py +++ b/tests/views/test_session.py @@ -17,7 +17,7 @@ class TestSession(UffdTestCase): @self.app.route('/test_login_required') @login_required() def test_login_required(): - return 'SUCCESS', 200 + return 'SUCCESS ' + request.user.loginname, 200 @self.app.route('/test_group_required1') @login_required(lambda: request.user.is_in_group('users')) @@ -38,15 +38,10 @@ class TestSession(UffdTestCase): self.assertIsNotNone(request.user) def assertLoggedIn(self): - self.assertIsNotNone(request.user) - self.assertEqual(self.client.get(path=url_for('test_login_required'), follow_redirects=True).data, b'SUCCESS') - self.assertEqual(request.user.loginname, self.get_user().loginname) + self.assertEqual(self.client.get(path=url_for('test_login_required'), follow_redirects=True).data, b'SUCCESS testuser') def assertLoggedOut(self): - self.assertIsNone(request.user) - self.assertNotEqual(self.client.get(path=url_for('test_login_required'), - follow_redirects=True).data, b'SUCCESS') - self.assertEqual(request.user, None) + self.assertNotIn(b'SUCCESS', self.client.get(path=url_for('test_login_required'), follow_redirects=True).data) def test_login(self): self.assertLoggedOut() @@ -78,7 +73,7 @@ class TestSession(UffdTestCase): def test_redirect(self): r = self.login_as('user', ref=url_for('test_login_required')) self.assertEqual(r.status_code, 200) - self.assertEqual(r.data, b'SUCCESS') + self.assertEqual(r.data, b'SUCCESS testuser') def test_wrong_password(self): r = self.client.post(path=url_for('session.login'), @@ -125,6 +120,20 @@ class TestSession(UffdTestCase): self.assertEqual(r.status_code, 200) self.assertLoggedOut() + def test_deactivated(self): + self.get_user().is_deactivated = True + db.session.commit() + r = self.login_as('user') + dump('login_deactivated', r) + self.assertEqual(r.status_code, 200) + self.assertLoggedOut() + + def test_deactivated_after_login(self): + self.login_as('user') + self.get_user().is_deactivated = True + db.session.commit() + self.assertLoggedOut() + def test_group_required(self): self.login() self.assertEqual(self.client.get(path=url_for('test_group_required1'), diff --git a/tests/views/test_signup.py b/tests/views/test_signup.py index 72f80488..7db3b7fe 100644 --- a/tests/views/test_signup.py +++ b/tests/views/test_signup.py @@ -3,8 +3,7 @@ import datetime from flask import url_for, request from uffd.database import db -from uffd.models import Signup, Role, RoleGroup, FeatureFlag -from uffd.views.session import login_get_user +from uffd.models import User, Signup, Role, RoleGroup, FeatureFlag from tests.utils import dump, UffdTestCase, db_flush @@ -161,13 +160,13 @@ class TestSignupViews(UffdTestCase): signup = Signup(loginname='newuser', displayname='New User', mail='new@example.com', password='notsecret') signup = refetch_signup(signup) self.assertFalse(signup.completed) - self.assertIsNone(login_get_user('newuser', 'notsecret')) + self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) r = self.client.get(path=url_for('signup.signup_confirm', signup_id=signup.id, token=signup.token), follow_redirects=True) dump('test_signup_confirm', r) self.assertEqual(r.status_code, 200) signup = refetch_signup(signup) self.assertFalse(signup.completed) - self.assertIsNone(login_get_user('newuser', 'notsecret')) + self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none()) r = self.client.post(path=url_for('signup.signup_confirm_submit', signup_id=signup.id, token=signup.token), follow_redirects=True, data={'password': 'notsecret'}) dump('test_signup_confirm_submit', r) self.assertEqual(r.status_code, 200) @@ -176,7 +175,7 @@ class TestSignupViews(UffdTestCase): self.assertEqual(signup.user.loginname, 'newuser') self.assertEqual(signup.user.displayname, 'New User') self.assertEqual(signup.user.primary_email.address, 'new@example.com') - self.assertIsNotNone(login_get_user('newuser', 'notsecret')) + self.assertTrue(User.query.filter_by(loginname='newuser').one_or_none().password.verify('notsecret')) def test_confirm_loggedin(self): baserole = Role(name='baserole', is_default=True) diff --git a/tests/views/test_user.py b/tests/views/test_user.py index a024b53d..6bffa84d 100644 --- a/tests/views/test_user.py +++ b/tests/views/test_user.py @@ -353,12 +353,31 @@ class TestUserViews(UffdTestCase): dump('user_show', r) self.assertEqual(r.status_code, 200) + def test_show_self(self): + r = self.client.get(path=url_for('user.show', id=self.get_admin().id), follow_redirects=True) + dump('user_show_self', r) + self.assertEqual(r.status_code, 200) + def test_delete(self): r = self.client.get(path=url_for('user.delete', id=self.get_user().id), follow_redirects=True) dump('user_delete', r) self.assertEqual(r.status_code, 200) self.assertIsNone(self.get_user()) + def test_deactivate(self): + r = self.client.get(path=url_for('user.deactivate', id=self.get_user().id), follow_redirects=True) + dump('user_deactivate', r) + self.assertEqual(r.status_code, 200) + self.assertTrue(self.get_user().is_deactivated) + + def test_activate(self): + self.get_user().is_deactivated = True + db.session.commit() + r = self.client.get(path=url_for('user.activate', id=self.get_user().id), follow_redirects=True) + dump('user_activate', r) + self.assertEqual(r.status_code, 200) + self.assertFalse(self.get_user().is_deactivated) + def test_csvimport(self): role1 = Role(name='role1') db.session.add(role1) diff --git a/uffd/commands/user.py b/uffd/commands/user.py index 2db24b3f..68aa675f 100644 --- a/uffd/commands/user.py +++ b/uffd/commands/user.py @@ -12,7 +12,7 @@ user_command = AppGroup('user', help='Manage users') def update_attrs(user, mail=None, displayname=None, password=None, prompt_password=False, clear_roles=False, - add_role=tuple(), remove_role=tuple()): + add_role=tuple(), remove_role=tuple(), deactivate=None): if password is None and prompt_password: password = click.prompt('Password', hide_input=True, confirmation_prompt='Confirm password') if mail is not None and not user.set_primary_email_address(mail): @@ -21,6 +21,8 @@ def update_attrs(user, mail=None, displayname=None, password=None, raise click.ClickException('Invalid displayname') if password is not None and not user.set_password(password): raise click.ClickException('Invalid password') + if deactivate is not None: + user.is_deactivated = deactivate if clear_roles: user.roles.clear() for role_name in add_role: @@ -49,6 +51,7 @@ def show(loginname): if user is None: raise click.ClickException(f'User {loginname} not found') click.echo(f'Loginname: {user.loginname}') + click.echo(f'Deactivated: {user.is_deactivated}') click.echo(f'Displayname: {user.displayname}') click.echo(f'Mail: {user.primary_email.address}') click.echo(f'Service User: {user.is_service_user}') @@ -65,7 +68,8 @@ def show(loginname): @click.option('--password', help='Password for SSO login. Login disabled if unset.') @click.option('--prompt-password', is_flag=True, flag_value=True, default=False, help='Read password interactively from terminal.') @click.option('--add-role', multiple=True, help='Add role to user. Repeat to add multiple roles.', metavar='ROLE_NAME') -def create(loginname, mail, displayname, service, password, prompt_password, add_role): +@click.option('--deactivate', is_flag=True, flag_value=True, default=None, help='Deactivate account.') +def create(loginname, mail, displayname, service, password, prompt_password, add_role, deactivate): with current_app.test_request_context(): if displayname is None: displayname = loginname @@ -74,7 +78,7 @@ def create(loginname, mail, displayname, service, password, prompt_password, add raise click.ClickException('Invalid loginname') try: db.session.add(user) - update_attrs(user, mail, displayname, password, prompt_password, add_role=add_role) + update_attrs(user, mail, displayname, password, prompt_password, add_role=add_role, deactivate=deactivate) db.session.commit() except IntegrityError: # pylint: disable=raise-missing-from @@ -89,13 +93,14 @@ def create(loginname, mail, displayname, service, password, prompt_password, add @click.option('--clear-roles', is_flag=True, flag_value=True, default=False, help='Remove all roles from user. Executed before --add-role.') @click.option('--add-role', multiple=True, help='Add role to user. Repeat to add multiple roles.') @click.option('--remove-role', multiple=True, help='Remove role from user. Repeat to remove multiple roles.') -def update(loginname, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role): +@click.option('--deactivate/--activate', default=None, help='Deactivate or reactivate account.') +def update(loginname, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role, deactivate): with current_app.test_request_context(): user = User.query.filter_by(loginname=loginname).one_or_none() if user is None: raise click.ClickException(f'User {loginname} not found') try: - update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role) + update_attrs(user, mail, displayname, password, prompt_password, clear_roles, add_role, remove_role, deactivate) db.session.commit() except IntegrityError: # pylint: disable=raise-missing-from diff --git a/uffd/migrations/versions/23293f32b503_deactivate_users.py b/uffd/migrations/versions/23293f32b503_deactivate_users.py new file mode 100644 index 00000000..c25dbf6c --- /dev/null +++ b/uffd/migrations/versions/23293f32b503_deactivate_users.py @@ -0,0 +1,90 @@ +"""Deactivate users + +Revision ID: 23293f32b503 +Revises: e249233e2a31 +Create Date: 2022-11-10 02:06:27.766520 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '23293f32b503' +down_revision = 'e249233e2a31' +branch_labels = None +depends_on = None + +def upgrade(): + meta = sa.MetaData(bind=op.get_bind()) + with op.batch_alter_table('service', schema=None) as batch_op: + batch_op.add_column(sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false())) + service = sa.Table('service', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('access_group_id', sa.Integer(), nullable=True), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), + sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), + sa.UniqueConstraint('name', name=op.f('uq_service_name')) + ) + with op.batch_alter_table('service', copy_from=service) as batch_op: + batch_op.alter_column('hide_deactivated_users', server_default=None) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false())) + user = sa.Table('user', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_uid', sa.Integer(), nullable=False), + sa.Column('loginname', sa.String(length=32), nullable=False), + sa.Column('displayname', sa.String(length=128), nullable=False), + sa.Column('primary_email_id', sa.Integer(), nullable=False), + sa.Column('recovery_email_id', sa.Integer(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_deactivated', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), + sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), + sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), + sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) + ) + with op.batch_alter_table('user', copy_from=user) as batch_op: + batch_op.alter_column('is_deactivated', server_default=None) + +def downgrade(): + meta = sa.MetaData(bind=op.get_bind()) + user = sa.Table('user', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unix_uid', sa.Integer(), nullable=False), + sa.Column('loginname', sa.String(length=32), nullable=False), + sa.Column('displayname', sa.String(length=128), nullable=False), + sa.Column('primary_email_id', sa.Integer(), nullable=False), + sa.Column('recovery_email_id', sa.Integer(), nullable=True), + sa.Column('pwhash', sa.Text(), nullable=True), + sa.Column('is_service_user', sa.Boolean(), nullable=False), + sa.Column('is_deactivated', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'), + sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'), + sa.ForeignKeyConstraint(['unix_uid'], ['uid_allocation.id'], name=op.f('fk_user_unix_uid_uid_allocation')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')), + sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid')) + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('is_deactivated') + service = sa.Table('service', meta, + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('limit_access', sa.Boolean(), nullable=False), + sa.Column('access_group_id', sa.Integer(), nullable=True), + sa.Column('remailer_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=False), + sa.Column('enable_email_preferences', sa.Boolean(), nullable=False), + sa.Column('hide_deactivated_users', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_service')), + sa.UniqueConstraint('name', name=op.f('uq_service_name')) + ) + with op.batch_alter_table('service', copy_from=service) as batch_op: + batch_op.drop_column('hide_deactivated_users') diff --git a/uffd/models/invite.py b/uffd/models/invite.py index 27c1db2b..ef8acc36 100644 --- a/uffd/models/invite.py +++ b/uffd/models/invite.py @@ -43,6 +43,8 @@ class Invite(db.Model): def permitted(self): if self.creator is None: return False # Creator does not exist (anymore) + if self.creator.is_deactivated: + return False if self.creator.is_in_group(current_app.config['ACL_ADMIN_GROUP']): return True if self.allow_signup and not self.creator.is_in_group(current_app.config['ACL_SIGNUP_GROUP']): diff --git a/uffd/models/service.py b/uffd/models/service.py index ff31419b..1817954c 100644 --- a/uffd/models/service.py +++ b/uffd/models/service.py @@ -35,6 +35,7 @@ class Service(db.Model): remailer_mode = Column(Enum(RemailerMode), default=RemailerMode.DISABLED, nullable=False) enable_email_preferences = Column(Boolean(), default=False, nullable=False) + hide_deactivated_users = Column(Boolean(), default=False, nullable=False) class ServiceUser(db.Model): '''Service-related configuration and state for a user diff --git a/uffd/models/user.py b/uffd/models/user.py index e3fa2f6a..2a933907 100644 --- a/uffd/models/user.py +++ b/uffd/models/user.py @@ -168,6 +168,7 @@ class User(db.Model): _password = Column('pwhash', Text(), nullable=True) password = PasswordHashAttribute('_password', LowEntropyPasswordHash) is_service_user = Column(Boolean(), default=False, nullable=False) + is_deactivated = Column(Boolean(), default=False, nullable=False) groups = relationship('Group', secondary='user_groups', back_populates='members') roles = relationship('Role', secondary='role_members', back_populates='members') diff --git a/uffd/templates/role/show.html b/uffd/templates/role/show.html index 3ee88d69..282bbb7c 100644 --- a/uffd/templates/role/show.html +++ b/uffd/templates/role/show.html @@ -9,7 +9,7 @@ <form action="{{ url_for("role.update", roleid=role.id) }}" method="POST"> <div class="align-self-center"> - <div class="float-sm-right pb-2"> + <div class="clearfix pb-2"><div class="float-sm-right"> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button> <a href="{{ url_for("role.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a> {% if role.id %} @@ -23,7 +23,7 @@ <a href="#" class="btn btn-secondary disabled">{{_("Set as default")}}</a> <a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a> {% endif %} - </div> + </div></div> <ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist"> <li class="nav-item"> <a class="nav-link active" id="settings-tab" data-toggle="tab" href="#settings" role="tab" aria-controls="settings" aria-selected="true">{{_("Settings")}}</a> diff --git a/uffd/templates/service/show.html b/uffd/templates/service/show.html index 08d03ba0..08e0629f 100644 --- a/uffd/templates/service/show.html +++ b/uffd/templates/service/show.html @@ -31,6 +31,13 @@ </select> </div> + <div class="form-group col"> + <div class="form-check"> + <input class="form-check-input" type="checkbox" id="hide-deactivated-users" name="hide_deactivated_users" value="1" aria-label="enabled" {{ 'checked' if service.hide_deactivated_users }}> + <label class="form-check-label" for="hide-deactivated-users">{{ _('Hide deactivated users from service') }}</label> + </div> + </div> + <div class="form-group col"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="service-enable-email-preferences" name="enable_email_preferences" value="1" aria-label="enabled" {{ 'checked' if service.enable_email_preferences }}> diff --git a/uffd/templates/user/list.html b/uffd/templates/user/list.html index 63748d0f..6e7d08a2 100644 --- a/uffd/templates/user/list.html +++ b/uffd/templates/user/list.html @@ -33,6 +33,9 @@ {% if user.is_service_user %} <span class="badge badge-secondary">{{_('service')}}</span> {% endif %} + {% if user.is_deactivated %} + <span class="badge badge-danger">{{ _('deactivated') }}</span> + {% endif %} </td> <td> {{ user.displayname }} diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html index 7a4f265d..04b2cf35 100644 --- a/uffd/templates/user/show.html +++ b/uffd/templates/user/show.html @@ -22,15 +22,27 @@ <form action="{{ url_for("user.create") }}" method="POST"> {% endif %} <div class="align-self-center"> - <div class="float-sm-right pb-2"> + {% if user.id and user.is_deactivated %} + <div class="alert alert-warning"> + {{ _('This account is deactivated. The user cannot login and existing sessions are not usable. The user cannot log into services, but existing sessions on services might still be active.') }} + </div> + {% endif %} + <div class="clearfix pb-2"><div class="float-sm-right"> <button type="submit" class="btn btn-primary"><i class="fa fa-save" aria-hidden="true"></i> {{_("Save")}}</button> <a href="{{ url_for("user.index") }}" class="btn btn-secondary">{{_("Cancel")}}</a> - {% if user.id %} + {% if user.id and not user.is_deactivated and user != request.user %} + <a href="{{ url_for("user.deactivate", id=user.id) }}" class="btn btn-secondary">{{ _("Deactivate") }}</a> + {% elif user.id and user.is_deactivated %} + <a href="{{ url_for("user.activate", id=user.id) }}" class="btn btn-primary">{{ _("Activate") }}</a> + {% else %} + <a href="#" class="btn btn-secondary disabled">{{ _("Deactivate") }}</a> + {% endif %} + {% if user.id and user != request.user %} <a href="{{ url_for("user.delete", id=user.id) }}" onClick='return confirm({{_("Are you sure?")|tojson}});' class="btn btn-danger"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a> {% else %} <a href="#" class="btn btn-danger disabled"><i class="fa fa-trash" aria-hidden="true"></i> {{_("Delete")}}</a> {% endif %} - </div> + </div></div> <ul class="nav nav-tabs pt-2 border-0" id="tablist" role="tablist"> <li class="nav-item"> <a class="nav-link active" id="profile-tab" data-toggle="tab" href="#profile" role="tab" aria-controls="profile" aria-selected="true">{{_("Profile")}}</a> diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo index 18d96bb2f77e2483e726cbbf418892af96e7fd69..68f53c06570d8c2f651b2e70a4884e8674ca69ba 100644 GIT binary patch delta 7509 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 MXGFGaviQ6n00%6DBme*a delta 6675 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 JC(m5i4FFlyV`cyV diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 667d221f..4f4d621c 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-11-08 20:12+0100\n" +"POT-Creation-Date: 2022-11-13 02:05+0100\n" "PO-Revision-Date: 2021-05-25 21:18+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -144,8 +144,8 @@ msgstr "Über uffd" #: uffd/templates/group/list.html:8 uffd/templates/invite/list.html:6 #: uffd/templates/mail/list.html:8 uffd/templates/role/list.html:8 -#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:99 -#: uffd/templates/service/show.html:127 uffd/templates/user/list.html:8 +#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:106 +#: uffd/templates/service/show.html:134 uffd/templates/user/list.html:8 msgid "New" msgstr "Neu" @@ -162,8 +162,8 @@ msgstr "GID" #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44 #: uffd/templates/selfservice/self.html:190 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20 -#: uffd/templates/service/show.html:133 uffd/templates/user/show.html:193 -#: uffd/templates/user/show.html:225 +#: uffd/templates/service/show.html:140 uffd/templates/user/show.html:205 +#: uffd/templates/user/show.html:237 msgid "Name" msgstr "Name" @@ -171,14 +171,14 @@ msgstr "Name" #: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15 #: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10 #: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:191 -#: uffd/templates/user/show.html:194 uffd/templates/user/show.html:226 +#: uffd/templates/user/show.html:206 uffd/templates/user/show.html:238 msgid "Description" msgstr "Beschreibung" #: uffd/templates/group/show.html:8 uffd/templates/mail/show.html:27 #: uffd/templates/role/show.html:13 uffd/templates/rolemod/show.html:8 #: uffd/templates/service/api.html:15 uffd/templates/service/oauth2.html:15 -#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:26 +#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:31 msgid "Save" msgstr "Speichern" @@ -189,7 +189,7 @@ msgstr "Speichern" #: uffd/templates/service/show.html:10 #: uffd/templates/session/deviceauth.html:39 #: uffd/templates/session/deviceauth.html:49 -#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:27 +#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:32 msgid "Cancel" msgstr "Abbrechen" @@ -197,7 +197,7 @@ msgstr "Abbrechen" #: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61 #: uffd/templates/selfservice/self.html:205 uffd/templates/service/api.html:11 #: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12 -#: uffd/templates/user/show.html:29 uffd/templates/user/show.html:181 +#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:193 msgid "Are you sure?" msgstr "Wirklich fortfahren?" @@ -207,8 +207,8 @@ msgstr "Wirklich fortfahren?" #: uffd/templates/role/show.html:21 uffd/templates/role/show.html:24 #: uffd/templates/selfservice/self.html:62 uffd/templates/service/api.html:12 #: uffd/templates/service/oauth2.html:12 uffd/templates/service/show.html:13 -#: uffd/templates/user/show.html:29 uffd/templates/user/show.html:31 -#: uffd/templates/user/show.html:104 +#: uffd/templates/user/show.html:41 uffd/templates/user/show.html:43 +#: uffd/templates/user/show.html:116 msgid "Delete" msgstr "Löschen" @@ -238,7 +238,7 @@ msgid "Created by" msgstr "Erstellt durch" #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34 -#: uffd/templates/service/show.html:134 +#: uffd/templates/service/show.html:141 msgid "Permissions" msgstr "Berechtigungen" @@ -266,7 +266,7 @@ msgstr "Account-Registrierung" msgid "user signups" msgstr "Account-Registrierungen" -#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:178 +#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:190 msgid "Disabled" msgstr "Deaktiviert" @@ -460,7 +460,7 @@ msgid "One address per line" msgstr "Eine Adresse pro Zeile" #: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:159 -#: uffd/templates/user/show.html:176 +#: uffd/templates/user/show.html:188 msgid "Two-Factor Authentication" msgstr "Zwei-Faktor-Authentifizierung" @@ -599,7 +599,7 @@ msgid "Reset two-factor configuration" msgstr "Zwei-Faktor-Authentifizierung zurücksetzen" #: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5 -#: uffd/templates/user/show.html:179 +#: uffd/templates/user/show.html:191 msgid "Recovery Codes" msgstr "Wiederherstellungscodes" @@ -637,7 +637,7 @@ msgstr "Generiere neue Wiederherstellungscodes" msgid "You have no remaining recovery codes." msgstr "Du hast keine Wiederherstellungscodes übrig." -#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:179 +#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:191 msgid "Authenticator Apps (TOTP)" msgstr "Authentifikator-Apps (TOTP)" @@ -667,7 +667,7 @@ msgstr "Registriert am" msgid "No authenticator apps registered yet" msgstr "Bisher keine Authentifikator-Apps registriert" -#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:179 +#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:191 msgid "U2F and FIDO2 Devices" msgstr "U2F und FIDO2 Geräte" @@ -972,7 +972,7 @@ msgstr "Passwort vergessen" #: uffd/templates/selfservice/forgot_password.html:9 #: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12 #: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18 -#: uffd/templates/user/show.html:66 +#: uffd/templates/user/show.html:78 msgid "Login Name" msgstr "Anmeldename" @@ -993,7 +993,7 @@ msgstr "" "Authentifizierung.\n" "\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast." -#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:36 +#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:48 msgid "Profile" msgstr "Profil" @@ -1010,7 +1010,7 @@ msgid "Changes may take several minutes to be visible in all services." msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar." #: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22 -#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:81 +#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:93 msgid "Display Name" msgstr "Anzeigename" @@ -1018,7 +1018,7 @@ msgstr "Anzeigename" msgid "Update Profile" msgstr "Änderungen speichern" -#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:98 +#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:110 msgid "E-Mail Addresses" msgstr "E-Mail-Adressen" @@ -1043,7 +1043,7 @@ msgstr "Neue E-Mail-Adresse" msgid "Add address" msgstr "Adresse hinzufügen" -#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:114 +#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:126 msgid "primary" msgstr "primär" @@ -1090,8 +1090,8 @@ msgid "Address for Password Reset E-Mails" msgstr "Adresse für Passwort-Zurücksetzen-E-Mails" #: uffd/templates/selfservice/self.html:103 -#: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:143 -#: uffd/templates/user/show.html:153 +#: uffd/templates/selfservice/self.html:116 uffd/templates/user/show.html:155 +#: uffd/templates/user/show.html:165 msgid "Use primary address" msgstr "Primäre Adresse verwenden" @@ -1110,7 +1110,7 @@ msgstr "E-Mail-Einstellungen speichern" #: uffd/templates/selfservice/self.html:136 #: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36 -#: uffd/templates/user/show.html:163 +#: uffd/templates/user/show.html:175 msgid "Password" msgstr "Passwort" @@ -1155,7 +1155,7 @@ msgid "Manage two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten" #: uffd/templates/selfservice/self.html:178 uffd/templates/user/list.html:20 -#: uffd/templates/user/show.html:39 uffd/templates/user/show.html:188 +#: uffd/templates/user/show.html:51 uffd/templates/user/show.html:200 #: uffd/views/role.py:21 msgid "Roles" msgstr "Rollen" @@ -1231,7 +1231,7 @@ msgstr "Zugriff auf Mail-Weiterleitungen" msgid "Resolve remailer addresses" msgstr "Auflösen von Remailer-Adressen" -#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:48 +#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:55 msgid "This option has no effect: Remailer config options are unset" msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" @@ -1239,7 +1239,7 @@ msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert" msgid "Access uffd metrics" msgstr "Zugriff auf uffd-Metriken" -#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:105 +#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:112 msgid "Client ID" msgstr "Client-ID" @@ -1286,8 +1286,8 @@ msgstr "Kein Zugriff" msgid "Manage OAuth2 and API clients" msgstr "OAuth2- und API-Clients verwalten" -#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:58 -#: uffd/templates/user/list.html:79 +#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:61 +#: uffd/templates/user/list.html:82 msgid "Close" msgstr "Schließen" @@ -1309,6 +1309,10 @@ msgid "Members of group \"%(group_name)s\" have access" msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff" #: uffd/templates/service/show.html:37 +msgid "Hide deactivated users from service" +msgstr "Deaktivierte Nutzer verstecken" + +#: uffd/templates/service/show.html:44 msgid "" "Allow users with access to select a different e-mail address for this " "service" @@ -1316,29 +1320,29 @@ msgstr "" "Ermögliche Nutzern mit Zugriff auf diesen Dienst eine andere E-Mail-" "Adresse auszuwählen" -#: uffd/templates/service/show.html:39 +#: uffd/templates/service/show.html:46 msgid "If disabled, the service always uses the primary e-mail address." msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet." -#: uffd/templates/service/show.html:46 +#: uffd/templates/service/show.html:53 msgid "Hide e-mail addresses with remailer" msgstr "E-Mail-Adressen mit Remailer verstecken" -#: uffd/templates/service/show.html:53 uffd/templates/service/show.html:76 +#: uffd/templates/service/show.html:60 uffd/templates/service/show.html:83 msgid "Remailer disabled" msgstr "Remailer deaktiviert" -#: uffd/templates/service/show.html:56 uffd/templates/service/show.html:79 +#: uffd/templates/service/show.html:63 uffd/templates/service/show.html:86 msgid "Remailer enabled" msgstr "Remailer aktiviert" -#: uffd/templates/service/show.html:59 uffd/templates/service/show.html:82 +#: uffd/templates/service/show.html:66 uffd/templates/service/show.html:89 msgid "Remailer enabled (deprecated, case-sensitive format)" msgstr "" "Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes " "Format)" -#: uffd/templates/service/show.html:63 +#: uffd/templates/service/show.html:70 msgid "" "Some services notify users about changes to their e-mail address. " "Modifying this setting immediatly affects the e-mail addresses of all " @@ -1349,15 +1353,15 @@ msgstr "" "-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von " "Benachrichtigungs-E-Mails führen." -#: uffd/templates/service/show.html:69 +#: uffd/templates/service/show.html:76 msgid "Overwrite remailer setting for specific users" msgstr "Überschreibe Remailer-Einstellung für ausgewählte Nutzer" -#: uffd/templates/service/show.html:72 +#: uffd/templates/service/show.html:79 msgid "Login names" msgstr "Anmeldenamen" -#: uffd/templates/service/show.html:87 +#: uffd/templates/service/show.html:94 msgid "" "Useful for testing remailer before enabling it for all users. Specify " "users as a comma-seperated list of login names." @@ -1467,7 +1471,7 @@ msgstr "Überprüfen" msgid "At least one and at most 128 characters, no other special requirements." msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen." -#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:90 +#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:102 msgid "E-Mail Address" msgstr "E-Mail-Adresse" @@ -1527,15 +1531,19 @@ msgstr "CSV-Import" msgid "UID" msgstr "UID" -#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:48 +#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:60 msgid "service" msgstr "service" -#: uffd/templates/user/list.html:57 +#: uffd/templates/user/list.html:37 +msgid "deactivated" +msgstr "deaktiviert" + +#: uffd/templates/user/list.html:60 msgid "Import a csv formated list of users" msgstr "Importiere eine als CSV formatierte Liste von Accounts" -#: uffd/templates/user/list.html:64 +#: uffd/templates/user/list.html:67 msgid "" "The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither " "setting the display name nor setting passwords is supported (yet). " @@ -1545,11 +1553,11 @@ msgstr "" "Anzeigename oder das Password können (derzeit) nicht gesetzt werden. " "Beispiel:" -#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:76 +#: uffd/templates/user/list.html:78 uffd/templates/user/show.html:88 msgid "Ignore login name blocklist" msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren" -#: uffd/templates/user/list.html:80 +#: uffd/templates/user/list.html:83 msgid "Import" msgstr "Importieren" @@ -1557,19 +1565,38 @@ msgstr "Importieren" msgid "New address" msgstr "Neue Adresse" -#: uffd/templates/user/show.html:46 +#: uffd/templates/user/show.html:27 +msgid "" +"This account is deactivated. The user cannot login and existing sessions " +"are not usable. The user cannot log into services, but existing sessions " +"on services might still be active." +msgstr "" +"Dieser Account ist deaktiviert. Der Nutzer kann sich nicht neu anmelden. " +"Existierende Sitzungen sind nicht nutzbar. Eine Anmeldung bei Diensten " +"ist nicht möglich, allerdings könnten bestehende Sitzungen weiterhin " +"aktiv sein." + +#: uffd/templates/user/show.html:34 uffd/templates/user/show.html:38 +msgid "Deactivate" +msgstr "Deaktivieren" + +#: uffd/templates/user/show.html:36 +msgid "Activate" +msgstr "Aktivieren" + +#: uffd/templates/user/show.html:58 msgid "User ID" msgstr "Account ID" -#: uffd/templates/user/show.html:54 +#: uffd/templates/user/show.html:66 msgid "will be choosen" msgstr "wird automatisch bestimmt" -#: uffd/templates/user/show.html:61 +#: uffd/templates/user/show.html:73 msgid "Service User" msgstr "Service-Account" -#: uffd/templates/user/show.html:69 +#: uffd/templates/user/show.html:81 msgid "" "Only letters, numbers, dashes (\"-\") and underscores (\"_\") are " "allowed. At most 32, at least 2 characters. There is a word blocklist. " @@ -1579,7 +1606,7 @@ msgstr "" "erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht " "erlaubter Namen. Muss einmalig sein." -#: uffd/templates/user/show.html:84 +#: uffd/templates/user/show.html:96 msgid "" "If you leave this empty it will be set to the login name. At most 128, at" " least 2 characters. No character restrictions." @@ -1587,7 +1614,7 @@ msgstr "" "Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, " "mindestens 2 Zeichen. Keine Zeichenbeschränkung." -#: uffd/templates/user/show.html:93 +#: uffd/templates/user/show.html:105 msgid "" "Make sure the address is correct! Services might use e-mail addresses as " "account identifiers and rely on them being unique and verified." @@ -1596,15 +1623,15 @@ msgstr "" " E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, " "dass diese verifiziert und einzigartig sind." -#: uffd/templates/user/show.html:102 +#: uffd/templates/user/show.html:114 msgid "Address" msgstr "Adresse" -#: uffd/templates/user/show.html:103 +#: uffd/templates/user/show.html:115 msgid "Verified" msgstr "Verifiziert" -#: uffd/templates/user/show.html:129 +#: uffd/templates/user/show.html:141 msgid "" "Make sure that addresses you add are correct! Services might use e-mail " "addresses as account identifiers and rely on them being unique and " @@ -1614,36 +1641,36 @@ msgstr "" "Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und " "verlassen sich darauf, dass diese verifiziert und einzigartig sind." -#: uffd/templates/user/show.html:133 +#: uffd/templates/user/show.html:145 msgid "Primary E-Mail Address" msgstr "Primäre E-Mail-Adresse" -#: uffd/templates/user/show.html:141 +#: uffd/templates/user/show.html:153 msgid "Recovery E-Mail Address" msgstr "Wiederherstellungs-E-Mail-Adresse" -#: uffd/templates/user/show.html:151 +#: uffd/templates/user/show.html:163 #, python-format msgid "Address for %(name)s" msgstr "Adresse für %(name)s" -#: uffd/templates/user/show.html:167 +#: uffd/templates/user/show.html:179 msgid "E-Mail to set it will be sent" msgstr "Mail zum Setzen wird versendet" -#: uffd/templates/user/show.html:178 +#: uffd/templates/user/show.html:190 msgid "Status:" msgstr "Status:" -#: uffd/templates/user/show.html:178 +#: uffd/templates/user/show.html:190 msgid "Enabled" msgstr "Aktiv" -#: uffd/templates/user/show.html:181 +#: uffd/templates/user/show.html:193 msgid "Reset 2FA" msgstr "2FA zurücksetzen" -#: uffd/templates/user/show.html:221 +#: uffd/templates/user/show.html:233 msgid "Resulting groups (only updated after save)" msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)" @@ -1785,8 +1812,8 @@ msgstr "" msgid "Two-factor authentication failed" msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen" -#: uffd/views/oauth2.py:167 uffd/views/selfservice.py:66 -#: uffd/views/session.py:72 +#: uffd/views/oauth2.py:172 uffd/views/selfservice.py:66 +#: uffd/views/session.py:67 #, python-format msgid "" "We received too many requests from your ip address/network! Please wait " @@ -1795,19 +1822,19 @@ msgstr "" "Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem " "Netzwerk empfangen! Bitte warte mindestens %(delay)s." -#: uffd/views/oauth2.py:175 +#: uffd/views/oauth2.py:180 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/views/oauth2.py:188 +#: uffd/views/oauth2.py:193 msgid "Device login failed" msgstr "Gerätelogin fehlgeschlagen" -#: uffd/views/oauth2.py:194 +#: uffd/views/oauth2.py:199 msgid "You need to login to access this service" msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können" -#: uffd/views/oauth2.py:201 +#: uffd/views/oauth2.py:206 #, python-format msgid "" "You don't have the permission to access the service " @@ -1933,7 +1960,7 @@ msgstr "Rolle %(role_name)s verlassen" msgid "Services" msgstr "Dienste" -#: uffd/views/session.py:70 +#: uffd/views/session.py:65 #, python-format msgid "" "We received too many invalid login attempts for this user! Please wait at" @@ -1942,27 +1969,34 @@ msgstr "" "Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account " "erhalten! Bitte warte mindestens %(delay)s." -#: uffd/views/session.py:78 +#: uffd/views/session.py:74 msgid "Login name or password is wrong" msgstr "Der Anmeldename oder das Passwort ist falsch" -#: uffd/views/session.py:84 +#: uffd/views/session.py:77 +#, python-format +msgid "Your account is deactivated. Contact %(contact_email)s for details." +msgstr "" +"Dein Account ist deaktiviert. Kontaktiere %(contact_email)s für weitere " +"Informationen." + +#: uffd/views/session.py:83 msgid "You do not have access to this service" msgstr "Du hast keinen Zugriff auf diesen Service" -#: uffd/views/session.py:96 uffd/views/session.py:107 +#: uffd/views/session.py:95 uffd/views/session.py:106 msgid "You need to login first" msgstr "Du musst dich erst anmelden" -#: uffd/views/session.py:128 uffd/views/session.py:138 +#: uffd/views/session.py:127 uffd/views/session.py:137 msgid "Initiation code is no longer valid" msgstr "Startcode ist nicht mehr gültig" -#: uffd/views/session.py:142 +#: uffd/views/session.py:141 msgid "Invalid confirmation code" msgstr "Ungültiger Bestätigungscode" -#: uffd/views/session.py:154 uffd/views/session.py:165 +#: uffd/views/session.py:153 uffd/views/session.py:164 msgid "Invalid initiation code" msgstr "Ungültiger Startcode" @@ -2019,7 +2053,15 @@ msgstr "Passwort ist ungültig" msgid "User updated" msgstr "Account aktualisiert" -#: uffd/views/user.py:156 +#: uffd/views/user.py:155 +msgid "User deactivated" +msgstr "Account deaktiviert" + +#: uffd/views/user.py:164 +msgid "User activated" +msgstr "Account aktiviert" + +#: uffd/views/user.py:174 msgid "Deleted user" msgstr "Account gelöscht" diff --git a/uffd/views/api.py b/uffd/views/api.py index cdab573d..d0be61d4 100644 --- a/uffd/views/api.py +++ b/uffd/views/api.py @@ -38,7 +38,11 @@ def generate_group_dict(group): return { 'id': group.unix_gid, 'name': group.name, - 'members': [user.loginname for user in group.members] + 'members': [ + user.loginname + for user in group.members + if not user.is_deactivated or not request.api_client.service.hide_deactivated_users + ] } @bp.route('/getgroups', methods=['GET', 'POST']) @@ -57,6 +61,8 @@ def getgroups(): query = query.filter(Group.name == values[0]) elif key == 'member' and len(values) == 1: query = query.join(Group.members).filter(User.loginname == values[0]) + if request.api_client.service.hide_deactivated_users: + query = query.filter(db.not_(User.is_deactivated)) else: abort(400) # Single-result queries perform better without eager loading @@ -81,6 +87,8 @@ def getusers(): key = (list(request.values.keys()) or [None])[0] values = request.values.getlist(key) query = ServiceUser.query.filter_by(service=request.api_client.service).join(ServiceUser.user) + if request.api_client.service.hide_deactivated_users: + query = query.filter(db.not_(User.is_deactivated)) if key is None: pass elif key == 'id' and len(values) == 1: @@ -117,6 +125,8 @@ def checkpassword(): if service_user is None or not service_user.user.password.verify(password): login_ratelimit.log(username) return jsonify(None) + if service_user.user.is_deactivated: + return jsonify(None) if service_user.user.password.needs_rehash: service_user.user.password = password db.session.commit() diff --git a/uffd/views/oauth2.py b/uffd/views/oauth2.py index 1d0df2d0..43d65fa6 100644 --- a/uffd/views/oauth2.py +++ b/uffd/views/oauth2.py @@ -85,6 +85,8 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): return False if oauthreq.grant.expired: return False + if oauthreq.grant.user.is_deactivated: + return False oauthreq.user = oauthreq.grant.user oauthreq.scopes = oauthreq.grant.scopes return True @@ -130,6 +132,9 @@ class UffdRequestValidator(oauthlib.oauth2.RequestValidator): if tok.expired: oauthreq.error_message = 'Token expired' return False + if tok.user.is_deactivated: + oauthreq.error_message = 'User deactivated' + return False if not set(scopes).issubset(tok.scopes): oauthreq.error_message = 'Scopes invalid' return False @@ -184,7 +189,7 @@ def authorize(): del session['devicelogin_id'] del session['devicelogin_secret'] del session['devicelogin_confirmation'] - if not initiation or initiation.expired or not confirmation: + if not initiation or initiation.expired or not confirmation or confirmation.user.is_deactivated: flash(_('Device login failed')) return redirect(url_for('session.login', ref=request.full_path, devicelogin=True)) credentials['user'] = confirmation.user diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py index 3459d01a..bbf062bf 100644 --- a/uffd/views/selfservice.py +++ b/uffd/views/selfservice.py @@ -68,7 +68,7 @@ def forgot_password(): reset_ratelimit.log(loginname+'/'+mail) host_ratelimit.log() flash(_("We sent a mail to this user's mail address if you entered the correct mail and login name combination")) - user = User.query.filter_by(loginname=loginname).one_or_none() + user = User.query.filter_by(loginname=loginname, is_deactivated=False).one_or_none() if not user: return redirect(url_for('session.login')) matches = any(map(lambda email: secrets.compare_digest(email.address, mail), user.verified_emails)) diff --git a/uffd/views/service.py b/uffd/views/service.py index 37fa1f67..374180ca 100644 --- a/uffd/views/service.py +++ b/uffd/views/service.py @@ -80,6 +80,7 @@ def edit_submit(id=None): else: service.limit_access = True service.access_group = Group.query.get(request.form['access-group']) + service.hide_deactivated_users = request.form.get('hide_deactivated_users') == '1' service.enable_email_preferences = request.form.get('enable_email_preferences') == '1' service.remailer_mode = RemailerMode[request.form['remailer-mode']] remailer_overwrite_mode = RemailerMode[request.form['remailer-overwrite-mode']] diff --git a/uffd/views/session.py b/uffd/views/session.py index 9e45c210..df3301af 100644 --- a/uffd/views/session.py +++ b/uffd/views/session.py @@ -25,18 +25,12 @@ def set_request_user(): if datetime.datetime.utcnow().timestamp() > session['logintime'] + current_app.config['SESSION_LIFETIME_SECONDS']: return user = User.query.get(session['user_id']) - if not user or not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']): + if not user or user.is_deactivated or not user.is_in_group(current_app.config['ACL_ACCESS_GROUP']): return request.user_pre_mfa = user if session.get('user_mfa'): request.user = user -def login_get_user(loginname, password): - user = User.query.filter_by(loginname=loginname).one_or_none() - if user is None or not user.password.verify(password): - return None - return user - @bp.route("/logout") def logout(): # The oauth2 module takes data from `session` and injects it into the url, @@ -56,6 +50,7 @@ def set_session(user, skip_mfa=False): @bp.route("/login", methods=('GET', 'POST')) def login(): + # pylint: disable=too-many-return-statements if request.user_pre_mfa: return redirect(url_for('mfa.auth', ref=request.values.get('ref', url_for('index')))) if request.method == 'GET': @@ -71,12 +66,16 @@ def login(): else: flash(_('We received too many requests from your ip address/network! Please wait at least %(delay)s.', delay=format_delay(host_delay))) return render_template('session/login.html', ref=request.values.get('ref')) - user = login_get_user(username, password) - if user is None: + + user = User.query.filter_by(loginname=username).one_or_none() + if user is None or not user.password.verify(password): login_ratelimit.log(username) host_ratelimit.log() flash(_('Login name or password is wrong')) return render_template('session/login.html', ref=request.values.get('ref')) + if user.is_deactivated: + flash(_('Your account is deactivated. Contact %(contact_email)s for details.', contact_email=current_app.config['ORGANISATION_CONTACT'])) + return render_template('session/login.html', ref=request.values.get('ref')) if user.password.needs_rehash: user.password = password db.session.commit() diff --git a/uffd/views/user.py b/uffd/views/user.py index 04fb953b..51d4d64e 100644 --- a/uffd/views/user.py +++ b/uffd/views/user.py @@ -146,6 +146,24 @@ def update(id): flash(_('User updated')) return redirect(url_for('user.show', id=user.id)) +@bp.route('/<int:id>/deactivate') +@csrf_protect(blueprint=bp) +def deactivate(id): + user = User.query.get_or_404(id) + user.is_deactivated = True + db.session.commit() + flash(_('User deactivated')) + return redirect(url_for('user.show', id=user.id)) + +@bp.route('/<int:id>/activate') +@csrf_protect(blueprint=bp) +def activate(id): + user = User.query.get_or_404(id) + user.is_deactivated = False + db.session.commit() + flash(_('User activated')) + return redirect(url_for('user.show', id=user.id)) + @bp.route("/<int:id>/del") @csrf_protect(blueprint=bp) def delete(id): -- GitLab