From 2ea835ec9ffb0010abde3d719f9356dd111d934b Mon Sep 17 00:00:00 2001 From: Matt Wright Date: Fri, 29 Jun 2012 12:37:22 -0400 Subject: [PATCH] Add a bunch of doc strings and add some more configuration values --- flask_security/confirmable.py | 28 ++++++++++++- flask_security/core.py | 75 ++++++++++++++++++++--------------- flask_security/datastore.py | 10 +++-- flask_security/decorators.py | 9 +++-- flask_security/recoverable.py | 29 ++++++++++++++ flask_security/tokens.py | 17 ++++++++ flask_security/utils.py | 14 +++---- flask_security/views.py | 40 ++++++------------- tests/functional_tests.py | 2 +- 9 files changed, 148 insertions(+), 76 deletions(-) diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index 054a2b9..a160d21 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -26,12 +26,20 @@ _datastore = LocalProxy(lambda: app.security.datastore) def find_user_by_confirmation_token(token): + """Returns a user with a matching confirmation token. + + :param token: The reset password token + """ if not token: raise ConfirmationError('Confirmation token required') return _datastore.find_user(confirmation_token=token) def send_confirmation_instructions(user): + """Sends the confirmation instructions email for the specified user. + + :param user: The user to send the instructions to + """ url = url_for('flask_security.confirm', confirmation_token=user.confirmation_token) @@ -47,6 +55,10 @@ def send_confirmation_instructions(user): def generate_confirmation_token(user): + """Generates a unique confirmation token for the specified user. + + :param user: The user to work with + """ while True: token = generate_token() try: @@ -67,8 +79,9 @@ def generate_confirmation_token(user): def should_confirm_email(fn): + """Handy decorator that returns early if confirmation should not occur.""" def wrapped(*args, **kwargs): - if _security.confirm_email: + if _security.confirmable: return fn(*args, **kwargs) return False return wrapped @@ -76,16 +89,24 @@ def should_confirm_email(fn): @should_confirm_email def requires_confirmation(user): + """Returns `True` if the user requires confirmation.""" return user.confirmed_at == None @should_confirm_email def confirmation_token_is_expired(user): + """Returns `True` if the user's confirmation token is expired.""" token_expires = datetime.utcnow() - _security.confirm_email_within return user.confirmation_sent_at < token_expires def confirm_by_token(token): + """Confirm the user given the specified token. If the token is invalid or + the user is already confirmed a `ConfirmationError` error will be raised. + If the token is expired a `TokenExpiredError` error will be raised. + + :param token: The user's confirmation token + """ try: user = find_user_by_confirmation_token(token) except UserNotFoundError: @@ -110,5 +131,10 @@ def confirm_by_token(token): def reset_confirmation_token(user): + """Resets the specified user's confirmation token and sends the user + an email with instructions explaining next steps. + + :param user: The user to work with + """ _datastore._save_model(generate_confirmation_token(user)) send_confirmation_instructions(user) diff --git a/flask_security/core.py b/flask_security/core.py index bee1355..4f5af9f 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -49,7 +49,9 @@ _default_config = { 'POST_REGISTER_VIEW': None, 'POST_CONFIRM_VIEW': None, 'DEFAULT_ROLES': [], - 'CONFIRM_EMAIL': False, + 'CONFIRMABLE': False, + 'REGISTERABLE': True, + 'RECOVERABLE': True, 'CONFIRM_EMAIL_WITHIN': '5 days', 'RESET_PASSWORD_WITHIN': '2 days', 'LOGIN_WITHOUT_CONFIRMATION': False, @@ -90,6 +92,8 @@ class UserMixin(BaseUserMixin): class AnonymousUser(AnonymousUserBase): + """AnonymousUser definition""" + def __init__(self): super(AnonymousUser, self).__init__() self.roles = ImmutableList() @@ -99,7 +103,7 @@ class AnonymousUser(AnonymousUserBase): return False -def load_user(user_id): +def _load_user(user_id): try: return current_app.security.datastore.with_id(user_id) except Exception, e: @@ -107,7 +111,7 @@ def load_user(user_id): return None -def on_identity_loaded(sender, identity): +def _on_identity_loaded(sender, identity): if hasattr(current_user, 'id'): identity.provides.add(UserNeed(current_user.id)) @@ -126,12 +130,16 @@ class Security(object): def __init__(self, app=None, datastore=None, **kwargs): self.init_app(app, datastore, **kwargs) - def init_app(self, app, datastore, registerable=True, recoverable=True): + def init_app(self, app, datastore): """Initializes the Flask-Security extension for the specified application and datastore implentation. :param app: The application. :param datastore: An instance of a user datastore. + :param confirmable: Set to `True` to enable email confirmation + :param registerable: Set to `False` to disable registration endpoints + :param recoverable: Set to `False` to disable password recovery + endpoints """ if app is None or datastore is None: return @@ -142,7 +150,7 @@ class Security(object): login_manager = LoginManager() login_manager.anonymous_user = AnonymousUser login_manager.login_view = utils.config_value(app, 'LOGIN_VIEW') - login_manager.user_loader(load_user) + login_manager.user_loader(_load_user) login_manager.init_app(app) Provider = utils.get_class_from_string(app, 'AUTH_PROVIDER') @@ -150,7 +158,7 @@ class Security(object): self.login_manager = login_manager self.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash) - self.auth_provider = Provider(Form) + self.auth_provider = Provider() self.principal = Principal(app) self.datastore = datastore self.LoginForm = utils.get_class_from_string(app, 'LOGIN_FORM') @@ -171,7 +179,9 @@ class Security(object): self.reset_password_error_view = utils.config_value(app, 'RESET_PASSWORD_ERROR_VIEW') self.default_roles = utils.config_value(app, "DEFAULT_ROLES") self.login_without_confirmation = utils.config_value(app, 'LOGIN_WITHOUT_CONFIRMATION') - self.confirm_email = utils.config_value(app, 'CONFIRM_EMAIL') + self.confirmable = utils.config_value(app, 'CONFIRMABLE') + self.registerable = utils.config_value(app, 'REGISTERABLE') + self.recoverable = utils.config_value(app, 'RECOVERABLE') self.email_sender = utils.config_value(app, 'EMAIL_SENDER') self.token_authentication_key = utils.config_value(app, 'TOKEN_AUTHENTICATION_KEY') self.token_authentication_header = utils.config_value(app, 'TOKEN_AUTHENTICATION_HEADER') @@ -184,7 +194,7 @@ class Security(object): values = self.reset_password_within_text.split() self.reset_password_within = timedelta(**{values[1]: int(values[0])}) - identity_loaded.connect_via(app)(on_identity_loaded) + identity_loaded.connect_via(app)(_on_identity_loaded) bp = Blueprint('flask_security', __name__, template_folder='templates') @@ -195,21 +205,21 @@ class Security(object): bp.route(self.logout_url, endpoint='logout')(login_required(views.logout)) - self.setup_registerable(bp) if registerable else None - self.setup_recoverable(bp) if recoverable else None - self.setup_confirmable(bp) if self.confirm_email else None + self._setup_registerable(bp) if self.registerable else None + self._setup_recoverable(bp) if self.recoverable else None + self._setup_confirmable(bp) if self.confirmable else None app.register_blueprint(bp, url_prefix=utils.config_value(app, 'URL_PREFIX')) app.security = self - def setup_registerable(self, bp): + def _setup_registerable(self, bp): bp.route(self.register_url, methods=['POST'], endpoint='register')(views.register) - def setup_recoverable(self, bp): + def _setup_recoverable(self, bp): bp.route(self.forgot_url, methods=['POST'], endpoint='forgot')(views.forgot) @@ -217,32 +227,30 @@ class Security(object): methods=['POST'], endpoint='reset')(views.reset) - def setup_confirmable(self, bp): + def _setup_confirmable(self, bp): bp.route(self.confirm_url, endpoint='confirm')(views.confirm) class AuthenticationProvider(object): - """The default authentication provider implementation. + """The default authentication provider implementation.""" + def _get_user(self, username_or_email): + datastore = current_app.security.datastore - :param login_form_class: The login form class to use when authenticating a - user - """ - - def __init__(self, login_form_class=None): - self.login_form_class = login_form_class or LoginForm - - def login_form(self, formdata=None): - """Returns an instance of the login form with the provided form. - - :param formdata: The incoming form data""" - return self.login_form_class(formdata) + try: + return datastore.find_user(email=username_or_email) + except exceptions.UserNotFoundError: + try: + return datastore.find_user(username=username_or_email) + except: + raise exceptions.UserNotFoundError() def authenticate(self, form): """Processes an authentication request and returns a user instance if authentication is successful. - :param form: An instance of a populated login form + :param form: A populated WTForm instance that contains `email` and + `password` form fields """ if not form.validate(): if form.email.errors: @@ -252,15 +260,16 @@ class AuthenticationProvider(object): return self.do_authenticate(form.email.data, form.password.data) - def do_authenticate(self, email, password): + def do_authenticate(self, username_or_email, password): """Returns the authenticated user if authentication is successfull. If - authentication fails an appropriate error is raised + authentication fails an appropriate `AuthenticationError` is raised - :param user_identifier: The user's identifier, usuall an email address - :param password: The user's unencrypted password + :param username_or_email: The username or email address of the user + :param password: The password supplied by the authentication request """ + try: - user = current_app.security.datastore.find_user(email=email) + user = self._get_user(username_or_email) except AttributeError, e: self.auth_error("Could not find user datastore: %s" % e) except exceptions.UserNotFoundError, e: diff --git a/flask_security/datastore.py b/flask_security/datastore.py index c0ef5a8..90d214a 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -90,7 +90,7 @@ class UserDatastore(object): kwargs.setdefault('active', True) kwargs.setdefault('roles', current_app.security.default_roles) - if current_app.security.confirm_email: + if current_app.security.confirmable: confirmable.generate_confirmation_token(kwargs) if email is None: @@ -196,7 +196,9 @@ class UserDatastore(object): class SQLAlchemyUserDatastore(UserDatastore): - """A SQLAlchemy datastore implementation for Flask-Security. + """A SQLAlchemy datastore implementation for Flask-Security that assumes the + use of the Flask-SQLAlchemy extension. + Example usage:: from flask import Flask @@ -249,7 +251,9 @@ class SQLAlchemyUserDatastore(UserDatastore): class MongoEngineUserDatastore(UserDatastore): - """A MongoEngine datastore implementation for Flask-Security. + """A MongoEngine datastore implementation for Flask-Security that assumes + the use of the Flask-MongoEngine extension. + Example usage:: from flask import Flask diff --git a/flask_security/decorators.py b/flask_security/decorators.py index 63ef946..f7f05f8 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -55,6 +55,7 @@ def _check_http_auth(): def http_auth_required(fn): + """Decorator that protects endpoints using Basic HTTP authentication.""" headers = {'WWW-Authenticate': 'Basic realm="Login Required"'} @wraps(fn) @@ -68,7 +69,7 @@ def http_auth_required(fn): def auth_token_required(fn): - + """Decorator that protects endpoints using token authentication.""" @wraps(fn) def decorated(*args, **kwargs): if _check_token(): @@ -80,8 +81,8 @@ def auth_token_required(fn): def roles_required(*roles): - """View decorator which specifies that a user must have all the specified - roles. Example:: + """Decorator which specifies that a user must have all the specified roles. + Example:: @app.route('/dashboard') @roles_required('admin', 'editor') @@ -113,7 +114,7 @@ def roles_required(*roles): def roles_accepted(*roles): - """View decorator which specifies that a user must have at least one of the + """Decorator which specifies that a user must have at least one of the specified roles. Example:: @app.route('/create_post') diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index a53144a..617cce9 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -28,12 +28,20 @@ _datastore = LocalProxy(lambda: app.security.datastore) def find_user_by_reset_token(token): + """Returns a user with a matching reset password token. + + :param token: The reset password token + """ if not token: raise ResetPasswordError('Reset password token required') return _datastore.find_user(reset_password_token=token) def send_reset_password_instructions(user): + """Sends the reset password instructions email for the specified user. + + :param user: The user to send the instructions to + """ url = url_for('flask_security.reset', email=user.email, reset_token=user.reset_password_token) @@ -51,6 +59,10 @@ def send_reset_password_instructions(user): def generate_reset_password_token(user): + """Generates a unique reset password token for the specified user. + + :param user: The user to work with + """ while True: token = generate_token() try: @@ -71,11 +83,23 @@ def generate_reset_password_token(user): def password_reset_token_is_expired(user): + """Returns `True` if the specified user's reset password token is expired. + + :param user: The user to examine + """ token_expires = datetime.utcnow() - _security.reset_password_within return user.reset_password_sent_at < token_expires def reset_by_token(token, email, password): + """Resets the password of the user given the specified token, email and + password. If the token is invalid a `ResetPasswordError` error will be + raised. If the token is expired a `TokenExpiredError` error will be raised. + + :param token: The user's reset password token + :param email: The user's email address + :param password: The user's new password + """ try: user = find_user_by_reset_token(token) except UserNotFoundError: @@ -98,6 +122,11 @@ def reset_by_token(token, email, password): def reset_password_reset_token(user): + """Resets the specified user's reset password token and sends the user + an email with instructions explaining next steps. + + :param user: The user to work with + """ _datastore._save_model(generate_reset_password_token(user)) send_reset_password_instructions(user) password_reset_requested.send(user, app=app._get_current_object()) diff --git a/flask_security/tokens.py b/flask_security/tokens.py index f68b98e..081bf2a 100644 --- a/flask_security/tokens.py +++ b/flask_security/tokens.py @@ -23,12 +23,20 @@ _datastore = LocalProxy(lambda: app.security.datastore) def find_user_by_authentication_token(token): + """Returns a user with a matching authentication token. + + :param token: The authentication token + """ if not token: raise BadCredentialsError('Authentication token required') return _datastore.find_user(authentication_token=token) def generate_authentication_token(user): + """Generates a unique authentication token for the specified user. + + :param user: The user to work with + """ while True: token = generate_token() try: @@ -49,12 +57,21 @@ def generate_authentication_token(user): def reset_authentication_token(user): + """Resets a user's authentication token and returns the new token value. + + :param user: The user to work with + """ user = generate_authentication_token(user) _datastore._save_model(user) return user.authentication_token def ensure_authentication_token(user): + """Ensures that a user has an authentication token. If the user has an + authentication token already, nothing is performed. + + :param user: The user to work with + """ if not user.authentication_token: reset_authentication_token(user) return user.authentication_token diff --git a/flask_security/utils.py b/flask_security/utils.py index 96a03a2..77abb1f 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -20,7 +20,7 @@ from .signals import user_registered, password_reset_requested def generate_token(): - """Generate an arbitrary URL safe token""" + """Generate an arbitrary URL safe token.""" return base64.urlsafe_b64encode(os.urandom(30)) @@ -59,14 +59,14 @@ def get_url(endpoint_or_url): def get_post_login_redirect(): - """Returns the URL to redirect to after a user logs in successfully""" + """Returns the URL to redirect to after a user logs in successfully.""" return (get_url(request.args.get('next')) or get_url(request.form.get('next')) or find_redirect('SECURITY_POST_LOGIN_VIEW')) def find_redirect(key): - """Returns the URL to redirect to after a user logs in successfully + """Returns the URL to redirect to after a user logs in successfully. :param key: The session or application configuration key to search for """ @@ -79,7 +79,7 @@ def find_redirect(key): def config_value(app, key, default=None): - """Get a Flask-Security configuration value + """Get a Flask-Security configuration value. :param app: The application to retrieve the configuration from :param key: The configuration key without the prefix `SECURITY_` @@ -89,7 +89,7 @@ def config_value(app, key, default=None): def send_mail(subject, recipient, template, context=None): - """Send an email via the Flask-Mail extension + """Send an email via the Flask-Mail extension. :param subject: Email subject :param recipient: Email recipient @@ -113,7 +113,7 @@ def send_mail(subject, recipient, template, context=None): @contextmanager def capture_registrations(confirmation_sent_at=None): - """Testing utility for capturing registrations + """Testing utility for capturing registrations. :param confirmation_sent_at: An optional datetime object to set the user's `confirmation_sent_at` to @@ -137,7 +137,7 @@ def capture_registrations(confirmation_sent_at=None): @contextmanager def capture_reset_password_requests(reset_password_sent_at=None): - """Testing utility for capturing password reset requests + """Testing utility for capturing password reset requests. :param reset_password_sent_at: An optional datetime object to set the user's `reset_password_sent_at` to diff --git a/flask_security/views.py b/flask_security/views.py index 1efe707..425955a 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -34,6 +34,8 @@ _logger = LocalProxy(lambda: app.logger) def _do_login(user, remember=True): + """Performs the login and sends the appropriate signal.""" + if login_user(user, remember): identity_changed.send(app._get_current_object(), identity=Identity(user.id)) @@ -44,13 +46,8 @@ def _do_login(user, remember=True): def authenticate(): - """View function which handles an authentication attempt. If authentication - is successful the user is redirected to, if set, the value of the `next` - form parameter. If that value is not set the user is redirected to the - value of the `SECURITY_POST_LOGIN_VIEW` configuration value. If - authenticate fails the user an appropriate error message is flashed and - the user is redirected to the referring page or the login view. - """ + """View function which handles an authentication request.""" + form = _security.LoginForm() try: @@ -74,10 +71,8 @@ def authenticate(): def logout(): - """View function which logs out the current user. When completed the user - is redirected to the value of the `next` query string parameter or the - `SECURITY_POST_LOGIN_VIEW` configuration value. - """ + """View function which handles a logout request.""" + for key in ('identity.name', 'identity.auth_type'): session.pop(key, None) @@ -92,13 +87,8 @@ def logout(): def register(): - """View function which registers a new user and, if configured so, the user - isautomatically logged in. If required confirmation instructions are sent - via email. After registration is completed the user is redirected to, if - set, the value of the `SECURITY_POST_REGISTER_VIEW` configuration value. - Otherwise the user is redirected to the `SECURITY_POST_LOGIN_VIEW` - configuration value. - """ + """View function which handles a registration request.""" + form = _security.RegisterForm(csrf_enabled=not app.testing) # Exit early if the form doesn't validate @@ -109,13 +99,13 @@ def register(): user_registered.send(user, app=app._get_current_object()) # Send confirmation instructions if necessary - if _security.confirm_email: + if _security.confirmable: send_confirmation_instructions(user) _logger.debug('User %s registered' % user) # Login the user if allowed - if not _security.confirm_email or _security.login_without_confirmation: + if not _security.confirmable or _security.login_without_confirmation: _do_login(user) return redirect(_security.post_register_view or @@ -126,9 +116,7 @@ def register(): def confirm(): - """View function which confirms a user's email address using a token taken - from the value of the `confirmation_token` query string argument. - """ + """View function which handles a account confirmation request.""" try: token = request.args.get('confirmation_token', None) @@ -156,8 +144,7 @@ def confirm(): def forgot(): - """View function that handles the generation of a password reset token. - """ + """View function that handles a forgotten password request.""" form = _security.ForgotPasswordForm(csrf_enabled=not app.testing) @@ -180,8 +167,7 @@ def forgot(): def reset(): - """View function that handles the reset of a user's password. - """ + """View function that handles a reset password request.""" form = _security.ResetPasswordForm(csrf_enabled=not app.testing) diff --git a/tests/functional_tests.py b/tests/functional_tests.py index b3100ab..89a097d 100644 --- a/tests/functional_tests.py +++ b/tests/functional_tests.py @@ -142,7 +142,7 @@ class RegisterableTests(SecurityTest): class ConfirmableTests(SecurityTest): AUTH_CONFIG = { - 'SECURITY_CONFIRM_EMAIL': True + 'SECURITY_CONFIRMABLE': True } def test_register_sends_confirmation_email(self):