From a721e63275c01b0b561fdb116d28631cbc05b8ab Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 8 Aug 2025 22:40:52 -0500 Subject: [PATCH 1/3] feat: add user API tokens; handler methods to manage/authenticate --- src/wuttjamaican/auth.py | 91 ++++++++++++++++++- .../efdcb2c75034_add_user_api_token.py | 39 ++++++++ src/wuttjamaican/db/model/__init__.py | 5 +- src/wuttjamaican/db/model/auth.py | 47 +++++++++- tests/db/model/test_auth.py | 9 ++ tests/test_auth.py | 55 +++++++++++ 6 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py diff --git a/src/wuttjamaican/auth.py b/src/wuttjamaican/auth.py index c69146d..2f8abea 100644 --- a/src/wuttjamaican/auth.py +++ b/src/wuttjamaican/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2024 Lance Edgar +# Copyright © 2023-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -26,8 +26,11 @@ Auth Handler This defines the default :term:`auth handler`. """ +import secrets import uuid as _uuid +from sqlalchemy import orm + from wuttjamaican.app import GenericHandler @@ -73,6 +76,8 @@ class AuthHandler(GenericHandler): default logic assumes a "username" but in practice it may be an email address etc. - whatever the user entered. + See also :meth:`authenticate_user_token()`. + :param session: Open :term:`db session`. :param username: Usually a string, but also may be a @@ -91,6 +96,33 @@ class AuthHandler(GenericHandler): if self.check_user_password(user, password): return user + def authenticate_user_token(self, session, token): + """ + Authenticate the given user API token string, and if valid, + return the corresponding user. + + See also :meth:`authenticate_user()`. + + :param session: Open :term:`db session`. + + :param token: Raw token string for the user. + + :returns: :class:`~wuttjamaican.db.model.auth.User` instance, + or ``None``. + """ + model = self.app.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 + def check_user_password(self, user, password, **kwargs): """ Check a user's password. @@ -545,6 +577,63 @@ class AuthHandler(GenericHandler): if permission in role.permissions: role.permissions.remove(permission) + ############################## + # API token methods + ############################## + + def add_api_token(self, user, description): + """ + Add and return a new API token for the user. + + This calls :meth:`generate_api_token_string()` to obtain the + actual token string. + + See also :meth:`delete_api_token()`. + + :param user: :class:`~wuttjamaican.db.model.auth.User` + instance for which to add the token. + + :param description: String description for the token. + + :rtype: :class:`~wuttjamaican.db.model.auth.UserAPIToken` + """ + model = self.app.model + session = self.app.get_session(user) + + # generate raw token + token_string = self.generate_api_token_string() + + # persist token in DB + token = model.UserAPIToken( + description=description, + token_string=token_string) + user.api_tokens.append(token) + session.add(token) + + return token + + def generate_api_token_string(self): + """ + Generate a new *raw* API token string. + + This is called by :meth:`add_api_token()`. + + :returns: Raw API token string. + """ + return secrets.token_urlsafe() + + def delete_api_token(self, token): + """ + Delete the given API token. + + See also :meth:`add_api_token()`. + + :param token: + :class:`~wuttjamaican.db.model.auth.UserAPIToken` instance. + """ + session = self.app.get_session(token) + session.delete(token) + ############################## # internal methods ############################## diff --git a/src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py b/src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py new file mode 100644 index 0000000..61ed5fa --- /dev/null +++ b/src/wuttjamaican/db/alembic/versions/efdcb2c75034_add_user_api_token.py @@ -0,0 +1,39 @@ +"""add user_api_token + +Revision ID: efdcb2c75034 +Revises: 6bf900765500 +Create Date: 2025-08-08 08:58:19.376105 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = 'efdcb2c75034' +down_revision: Union[str, None] = '6bf900765500' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # user_api_token + op.create_table('user_api_token', + sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('user_uuid', wuttjamaican.db.util.UUID(), nullable=False), + sa.Column('description', sa.String(length=255), nullable=False), + sa.Column('token_string', sa.String(length=255), nullable=False), + sa.Column('created', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], name=op.f('fk_user_api_token_user_uuid_user')), + sa.PrimaryKeyConstraint('uuid', name=op.f('pk_user_api_token')) + ) + + +def downgrade() -> None: + + # user_api_token + op.drop_table('user_api_token') diff --git a/src/wuttjamaican/db/model/__init__.py b/src/wuttjamaican/db/model/__init__.py index 6a9883b..0fcb14a 100644 --- a/src/wuttjamaican/db/model/__init__.py +++ b/src/wuttjamaican/db/model/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2024 Lance Edgar +# Copyright © 2023-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -40,6 +40,7 @@ And the :term:`data models `: * :class:`~wuttjamaican.db.model.auth.Permission` * :class:`~wuttjamaican.db.model.auth.User` * :class:`~wuttjamaican.db.model.auth.UserRole` +* :class:`~wuttjamaican.db.model.auth.UserAPIToken` * :class:`~wuttjamaican.db.model.upgrades.Upgrade` And the :term:`batch` model base/mixin classes: @@ -51,6 +52,6 @@ And the :term:`batch` model base/mixin classes: from wuttjamaican.db.util import uuid_column, uuid_fk_column, UUID from .base import Base, Setting, Person -from .auth import Role, Permission, User, UserRole +from .auth import Role, Permission, User, UserRole, UserAPIToken from .upgrades import Upgrade from .batch import BatchMixin, BatchRowMixin diff --git a/src/wuttjamaican/db/model/auth.py b/src/wuttjamaican/db/model/auth.py index 44e71b4..eb42a47 100644 --- a/src/wuttjamaican/db/model/auth.py +++ b/src/wuttjamaican/db/model/auth.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttJamaican -- Base package for Wutta Framework -# Copyright © 2023-2024 Lance Edgar +# Copyright © 2023-2025 Lance Edgar # # This file is part of Wutta Framework. # @@ -39,6 +39,8 @@ So a user's permissions are "inherited" from the role(s) to which they belong. """ +import datetime + import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.ext.associationproxy import association_proxy @@ -211,6 +213,16 @@ class User(Base): # getset_factory=getset_factory, ) + api_tokens = orm.relationship( + 'UserAPIToken', + back_populates='user', + order_by='UserAPIToken.created', + cascade='all, delete-orphan', + cascade_backrefs=False, + doc=""" + List of :class:`UserAPIToken` instances belonging to the user. + """) + def __str__(self): if self.person: name = str(self.person) @@ -246,3 +258,36 @@ class UserRole(Base): doc=""" Reference to the :class:`Role` involved. """) + + +class UserAPIToken(Base): + """ + User authentication token for use with HTTP API + """ + __tablename__ = 'user_api_token' + + uuid = uuid_column() + + user_uuid = uuid_fk_column('user.uuid', nullable=False) + user = orm.relationship( + User, + back_populates='api_tokens', + cascade_backrefs=False, + doc=""" + Reference to the :class:`User` whose token this is. + """) + + description = sa.Column(sa.String(length=255), nullable=False, doc=""" + Description of the token. + """) + + token_string = sa.Column(sa.String(length=255), nullable=False, doc=""" + Raw token string, to be used by API clients. + """) + + created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" + Date/time when the token was created. + """) + + def __str__(self): + return self.description or "" diff --git a/tests/db/model/test_auth.py b/tests/db/model/test_auth.py index 6bcacc5..bdee085 100644 --- a/tests/db/model/test_auth.py +++ b/tests/db/model/test_auth.py @@ -43,3 +43,12 @@ else: person = Person(full_name="Barney Rubble") user.person = person self.assertEqual(str(user), "Barney Rubble") + + + class TestUserAPIToken(TestCase): + + def test_str(self): + token = model.UserAPIToken() + self.assertEqual(str(token), "") + token.description = "test token" + self.assertEqual(str(token), "test token") diff --git a/tests/test_auth.py b/tests/test_auth.py index d4d1a9c..c1b048b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -63,6 +63,32 @@ else: user = self.handler.authenticate_user(self.session, 'barney', 'goodpass') self.assertIsNone(user) + def test_authenticate_user_token(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + token = self.handler.add_api_token(barney, "test token") + self.session.commit() + + user = self.handler.authenticate_user_token(self.session, None) + self.assertIsNone(user) + + user = self.handler.authenticate_user_token(self.session, token.token_string) + self.assertIs(user, barney) + + barney.active = False + self.session.flush() + user = self.handler.authenticate_user_token(self.session, token.token_string) + self.assertIsNone(user) + + barney.active = True + self.session.flush() + user = self.handler.authenticate_user_token(self.session, token.token_string) + self.assertIs(user, barney) + + user = self.handler.authenticate_user_token(self.session, 'bad-token') + self.assertIsNone(user) + def test_check_user_password(self): model = self.app.model barney = model.User(username='barney') @@ -416,3 +442,32 @@ else: self.handler.revoke_permission(myrole, 'foo') self.session.commit() self.assertEqual(self.session.query(model.Permission).count(), 0) + + def test_generate_api_token_string(self): + token = self.handler.generate_api_token_string() + # TODO: not sure how to propertly test this yet... + self.assertEqual(len(token), 43) + + def test_add_api_token(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + + token = self.handler.add_api_token(barney, "test token") + self.assertIs(token.user, barney) + self.assertEqual(token.description, "test token") + # TODO: not sure how to propertly test this yet... + self.assertEqual(len(token.token_string), 43) + + def test_delete_api_token(self): + model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + token = self.handler.add_api_token(barney, "test token") + self.session.commit() + + self.session.refresh(barney) + self.assertEqual(len(barney.api_tokens), 1) + self.handler.delete_api_token(token) + self.session.refresh(barney) + self.assertEqual(len(barney.api_tokens), 0) From ad6a3377ab037a6ad536f490750f096dcc06b439 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 8 Aug 2025 22:41:33 -0500 Subject: [PATCH 2/3] feat: add WuttaConfigProfile base class convenience for use with various configurable features/services etc. --- src/wuttjamaican/conf.py | 86 ++++++++++++++++++++++++++++++++++++++++ tests/test_conf.py | 20 +++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index b207681..daad029 100644 --- a/src/wuttjamaican/conf.py +++ b/src/wuttjamaican/conf.py @@ -1013,3 +1013,89 @@ def make_config( extension.startup(config) return config + + +class WuttaConfigProfile: + """ + Base class to represent a configured "profile" in the context of + some service etc. + + :param config: App :term:`config object`. + + :param key: Config key for the profile. + + Generally each subclass will represent a certain type of config + profile, and each instance will represent a single profile + (identified by the ``key``). + """ + + def __init__(self, config, key): + self.config = config + self.app = self.config.get_app() + self.key = key + self.load() + + @property + def section(self): + """ + The primary config section under which profiles may be + defined. + + There is no default; each subclass must declare it. + + This corresponds to the typical INI file section, for instance + a section of ``wutta.telemetry`` assumes file contents like: + + .. code-block:: ini + + [wutta.telemetry] + default.submit_url = /nodes/telemetry + special.submit_url = /nodes/telemetry-special + """ + raise NotImplementedError + + def load(self): + """ + Read all relevant settings from config, and assign attributes + on the profile instance accordingly. + + There is no default logic but subclass will generally override. + + While a caller can use :meth:`get_str()` to obtain arbitrary + config values dynamically, it is often useful for the profile + to pre-load some config values. This allows "smarter" + interpretation of config values in some cases, and at least + ensures common/shared logic. + + There is no constraint or other guidance in terms of which + profile attributes might be set by this method. Subclass + should document if necessary. + """ + + def get_str(self, option, **kwargs): + """ + Get a string value for the profile, from config. + + :param option: Name of config option for which to return value. + + This just calls :meth:`~WuttaConfig.get()` on the config + object, but for a particular setting name which it composes + dynamically. + + Assuming a config file like: + + .. code-block:: ini + + [wutta.telemetry] + default.submit_url = /nodes/telemetry + + Then a ``default`` profile under the ``wutta.telemetry`` + section would effectively have a ``submit_url`` option:: + + class TelemetryProfile(WuttaConfigProfile): + section = "wutta.telemetry" + + profile = TelemetryProfile("default") + url = profile.get_str("submit_url") + """ + return self.config.get(f'{self.section}.{self.key}.{option}', **kwargs) diff --git a/tests/test_conf.py b/tests/test_conf.py index 5b46539..75271ca 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -12,7 +12,7 @@ from wuttjamaican import conf as mod from wuttjamaican import conf from wuttjamaican.exc import ConfigurationError from wuttjamaican.app import AppHandler -from wuttjamaican.testing import FileTestCase +from wuttjamaican.testing import FileTestCase, ConfigTestCase class TestWuttaConfig(FileTestCase): @@ -867,3 +867,21 @@ class TestMakeConfig(FileTestCase): foo_cls.assert_called_once_with() foo_obj.configure.assert_called_once_with(testconfig) foo_obj.startup.assert_called_once_with(testconfig) + + +class TestWuttaConfigProfile(ConfigTestCase): + + def make_profile(self, key): + return mod.WuttaConfigProfile(self.config, key) + + def test_section(self): + profile = self.make_profile('default') + self.assertRaises(NotImplementedError, getattr, profile, 'section') + + def test_get_str(self): + self.config.setdefault('wutta.telemetry.default.submit_url', '/nodes/telemetry') + with patch.object(mod.WuttaConfigProfile, 'section', new='wutta.telemetry'): + profile = self.make_profile('default') + self.assertEqual(profile.section, 'wutta.telemetry') + url = profile.get_str('submit_url') + self.assertEqual(url, '/nodes/telemetry') From 5b7117078cb255fbafc69d3659e93747d7b01cee Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 9 Aug 2025 12:22:45 -0500 Subject: [PATCH 3/3] =?UTF-8?q?bump:=20version=200.21.1=20=E2=86=92=200.22?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae7825..04b2d40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to WuttJamaican will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.22.0 (2025-08-09) + +### Feat + +- add WuttaConfigProfile base class +- add user API tokens; handler methods to manage/authenticate +- allow arbitrary kwargs for `config.get()` and `app.get_setting()` + +## v0.21.1 (2025-06-29) + ## v0.21.0 (2025-06-29) ### Feat diff --git a/pyproject.toml b/pyproject.toml index 954a7a0..082971f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.21.1" +version = "0.22.0" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]