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,