feat: add user API tokens; handler methods to manage/authenticate
This commit is contained in:
parent
988749d80e
commit
a721e63275
6 changed files with 242 additions and 4 deletions
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# WuttJamaican -- Base package for Wutta Framework
|
# WuttJamaican -- Base package for Wutta Framework
|
||||||
# Copyright © 2023-2024 Lance Edgar
|
# Copyright © 2023-2025 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Wutta Framework.
|
# This file is part of Wutta Framework.
|
||||||
#
|
#
|
||||||
|
@ -26,8 +26,11 @@ Auth Handler
|
||||||
This defines the default :term:`auth handler`.
|
This defines the default :term:`auth handler`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
from wuttjamaican.app import GenericHandler
|
from wuttjamaican.app import GenericHandler
|
||||||
|
|
||||||
|
|
||||||
|
@ -73,6 +76,8 @@ class AuthHandler(GenericHandler):
|
||||||
default logic assumes a "username" but in practice it may be
|
default logic assumes a "username" but in practice it may be
|
||||||
an email address etc. - whatever the user entered.
|
an email address etc. - whatever the user entered.
|
||||||
|
|
||||||
|
See also :meth:`authenticate_user_token()`.
|
||||||
|
|
||||||
:param session: Open :term:`db session`.
|
:param session: Open :term:`db session`.
|
||||||
|
|
||||||
:param username: Usually a string, but also may be a
|
:param username: Usually a string, but also may be a
|
||||||
|
@ -91,6 +96,33 @@ class AuthHandler(GenericHandler):
|
||||||
if self.check_user_password(user, password):
|
if self.check_user_password(user, password):
|
||||||
return user
|
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):
|
def check_user_password(self, user, password, **kwargs):
|
||||||
"""
|
"""
|
||||||
Check a user's password.
|
Check a user's password.
|
||||||
|
@ -545,6 +577,63 @@ class AuthHandler(GenericHandler):
|
||||||
if permission in role.permissions:
|
if permission in role.permissions:
|
||||||
role.permissions.remove(permission)
|
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
|
# internal methods
|
||||||
##############################
|
##############################
|
||||||
|
|
|
@ -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')
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# WuttJamaican -- Base package for Wutta Framework
|
# WuttJamaican -- Base package for Wutta Framework
|
||||||
# Copyright © 2023-2024 Lance Edgar
|
# Copyright © 2023-2025 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Wutta Framework.
|
# This file is part of Wutta Framework.
|
||||||
#
|
#
|
||||||
|
@ -40,6 +40,7 @@ And the :term:`data models <data model>`:
|
||||||
* :class:`~wuttjamaican.db.model.auth.Permission`
|
* :class:`~wuttjamaican.db.model.auth.Permission`
|
||||||
* :class:`~wuttjamaican.db.model.auth.User`
|
* :class:`~wuttjamaican.db.model.auth.User`
|
||||||
* :class:`~wuttjamaican.db.model.auth.UserRole`
|
* :class:`~wuttjamaican.db.model.auth.UserRole`
|
||||||
|
* :class:`~wuttjamaican.db.model.auth.UserAPIToken`
|
||||||
* :class:`~wuttjamaican.db.model.upgrades.Upgrade`
|
* :class:`~wuttjamaican.db.model.upgrades.Upgrade`
|
||||||
|
|
||||||
And the :term:`batch` model base/mixin classes:
|
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 wuttjamaican.db.util import uuid_column, uuid_fk_column, UUID
|
||||||
|
|
||||||
from .base import Base, Setting, Person
|
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 .upgrades import Upgrade
|
||||||
from .batch import BatchMixin, BatchRowMixin
|
from .batch import BatchMixin, BatchRowMixin
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# WuttJamaican -- Base package for Wutta Framework
|
# WuttJamaican -- Base package for Wutta Framework
|
||||||
# Copyright © 2023-2024 Lance Edgar
|
# Copyright © 2023-2025 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Wutta Framework.
|
# 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.
|
belong.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
@ -211,6 +213,16 @@ class User(Base):
|
||||||
# getset_factory=getset_factory,
|
# 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):
|
def __str__(self):
|
||||||
if self.person:
|
if self.person:
|
||||||
name = str(self.person)
|
name = str(self.person)
|
||||||
|
@ -246,3 +258,36 @@ class UserRole(Base):
|
||||||
doc="""
|
doc="""
|
||||||
Reference to the :class:`Role` involved.
|
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 ""
|
||||||
|
|
|
@ -43,3 +43,12 @@ else:
|
||||||
person = Person(full_name="Barney Rubble")
|
person = Person(full_name="Barney Rubble")
|
||||||
user.person = person
|
user.person = person
|
||||||
self.assertEqual(str(user), "Barney Rubble")
|
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")
|
||||||
|
|
|
@ -63,6 +63,32 @@ else:
|
||||||
user = self.handler.authenticate_user(self.session, 'barney', 'goodpass')
|
user = self.handler.authenticate_user(self.session, 'barney', 'goodpass')
|
||||||
self.assertIsNone(user)
|
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):
|
def test_check_user_password(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
barney = model.User(username='barney')
|
barney = model.User(username='barney')
|
||||||
|
@ -416,3 +442,32 @@ else:
|
||||||
self.handler.revoke_permission(myrole, 'foo')
|
self.handler.revoke_permission(myrole, 'foo')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertEqual(self.session.query(model.Permission).count(), 0)
|
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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue