Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/auth.py: 100%
141 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-15 11:33 -0500
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-15 11:33 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Auth Handler
26This defines the default :term:`auth handler`.
27"""
29import uuid as _uuid
31from wuttjamaican.app import GenericHandler
34# nb. this only works if passlib is installed (part of 'db' extra)
35try:
36 from passlib.context import CryptContext
37except ImportError: # pragma: no cover
38 pass
39else:
40 password_context = CryptContext(schemes=['bcrypt'])
44class AuthHandler(GenericHandler):
45 """
46 Base class and default implementation for the :term:`auth
47 handler`.
49 This is responsible for "authentication and authorization" - for
50 instance:
52 * authenticate user from login credentials
53 * check which permissions a user/role has
54 * create/modify users, roles
55 * grant/revoke role permissions
56 """
58 def authenticate_user(self, session, username, password, **kwargs):
59 """
60 Authenticate the given user credentials, and if successful,
61 return the :class:`~wuttjamaican.db.model.auth.User`.
63 Default logic will (try to) locate a user with matching
64 username, then confirm the supplied password is also a match.
66 Custom handlers can authenticate against anything else, using
67 the given credentials. But they still must return a "native"
68 ``User`` object for the app to consider the authentication
69 successful. The handler may auto-create the user if needed.
71 Generally speaking the credentials will have come directly
72 from a user login attempt in the web app etc. Again the
73 default logic assumes a "username" but in practice it may be
74 an email address etc. - whatever the user entered.
76 :param session: Open :term:`db session`.
78 :param username: Usually a string, but also may be a
79 :class:`~wuttjamaican.db.model.auth.User` instance, in
80 which case no user lookup will occur. (However the user is
81 still authenticated otherwise, i.e. the password must be
82 correct etc.)
84 :param password: Password as string.
86 :returns: :class:`~wuttjamaican.db.model.auth.User` instance,
87 or ``None``.
88 """
89 user = self.get_user(username, session=session)
90 if user and user.active and user.password:
91 if self.check_user_password(user, password):
92 return user
94 def check_user_password(self, user, password, **kwargs):
95 """
96 Check a user's password.
98 This will hash the given password and compare it to the hashed
99 password we have on file for the given user account.
101 This is normally part of the login process, so the
102 ``password`` param refers to the password entered by a user;
103 this method will determine if it was correct.
105 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
107 :param password: User-entered password in plain text.
109 :returns: ``True`` if password matches; else ``False``.
110 """
111 return password_context.verify(password, user.password)
113 def get_role(self, session, key, **kwargs):
114 """
115 Locate and return a :class:`~wuttjamaican.db.model.auth.Role`
116 per the given key, if possible.
118 :param session: Open :term:`db session`.
120 :param key: Value to use when searching for the role. Can be
121 a UUID or name of a role.
123 :returns: :class:`~wuttjamaican.db.model.auth.Role` instance;
124 or ``None``.
125 """
126 model = self.app.model
128 if not key:
129 return
131 # maybe it is a uuid
132 if isinstance(key, _uuid.UUID):
133 role = session.get(model.Role, key)
134 if role:
135 return role
137 else: # assuming it is a string
139 # try to match on Role.uuid
140 try:
141 role = session.get(model.Role, _uuid.UUID(key))
142 if role:
143 return role
144 except ValueError:
145 pass
147 # try to match on Role.name
148 role = session.query(model.Role)\
149 .filter_by(name=key)\
150 .first()
151 if role:
152 return role
154 # try settings; if value then recurse
155 key = self.config.get(f'{self.appname}.role.{key}',
156 session=session)
157 if key:
158 return self.get_role(session, key)
160 def get_user(self, obj, session=None, **kwargs):
161 """
162 Return the :class:`~wuttjamaican.db.model.auth.User`
163 associated with the given object, if one can be found.
165 This method should accept "any" type of ``obj`` and inspect it
166 to determine if/how a user can be found. It should return the
167 "first, most obvious" user in the event that the given object
168 is associated with multiple users.
170 For instance ``obj`` may be a string in which case a lookup
171 may be tried on
172 :attr:`~wuttjamaican.db.model.auth.User.username`. Or it may
173 be a :class:`~wuttjamaican.db.model.base.Person` in which case
174 their :attr:`~wuttjamaican.db.model.base.Person.user` may be
175 returned.
177 :param obj: Object for which user should be returned.
179 :param session: Open :term:`db session`. This is optional in
180 some cases, i.e. one can be determined automatically if
181 ``obj`` is some kind of object already contained in a
182 session (e.g. ``Person``). But a ``session`` must be
183 provided if ``obj`` is a simple string and you need to do a
184 lookup by username etc.
186 :returns: :class:`~wuttjamaican.db.model.auth.User` or ``None``.
187 """
188 model = self.app.model
190 # maybe obj is already a user
191 if isinstance(obj, model.User):
192 return obj
194 # nb. these lookups require a db session
195 if session:
197 # or maybe it is a uuid
198 if isinstance(obj, _uuid.UUID):
199 user = session.get(model.User, obj)
200 if user:
201 return user
203 # or maybe it is a string
204 elif isinstance(obj, str):
206 # try to match on User.uuid
207 try:
208 user = session.get(model.User, _uuid.UUID(obj))
209 if user:
210 return user
211 except ValueError:
212 pass
214 # try to match on User.username
215 user = session.query(model.User)\
216 .filter(model.User.username == obj)\
217 .first()
218 if user:
219 return user
221 # nb. obj is presumbly another type of object, e.g. Person
223 # maybe we can find a person, then get user
224 person = self.app.get_person(obj)
225 if person:
226 return person.user
228 def make_person(self, **kwargs):
229 """
230 Make and return a new
231 :class:`~wuttjamaican.db.model.base.Person`.
233 This is a convenience wrapper around
234 :class:`~wuttjamaican.people.PeopleHandler.make_person()`.
235 """
236 people = self.app.get_people_handler()
237 return people.make_person(**kwargs)
239 def make_user(self, session=None, **kwargs):
240 """
241 Make and return a new
242 :class:`~wuttjamaican.db.model.auth.User`.
244 This is mostly a simple wrapper around the
245 :class:`~wuttjamaican.db.model.auth.User` constructor. All
246 ``kwargs`` are passed on to the constructor as-is, for
247 instance. It also will add the user to the session, if
248 applicable.
250 This method also adds one other convenience:
252 If there is no ``username`` specified in the ``kwargs`` then
253 it will call :meth:`make_unique_username()` to automatically
254 provide one. (Note that the ``kwargs`` will be passed along
255 to that call as well.)
257 :param session: Open :term:`db session`, if applicable.
259 :returns: The new :class:`~wuttjamaican.db.model.auth.User`
260 instance.
261 """
262 model = self.app.model
264 if session and 'username' not in kwargs:
265 kwargs['username'] = self.make_unique_username(session, **kwargs)
267 user = model.User(**kwargs)
268 if session:
269 session.add(user)
270 return user
272 def delete_user(self, user, **kwargs):
273 """
274 Delete the given user account. Use with caution! As this
275 generally cannot be undone.
277 Default behavior simply deletes the user account. Depending
278 on the DB schema and data present, this may cause an error
279 (i.e. if the user is still referenced by other tables).
281 :param user: :class:`~wuttjamaican.db.model.auth.User` to
282 delete.
283 """
284 session = self.app.get_session(user)
285 session.delete(user)
287 def make_preferred_username(self, session, **kwargs):
288 """
289 Generate a "preferred" username, using data from ``kwargs`` as
290 hints.
292 Note that ``kwargs`` should be of the same sort that might be
293 passed to the :class:`~wuttjamaican.db.model.auth.User`
294 constructor.
296 So far this logic is rather simple:
298 If ``kwargs`` contains ``person`` then a username will be
299 constructed using the name data from the person
300 (e.g. ``'john.doe'``).
302 In all other cases it will return ``'newuser'``.
304 .. note::
306 This method does not confirm if the username it generates
307 is actually "available" for a new user. See
308 :meth:`make_unique_username()` for that.
310 :param session: Open :term:`db session`.
312 :returns: Generated username as string.
313 """
314 person = kwargs.get('person')
315 if person:
316 first = (person.first_name or '').strip().lower()
317 last = (person.last_name or '').strip().lower()
318 if first and last:
319 return f'{first}.{last}'
320 if first:
321 return first
322 if last:
323 return last
325 return 'newuser'
327 def make_unique_username(self, session, **kwargs):
328 """
329 Generate a *unique* username, using data from ``kwargs`` as
330 hints.
332 Note that ``kwargs`` should be of the same sort that might be
333 passed to the :class:`~wuttjamaican.db.model.auth.User`
334 constructor.
336 This method is a convenience which does two things:
338 First it calls :meth:`make_preferred_username()` to obtain the
339 "preferred" username. (It passes all ``kwargs`` along when it
340 makes that call.)
342 Then it checks to see if the resulting username is already
343 taken. If it is, then a "counter" is appended to the
344 username, and incremented until a username can be found which
345 is *not* yet taken.
347 It returns the first "available" (hence unique) username which
348 is found. Note that it is considered unique and therefore
349 available *at the time*; however this method does not
350 "reserve" the username in any way. It is assumed that you
351 would create the user yourself once you have the username.
353 :param session: Open :term:`db session`.
355 :returns: Username as string.
356 """
357 model = self.app.model
359 original_username = self.make_preferred_username(session, **kwargs)
360 username = original_username
362 # check for unique username
363 counter = 1
364 while True:
365 users = session.query(model.User)\
366 .filter(model.User.username == username)\
367 .count()
368 if not users:
369 break
370 username = f"{original_username}{counter:02d}"
371 counter += 1
373 return username
375 def set_user_password(self, user, password, **kwargs):
376 """
377 Set a user's password.
379 This will update the
380 :attr:`~wuttjamaican.db.model.auth.User.password` attribute
381 for the user. The value will be hashed using ``bcrypt``.
383 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
385 :param password: New password in plain text.
386 """
387 user.password = password_context.hash(password)
389 def get_role_administrator(self, session, **kwargs):
390 """
391 Returns the special "Administrator" role.
392 """
393 return self._special_role(session, _uuid.UUID('d937fa8a965611dfa0dd001143047286'),
394 "Administrator")
396 def get_role_anonymous(self, session, **kwargs):
397 """
398 Returns the special "Anonymous" (aka. "Guest") role.
399 """
400 return self._special_role(session, _uuid.UUID('f8a27c98965a11dfaff7001143047286'),
401 "Anonymous")
403 def get_role_authenticated(self, session, **kwargs):
404 """
405 Returns the special "Authenticated" role.
406 """
407 return self._special_role(session, _uuid.UUID('b765a9cc331a11e6ac2a3ca9f40bc550'),
408 "Authenticated")
410 def user_is_admin(self, user, **kwargs):
411 """
412 Check if given user is a member of the "Administrator" role.
414 :rtype: bool
415 """
416 if user:
417 session = self.app.get_session(user)
418 admin = self.get_role_administrator(session)
419 if admin in user.roles:
420 return True
422 return False
424 def get_permissions(self, session, principal,
425 include_anonymous=True,
426 include_authenticated=True):
427 """
428 Return a set of permission names, which represents all
429 permissions effectively granted to the given user or role.
431 :param session: Open :term:`db session`.
433 :param principal: :class:`~wuttjamaican.db.model.auth.User` or
434 :class:`~wuttjamaican.db.model.auth.Role` instance. Can
435 also be ``None``, in which case the "Anonymous" role will
436 be assumed.
438 :param include_anonymous: Whether the "Anonymous" role should
439 be included when checking permissions. If ``False``, the
440 Anonymous permissions will *not* be checked.
442 :param include_authenticated: Whether the "Authenticated" role
443 should be included when checking permissions.
445 :returns: Set of permission names.
446 :rtype: set
447 """
448 # we will use any `roles` attribute which may be present. in
449 # practice we would be assuming a User in this case
450 if hasattr(principal, 'roles'):
451 roles = [role
452 for role in principal.roles
453 if self._role_is_pertinent(role)]
455 # here our User assumption gets a little more explicit
456 if include_authenticated:
457 roles.append(self.get_role_authenticated(session))
459 # otherwise a non-null principal is assumed to be a Role
460 elif principal is not None:
461 roles = [principal]
463 # fallback assumption is "no roles"
464 else:
465 roles = []
467 # maybe include anonymous role
468 if include_anonymous:
469 roles.append(self.get_role_anonymous(session))
471 # build the permissions cache
472 cache = set()
473 for role in roles:
474 if hasattr(role, 'permissions'):
475 cache.update(role.permissions)
477 return cache
479 def has_permission(self, session, principal, permission,
480 include_anonymous=True,
481 include_authenticated=True):
482 """
483 Check if the given user or role has been granted the given
484 permission.
486 .. note::
488 While this method is perfectly usable, it is a bit "heavy"
489 if you need to make multiple permission checks for the same
490 user. To optimize, call :meth:`get_permissions()` and keep
491 the result, then instead of calling ``has_permission()``
492 just check if a given permission is contained in the cached
493 result set.
495 (The logic just described is exactly what this method does,
496 except it will not keep the result set, hence calling it
497 multiple times for same user is not optimal.)
499 :param session: Open :term:`db session`.
501 :param principal: Either a
502 :class:`~wuttjamaican.db.model.auth.User` or
503 :class:`~wuttjamaican.db.model.auth.Role` instance. It is
504 also expected that this may sometimes be ``None``, in which
505 case the "Anonymous" role will be assumed.
507 :param permission: Name of the permission for which to check.
509 :param include_anonymous: Whether the "Anonymous" role should
510 be included when checking permissions. If ``False``, then
511 Anonymous permissions will *not* be checked.
513 :param include_authenticated: Whether the "Authenticated" role
514 should be included when checking permissions.
516 :returns: Boolean indicating if the permission is granted.
517 """
518 perms = self.get_permissions(session, principal,
519 include_anonymous=include_anonymous,
520 include_authenticated=include_authenticated)
521 return permission in perms
523 def grant_permission(self, role, permission, **kwargs):
524 """
525 Grant a permission to the role. If the role already has the
526 permission, nothing is done.
528 :param role: :class:`~wuttjamaican.db.model.auth.Role`
529 instance.
531 :param permission: Name of the permission as string.
532 """
533 if permission not in role.permissions:
534 role.permissions.append(permission)
536 def revoke_permission(self, role, permission, **kwargs):
537 """
538 Revoke a permission from the role. If the role does not have
539 the permission, nothing is done.
541 :param role: A :class:`~rattail.db.model.users.Role` instance.
543 :param permission: Name of the permission as string.
544 """
545 if permission in role.permissions:
546 role.permissions.remove(permission)
548 ##############################
549 # internal methods
550 ##############################
552 def _role_is_pertinent(self, role):
553 """
554 Check the role to ensure it is "pertinent" for the current app.
556 The idea behind this is for sake of a multi-node system, where
557 users and roles are synced between nodes. Some roles may be
558 defined for only certain types of nodes and hence not
559 "pertinent" for all nodes.
561 As of now there is no actual support for that, but this stub
562 method exists for when it will.
563 """
564 return True
566 def _special_role(self, session, uuid, name):
567 """
568 Fetch a "special" role, creating if needed.
569 """
570 model = self.app.model
571 role = session.get(model.Role, uuid)
572 if not role:
573 role = model.Role(uuid=uuid, name=name)
574 session.add(role)
575 return role