Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/auth.py: 100%
123 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-08-05 15:33 -0500
« prev ^ index » next coverage.py v7.3.2, created at 2024-08-05 15: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"""
29from wuttjamaican.app import GenericHandler
32# nb. this only works if passlib is installed (part of 'db' extra)
33try:
34 from passlib.context import CryptContext
35except ImportError: # pragma: no cover
36 pass
37else:
38 password_context = CryptContext(schemes=['bcrypt'])
42class AuthHandler(GenericHandler):
43 """
44 Base class and default implementation for the :term:`auth
45 handler`.
47 This is responsible for "authentication and authorization" - for
48 instance:
50 * authenticate user from login credentials
51 * check which permissions a user/role has
52 * create/modify users, roles
53 * grant/revoke role permissions
54 """
56 def authenticate_user(self, session, username, password, **kwargs):
57 """
58 Authenticate the given user credentials, and if successful,
59 return the :class:`~wuttjamaican.db.model.auth.User`.
61 Default logic will (try to) locate a user with matching
62 username, then confirm the supplied password is also a match.
64 Custom handlers can authenticate against anything else, using
65 the given credentials. But they still must return a "native"
66 ``User`` object for the app to consider the authentication
67 successful. The handler may auto-create the user if needed.
69 Generally speaking the credentials will have come directly
70 from a user login attempt in the web app etc. Again the
71 default logic assumes a "username" but in practice it may be
72 an email address etc. - whatever the user entered.
74 :param session: Open :term:`db session`.
76 :param username: Usually a string, but also may be a
77 :class:`~wuttjamaican.db.model.auth.User` instance, in
78 which case no user lookup will occur. (However the user is
79 still authenticated otherwise, i.e. the password must be
80 correct etc.)
82 :param password: Password as string.
84 :returns: :class:`~wuttjamaican.db.model.auth.User` instance,
85 or ``None``.
86 """
87 user = self.get_user(username, session=session)
88 if user and user.active and user.password:
89 if self.check_user_password(user, password):
90 return user
92 def check_user_password(self, user, password, **kwargs):
93 """
94 Check a user's password.
96 This will hash the given password and compare it to the hashed
97 password we have on file for the given user account.
99 This is normally part of the login process, so the
100 ``password`` param refers to the password entered by a user;
101 this method will determine if it was correct.
103 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
105 :param password: User-entered password in plain text.
107 :returns: ``True`` if password matches; else ``False``.
108 """
109 return password_context.verify(password, user.password)
111 def get_role(self, session, key, **kwargs):
112 """
113 Locate and return a :class:`~wuttjamaican.db.model.auth.Role`
114 per the given key, if possible.
116 :param session: Open :term:`db session`.
118 :param key: Value to use when searching for the role. Can be
119 a UUID or name of a role.
121 :returns: :class:`~wuttjamaican.db.model.auth.Role` instance;
122 or ``None``.
123 """
124 model = self.app.model
126 if not key:
127 return
129 # try to match on Role.uuid
130 role = session.get(model.Role, key)
131 if role:
132 return role
134 # try to match on Role.name
135 role = session.query(model.Role)\
136 .filter_by(name=key)\
137 .first()
138 if role:
139 return role
141 # try settings; if value then recurse
142 key = self.config.get(f'{self.appname}.role.{key}',
143 session=session)
144 if key:
145 return self.get_role(session, key)
147 def get_user(self, obj, session=None, **kwargs):
148 """
149 Return the :class:`~wuttjamaican.db.model.auth.User`
150 associated with the given object, if one can be found.
152 This method should accept "any" type of ``obj`` and inspect it
153 to determine if/how a user can be found. It should return the
154 "first, most obvious" user in the event that the given object
155 is associated with multiple users.
157 For instance ``obj`` may be a string in which case a lookup
158 may be tried on
159 :attr:`~wuttjamaican.db.model.auth.User.username`. Or it may
160 be a :class:`~wuttjamaican.db.model.base.Person` in which case
161 their :attr:`~wuttjamaican.db.model.base.Person.user` may be
162 returned.
164 :param obj: Object for which user should be returned.
166 :param session: Open :term:`db session`. This is optional in
167 some cases, i.e. one can be determined automatically if
168 ``obj`` is some kind of object already contained in a
169 session (e.g. ``Person``). But a ``session`` must be
170 provided if ``obj`` is a simple string and you need to do a
171 lookup by username etc.
173 :returns: :class:`~wuttjamaican.db.model.auth.User` or ``None``.
174 """
175 model = self.app.model
177 # maybe obj is already a user
178 if isinstance(obj, model.User):
179 return obj
181 # or maybe it is a string
182 # (nb. these lookups require a db session)
183 if isinstance(obj, str) and session:
185 # try to match on User.uuid
186 user = session.get(model.User, obj)
187 if user:
188 return user
190 # try to match on User.username
191 user = session.query(model.User)\
192 .filter(model.User.username == obj)\
193 .first()
194 if user:
195 return user
197 # nb. obj is presumbly another type of object, e.g. Person
199 # maybe we can find a person, then get user
200 person = self.app.get_person(obj)
201 if person:
202 return person.user
204 def make_user(self, session=None, **kwargs):
205 """
206 Make and return a new
207 :class:`~wuttjamaican.db.model.auth.User`.
209 This is mostly a simple wrapper around the
210 :class:`~wuttjamaican.db.model.auth.User` constructor. All
211 ``kwargs`` are passed on to the constructor as-is, for
212 instance. It also will add the user to the session, if
213 applicable.
215 This method also adds one other convenience:
217 If there is no ``username`` specified in the ``kwargs`` then
218 it will call :meth:`make_unique_username()` to automatically
219 provide one. (Note that the ``kwargs`` will be passed along
220 to that call as well.)
222 :param session: Open :term:`db session`, if applicable.
224 :returns: The new :class:`~wuttjamaican.db.model.auth.User`
225 instance.
226 """
227 model = self.app.model
229 if session and 'username' not in kwargs:
230 kwargs['username'] = self.make_unique_username(session, **kwargs)
232 user = model.User(**kwargs)
233 if session:
234 session.add(user)
235 return user
237 def delete_user(self, user, **kwargs):
238 """
239 Delete the given user account. Use with caution! As this
240 generally cannot be undone.
242 Default behavior simply deletes the user account. Depending
243 on the DB schema and data present, this may cause an error
244 (i.e. if the user is still referenced by other tables).
246 :param user: :class:`~wuttjamaican.db.model.auth.User` to
247 delete.
248 """
249 session = self.app.get_session(user)
250 session.delete(user)
252 def make_preferred_username(self, session, **kwargs):
253 """
254 Generate a "preferred" username, using data from ``kwargs`` as
255 hints.
257 Note that ``kwargs`` should be of the same sort that might be
258 passed to the :class:`~wuttjamaican.db.model.auth.User`
259 constructor.
261 So far this logic is rather simple:
263 If ``kwargs`` contains ``person`` then a username will be
264 constructed using the name data from the person
265 (e.g. ``'john.doe'``).
267 In all other cases it will return ``'newuser'``.
269 .. note::
271 This method does not confirm if the username it generates
272 is actually "available" for a new user. See
273 :meth:`make_unique_username()` for that.
275 :param session: Open :term:`db session`.
277 :returns: Generated username as string.
278 """
279 person = kwargs.get('person')
280 if person:
281 first = (person.first_name or '').strip().lower()
282 last = (person.last_name or '').strip().lower()
283 if first and last:
284 return f'{first}.{last}'
285 if first:
286 return first
287 if last:
288 return last
290 return 'newuser'
292 def make_unique_username(self, session, **kwargs):
293 """
294 Generate a *unique* username, using data from ``kwargs`` as
295 hints.
297 Note that ``kwargs`` should be of the same sort that might be
298 passed to the :class:`~wuttjamaican.db.model.auth.User`
299 constructor.
301 This method is a convenience which does two things:
303 First it calls :meth:`make_preferred_username()` to obtain the
304 "preferred" username. (It passes all ``kwargs`` along when it
305 makes that call.)
307 Then it checks to see if the resulting username is already
308 taken. If it is, then a "counter" is appended to the
309 username, and incremented until a username can be found which
310 is *not* yet taken.
312 It returns the first "available" (hence unique) username which
313 is found. Note that it is considered unique and therefore
314 available *at the time*; however this method does not
315 "reserve" the username in any way. It is assumed that you
316 would create the user yourself once you have the username.
318 :param session: Open :term:`db session`.
320 :returns: Username as string.
321 """
322 model = self.app.model
324 original_username = self.make_preferred_username(session, **kwargs)
325 username = original_username
327 # check for unique username
328 counter = 1
329 while True:
330 users = session.query(model.User)\
331 .filter(model.User.username == username)\
332 .count()
333 if not users:
334 break
335 username = f"{original_username}{counter:02d}"
336 counter += 1
338 return username
340 def set_user_password(self, user, password, **kwargs):
341 """
342 Set a user's password.
344 This will update the
345 :attr:`~wuttjamaican.db.model.auth.User.password` attribute
346 for the user. The value will be hashed using ``bcrypt``.
348 :param user: :class:`~wuttjamaican.db.model.auth.User` instance.
350 :param password: New password in plain text.
351 """
352 user.password = password_context.hash(password)
354 def get_role_administrator(self, session, **kwargs):
355 """
356 Returns the special "Administrator" role.
357 """
358 return self._special_role(session, 'd937fa8a965611dfa0dd001143047286', "Administrator")
360 def get_role_anonymous(self, session, **kwargs):
361 """
362 Returns the special "Anonymous" (aka. "Guest") role.
363 """
364 return self._special_role(session, 'f8a27c98965a11dfaff7001143047286', "Anonymous")
366 def get_role_authenticated(self, session, **kwargs):
367 """
368 Returns the special "Authenticated" role.
369 """
370 return self._special_role(session, 'b765a9cc331a11e6ac2a3ca9f40bc550', "Authenticated")
372 def user_is_admin(self, user, **kwargs):
373 """
374 Check if given user is a member of the "Administrator" role.
376 :rtype: bool
377 """
378 if user:
379 session = self.app.get_session(user)
380 admin = self.get_role_administrator(session)
381 if admin in user.roles:
382 return True
384 return False
386 def get_permissions(self, session, principal,
387 include_anonymous=True,
388 include_authenticated=True):
389 """
390 Return a set of permission names, which represents all
391 permissions effectively granted to the given user or role.
393 :param session: Open :term:`db session`.
395 :param principal: :class:`~wuttjamaican.db.model.auth.User` or
396 :class:`~wuttjamaican.db.model.auth.Role` instance. Can
397 also be ``None``, in which case the "Anonymous" role will
398 be assumed.
400 :param include_anonymous: Whether the "Anonymous" role should
401 be included when checking permissions. If ``False``, the
402 Anonymous permissions will *not* be checked.
404 :param include_authenticated: Whether the "Authenticated" role
405 should be included when checking permissions.
407 :returns: Set of permission names.
408 :rtype: set
409 """
410 # we will use any `roles` attribute which may be present. in
411 # practice we would be assuming a User in this case
412 if hasattr(principal, 'roles'):
413 roles = [role
414 for role in principal.roles
415 if self._role_is_pertinent(role)]
417 # here our User assumption gets a little more explicit
418 if include_authenticated:
419 roles.append(self.get_role_authenticated(session))
421 # otherwise a non-null principal is assumed to be a Role
422 elif principal is not None:
423 roles = [principal]
425 # fallback assumption is "no roles"
426 else:
427 roles = []
429 # maybe include anonymous role
430 if include_anonymous:
431 roles.append(self.get_role_anonymous(session))
433 # build the permissions cache
434 cache = set()
435 for role in roles:
436 if hasattr(role, 'permissions'):
437 cache.update(role.permissions)
439 return cache
441 def has_permission(self, session, principal, permission,
442 include_anonymous=True,
443 include_authenticated=True):
444 """
445 Check if the given user or role has been granted the given
446 permission.
448 .. note::
450 While this method is perfectly usable, it is a bit "heavy"
451 if you need to make multiple permission checks for the same
452 user. To optimize, call :meth:`get_permissions()` and keep
453 the result, then instead of calling ``has_permission()``
454 just check if a given permission is contained in the cached
455 result set.
457 (The logic just described is exactly what this method does,
458 except it will not keep the result set, hence calling it
459 multiple times for same user is not optimal.)
461 :param session: Open :term:`db session`.
463 :param principal: Either a
464 :class:`~wuttjamaican.db.model.auth.User` or
465 :class:`~wuttjamaican.db.model.auth.Role` instance. It is
466 also expected that this may sometimes be ``None``, in which
467 case the "Anonymous" role will be assumed.
469 :param permission: Name of the permission for which to check.
471 :param include_anonymous: Whether the "Anonymous" role should
472 be included when checking permissions. If ``False``, then
473 Anonymous permissions will *not* be checked.
475 :param include_authenticated: Whether the "Authenticated" role
476 should be included when checking permissions.
478 :returns: Boolean indicating if the permission is granted.
479 """
480 perms = self.get_permissions(session, principal,
481 include_anonymous=include_anonymous,
482 include_authenticated=include_authenticated)
483 return permission in perms
485 def grant_permission(self, role, permission, **kwargs):
486 """
487 Grant a permission to the role. If the role already has the
488 permission, nothing is done.
490 :param role: :class:`~wuttjamaican.db.model.auth.Role`
491 instance.
493 :param permission: Name of the permission as string.
494 """
495 if permission not in role.permissions:
496 role.permissions.append(permission)
498 def revoke_permission(self, role, permission, **kwargs):
499 """
500 Revoke a permission from the role. If the role does not have
501 the permission, nothing is done.
503 :param role: A :class:`~rattail.db.model.users.Role` instance.
505 :param permission: Name of the permission as string.
506 """
507 if permission in role.permissions:
508 role.permissions.remove(permission)
510 ##############################
511 # internal methods
512 ##############################
514 def _role_is_pertinent(self, role):
515 """
516 Check the role to ensure it is "pertinent" for the current app.
518 The idea behind this is for sake of a multi-node system, where
519 users and roles are synced between nodes. Some roles may be
520 defined for only certain types of nodes and hence not
521 "pertinent" for all nodes.
523 As of now there is no actual support for that, but this stub
524 method exists for when it will.
525 """
526 return True
528 def _special_role(self, session, uuid, name):
529 """
530 Fetch a "special" role, creating if needed.
531 """
532 model = self.app.model
533 role = session.get(model.Role, uuid)
534 if not role:
535 role = model.Role(uuid=uuid, name=name)
536 session.add(role)
537 return role