Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve CLI #354

Merged
merged 1 commit into from
Jun 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Features
not making sense - the documentation has never been correct.
- (:pr:`339`) Require ``fs_uniquifier`` in the UserModel and stop using/referencing the UserModel
primary key.
- (:pr:`xxx`) Change ``USER_IDENTITY_ATTRIBUTES`` configuration variable semantics.
- (:pr:`349`) Change ``USER_IDENTITY_ATTRIBUTES`` configuration variable semantics.

Fixed
+++++
Expand Down Expand Up @@ -56,19 +56,17 @@ Backwards Compatibility Concerns
interprets or uses the UserModel primary key - just the ``fs_uniquifier`` field. See the changes section for 3.3
for information on how to do the schema and data upgrades required to add this field.

- (:pr:`xxx`) :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES` has changed syntax and semantics. It now contains
- (:pr:`349`) :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES` has changed syntax and semantics. It now contains
the combined information from the old USER_IDENTITY_ATTRIBUTES and the newly introduced in 3.4 :py:data:`SECURITY_USER_IDENTITY_MAPPINGS`.
This enabled changing the underlying way we validate credentials in the login form and unified sign in form.
In prior releases we simply tried to look up the form value as the PK of the UserModel - this often failed and then
looped through the other ``USER_IDENTITY_ATTRIBUTES``. This had a history of issues, including many applications not
wanting to have a standard PK for the user model. Now, using the mapping configuration, the UserModel attribute/column the input
corresponds to is determined, then the UserModel is queried specifically for that *attribute:value* pair.

This affects Registration as well - in prior releases, the default RegisterForm had a field called ``email``. However
the code called UserModel.get_user() with the value of this field, which would proceed to query ALL configured USER_IDENTITY_ATTRIBUTES.
Some applications used this fact to effectively change what the application user needed to log in - however mostly it generated
extreme confusion with developers. While it is easy to extend the RegistrationForm and template to add additional attributes,
``email`` is required (a regression from 3.4)
- (:pr:`xxx`) The :class:`flask_security.PhoneUtil` is now initialized as part of Flask-Security initialization rather than
``@app.before_first_request`` (since that broke the CLI). So it isn't called in an application context, the *app* being initialized is
passed as an argument to *__init__*.

.. _here: https://github.com/Flask-Middleware/flask-security/issues/85

Expand Down
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,11 @@ Utils

.. autoclass:: flask_security.PhoneUtil
:members:
:special-members: __init__

.. autoclass:: flask_security.MailUtil
:members:
:special-members: __init__

.. autoclass:: flask_security.SmsSenderBaseClass
:members: send_sms
Expand Down
130 changes: 100 additions & 30 deletions flask_security/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ def wrapper(*args, **kwargs):

@click.group()
def users():
"""User commands."""
"""User commands.

For commands that require a USER - pass in any identity attribute.
"""


@click.group()
Expand All @@ -78,39 +81,39 @@ def roles():

@users.command(
"create",
help="Create a new user with one or more identity attributes of the form:"
" attr:value. If attr isn't set 'email' is presumed.",
help="Create a new user with one or more attributes using the syntax:"
" attr:value. If attr isn't set 'email' is presumed."
" Values will be validated using the configured confirm_register_form;"
" however, any ADDITIONAL attribute:value pairs will be sent to"
" datastore.create_user",
)
@click.argument(
"identities", nargs=-1,
"attributes", nargs=-1,
)
@click.password_option()
@click.option("-a", "--active", default=False, is_flag=True)
@with_appcontext
@commit
def users_create(identities, password, active):
def users_create(attributes, password, active):
"""Create a user."""
kwargs = {}

identity_attributes = get_identity_attributes()
for identity in identities:
for attrarg in attributes:
# If given identity is an identity_attribute - do a bit of pre-validating
# to provide nicer errors.
attr = "email"
if ":" in identity:
attr, identity = identity.split(":")
if attr not in identity_attributes:
raise click.UsageError(
f"Identity attribute '{attr}' must be part of"
f" SECURITY_USER_IDENTITY_ATTRIBUTES"
)

details = get_identity_attribute(attr)
idata = details["mapper"](identity)
if not idata:
raise click.UsageError(
f"Attr {attr} with value {identity} wasn't accepted by mapper"
)

kwargs[attr] = identity
if ":" in attrarg:
attr, attrarg = attrarg.split(":")
if attr in identity_attributes:
details = get_identity_attribute(attr)
idata = details["mapper"](attrarg)
if not idata:
raise click.UsageError(
f"Attr {attr} with value {attrarg} wasn't accepted by mapper"
)

kwargs[attr] = attrarg
kwargs.update(**{"password": password})

form = _security.confirm_register_form(MultiDict(kwargs), meta={"csrf": False})
Expand All @@ -129,13 +132,13 @@ def users_create(identities, password, active):
@roles.command("create")
@click.argument("name")
@click.option("-d", "--description", default=None)
@click.option("-p", "--permissions")
@click.option("-p", "--permissions", help="A comma separated list")
@with_appcontext
@commit
def roles_create(**kwargs):
"""Create a role."""

# For some reaosn Click puts arguments in kwargs - even if they weren't specified.
# For some reason Click puts arguments in kwargs - even if they weren't specified.
if "permissions" in kwargs and not kwargs["permissions"]:
del kwargs["permissions"]
if "permissions" in kwargs and not hasattr(_datastore.role_model, "permissions"):
Expand All @@ -153,14 +156,14 @@ def roles_add(user, role):
"""Add user to role."""
user_obj = find_user(user)
if user_obj is None:
raise click.UsageError("ERROR: User not found.")
raise click.UsageError("User not found.")

role = _datastore._prepare_role_modify_args(role)
if role is None:
raise click.UsageError("Cannot find role.")
if _datastore.add_role_to_user(user_obj, role):
click.secho(
f'Role "{role}" added to user "{user}" successfully.', fg="green",
f'Role "{role.name}" added to user "{user}" successfully.', fg="green",
)
else:
raise click.UsageError("Cannot add role to user.")
Expand All @@ -175,19 +178,66 @@ def roles_remove(user, role):
"""Remove user from role."""
user_obj = find_user(user)
if user_obj is None:
raise click.UsageError("ERROR: User not found.")
raise click.UsageError("User not found.")

role = _datastore._prepare_role_modify_args(role)
if role is None:
raise click.UsageError("Cannot find role.")
if _datastore.remove_role_from_user(user_obj, role):
click.secho(
f'Role "{role}" removed from user "{user}" successfully.', fg="green",
f'Role "{role.name}" removed from user "{user}" successfully.', fg="green",
)
else:
raise click.UsageError("Cannot remove role from user.")


@roles.command("add_permissions")
@click.argument("role")
@click.argument("permissions")
@with_appcontext
@commit
def roles_add_permissions(role, permissions):
"""Add permissions to role.

Role is an existing role name.
Permissions are a comma separated list.
"""
role = _datastore._prepare_role_modify_args(role)
if role is None:
raise click.UsageError("Cannot find role.")
if _datastore.add_permissions_to_role(role, permissions):
click.secho(
f'Permission(s) "{permissions}" added to role "{role.name}" successfully.',
fg="green",
)
else: # pragma: no cover
raise click.UsageError("Cannot add permission(s) to role.")


@roles.command("remove_permissions")
@click.argument("role")
@click.argument("permissions")
@with_appcontext
@commit
def roles_remove_permissions(role, permissions):
"""Remove permissions from role.

Role is an existing role name.
Permissions are a comma separated list.
"""
role = _datastore._prepare_role_modify_args(role)
if role is None:
raise click.UsageError("Cannot find role.")
if _datastore.remove_permissions_from_role(role, permissions):
click.secho(
f'Permission(s) "{permissions}" removed from role'
f' "{role.name}" successfully.',
fg="green",
)
else: # pragma: no cover
raise click.UsageError("Cannot remove permission(s) from role.")


@users.command("activate")
@click.argument("user")
@with_appcontext
Expand All @@ -196,7 +246,7 @@ def users_activate(user):
"""Activate a user."""
user_obj = find_user(user)
if user_obj is None:
raise click.UsageError("ERROR: User not found.")
raise click.UsageError("User not found.")
if _datastore.activate_user(user_obj):
click.secho(f'User "{user}" has been activated.', fg="green")
else:
Expand All @@ -211,8 +261,28 @@ def users_deactivate(user):
"""Deactivate a user."""
user_obj = find_user(user)
if user_obj is None:
raise click.UsageError("ERROR: User not found.")
raise click.UsageError("User not found.")
if _datastore.deactivate_user(user_obj):
click.secho(f'User "{user}" has been deactivated.', fg="green")
else:
click.secho(f'User "{user}" was already deactivated.', fg="yellow")


@users.command(
"reset_access",
help="Reset all authentication credentials for user."
" This includes session, auth token, two-factor"
" and unified sign in secrets. ",
)
@click.argument("user")
@with_appcontext
@commit
def users_reset_access(user):
""" Reset all authentication tokens etc."""
user_obj = find_user(user)
if user_obj is None:
raise click.UsageError("User not found.")
_datastore.reset_user_access(user_obj)
click.secho(
f'User "{user}" authentication credentials have been reset.', fg="green"
)
9 changes: 2 additions & 7 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,13 +1107,8 @@ def _csrf_init():
# Add configured header to WTF_CSRF_HEADERS
current_app.config["WTF_CSRF_HEADERS"].append(cv("CSRF_HEADER"))

@app.before_first_request
def _init_phone_util():
state._phone_util = state.phone_util_cls()

@app.before_first_request
def _init_mail_util():
state._mail_util = state.mail_util_cls()
state._phone_util = state.phone_util_cls(app)
state._mail_util = state.mail_util_cls(app)

app.extensions["security"] = state

Expand Down
23 changes: 17 additions & 6 deletions flask_security/datastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def add_role_to_user(self, user, role):
:param user: The user to manipulate. Can be an User object or email
:param role: The role to add to the user. Can be a Role object or
string role name
:return: True is role was added, False if role already existed.
"""
role = self._prepare_role_modify_args(role)
if role not in user.roles:
Expand All @@ -175,6 +176,8 @@ def remove_role_from_user(self, user, role):
:param user: The user to manipulate. Can be an User object or email
:param role: The role to remove from the user. Can be a Role object or
string role name
:return: True if role was removed, False if role doesn't exist or user didn't
have role.
"""
rv = False
role = self._prepare_role_modify_args(role)
Expand All @@ -190,33 +193,41 @@ def add_permissions_to_role(self, role, permissions):
:param role: The role to modify. Can be a Role object or
string role name
:param permissions: a set, list, or single string.
:return: True if permissions added, False if role doesn't exist.

Caller must commit to DB.

.. versionadded:: 4.0.0
"""

rv = False
role = self._prepare_role_modify_args(role)
role.add_permissions(permissions)
self.put(role)
return role.get_permissions()
if role:
rv = True
role.add_permissions(permissions)
self.put(role)
return rv

def remove_permissions_from_role(self, role, permissions):
"""Remove one or more permissions from a role.

:param role: The role to modify. Can be a Role object or
string role name
:param permissions: a set, list, or single string.
:return: True if permissions removed, False if role doesn't exist.

Caller must commit to DB.

.. versionadded:: 4.0.0
"""

rv = False
role = self._prepare_role_modify_args(role)
role.remove_permissions(permissions)
self.put(role)
return role.get_permissions()
if role:
rv = True
role.remove_permissions(permissions)
self.put(role)
return rv

def toggle_active(self, user):
"""Toggles a user's active status. Always returns True."""
Expand Down
10 changes: 8 additions & 2 deletions flask_security/mail_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@
class MailUtil:
"""
To provide your own implementation, pass in the class as ``mail_util_cls``
at init time. Your class will be instantiated once prior to the first
request being handled.
at init time. Your class will be instantiated once as part of app initialization.

.. versionadded:: 4.0.0
"""

def __init__(self, app):
""" Instantiate class.

:param app: The Flask application being initialized.
"""
pass

def send_mail(
self, template, subject, recipient, sender, body, html, user, **kwargs
):
Expand Down
13 changes: 11 additions & 2 deletions flask_security/phone_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,21 @@ class PhoneUtil:
Subclass this to use a different underlying phone number parsing library.

To provide your own implementation, pass in the class as ``phone_util_cls``
at init time. Your class will be instantiated once prior to the first
request being handled.
at init time. Your class will be instantiated once as part of app initialization.

.. versionadded:: 3.4.0

.. versionchanged:: 4.0.0
__init__ takes app argument
"""

def __init__(self, app):
""" Instantiate class.

:param app: The Flask application being initialized.
"""
pass

def validate_phone_number(self, input_data):
""" Return ``None`` if a valid phone number else an error message. """
import phonenumbers
Expand Down
Loading