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:
		
							parent
							
								
									fd26314d2b
								
							
						
					
					
						commit
						2b7ac6a5fc
					
				
					 6 changed files with 180 additions and 7 deletions
				
			
		| 
						 | 
					@ -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
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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')
 | 
				
			||||||
| 
						 | 
					@ -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,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 ""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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):
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue