diff --git a/tests/commands/test_user.py b/tests/commands/test_user.py index 3612fc3ba5ec215443a4ca5de225129080cfa635..3fcf49e85a75765e4260d26348571bd0564e1649 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 fbbf8ab7896fc41e0b54f7825346a3657053a9fe..31fb104a4063a51c3100c2469380e6648ec9854f 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 fce8554b4095cbe8ab1779bb59b04cf956fa7d9f..996bb1be9b839d6c730338182e02a1bcb7c7a9d2 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 72c9ff7f6d2b7a0cc0e898109dc46fe988fd3a3e..6d26baf6b5f1f910df17cbe9a04a3011eae1a169 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 1b598aa92ac57e06b9be460e01821e89a40868f5..b5b202414faa3ba2be6825e21e74574c5b2544ee 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 a40e447f07702651163a20429bf80d2ef20a082c..51e296a4820fe7a9c52f4d98f5ee2d1cf0fc565c 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 0cb5c340688eed0a6d2eab8b2f67453f6e2268eb..1780ac87fa4ed8e49f61e5ae597d25492968d948 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 72f80488fb5ad6b58662f330ab66ca8c8a331a40..7db3b7fe7b7f4a91cbbf55827e8e332ab769acaa 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 a024b53d6c79322c427108feda3953be778af98c..6bffa84d0dbae6b62799d492546fc0f4367bc167 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 2db24b3fe83cacda6207d4b538ff5f717561b88b..68aa675faa3588dcc2c6b65fa536b0b7a9b4fa39 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 0000000000000000000000000000000000000000..c25dbf6cbc1045a0f398f0a2730da096c466806e --- /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 27c1db2bbd15a4a4a432908b14cdd984c97c26bd..ef8acc36f7f377c2a814f76882088ab37c8f47aa 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 ff31419bc276b07850ce5c619f4e4f6c24380668..1817954c7a0deab79adc915be6ac30151cea50e9 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 e3fa2f6a9116622f74cb7356d87d9a6685de784c..2a9339079f0c9b864e40fd214ea619323a83ee97 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 3ee88d6908541abcc1e7ab5f0e0ec794b9d036b0..282bbb7c186968fcdbbdaa0afddf7d45a48db961 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 08d03ba01e0607d68e7ebcac95a29b3b528581c1..08e0629febbba15399c509cf64c895b8660911b6 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 63748d0f3a1971bb008741aafc36730bf6e4633c..6e7d08a2bfc736013e0dee2dd211b2b077ea18d6 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 7a4f265d47fd940ef7067c56be836cf2f8a9ad13..04b2cf359c000a995d3d8687ecd3940aed0e86a2 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 Binary files a/uffd/translations/de/LC_MESSAGES/messages.mo and b/uffd/translations/de/LC_MESSAGES/messages.mo differ diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po index 667d221f4a330cd57d28e0d83f184245fa7470c5..4f4d621c0e7c7c28629c69dc0a1e8584c4bb0278 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 cdab573db85fce5e919682008f01ebaddce9e662..d0be61d4c731842edd6167cbde475fe3bd25dc2e 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 1d0df2d02aa82f2b1b812eccb2ac859ad11b1e6e..43d65fa61e5b6b1dd8d253b3a6e53c1bda065c11 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 3459d01adafaa0f1ce5fd43cc91d022e0011ced3..bbf062bfaf3bce1529d1433102a6041d573cb6b3 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 37fa1f67bedf10adab5c505cec021b117ed85737..374180cae6732000954ae61e6c8c1751b4747c07 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 9e45c210220a110de38d83a91e06e9a95de7d814..df3301af7b2982069635f5e3646eb550d44c1c86 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 04fb953bb333aa3c3fdc0a8b3979aa87c305795b..51d4d64e7f734f2e8f0c4acebcbc800dea50c2f4 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):