From c32e565f685cde695c45d89e0f45a1a0512c26f8 Mon Sep 17 00:00:00 2001 From: Max Erenberg Date: Mon, 2 Aug 2021 08:01:13 +0000 Subject: [PATCH] implement renewals and password resets --- ceo_common/errors.py | 4 ++ ceod/api/members.py | 26 +++++++++++- ceod/model/User.py | 8 +++- ceod/model/validators.py | 6 +++ ceod/transactions/AbstractTransaction.py | 9 ++-- .../members/AddMemberTransaction.py | 8 +--- .../members/RenewMemberTransaction.py | 42 +++++++++++++++++++ .../members/ResetPasswordTransaction.py | 27 ++++++++++++ ceod/transactions/members/__init__.py | 2 + ceod/transactions/members/utils.py | 7 ++++ 10 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 ceod/transactions/members/RenewMemberTransaction.py create mode 100644 ceod/transactions/members/ResetPasswordTransaction.py create mode 100644 ceod/transactions/members/utils.py diff --git a/ceo_common/errors.py b/ceo_common/errors.py index b1a1e36..03a86a2 100644 --- a/ceo_common/errors.py +++ b/ceo_common/errors.py @@ -4,3 +4,7 @@ class UserNotFoundError(Exception): class GroupNotFoundError(Exception): pass + + +class BadRequest(Exception): + pass diff --git a/ceod/api/members.py b/ceod/api/members.py index e4d1287..ef9d56f 100644 --- a/ceod/api/members.py +++ b/ceod/api/members.py @@ -1,13 +1,16 @@ from flask import Blueprint, request from zope import component -from .utils import authz_restrict_to_staff, user_is_in_group, \ - requires_authentication_no_realm, create_streaming_response +from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \ + user_is_in_group, requires_authentication_no_realm, \ + create_streaming_response from ceo_common.errors import UserNotFoundError from ceo_common.interfaces import ILDAPService from ceod.transactions.members import ( AddMemberTransaction, ModifyMemberTransaction, + RenewMemberTransaction, + ResetPasswordTransaction, ) bp = Blueprint('members', __name__) @@ -57,3 +60,22 @@ def patch_user(auth_user: str, username: str): forwarding_addresses=body.get('forwarding_addresses'), ) return create_streaming_response(txn) + + +@bp.route('//renew', methods=['POST']) +@authz_restrict_to_staff +def renew_user(username: str): + body = request.get_json(force=True) + txn = RenewMemberTransaction( + username, + terms=body.get('terms'), + non_member_terms=body.get('non_member_terms'), + ) + return create_streaming_response(txn) + + +@bp.route('//pwreset', methods=['POST']) +@authz_restrict_to_syscom +def reset_user_password(username: str): + txn = ResetPasswordTransaction(username) + return create_streaming_response(txn) diff --git a/ceod/model/User.py b/ceod/model/User.py index 93a4512..5fb5ab8 100644 --- a/ceod/model/User.py +++ b/ceod/model/User.py @@ -7,7 +7,7 @@ from zope import component from zope.interface import implementer from .utils import strings_to_bytes, bytes_to_strings -from .validators import is_valid_shell +from .validators import is_valid_shell, is_valid_term from ceo_common.interfaces import ILDAPService, IKerberosService, IFileService, \ IUser, IConfig, IMailmanService @@ -178,6 +178,9 @@ class User: self.ldap_srv.modify_user(self, new_user) def add_terms(self, terms: List[str]): + for term in terms: + if not is_valid_term(term): + raise Exception('%s is not a valid term' % term) new_user = copy.copy(self) new_user.terms = self.terms.copy() new_user.terms.extend(terms) @@ -185,6 +188,9 @@ class User: self.terms = new_user.terms def add_non_member_terms(self, terms: List[str]): + for term in terms: + if not is_valid_term(term): + raise Exception('%s is not a valid term' % term) new_user = copy.copy(self) new_user.non_member_terms = self.non_member_terms.copy() new_user.non_member_terms.extend(terms) diff --git a/ceod/model/validators.py b/ceod/model/validators.py index a59b602..46bb27f 100644 --- a/ceod/model/validators.py +++ b/ceod/model/validators.py @@ -58,3 +58,9 @@ def is_valid_shell(shell: str) -> bool: line.strip() for line in open('/etc/shells') if line != '' and not line.isspace() ] + + +def is_valid_term(term: str) -> bool: + return len(term) == 5 and \ + term[0] in ['s', 'f', 'w'] and \ + term[1:5].isdigit() diff --git a/ceod/transactions/AbstractTransaction.py b/ceod/transactions/AbstractTransaction.py index aae7a42..04fc83c 100644 --- a/ceod/transactions/AbstractTransaction.py +++ b/ceod/transactions/AbstractTransaction.py @@ -41,7 +41,10 @@ class AbstractTransaction(ABC): for _ in self.execute_iter(): pass - @abstractmethod def rollback(self): - """Roll back the transaction, when it fails.""" - raise NotImplementedError() + """ + Roll back the transaction, when it fails. + If the transaction only has one operation, then there is no need + to implement this. + """ + pass diff --git a/ceod/transactions/members/AddMemberTransaction.py b/ceod/transactions/members/AddMemberTransaction.py index 36d769d..758851b 100644 --- a/ceod/transactions/members/AddMemberTransaction.py +++ b/ceod/transactions/members/AddMemberTransaction.py @@ -1,11 +1,10 @@ -import base64 -import os import traceback from typing import Union, List from zope import component from ..AbstractTransaction import AbstractTransaction +from .utils import gen_password from ceo_common.interfaces import IConfig, IMailService from ceo_common.logger_factory import logger_factory from ceod.model import User, Group @@ -13,11 +12,6 @@ from ceod.model import User, Group logger = logger_factory(__name__) -def gen_password() -> str: - """Generate a temporary password.""" - return base64.b64encode(os.urandom(18)).decode() - - class AddMemberTransaction(AbstractTransaction): """Transaction to add a new club member.""" diff --git a/ceod/transactions/members/RenewMemberTransaction.py b/ceod/transactions/members/RenewMemberTransaction.py new file mode 100644 index 0000000..ba37f02 --- /dev/null +++ b/ceod/transactions/members/RenewMemberTransaction.py @@ -0,0 +1,42 @@ +from typing import Union, List + +from zope import component + +from ..AbstractTransaction import AbstractTransaction +from ceo_common.errors import BadRequest +from ceo_common.interfaces import ILDAPService + + +class RenewMemberTransaction(AbstractTransaction): + """Transaction to renew a user's terms or non-member terms.""" + + operations = [ + 'add_terms', + 'add_non_member_terms', + ] + + def __init__( + self, + username: str, + terms: Union[List[str], None], + non_member_terms: Union[List[str], None], + ): + super().__init__() + self.username = username + if (terms and non_member_terms) or not (terms or non_member_terms): + raise BadRequest('Must specify either terms or non-member terms') + self.terms = terms + self.non_member_terms = non_member_terms + self.ldap_srv = component.getUtility(ILDAPService) + + def child_execute_iter(self): + user = self.ldap_srv.get_user(self.username) + + if self.terms: + user.add_terms(self.terms) + yield 'add_terms' + elif self.non_member_terms: + user.add_non_member_terms(self.non_member_terms) + yield 'add_non_member_terms' + + self.finish('OK') diff --git a/ceod/transactions/members/ResetPasswordTransaction.py b/ceod/transactions/members/ResetPasswordTransaction.py new file mode 100644 index 0000000..5986663 --- /dev/null +++ b/ceod/transactions/members/ResetPasswordTransaction.py @@ -0,0 +1,27 @@ +from zope import component + +from ..AbstractTransaction import AbstractTransaction +from .utils import gen_password +from ceo_common.interfaces import ILDAPService + + +class ResetPasswordTransaction(AbstractTransaction): + """Transaction to reset a user's password.""" + + operations = [ + 'change_password', + ] + + def __init__(self, username: str): + super().__init__() + self.username = username + self.ldap_srv = component.getUtility(ILDAPService) + + def child_execute_iter(self): + user = self.ldap_srv.get_user(self.username) + + password = gen_password() + user.change_password(password) + yield 'change_password' + + self.finish({'password': password}) diff --git a/ceod/transactions/members/__init__.py b/ceod/transactions/members/__init__.py index da8a9cd..e02005a 100644 --- a/ceod/transactions/members/__init__.py +++ b/ceod/transactions/members/__init__.py @@ -1,2 +1,4 @@ from .AddMemberTransaction import AddMemberTransaction from .ModifyMemberTransaction import ModifyMemberTransaction +from .RenewMemberTransaction import RenewMemberTransaction +from .ResetPasswordTransaction import ResetPasswordTransaction diff --git a/ceod/transactions/members/utils.py b/ceod/transactions/members/utils.py new file mode 100644 index 0000000..bd4a57d --- /dev/null +++ b/ceod/transactions/members/utils.py @@ -0,0 +1,7 @@ +import base64 +import os + + +def gen_password() -> str: + """Generate a temporary password.""" + return base64.b64encode(os.urandom(18)).decode()