From c002d3d182d32712d188b9aa6443849a611f91f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 14 May 2023 20:10:05 -0500 Subject: [PATCH] Add basic support for managing, and accepting API tokens also various other changes in pursuit of that. so far tokens are only accepted by web API and not traditional web app --- tailbone/auth.py | 33 ++++++++ tailbone/forms/core.py | 22 +++++- tailbone/grids/core.py | 17 ++++ tailbone/templates/form.mako | 7 +- tailbone/templates/page.mako | 2 +- tailbone/templates/users/view.mako | 120 +++++++++++++++++++++++++++++ tailbone/views/master.py | 33 ++++---- tailbone/views/users.py | 105 +++++++++++++++++++++++++ tailbone/webapi.py | 5 +- 9 files changed, 318 insertions(+), 26 deletions(-) diff --git a/tailbone/auth.py b/tailbone/auth.py index 0c90003a..1f057404 100644 --- a/tailbone/auth.py +++ b/tailbone/auth.py @@ -25,6 +25,7 @@ Authentication & Authorization """ import logging +import re from rattail import enum from rattail.util import prettify, NOTSET @@ -32,6 +33,7 @@ from rattail.util import prettify, NOTSET from zope.interface import implementer from pyramid.interfaces import IAuthorizationPolicy from pyramid.security import remember, forget, Everyone, Authenticated +from pyramid.authentication import SessionAuthenticationPolicy from tailbone.db import Session @@ -87,6 +89,37 @@ def set_session_timeout(request, timeout): request.session['_timeout'] = timeout or None +class TailboneAuthenticationPolicy(SessionAuthenticationPolicy): + """ + Custom authentication policy for Tailbone. + + This is mostly Pyramid's built-in session-based policy, but adds + logic to accept Rattail User API Tokens in lieu of current user + being identified via the session. + + Note that the traditional Tailbone web app does *not* use this + policy, only the Tailbone web API uses it by default. + """ + + def unauthenticated_userid(self, request): + + # figure out userid from header token if present + credentials = request.headers.get('Authorization') + if credentials: + match = re.match(r'^Bearer (\S+)$', credentials) + if match: + token = match.group(1) + rattail_config = request.registry.settings.get('rattail_config') + app = rattail_config.get_app() + auth = app.get_auth_handler() + user = auth.authenticate_user_token(Session(), token) + if user: + return user.uuid + + # otherwise do normal session-based logic + return super(TailboneAuthenticationPolicy, self).unauthenticated_userid(request) + + @implementer(IAuthorizationPolicy) class TailboneAuthorizationPolicy(object): diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index 04cbb64a..c4a7b0ea 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -338,7 +338,7 @@ class Form(object): assume_local_times=False, renderers=None, renderer_kwargs={}, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, action_url=None, cancel_url=None, component='tailbone-form', - vuejs_field_converters={}, + vuejs_component_kwargs=None, vuejs_field_converters={}, # TODO: ugh this is getting out hand! can_edit_help=False, edit_help_url=None, route_prefix=None, ): @@ -379,6 +379,7 @@ class Form(object): self.action_url = action_url self.cancel_url = cancel_url self.component = component + self.vuejs_component_kwargs = vuejs_component_kwargs or {} self.vuejs_field_converters = vuejs_field_converters or {} self.can_edit_help = can_edit_help self.edit_help_url = edit_help_url @@ -913,6 +914,25 @@ class Form(object): return False return True + def set_vuejs_component_kwargs(self, **kwargs): + self.vuejs_component_kwargs.update(kwargs) + + def render_vuejs_component(self): + """ + Render the Vue.js component HTML for the form. + + Most typically this is something like: + + .. code-block:: html + + + + """ + kwargs = dict(self.vuejs_component_kwargs) + if self.can_edit_help: + kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp') + return HTML.tag(self.component, **kwargs) + def render_buefy_field(self, fieldname, bfield_attrs={}): """ Render the given field in a Buefy-compatible way. Note that diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py index f1f00904..230bd061 100644 --- a/tailbone/grids/core.py +++ b/tailbone/grids/core.py @@ -1613,6 +1613,23 @@ class GridAction(object): """ Represents an action available to a grid. This is used to construct the 'actions' column when rendering the grid. + + :param key: Key for the action (e.g. ``'edit'``), unique within + the grid. + + :param label: Label to be displayed for the action. If not set, + will be a capitalized version of ``key``. + + :param icon: Icon name for the action. + + :param click_handler: Optional JS click handler for the action. + This value will be rendered as-is within the final grid + template, hence the JS string must be callable code. Note + that ``props.row`` will be available in the calling context, + so a couple of examples: + + * ``deleteThisThing(props.row)`` + * ``$emit('do-something', props.row)`` """ def __init__(self, key, label=None, url='#', icon=None, target=None, diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako index cb6ef9c1..5878e030 100644 --- a/tailbone/templates/form.mako +++ b/tailbone/templates/form.mako @@ -11,12 +11,7 @@ <%def name="render_buefy_form()">
- <${form.component} - % if can_edit_help: - :configure-fields-help="configureFieldsHelp" - % endif - > - + ${form.render_vuejs_component()}
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako index 94147a04..b5ac8773 100644 --- a/tailbone/templates/page.mako +++ b/tailbone/templates/page.mako @@ -32,7 +32,7 @@ let ThisPage = { template: '#this-page-template', - mixins: [FormPosterMixin], + mixins: [SimpleRequestMixin], props: { configureFieldsHelp: Boolean, }, diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako index b34902a1..f65b6d1c 100644 --- a/tailbone/templates/users/view.mako +++ b/tailbone/templates/users/view.mako @@ -21,5 +21,125 @@ % endif +<%def name="render_this_page()"> + ${parent.render_this_page()} + + % if master.has_perm('manage_api_tokens'): + + + + + + % endif + + +<%def name="modify_this_page_vars()"> + ${parent.modify_this_page_vars()} + % if master.has_perm('manage_api_tokens'): + + % endif + + ${parent.body()} diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 5e2f539c..2d6bae16 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -2282,9 +2282,15 @@ class MasterView(View): if info and info.markdown_text: return info.markdown_text + def can_edit_help(self): + if self.has_perm('edit_help'): + return True + if self.request.has_perm('common.edit_help'): + return True + return False + def edit_help(self): - if (not self.has_perm('edit_help') - and not self.request.has_perm('common.edit_help')): + if not self.can_edit_help(): raise self.forbidden() model = self.model @@ -2317,8 +2323,7 @@ class MasterView(View): return {'ok': True} def edit_field_help(self): - if (not self.has_perm('edit_help') - and not self.request.has_perm('common.edit_help')): + if not self.can_edit_help(): raise self.forbidden() model = self.model @@ -2371,8 +2376,7 @@ class MasterView(View): 'grid_index': self.grid_index, 'help_url': self.get_help_url(), 'help_markdown': self.get_help_markdown(), - 'can_edit_help': (self.has_perm('edit_help') - or self.request.has_perm('common.edit_help')), + 'can_edit_help': self.can_edit_help(), 'quickie': None, } @@ -2638,16 +2642,16 @@ class MasterView(View): elif is_primary: btn_kw['type'] = 'is-primary' + if icon_left: + btn_kw['icon_left'] = icon_left + elif is_external: + btn_kw['icon_left'] = 'external-link-alt' + elif url: + btn_kw['icon_left'] = 'eye' + if url: btn_kw['href'] = url - if icon_left: - btn_kw['icon_left'] = icon_left - elif is_external: - btn_kw['icon_left'] = 'external-link-alt' - else: - btn_kw['icon_left'] = 'eye' - if target: btn_kw['target'] = target elif is_external: @@ -4017,8 +4021,7 @@ class MasterView(View): 'action_url': self.request.current_route_url(_query=None), 'assume_local_times': self.has_local_times, 'route_prefix': route_prefix, - 'can_edit_help': (self.has_perm('edit_help') - or self.request.has_perm('common.edit_help')), + 'can_edit_help': self.can_edit_help(), } if defaults['can_edit_help']: diff --git a/tailbone/views/users.py b/tailbone/views/users.py index ff614460..833c6cf5 100644 --- a/tailbone/views/users.py +++ b/tailbone/views/users.py @@ -38,6 +38,7 @@ from webhelpers2.html import HTML, tags from tailbone import forms from tailbone.views import MasterView, View from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer +from tailbone.util import raw_datetime class UserView(PrincipalMasterView): @@ -51,6 +52,10 @@ class UserView(PrincipalMasterView): touchable = True mergeable = True + labels = { + 'api_tokens': "API Tokens", + } + grid_columns = [ 'username', 'person', @@ -68,6 +73,7 @@ class UserView(PrincipalMasterView): 'active_sticky', 'set_password', 'prevent_password_change', + 'api_tokens', 'roles', 'permissions', ] @@ -218,6 +224,17 @@ class UserView(PrincipalMasterView): # if self.creating: # f.set_required('password') + # api_tokens + if self.creating or self.editing: + f.remove('api_tokens') + elif self.has_perm('manage_api_tokens'): + f.set_renderer('api_tokens', self.render_api_tokens) + f.set_vuejs_component_kwargs(**{':apiTokens': 'apiTokens', + '@api-new-token': 'apiNewToken', + '@api-token-delete': 'apiTokenDelete'}) + else: + f.remove('api_tokens') + # roles f.set_renderer('roles', self.render_roles) if self.creating or self.editing: @@ -260,6 +277,75 @@ class UserView(PrincipalMasterView): if self.viewing or self.deleting: f.remove('set_password') + def render_api_tokens(self, user, field): + route_prefix = self.get_route_prefix() + permission_prefix = self.get_permission_prefix() + + factory = self.get_grid_factory() + g = factory( + key='{}.api_tokens'.format(route_prefix), + data=[], + columns=['description', 'created'], + main_actions=[ + self.make_action('delete', icon='trash', + click_handler="$emit('api-token-delete', props.row)")]) + + button = self.make_buefy_button("New", is_primary=True, + icon_left='plus', + **{'@click': "$emit('api-new-token')"}) + + table = HTML.literal( + g.render_buefy_table_element(data_prop='apiTokens')) + + return HTML.tag('div', c=[button, table]) + + def add_api_token(self): + user = self.get_instance() + data = self.request.json_body + + token = self.auth_handler.add_api_token(user, data['description']) + self.Session.flush() + + return {'ok': True, + 'raw_token': token.token_string, + 'tokens': self.get_api_tokens(user)} + + def delete_api_token(self): + model = self.model + user = self.get_instance() + data = self.request.json_body + + token = self.Session.get(model.UserAPIToken, data['uuid']) + if not token: + return {'error': "API token not found"} + + if token.user is not user: + return {'error': "API token not found"} + + self.auth_handler.delete_api_token(token) + self.Session.flush() + + return {'ok': True, + 'tokens': self.get_api_tokens(user)} + + def template_kwargs_view(self, **kwargs): + kwargs = super(UserView, self).template_kwargs_view(**kwargs) + user = kwargs['instance'] + + kwargs['api_tokens_data'] = self.get_api_tokens(user) + + return kwargs + + def get_api_tokens(self, user): + tokens = [] + for token in reversed(user.api_tokens): + tokens.append({ + 'uuid': token.uuid, + 'description': token.description, + 'created': raw_datetime(self.rattail_config, token.created), + }) + return tokens + def get_possible_roles(self): model = self.model @@ -554,6 +640,25 @@ class UserView(PrincipalMasterView): config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), "Edit the Roles to which a {} belongs".format(model_title)) + # manage API tokens + config.add_tailbone_permission(permission_prefix, + '{}.manage_api_tokens'.format(permission_prefix), + "Manage API tokens for any {}".format(model_title)) + config.add_route('{}.add_api_token'.format(route_prefix), + '{}/add-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='add_api_token', + route_name='{}.add_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + config.add_route('{}.delete_api_token'.format(route_prefix), + '{}/delete-api-token'.format(instance_url_prefix), + request_method='POST') + config.add_view(cls, attr='delete_api_token', + route_name='{}.delete_api_token'.format(route_prefix), + permission='{}.manage_api_tokens'.format(permission_prefix), + renderer='json') + # edit preferences for any user config.add_tailbone_permission(permission_prefix, '{}.preferences'.format(permission_prefix), diff --git a/tailbone/webapi.py b/tailbone/webapi.py index a437f0c3..7a2c81b4 100644 --- a/tailbone/webapi.py +++ b/tailbone/webapi.py @@ -28,10 +28,9 @@ import simplejson from cornice.renderer import CorniceRenderer from pyramid.config import Configurator -from pyramid.authentication import SessionAuthenticationPolicy from tailbone import app -from tailbone.auth import TailboneAuthorizationPolicy +from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy from tailbone.providers import get_all_providers @@ -51,8 +50,8 @@ def make_pyramid_config(settings): pyramid_config = Configurator(settings=settings, root_factory=app.Root) # configure user authorization / authentication + pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy()) pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) - pyramid_config.set_authentication_policy(SessionAuthenticationPolicy()) # always require CSRF token protection pyramid_config.set_default_csrf_options(require_csrf=True,