Add schema, basic logic for user API tokens

and the default API client now tries to use token if configured, or
can fallback to login w/ credentials
This commit is contained in:
Lance Edgar 2023-05-14 20:09:07 -05:00
parent fd26314d2b
commit 2b7ac6a5fc
6 changed files with 180 additions and 7 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2022 Lance Edgar # Copyright © 2010-2023 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -26,10 +26,10 @@ Auth Handler
See also :doc:`rattail-manual:base/handlers/other/auth`. See also :doc:`rattail-manual:base/handlers/other/auth`.
""" """
from __future__ import unicode_literals, absolute_import import secrets
import warnings import warnings
from sqlalchemy import orm
import sqlalchemy_continuum as continuum import sqlalchemy_continuum as continuum
from rattail.app import GenericHandler, MergeMixin from rattail.app import GenericHandler, MergeMixin
@ -77,6 +77,24 @@ class AuthHandler(GenericHandler, MergeMixin):
return authenticate_user(session, username, password) return authenticate_user(session, username, password)
def authenticate_user_token(self, session, token):
"""
Authenticate the given user API token string, and if valid,
return the corresponding User object.
"""
model = self.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 has_permission(self, session, principal, permission, def has_permission(self, session, principal, permission,
include_guest=True, include_guest=True,
include_authenticated=True): include_authenticated=True):
@ -339,6 +357,39 @@ class AuthHandler(GenericHandler, MergeMixin):
# TODO: remove this, legacy code # TODO: remove this, legacy code
return user.get_email_address() return user.get_email_address()
def generate_raw_api_token(self):
"""
Generate a new *raw* API token string.
"""
return secrets.token_urlsafe()
def add_api_token(self, user, description, **kwargs):
"""
Add a new API token for the user.
"""
model = self.model
session = self.app.get_session(user)
# generate raw API token, in the form required for use within
# the API client
token_string = self.generate_raw_api_token()
# create DB record for the token
token = model.UserAPIToken(
user=user,
description=description,
token_string=token_string)
session.add(token)
return token
def delete_api_token(self, token, **kwargs):
"""
Delete a new API token for the user.
"""
session = self.app.get_session(token)
session.delete(token)
def get_merge_preview_fields(self, **kwargs): def get_merge_preview_fields(self, **kwargs):
""" """
Returns a sequence of fields which will be used during a merge Returns a sequence of fields which will be used during a merge

View file

@ -0,0 +1,57 @@
# -*- coding: utf-8; -*-
"""add table for API Tokens
Revision ID: 4747a017b8f9
Revises: 0aeaac17cd6e
Create Date: 2023-05-14 11:18:12.265992
"""
# revision identifiers, used by Alembic.
revision = '4747a017b8f9'
down_revision = '0aeaac17cd6e'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import rattail.db.types
def upgrade():
# user_api_token
op.create_table('user_api_token',
sa.Column('uuid', sa.String(length=32), nullable=False),
sa.Column('user_uuid', sa.String(length=32), 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(), nullable=False),
sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'], name='user_api_token_fk_user'),
sa.PrimaryKeyConstraint('uuid')
)
op.create_table('user_api_token_version',
sa.Column('uuid', sa.String(length=32), autoincrement=False, nullable=False),
sa.Column('user_uuid', sa.String(length=32), autoincrement=False, nullable=True),
sa.Column('description', sa.String(length=255), autoincrement=False, nullable=True),
sa.Column('token_string', sa.String(length=255), autoincrement=False, nullable=True),
sa.Column('created', sa.DateTime(), nullable=True),
sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
sa.Column('operation_type', sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint('uuid', 'transaction_id')
)
op.create_index(op.f('ix_user_api_token_version_end_transaction_id'), 'user_api_token_version', ['end_transaction_id'], unique=False)
op.create_index(op.f('ix_user_api_token_version_operation_type'), 'user_api_token_version', ['operation_type'], unique=False)
op.create_index(op.f('ix_user_api_token_version_transaction_id'), 'user_api_token_version', ['transaction_id'], unique=False)
def downgrade():
# user_api_token
op.drop_index(op.f('ix_user_api_token_version_transaction_id'), table_name='user_api_token_version')
op.drop_index(op.f('ix_user_api_token_version_operation_type'), table_name='user_api_token_version')
op.drop_index(op.f('ix_user_api_token_version_end_transaction_id'), table_name='user_api_token_version')
op.drop_table('user_api_token_version')
op.drop_table('user_api_token')

View file

@ -30,7 +30,7 @@ from .contact import PhoneNumber, EmailAddress, MailingAddress
from .people import (Person, PersonNote, from .people import (Person, PersonNote,
PersonPhoneNumber, PersonEmailAddress, PersonMailingAddress, PersonPhoneNumber, PersonEmailAddress, PersonMailingAddress,
MergePeopleRequest) MergePeopleRequest)
from .users import Role, Permission, User, UserRole, UserEvent from .users import Role, Permission, User, UserRole, UserEvent, UserAPIToken
from .stores import Store, StorePhoneNumber, StoreEmailAddress from .stores import Store, StorePhoneNumber, StoreEmailAddress
from .customers import (Customer, from .customers import (Customer,
CustomerPhoneNumber, CustomerPhoneNumber,

View file

@ -366,3 +366,50 @@ class UserEvent(Base):
occurred = sa.Column(sa.DateTime(), nullable=True, default=datetime.datetime.utcnow, doc=""" occurred = sa.Column(sa.DateTime(), nullable=True, default=datetime.datetime.utcnow, doc="""
Timestamp at which the event occurred. Timestamp at which the event occurred.
""") """)
class UserAPIToken(Base):
"""
User authentication token for use with Tailbone API
"""
__tablename__ = 'user_api_token'
__table_args__ = (
sa.ForeignKeyConstraint(['user_uuid'], ['user.uuid'],
name='user_api_token_fk_user'),
)
__versioned__ = {}
model_title = "API Token"
model_title_plural = "API Tokens"
uuid = uuid_column()
user_uuid = sa.Column(sa.String(length=32), nullable=False, doc="""
Reference to the User associated with the token.
""")
user = orm.relationship(
'User',
doc="""
Reference to the User associated with the token.
""",
backref=orm.backref(
'api_tokens',
cascade_backrefs=False,
order_by='UserAPIToken.created',
doc="""
List of API tokens for the user.
"""))
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="""
Token string, to be used by API clients.
""")
created = sa.Column(sa.DateTime(), nullable=False, default=datetime.datetime.utcnow, doc="""
Date/time when the token was created.
""")
def __str__(self):
return self.description or ""

View file

@ -82,6 +82,12 @@ class TailboneAPIClient(object):
self.session = requests.Session() self.session = requests.Session()
# maybe *disable* SSL cert verification
# (should only be used for testing! e.g. w/ self-signed certs)
if not self.config.getbool('tailbone.api', 'ssl_verify', default=True):
self.session.verify = False
# maybe set max retries, e.g. for flaky connections
if self.max_retries is not None: if self.max_retries is not None:
adapter = requests.adapters.HTTPAdapter(max_retries=self.max_retries) adapter = requests.adapters.HTTPAdapter(max_retries=self.max_retries)
self.session.mount(self.base_url, adapter) self.session.mount(self.base_url, adapter)
@ -91,12 +97,25 @@ class TailboneAPIClient(object):
# 400 Client Error: Bad CSRF Origin for url # 400 Client Error: Bad CSRF Origin for url
parts = urlparse(self.base_url) parts = urlparse(self.base_url)
self.session.headers.update({ self.session.headers.update({
'Origin': '{}://{}'.format(parts.scheme, parts.netloc)}) 'Origin': f'{parts.scheme}://{parts.netloc}',
})
# fetch basic 'session' endpoint, to get current xsrf token
# (this does not require any authentication, which is next)
response = self.get('/session') response = self.get('/session')
self.session.headers.update({ self.session.headers.update({
'X-XSRF-TOKEN': response.cookies['XSRF-TOKEN']}) 'X-XSRF-TOKEN': response.cookies['XSRF-TOKEN']})
# authenticate via token (preferred), or user/pass login
token = self.config.get('tailbone.api', 'token')
if token:
self.session.headers.update({
'Authorization': 'Bearer {}'.format(token),
})
else: # no token, so attempt login w/ credentials
if not self.login():
raise RuntimeError("login failed! (consider using token auth)")
def _request(self, request_method, api_method, params=None, data=None): def _request(self, request_method, api_method, params=None, data=None):
""" """
Perform a request for the given API method, and return the response. Perform a request for the given API method, and return the response.
@ -118,6 +137,7 @@ class TailboneAPIClient(object):
""" """
Perform a GET request for the given API method, and return the response. Perform a GET request for the given API method, and return the response.
""" """
self._init()
return self._request('GET', api_method, params=params) return self._request('GET', api_method, params=params)
def post(self, api_method, **kwargs): def post(self, api_method, **kwargs):

View file

@ -333,8 +333,6 @@ class TelemetryHandler(GenericHandler):
def submit_data_tailbone_api(self, data, profile, **kwargs): def submit_data_tailbone_api(self, data, profile, **kwargs):
api = TailboneAPIClient(self.config) api = TailboneAPIClient(self.config)
if not api.login():
raise RuntimeError("login failed!")
data['uuid'] = profile.submit_uuid data['uuid'] = profile.submit_uuid
api.post(profile.submit_url, data=data) api.post(profile.submit_url, data=data)