Coverage for .tox/coverage/lib/python3.13/site-packages/wuttjamaican/auth.py: 100%
168 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-07 21:12 -0500
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-07 21:12 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2026 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
25"""
27import secrets
28import warnings
29import uuid as _uuid
31import bcrypt
33from wuttjamaican.app import GenericHandler
36class AuthHandler(GenericHandler): # pylint: disable=too-many-public-methods
37 """
38 Base class and default implementation for the :term:`auth
39 handler`.
40 """
42 def authenticate_user(self, session, username, password):
43 """
44 Authenticate the given user credentials, and if successful,
45 return the :class:`~wuttjamaican.db.model.auth.User`.
47 Default logic will (try to) locate a user with matching
48 username, then confirm the supplied password is also a match.
50 Custom handlers can authenticate against anything else, using
51 the given credentials. But they still must return a "native"
52 ``User`` object for the app to consider the authentication
53 successful. The handler may auto-create the user if needed.
55 Generally speaking the credentials will have come directly
56 from a user login attempt in the web app etc. Again the
57 default logic assumes a "username" but in practice it may be
58 an email address etc. - whatever the user entered.
60 See also :meth:`authenticate_user_token()`.
62 :param session: Open :term:`db session`.
64 :param username: Usually a string, but also may be a
65 :class:`~wuttjamaican.db.model.auth.User` instance, in
66 which case no user lookup will occur. (However the user is
67 still authenticated otherwise, i.e. the password must be
68 correct etc.)
70 :param password: Password as string.
72 :returns: :class:`~wuttjamaican.db.model.auth.User` instance,
73 or ``None``.
74 """
75 user = self.get_user(username, session=session)
76 if user and user.active and user.password:
77 if self.check_user_password(user, password):
78 return user
79 return None
81 def authenticate_user_token(self, session, token):
82 """
83 Authenticate the given user API token string, and if valid,
84 return the corresponding user.
86 See also :meth:`authenticate_user()`.
88 :param session: Open :term:`db session`.
90 :param token: Raw token string for the user.
92 :returns: :class:`~wuttjamaican.db.model.auth.User` instance,
93 or ``None``.
94 """
95 from sqlalchemy import orm # pylint: disable=import-outside-toplevel
97 model = self.app.model
99 try:
100 token = (
101 session.query(model.UserAPIToken)
102 .filter(model.UserAPIToken.token_string == token)
103 .one()
104 )
105 except orm.exc.NoResultFound:
106 pass
107 else:
108 user = token.user
109 if user.active:
110 return user
111 return None
113 def check_user_password(self, user, password):
114 """
115 Check a user's password.
117 This will hash the given password and compare it to the hashed
118 password we have on file for the given user account.
120 This is normally part of the login process, so the
121 ``password`` param refers to the password entered by a user;
122 this method will determine if it was correct.
124 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
126 :param password: User-entered password in plain text.
128 :returns: ``True`` if password matches; else ``False``.
129 """
130 return bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8"))
132 def get_role(self, session, key):
133 """
134 Locate and return a :class:`~wuttjamaican.db.model.auth.Role`
135 per the given key, if possible.
137 :param session: Open :term:`db session`.
139 :param key: Value to use when searching for the role. Can be
140 a UUID or name of a role.
142 :returns: :class:`~wuttjamaican.db.model.auth.Role` instance;
143 or ``None``.
144 """
145 model = self.app.model
147 if not key:
148 return None
150 # maybe it is a uuid
151 if isinstance(key, _uuid.UUID):
152 role = session.get(model.Role, key)
153 if role:
154 return role
156 else: # assuming it is a string
157 # try to match on Role.uuid
158 try:
159 role = session.get(model.Role, _uuid.UUID(key))
160 if role:
161 return role
162 except ValueError:
163 pass
165 # try to match on Role.name
166 role = session.query(model.Role).filter_by(name=key).first()
167 if role:
168 return role
170 # try settings; if value then recurse
171 key = self.config.get(f"{self.appname}.role.{key}", session=session)
172 if key:
173 return self.get_role(session, key)
174 return None
176 def get_user(self, obj, session=None):
177 """
178 Return the :class:`~wuttjamaican.db.model.auth.User`
179 associated with the given object, if one can be found.
181 This method should accept "any" type of ``obj`` and inspect it
182 to determine if/how a user can be found. It should return the
183 "first, most obvious" user in the event that the given object
184 is associated with multiple users.
186 For instance ``obj`` may be a string in which case a lookup
187 may be tried on
188 :attr:`~wuttjamaican.db.model.auth.User.username`. Or it may
189 be a :class:`~wuttjamaican.db.model.base.Person` in which case
190 their :attr:`~wuttjamaican.db.model.base.Person.user` may be
191 returned.
193 :param obj: Object for which user should be returned.
195 :param session: Open :term:`db session`. This is optional in
196 some cases, i.e. one can be determined automatically if
197 ``obj`` is some kind of object already contained in a
198 session (e.g. ``Person``). But a ``session`` must be
199 provided if ``obj`` is a simple string and you need to do a
200 lookup by username etc.
202 :returns: :class:`~wuttjamaican.db.model.auth.User` or ``None``.
203 """
204 model = self.app.model
206 # maybe obj is already a user
207 if isinstance(obj, model.User):
208 return obj
210 # nb. these lookups require a db session
211 if session:
212 # or maybe it is a uuid
213 if isinstance(obj, _uuid.UUID):
214 user = session.get(model.User, obj)
215 if user:
216 return user
218 # or maybe it is a string
219 elif isinstance(obj, str):
220 # try to match on User.uuid
221 try:
222 user = session.get(model.User, _uuid.UUID(obj))
223 if user:
224 return user
225 except ValueError:
226 pass
228 # try to match on User.username
229 user = (
230 session.query(model.User).filter(model.User.username == obj).first()
231 )
232 if user:
233 return user
235 # nb. obj is presumbly another type of object, e.g. Person
237 # maybe we can find a person, then get user
238 person = self.app.get_person(obj)
239 if person:
240 return person.user
241 return None
243 def make_person(self, **kwargs):
244 """
245 Make and return a new
246 :class:`~wuttjamaican.db.model.base.Person`.
248 This is a convenience wrapper around
249 :class:`~wuttjamaican.people.PeopleHandler.make_person()`.
250 """
251 people = self.app.get_people_handler()
252 return people.make_person(**kwargs)
254 def make_user(self, session=None, **kwargs):
255 """
256 Make and return a new
257 :class:`~wuttjamaican.db.model.auth.User`.
259 This is mostly a simple wrapper around the
260 :class:`~wuttjamaican.db.model.auth.User` constructor. All
261 ``kwargs`` are passed on to the constructor as-is, for
262 instance. It also will add the user to the session, if
263 applicable.
265 This method also adds one other convenience:
267 If there is no ``username`` specified in the ``kwargs`` then
268 it will call :meth:`make_unique_username()` to automatically
269 provide one. (Note that the ``kwargs`` will be passed along
270 to that call as well.)
272 :param session: Open :term:`db session`, if applicable.
274 :returns: The new :class:`~wuttjamaican.db.model.auth.User`
275 instance.
276 """
277 model = self.app.model
279 if session and "username" not in kwargs:
280 kwargs["username"] = self.make_unique_username(session, **kwargs)
282 user = model.User(**kwargs)
283 if session:
284 session.add(user)
285 return user
287 def delete_user(self, user):
288 """
289 Delete the given user account. Use with caution! As this
290 generally cannot be undone.
292 Default behavior simply deletes the user account. Depending
293 on the DB schema and data present, this may cause an error
294 (i.e. if the user is still referenced by other tables).
296 :param user: :class:`~wuttjamaican.db.model.auth.User` to
297 delete.
298 """
299 session = self.app.get_session(user)
300 session.delete(user)
302 def make_preferred_username(
303 self, session, **kwargs
304 ): # pylint: disable=unused-argument
305 """
306 Generate a "preferred" username, using data from ``kwargs`` as
307 hints.
309 Note that ``kwargs`` should be of the same sort that might be
310 passed to the :class:`~wuttjamaican.db.model.auth.User`
311 constructor.
313 So far this logic is rather simple:
315 If ``kwargs`` contains ``person`` then a username will be
316 constructed using the name data from the person
317 (e.g. ``'john.doe'``).
319 In all other cases it will return ``'newuser'``.
321 .. note::
323 This method does not confirm if the username it generates
324 is actually "available" for a new user. See
325 :meth:`make_unique_username()` for that.
327 :param session: Open :term:`db session`.
329 :returns: Generated username as string.
330 """
331 person = kwargs.get("person")
332 if person:
333 first = (person.first_name or "").strip().lower()
334 last = (person.last_name or "").strip().lower()
335 if first and last:
336 return f"{first}.{last}"
337 if first:
338 return first
339 if last:
340 return last
342 return "newuser"
344 def make_unique_username(self, session, **kwargs):
345 """
346 Generate a *unique* username, using data from ``kwargs`` as
347 hints.
349 Note that ``kwargs`` should be of the same sort that might be
350 passed to the :class:`~wuttjamaican.db.model.auth.User`
351 constructor.
353 This method is a convenience which does two things:
355 First it calls :meth:`make_preferred_username()` to obtain the
356 "preferred" username. (It passes all ``kwargs`` along when it
357 makes that call.)
359 Then it checks to see if the resulting username is already
360 taken. If it is, then a "counter" is appended to the
361 username, and incremented until a username can be found which
362 is *not* yet taken.
364 It returns the first "available" (hence unique) username which
365 is found. Note that it is considered unique and therefore
366 available *at the time*; however this method does not
367 "reserve" the username in any way. It is assumed that you
368 would create the user yourself once you have the username.
370 :param session: Open :term:`db session`.
372 :returns: Username as string.
373 """
374 model = self.app.model
376 original_username = self.make_preferred_username(session, **kwargs)
377 username = original_username
379 # check for unique username
380 counter = 1
381 while True:
382 users = (
383 session.query(model.User)
384 .filter(model.User.username == username)
385 .count()
386 )
387 if not users:
388 break
389 username = f"{original_username}{counter:02d}"
390 counter += 1
392 return username
394 def set_user_password(self, user, password):
395 """
396 Set a user's password.
398 This will update the
399 :attr:`~wuttjamaican.db.model.auth.User.password` attribute
400 for the user. The value will be hashed using ``bcrypt``.
402 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
404 :param password: New password in plain text.
405 """
406 user.password = bcrypt.hashpw(
407 password.encode("utf-8"), bcrypt.gensalt()
408 ).decode("utf-8")
410 def get_role_administrator(self, session):
411 """
412 Returns the special "Administrator" role.
413 """
414 return self._special_role(
415 session, _uuid.UUID("d937fa8a965611dfa0dd001143047286"), "Administrator"
416 )
418 def get_role_anyone(self, session):
419 """
420 Returns the special "Anyone" role.
421 """
422 return self._special_role(
423 session, _uuid.UUID("f8a27c98965a11dfaff7001143047286"), "Anyone"
424 )
426 def get_role_anonymous(
427 self, session
428 ): # pragma: no cover; pylint: disable=empty-docstring
429 """ """
430 warnings.warn(
431 "AuthHandler.get_role_anonymous() is deprecated; "
432 "please use AuthHandler.get_role_anyone() instead",
433 DeprecationWarning,
434 stacklevel=2,
435 )
436 return self.get_role_anyone(session)
438 def get_role_authenticated(self, session):
439 """
440 Returns the special "Authenticated" role.
441 """
442 return self._special_role(
443 session, _uuid.UUID("b765a9cc331a11e6ac2a3ca9f40bc550"), "Authenticated"
444 )
446 def user_is_admin(self, user):
447 """
448 Check if given user is a member of the "Administrator" role.
450 :rtype: bool
451 """
452 if user:
453 session = self.app.get_session(user)
454 admin = self.get_role_administrator(session)
455 if admin in user.roles:
456 return True
458 return False
460 def get_permissions(
461 self, session, principal, include_anyone=True, include_authenticated=True
462 ):
463 """
464 Return a set of permission names, which represents all
465 permissions effectively granted to the given user or role.
467 :param session: Open :term:`db session`.
469 :param principal: :class:`~wuttjamaican.db.model.auth.User` or
470 :class:`~wuttjamaican.db.model.auth.Role` instance. Can
471 also be ``None``, in which case the "Anyone" role will be
472 assumed.
474 :param include_anyone: Whether the "Anyone" role should be
475 included when checking permissions. If ``False``, the
476 Anyone permissions will *not* be checked.
478 :param include_authenticated: Whether the "Authenticated" role
479 should be included when checking permissions.
481 :returns: Set of permission names.
482 :rtype: set
483 """
484 # we will use any `roles` attribute which may be present. in
485 # practice we would be assuming a User in this case
486 if hasattr(principal, "roles"):
487 roles = [role for role in principal.roles if self._role_is_pertinent(role)]
489 # here our User assumption gets a little more explicit
490 if include_authenticated:
491 roles.append(self.get_role_authenticated(session))
493 # otherwise a non-null principal is assumed to be a Role
494 elif principal is not None:
495 roles = [principal]
497 # fallback assumption is "no roles"
498 else:
499 roles = []
501 # maybe include anyone role
502 if include_anyone:
503 roles.append(self.get_role_anyone(session))
505 # build the permissions cache
506 cache = set()
507 for role in roles:
508 if hasattr(role, "permissions"):
509 cache.update(role.permissions)
511 return cache
513 def has_permission( # pylint: disable=too-many-arguments,too-many-positional-arguments
514 self,
515 session,
516 principal,
517 permission,
518 include_anyone=True,
519 include_authenticated=True,
520 ):
521 """
522 Check if the given user or role has been granted the given
523 permission.
525 .. note::
527 While this method is perfectly usable, it is a bit "heavy"
528 if you need to make multiple permission checks for the same
529 user. To optimize, call :meth:`get_permissions()` and keep
530 the result, then instead of calling ``has_permission()``
531 just check if a given permission is contained in the cached
532 result set.
534 (The logic just described is exactly what this method does,
535 except it will not keep the result set, hence calling it
536 multiple times for same user is not optimal.)
538 :param session: Open :term:`db session`.
540 :param principal: Either a
541 :class:`~wuttjamaican.db.model.auth.User` or
542 :class:`~wuttjamaican.db.model.auth.Role` instance. It is
543 also expected that this may sometimes be ``None``, in which
544 case the "Anyone" role will be assumed.
546 :param permission: Name of the permission for which to check.
548 :param include_anyone: Whether the "Anyone" role should be
549 included when checking permissions. If ``False``, then
550 Anyone permissions will *not* be checked.
552 :param include_authenticated: Whether the "Authenticated" role
553 should be included when checking permissions.
555 :returns: Boolean indicating if the permission is granted.
556 """
557 perms = self.get_permissions(
558 session,
559 principal,
560 include_anyone=include_anyone,
561 include_authenticated=include_authenticated,
562 )
563 return permission in perms
565 def grant_permission(self, role, permission):
566 """
567 Grant a permission to the role. If the role already has the
568 permission, nothing is done.
570 :param role: :class:`~wuttjamaican.db.model.auth.Role`
571 instance.
573 :param permission: Name of the permission as string.
574 """
575 if permission not in role.permissions:
576 role.permissions.append(permission)
578 def revoke_permission(self, role, permission):
579 """
580 Revoke a permission from the role. If the role does not have
581 the permission, nothing is done.
583 :param role: A :class:`~rattail.db.model.users.Role` instance.
585 :param permission: Name of the permission as string.
586 """
587 if permission in role.permissions:
588 role.permissions.remove(permission)
590 ##############################
591 # API token methods
592 ##############################
594 def add_api_token(self, user, description):
595 """
596 Add and return a new API token for the user.
598 This calls :meth:`generate_api_token_string()` to obtain the
599 actual token string.
601 See also :meth:`delete_api_token()`.
603 :param user: :class:`~wuttjamaican.db.model.auth.User`
604 instance for which to add the token.
606 :param description: String description for the token.
608 :rtype: :class:`~wuttjamaican.db.model.auth.UserAPIToken`
609 """
610 model = self.app.model
611 session = self.app.get_session(user)
613 # generate raw token
614 token_string = self.generate_api_token_string()
616 # persist token in DB
617 token = model.UserAPIToken(description=description, token_string=token_string)
618 user.api_tokens.append(token)
619 session.add(token)
621 return token
623 def generate_api_token_string(self):
624 """
625 Generate a new *raw* API token string.
627 This is called by :meth:`add_api_token()`.
629 :returns: Raw API token string.
630 """
631 return secrets.token_urlsafe()
633 def delete_api_token(self, token):
634 """
635 Delete the given API token.
637 See also :meth:`add_api_token()`.
639 :param token:
640 :class:`~wuttjamaican.db.model.auth.UserAPIToken` instance.
641 """
642 session = self.app.get_session(token)
643 session.delete(token)
645 ##############################
646 # internal methods
647 ##############################
649 def _role_is_pertinent(self, role): # pylint: disable=unused-argument
650 """
651 Check the role to ensure it is "pertinent" for the current app.
653 The idea behind this is for sake of a multi-node system, where
654 users and roles are synced between nodes. Some roles may be
655 defined for only certain types of nodes and hence not
656 "pertinent" for all nodes.
658 As of now there is no actual support for that, but this stub
659 method exists for when it will.
660 """
661 return True
663 def _special_role(self, session, uuid, name):
664 """
665 Fetch a "special" role, creating if needed.
666 """
667 model = self.app.model
668 role = session.get(model.Role, uuid)
669 if not role:
670 role = model.Role(uuid=uuid, name=name)
671 session.add(role)
672 return role