# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Auth Handler
See also :doc:`rattail-manual:base/handlers/other/auth`.
"""
import secrets
import warnings
from sqlalchemy import orm
import sqlalchemy_continuum as continuum
from rattail.app import GenericHandler, MergeMixin
[docs]
class AuthHandler(GenericHandler, MergeMixin):
"""
Base class and default implementation for the so-called "auth"
handler, by which we mean "authentication and authorization".
In practice this is also responsible for creating new users, and
various things pertaining to roles, etc.
"""
[docs]
def authenticate_user(self, session, username, password):
"""
Authenticate the given user credentials, and if successful,
return the user object.
Default logic will (try to) locate a user record with matching
username, then confirm the supplied password is also a match.
You may of course define a custom handler and then could
authenticate against anything you like, e.g. the POS system or
LDAP etc. The only trick is that this must return a Rattail
user, not some other kind. So you may have to devisde a way
to auto-create the Rattail user as needed, when authentication
for the external system succeeds.
Generally speaking the credentials passed in will have come
directly from a user login attempt in the web app etc. Again
the default logic assumes a "username" but in practice it may
be an email address etc. - whatever the user types.
:param session: Current session for Rattail DB.
:param username: Username as string.
:param password: Password as string.
:returns: On success, a :class:`~rattail.db.model.users.User`
instance; else ``None``.
"""
from rattail.db.auth import authenticate_user
return authenticate_user(session, username, password)
[docs]
def authenticate_user_token(self, session, token):
"""
Authenticate the given user API token string, and if valid,
return the corresponding User object.
"""
model = self.model
try:
token = session.query(model.UserAPIToken)\
.filter(model.UserAPIToken.token_string == token)\
.one()
except orm.exc.NoResultFound:
pass
else:
user = token.user
if user.active:
return user
[docs]
def get_user(self, obj, **kwargs):
"""
Return the User associated with the given object, if any.
"""
model = self.model
if isinstance(obj, model.User):
return obj
else:
person = self.app.get_person(obj)
if person and person.users:
# TODO: what if multiple users / ambiguous?
return person.users[0]
[docs]
def has_permission(self, session, principal, permission,
include_guest=True,
include_authenticated=True):
"""
Check if the given user or role has been granted the given
permission.
:param session: Current session for Rattail DB.
:param principal: Either a
:class:`~rattail.db.model.users.User` or
:class:`~rattail.db.model.users.Role` instance. It is also
expected that this may sometimes be ``None``, in which case
the "Guest" role will typically be assumed.
:param permission: Name of the permission for which to check.
:param include_guest: Whether or not the "Guest" role should
be included when checking permissions. If ``False``, then
Guest's permissions will *not* be consulted.
:param include_authenticated: Whether or not the
"Authenticated" role should be included when checking
permissions.
:returns: Boolean indicating if the permission has been
granted.
"""
perms = self.get_permissions(session, principal,
include_guest=include_guest,
include_authenticated=include_authenticated)
return permission in perms
[docs]
def get_permissions(self, session, principal,
include_guest=True,
include_authenticated=True):
"""
Return a set of permission names, which represents all
permissions effectively granted to the given user or role.
:param session: Current session for Rattail DB.
:param principal: Either a
:class:`~rattail.db.model.users.User` or
:class:`~rattail.db.model.users.Role` instance. It is also
expected that this may sometimes be ``None``, in which case
the "Guest" role will typically be assumed.
:param include_guest: Whether or not the "Guest" role should
be included when checking permissions. If ``False``, then
Guest's permissions will *not* be consulted.
:param include_authenticated: Whether or not the
"Authenticated" role should be included when checking
permissions.
:returns: Set of permission names.
"""
from rattail.db.auth import guest_role, authenticated_role
# we will use any `roles` attribute which may be present. in practice we
# would be assuming a User in this case
if hasattr(principal, 'roles'):
roles = []
for role in principal.roles:
include = False
if role.node_type:
if role.node_type == self.config.node_type():
include = True
else:
include = True
if include:
roles.append(role)
# here our User assumption gets a little more explicit
if include_authenticated:
roles.append(authenticated_role(session))
# otherwise a non-null principal is assumed to be a Role
elif principal is not None:
roles = [principal]
# fallback assumption is "no roles"
else:
roles = []
# maybe include guest roles
if include_guest:
roles.append(guest_role(session))
# build the permissions cache
cache = set()
for role in roles:
cache.update(role.permissions)
return cache
def cache_permissions(self, *args, **kwargs): # pragma: no cover
warnings.warn("method is deprecated, please use "
"get_permissions() method instead",
DeprecationWarning, stacklevel=2)
return self.get_permissions(*args, **kwargs)
[docs]
def grant_permission(self, role, permission):
"""
Grant a permission to the role. If the role already has the
permission, nothing is done.
:param role: A :class:`~rattail.db.model.users.Role` instance.
:param permission: Name of the permission as string.
"""
if permission not in role.permissions:
role.permissions.append(permission)
[docs]
def revoke_permission(self, role, permission):
"""
Revoke a permission from the role. If the role does not have
the permission, nothing is done.
:param role: A :class:`~rattail.db.model.users.Role` instance.
:param permission: Name of the permission as string.
"""
if permission in role.permissions:
role.permissions.remove(permission)
[docs]
def generate_preferred_username(self, session, **kwargs):
"""
Generate a "preferred" username using data from ``kwargs`` as
hints.
Note that ``kwargs`` should be of the same sort that might be
passed to the constructor for a new
:class:`~rattail.db.model.users.User` instance.
So far there is only one "hint" which is honored by the
default logic; however the intention is to leave this flexible
as other kinds of hints may be useful in the future.
This method does not confirm if the username it generates is
actually "available" for a new user. If you need confirmation
then use :meth:`generate_unique_username()` instead.
:param session: Current session for Rattail DB.
:param person: Reference to a
:class:`~rattail.db.model.people.Person` instance. If you
specify this hint, then default logic will generate a
username using first and last names, like ``'first.last'``.
(You can override with a custom handler if needed.)
:returns: Generated username as string.
"""
person = kwargs.get('person')
if person:
first = (person.first_name or '').strip().lower()
last = (person.last_name or '').strip().lower()
return '{}.{}'.format(first, last)
return 'newuser'
def generate_username(self, *args, **kwargs): # pragma: no cover
warnings.warn("method is deprecated, please use "
"generate_preferred_username() method instead",
DeprecationWarning, stacklevel=2)
return self.generate_preferred_username(*args, **kwargs)
[docs]
def generate_unique_username(self, session, **kwargs):
"""
Generate a *unique* username using data from ``kwargs`` as
hints.
Note that ``kwargs`` should be of the same sort that might be
passed to the constructor for a new
:class:`~rattail.db.model.users.User` instance.
This method is a convenience which does two things:
First it calls :meth:`generate_preferred_username()` to obtain
the "preferred" username. (It passes ``kwargs`` along when it
makes the call. See :meth:`generate_preferred_username()` for
more info.)
Then it checks to see if the resulting username is already
taken. If it is, then a "counter" is appended to the
username, and incremented until a username can be found which
is *not* yet taken.
It returns the first "available" (hence unique) username which
is found. Note that it is considered unique and therefore
available *at the time*; however this method does not
"reserve" the username in any way. It is assumed that you
would create the user yourself once you have the username.
:param session: Current session for Rattail DB.
:returns: Username as string.
"""
model = self.model
original_username = self.generate_preferred_username(session, **kwargs)
username = original_username
# only if given a session, can we check for unique username
if session:
counter = 1
while True:
users = session.query(model.User)\
.filter(model.User.username == username)\
.count()
if not users:
break
username = "{}{:02d}".format(original_username, counter)
counter += 1
return username
[docs]
def make_user(self, session=None, **kwargs):
"""
Make and return a new user.
This is mostly just a simple wrapper around the normal
:class:`~rattail.db.model.users.User` constructor. All
``kwargs`` for instance are passed on to the constructor.
Default logic here only adds one other convenience:
If there is no ``username`` specified in the ``kwargs`` then
it will call :meth:`generate_unique_username()` to
automatically provide a username. Note that all ``kwargs``
are passed along in that call.
:param session: Current session for the Rattail DB. This is
"sort of" optional, but please do provide it, as it may
become requied in the future.
:returns: A new :class:`~rattail.db.model.users.User` instance.
"""
model = self.model
if 'username' not in kwargs:
kwargs['username'] = self.generate_unique_username(session, **kwargs)
user = model.User(**kwargs)
if session:
session.add(user)
return user
[docs]
def get_email_address(self, user, **kwargs):
"""
Get the "best" email address we have on file for the given user.
"""
warnings.warn("auth.get_email_address(user) is deprecated; please "
"use app.get_contact_email_address(user) instead",
DeprecationWarning, stacklevel=2)
return self.app.get_contact_email_address(user)
[docs]
def get_short_display_name(self, user, **kwargs):
"""
Returns "short display name" for the user. This is for
convenience of mobile view, at least...
"""
# TODO: this should reference employee.short_name
employee = self.app.get_employee(user)
if employee and employee.display_name:
return employee.display_name
person = self.app.get_person(user)
if person:
if person.first_name and person.last_name:
return "{} {}.".format(person.first_name, person.last_name[0])
if person.first_name:
return person.first_name
return user.username
[docs]
def generate_raw_api_token(self):
"""
Generate a new *raw* API token string.
"""
return secrets.token_urlsafe()
[docs]
def add_api_token(self, user, description, **kwargs):
"""
Add a new API token for the user.
"""
model = self.model
session = self.app.get_session(user)
# generate raw API token, in the form required for use within
# the API client
token_string = self.generate_raw_api_token()
# create DB record for the token
token = model.UserAPIToken(
user=user,
description=description,
token_string=token_string)
session.add(token)
return token
[docs]
def delete_api_token(self, token, **kwargs):
"""
Delete a new API token for the user.
"""
session = self.app.get_session(token)
session.delete(token)
[docs]
def get_merge_preview_fields(self, **kwargs):
"""
Returns a sequence of fields which will be used during a merge
preview.
"""
F = self.make_merge_field
return [
F('uuid'),
F('username'),
F('person_uuid', coalesce=True),
F('person_name', coalesce=True),
F('role_count'), # coalesced manually
F('active', coalesce=True),
F('sent_message_count', additive=True),
F('received_message_count', additive=True),
]
[docs]
def get_merge_preview_data(self, user, **kwargs):
return {
'uuid': user.uuid,
'username': user.username,
'person_uuid': user.person_uuid,
'person_name': user.person.display_name if user.person else None,
'_roles': user.roles, # needed for final role count
'role_count': len(user.roles),
'active': user.active,
'sent_message_count': len(user.sent_messages),
'received_message_count': len(user._messages),
}
[docs]
def get_merge_resulting_data(self, removing, keeping, **kwargs):
result = super(AuthHandler, self).get_merge_resulting_data(
removing, keeping, **kwargs)
# nb. must "manually" coalesce the role count
result['role_count'] = len(set(removing['_roles'] + keeping['_roles']))
return result
[docs]
def why_not_merge(self, removing, keeping, **kwargs):
if removing.sent_messages:
return "Cannot (yet) remove a user who has sent messages"
if removing._messages:
return "Cannot (yet) remove a user who has received messages"
if removing._roles:
return "Cannot (yet) remove a user who is assigned to roles"
[docs]
def merge_update_keeping_object(self, removing, keeping):
super(AuthHandler, self).merge_update_keeping_object(removing, keeping)
session = self.app.get_session(keeping)
model = self.model
# update any notes authored by old user, to reflect new user
notes = session.query(model.Note)\
.filter(model.Note.created_by == removing)\
.all()
for note in notes:
note.created_by = keeping
[docs]
def delete_user(self, user, **kwargs):
"""
Delete the given user account. Use with caution! As this
generally cannot be undone.
Default behavior here is of course to delete the account, but
it also must try to "remove" the user association from various
places, in particular the continuum transactions table.
Please note that this will leave certain record versions as
appearing to be "without an author".
:param user: Reference to a
:class:`~rattail.db.model.users.User` to be deleted.
:returns: Boolean indicating success.
Note that the utility of this method even having a return
value is deemed questionable, so it's possible in the
future this may just return ``None`` on success, and raise
an error to indicate failure.
"""
session = self.app.get_session(user)
# disassociate user from transactions
if self.config.versioning_has_been_enabled:
self.remove_user_from_continuum_transactions(user)
# finally, delete the user outright
session.delete(user)
return True
[docs]
def remove_user_from_continuum_transactions(self, user):
"""
Remove the given user from all Continuum transactions,
i.e. all data versioning tables.
You probably will not need to invoke this directly; it is
invoked as needed from within :meth:`delete_user()`.
:param user: A :class:`~rattail.db.model.users.User` instance
which should be purged from the versioning tables.
"""
session = self.app.get_session(user)
model = self.model
# remove the user from any continuum transactions
# nb. we can use "any" model class here, to obtain Transaction
Transaction = continuum.transaction_class(model.User)
transactions = session.query(Transaction)\
.filter(Transaction.user_id == user.uuid)\
.all()
for txn in transactions:
txn.user_id = None