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.component}>
+ ${form.render_vuejs_component()}
%def>
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>
+<%def name="render_this_page()">
+ ${parent.render_this_page()}
+
+ % if master.has_perm('manage_api_tokens'):
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your new API token is shown below.
+
+
+ IMPORTANT: You must record this token elsewhere
+ for later reference. You will NOT be able to
+ recover the value if you lose it.
+
+
+ {{ apiNewTokenRaw }}
+
+
+ {{ apiNewTokenDescription }}
+
+
+
+
+
+
+
+
+ % endif
+%def>
+
+<%def name="modify_this_page_vars()">
+ ${parent.modify_this_page_vars()}
+ % if master.has_perm('manage_api_tokens'):
+
+ % endif
+%def>
+
${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,