Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/auth.py: 100%
167 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 13:14 -0500
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 13:14 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2025 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 secrets
30import uuid as _uuid
32import bcrypt
34from wuttjamaican.app import GenericHandler
37class AuthHandler(GenericHandler): # pylint: disable=too-many-public-methods
38 """
39 Base class and default implementation for the :term:`auth
40 handler`.
42 This is responsible for "authentication and authorization" - for
43 instance:
45 * authenticate user from login credentials
46 * check which permissions a user/role has
47 * create/modify users, roles
48 * grant/revoke role permissions
49 """
51 def authenticate_user(self, session, username, password):
52 """
53 Authenticate the given user credentials, and if successful,
54 return the :class:`~wuttjamaican.db.model.auth.User`.
56 Default logic will (try to) locate a user with matching
57 username, then confirm the supplied password is also a match.
59 Custom handlers can authenticate against anything else, using
60 the given credentials. But they still must return a "native"
61 ``User`` object for the app to consider the authentication
62 successful. The handler may auto-create the user if needed.
64 Generally speaking the credentials will have come directly
65 from a user login attempt in the web app etc. Again the
66 default logic assumes a "username" but in practice it may be
67 an email address etc. - whatever the user entered.
69 See also :meth:`authenticate_user_token()`.
71 :param session: Open :term:`db session`.
73 :param username: Usually a string, but also may be a
74 :class:`~wuttjamaican.db.model.auth.User` instance, in
75 which case no user lookup will occur. (However the user is
76 still authenticated otherwise, i.e. the password must be
77 correct etc.)
79 :param password: Password as string.
81 :returns: :class:`~wuttjamaican.db.model.auth.User` instance,
82 or ``None``.
83 """
84 user = self.get_user(username, session=session)
85 if user and user.active and user.password:
86 if self.check_user_password(user, password):
87 return user
88 return None
90 def authenticate_user_token(self, session, token):
91 """
92 Authenticate the given user API token string, and if valid,
93 return the corresponding user.
95 See also :meth:`authenticate_user()`.
97 :param session: Open :term:`db session`.
99 :param token: Raw token string for the user.
101 :returns: :class:`~wuttjamaican.db.model.auth.User` instance,
102 or ``None``.
103 """
104 from sqlalchemy import orm # pylint: disable=import-outside-toplevel
106 model = self.app.model
108 try:
109 token = (
110 session.query(model.UserAPIToken)
111 .filter(model.UserAPIToken.token_string == token)
112 .one()
113 )
114 except orm.exc.NoResultFound:
115 pass
116 else:
117 user = token.user
118 if user.active:
119 return user
120 return None
122 def check_user_password(self, user, password):
123 """
124 Check a user's password.
126 This will hash the given password and compare it to the hashed
127 password we have on file for the given user account.
129 This is normally part of the login process, so the
130 ``password`` param refers to the password entered by a user;
131 this method will determine if it was correct.
133 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
135 :param password: User-entered password in plain text.
137 :returns: ``True`` if password matches; else ``False``.
138 """
139 return bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8"))
141 def get_role(self, session, key):
142 """
143 Locate and return a :class:`~wuttjamaican.db.model.auth.Role`
144 per the given key, if possible.
146 :param session: Open :term:`db session`.
148 :param key: Value to use when searching for the role. Can be
149 a UUID or name of a role.
151 :returns: :class:`~wuttjamaican.db.model.auth.Role` instance;
152 or ``None``.
153 """
154 model = self.app.model
156 if not key:
157 return None
159 # maybe it is a uuid
160 if isinstance(key, _uuid.UUID):
161 role = session.get(model.Role, key)
162 if role:
163 return role
165 else: # assuming it is a string
166 # try to match on Role.uuid
167 try:
168 role = session.get(model.Role, _uuid.UUID(key))
169 if role:
170 return role
171 except ValueError:
172 pass
174 # try to match on Role.name
175 role = session.query(model.Role).filter_by(name=key).first()
176 if role:
177 return role
179 # try settings; if value then recurse
180 key = self.config.get(f"{self.appname}.role.{key}", session=session)
181 if key:
182 return self.get_role(session, key)
183 return None
185 def get_user(self, obj, session=None):
186 """
187 Return the :class:`~wuttjamaican.db.model.auth.User`
188 associated with the given object, if one can be found.
190 This method should accept "any" type of ``obj`` and inspect it
191 to determine if/how a user can be found. It should return the
192 "first, most obvious" user in the event that the given object
193 is associated with multiple users.
195 For instance ``obj`` may be a string in which case a lookup
196 may be tried on
197 :attr:`~wuttjamaican.db.model.auth.User.username`. Or it may
198 be a :class:`~wuttjamaican.db.model.base.Person` in which case
199 their :attr:`~wuttjamaican.db.model.base.Person.user` may be
200 returned.
202 :param obj: Object for which user should be returned.
204 :param session: Open :term:`db session`. This is optional in
205 some cases, i.e. one can be determined automatically if
206 ``obj`` is some kind of object already contained in a
207 session (e.g. ``Person``). But a ``session`` must be
208 provided if ``obj`` is a simple string and you need to do a
209 lookup by username etc.
211 :returns: :class:`~wuttjamaican.db.model.auth.User` or ``None``.
212 """
213 model = self.app.model
215 # maybe obj is already a user
216 if isinstance(obj, model.User):
217 return obj
219 # nb. these lookups require a db session
220 if session:
221 # or maybe it is a uuid
222 if isinstance(obj, _uuid.UUID):
223 user = session.get(model.User, obj)
224 if user:
225 return user
227 # or maybe it is a string
228 elif isinstance(obj, str):
229 # try to match on User.uuid
230 try:
231 user = session.get(model.User, _uuid.UUID(obj))
232 if user:
233 return user
234 except ValueError:
235 pass
237 # try to match on User.username
238 user = (
239 session.query(model.User).filter(model.User.username == obj).first()
240 )
241 if user:
242 return user
244 # nb. obj is presumbly another type of object, e.g. Person
246 # maybe we can find a person, then get user
247 person = self.app.get_person(obj)
248 if person:
249 return person.user
250 return None
252 def make_person(self, **kwargs):
253 """
254 Make and return a new
255 :class:`~wuttjamaican.db.model.base.Person`.
257 This is a convenience wrapper around
258 :class:`~wuttjamaican.people.PeopleHandler.make_person()`.
259 """
260 people = self.app.get_people_handler()
261 return people.make_person(**kwargs)
263 def make_user(self, session=None, **kwargs):
264 """
265 Make and return a new
266 :class:`~wuttjamaican.db.model.auth.User`.
268 This is mostly a simple wrapper around the
269 :class:`~wuttjamaican.db.model.auth.User` constructor. All
270 ``kwargs`` are passed on to the constructor as-is, for
271 instance. It also will add the user to the session, if
272 applicable.
274 This method also adds one other convenience:
276 If there is no ``username`` specified in the ``kwargs`` then
277 it will call :meth:`make_unique_username()` to automatically
278 provide one. (Note that the ``kwargs`` will be passed along
279 to that call as well.)
281 :param session: Open :term:`db session`, if applicable.
283 :returns: The new :class:`~wuttjamaican.db.model.auth.User`
284 instance.
285 """
286 model = self.app.model
288 if session and "username" not in kwargs:
289 kwargs["username"] = self.make_unique_username(session, **kwargs)
291 user = model.User(**kwargs)
292 if session:
293 session.add(user)
294 return user
296 def delete_user(self, user):
297 """
298 Delete the given user account. Use with caution! As this
299 generally cannot be undone.
301 Default behavior simply deletes the user account. Depending
302 on the DB schema and data present, this may cause an error
303 (i.e. if the user is still referenced by other tables).
305 :param user: :class:`~wuttjamaican.db.model.auth.User` to
306 delete.
307 """
308 session = self.app.get_session(user)
309 session.delete(user)
311 def make_preferred_username(
312 self, session, **kwargs
313 ): # pylint: disable=unused-argument
314 """
315 Generate a "preferred" username, using data from ``kwargs`` as
316 hints.
318 Note that ``kwargs`` should be of the same sort that might be
319 passed to the :class:`~wuttjamaican.db.model.auth.User`
320 constructor.
322 So far this logic is rather simple:
324 If ``kwargs`` contains ``person`` then a username will be
325 constructed using the name data from the person
326 (e.g. ``'john.doe'``).
328 In all other cases it will return ``'newuser'``.
330 .. note::
332 This method does not confirm if the username it generates
333 is actually "available" for a new user. See
334 :meth:`make_unique_username()` for that.
336 :param session: Open :term:`db session`.
338 :returns: Generated username as string.
339 """
340 person = kwargs.get("person")
341 if person:
342 first = (person.first_name or "").strip().lower()
343 last = (person.last_name or "").strip().lower()
344 if first and last:
345 return f"{first}.{last}"
346 if first:
347 return first
348 if last:
349 return last
351 return "newuser"
353 def make_unique_username(self, session, **kwargs):
354 """
355 Generate a *unique* username, using data from ``kwargs`` as
356 hints.
358 Note that ``kwargs`` should be of the same sort that might be
359 passed to the :class:`~wuttjamaican.db.model.auth.User`
360 constructor.
362 This method is a convenience which does two things:
364 First it calls :meth:`make_preferred_username()` to obtain the
365 "preferred" username. (It passes all ``kwargs`` along when it
366 makes that call.)
368 Then it checks to see if the resulting username is already
369 taken. If it is, then a "counter" is appended to the
370 username, and incremented until a username can be found which
371 is *not* yet taken.
373 It returns the first "available" (hence unique) username which
374 is found. Note that it is considered unique and therefore
375 available *at the time*; however this method does not
376 "reserve" the username in any way. It is assumed that you
377 would create the user yourself once you have the username.
379 :param session: Open :term:`db session`.
381 :returns: Username as string.
382 """
383 model = self.app.model
385 original_username = self.make_preferred_username(session, **kwargs)
386 username = original_username
388 # check for unique username
389 counter = 1
390 while True:
391 users = (
392 session.query(model.User)
393 .filter(model.User.username == username)
394 .count()
395 )
396 if not users:
397 break
398 username = f"{original_username}{counter:02d}"
399 counter += 1
401 return username
403 def set_user_password(self, user, password):
404 """
405 Set a user's password.
407 This will update the
408 :attr:`~wuttjamaican.db.model.auth.User.password` attribute
409 for the user. The value will be hashed using ``bcrypt``.
411 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
413 :param password: New password in plain text.
414 """
415 user.password = bcrypt.hashpw(
416 password.encode("utf-8"), bcrypt.gensalt()
417 ).decode("utf-8")
419 def get_role_administrator(self, session):
420 """
421 Returns the special "Administrator" role.
422 """
423 return self._special_role(
424 session, _uuid.UUID("d937fa8a965611dfa0dd001143047286"), "Administrator"
425 )
427 def get_role_anonymous(self, session):
428 """
429 Returns the special "Anonymous" (aka. "Guest") role.
430 """
431 return self._special_role(
432 session, _uuid.UUID("f8a27c98965a11dfaff7001143047286"), "Anonymous"
433 )
435 def get_role_authenticated(self, session):
436 """
437 Returns the special "Authenticated" role.
438 """
439 return self._special_role(
440 session, _uuid.UUID("b765a9cc331a11e6ac2a3ca9f40bc550"), "Authenticated"
441 )
443 def user_is_admin(self, user):
444 """
445 Check if given user is a member of the "Administrator" role.
447 :rtype: bool
448 """
449 if user:
450 session = self.app.get_session(user)
451 admin = self.get_role_administrator(session)
452 if admin in user.roles:
453 return True
455 return False
457 def get_permissions(
458 self, session, principal, include_anonymous=True, include_authenticated=True
459 ):
460 """
461 Return a set of permission names, which represents all
462 permissions effectively granted to the given user or role.
464 :param session: Open :term:`db session`.
466 :param principal: :class:`~wuttjamaican.db.model.auth.User` or
467 :class:`~wuttjamaican.db.model.auth.Role` instance. Can
468 also be ``None``, in which case the "Anonymous" role will
469 be assumed.
471 :param include_anonymous: Whether the "Anonymous" role should
472 be included when checking permissions. If ``False``, the
473 Anonymous permissions will *not* be checked.
475 :param include_authenticated: Whether the "Authenticated" role
476 should be included when checking permissions.
478 :returns: Set of permission names.
479 :rtype: set
480 """
481 # we will use any `roles` attribute which may be present. in
482 # practice we would be assuming a User in this case
483 if hasattr(principal, "roles"):
484 roles = [role for role in principal.roles if self._role_is_pertinent(role)]
486 # here our User assumption gets a little more explicit
487 if include_authenticated:
488 roles.append(self.get_role_authenticated(session))
490 # otherwise a non-null principal is assumed to be a Role
491 elif principal is not None:
492 roles = [principal]
494 # fallback assumption is "no roles"
495 else:
496 roles = []
498 # maybe include anonymous role
499 if include_anonymous:
500 roles.append(self.get_role_anonymous(session))
502 # build the permissions cache
503 cache = set()
504 for role in roles:
505 if hasattr(role, "permissions"):
506 cache.update(role.permissions)
508 return cache
510 def has_permission( # pylint: disable=too-many-arguments,too-many-positional-arguments
511 self,
512 session,
513 principal,
514 permission,
515 include_anonymous=True,
516 include_authenticated=True,
517 ):
518 """
519 Check if the given user or role has been granted the given
520 permission.
522 .. note::
524 While this method is perfectly usable, it is a bit "heavy"
525 if you need to make multiple permission checks for the same
526 user. To optimize, call :meth:`get_permissions()` and keep
527 the result, then instead of calling ``has_permission()``
528 just check if a given permission is contained in the cached
529 result set.
531 (The logic just described is exactly what this method does,
532 except it will not keep the result set, hence calling it
533 multiple times for same user is not optimal.)
535 :param session: Open :term:`db session`.
537 :param principal: Either a
538 :class:`~wuttjamaican.db.model.auth.User` or
539 :class:`~wuttjamaican.db.model.auth.Role` instance. It is
540 also expected that this may sometimes be ``None``, in which
541 case the "Anonymous" role will be assumed.
543 :param permission: Name of the permission for which to check.
545 :param include_anonymous: Whether the "Anonymous" role should
546 be included when checking permissions. If ``False``, then
547 Anonymous permissions will *not* be checked.
549 :param include_authenticated: Whether the "Authenticated" role
550 should be included when checking permissions.
552 :returns: Boolean indicating if the permission is granted.
553 """
554 perms = self.get_permissions(
555 session,
556 principal,
557 include_anonymous=include_anonymous,
558 include_authenticated=include_authenticated,
559 )
560 return permission in perms
562 def grant_permission(self, role, permission):
563 """
564 Grant a permission to the role. If the role already has the
565 permission, nothing is done.
567 :param role: :class:`~wuttjamaican.db.model.auth.Role`
568 instance.
570 :param permission: Name of the permission as string.
571 """
572 if permission not in role.permissions:
573 role.permissions.append(permission)
575 def revoke_permission(self, role, permission):
576 """
577 Revoke a permission from the role. If the role does not have
578 the permission, nothing is done.
580 :param role: A :class:`~rattail.db.model.users.Role` instance.
582 :param permission: Name of the permission as string.
583 """
584 if permission in role.permissions:
585 role.permissions.remove(permission)
587 ##############################
588 # API token methods
589 ##############################
591 def add_api_token(self, user, description):
592 """
593 Add and return a new API token for the user.
595 This calls :meth:`generate_api_token_string()` to obtain the
596 actual token string.
598 See also :meth:`delete_api_token()`.
600 :param user: :class:`~wuttjamaican.db.model.auth.User`
601 instance for which to add the token.
603 :param description: String description for the token.
605 :rtype: :class:`~wuttjamaican.db.model.auth.UserAPIToken`
606 """
607 model = self.app.model
608 session = self.app.get_session(user)
610 # generate raw token
611 token_string = self.generate_api_token_string()
613 # persist token in DB
614 token = model.UserAPIToken(description=description, token_string=token_string)
615 user.api_tokens.append(token)
616 session.add(token)
618 return token
620 def generate_api_token_string(self):
621 """
622 Generate a new *raw* API token string.
624 This is called by :meth:`add_api_token()`.
626 :returns: Raw API token string.
627 """
628 return secrets.token_urlsafe()
630 def delete_api_token(self, token):
631 """
632 Delete the given API token.
634 See also :meth:`add_api_token()`.
636 :param token:
637 :class:`~wuttjamaican.db.model.auth.UserAPIToken` instance.
638 """
639 session = self.app.get_session(token)
640 session.delete(token)
642 ##############################
643 # internal methods
644 ##############################
646 def _role_is_pertinent(self, role): # pylint: disable=unused-argument
647 """
648 Check the role to ensure it is "pertinent" for the current app.
650 The idea behind this is for sake of a multi-node system, where
651 users and roles are synced between nodes. Some roles may be
652 defined for only certain types of nodes and hence not
653 "pertinent" for all nodes.
655 As of now there is no actual support for that, but this stub
656 method exists for when it will.
657 """
658 return True
660 def _special_role(self, session, uuid, name):
661 """
662 Fetch a "special" role, creating if needed.
663 """
664 model = self.app.model
665 role = session.get(model.Role, uuid)
666 if not role:
667 role = model.Role(uuid=uuid, name=name)
668 session.add(role)
669 return role