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
This commit is contained in:
Lance Edgar 2023-05-14 20:10:05 -05:00
parent 85947878c4
commit c002d3d182
9 changed files with 318 additions and 26 deletions

View file

@ -25,6 +25,7 @@ Authentication & Authorization
""" """
import logging import logging
import re
from rattail import enum from rattail import enum
from rattail.util import prettify, NOTSET from rattail.util import prettify, NOTSET
@ -32,6 +33,7 @@ from rattail.util import prettify, NOTSET
from zope.interface import implementer from zope.interface import implementer
from pyramid.interfaces import IAuthorizationPolicy from pyramid.interfaces import IAuthorizationPolicy
from pyramid.security import remember, forget, Everyone, Authenticated from pyramid.security import remember, forget, Everyone, Authenticated
from pyramid.authentication import SessionAuthenticationPolicy
from tailbone.db import Session from tailbone.db import Session
@ -87,6 +89,37 @@ def set_session_timeout(request, timeout):
request.session['_timeout'] = timeout or None 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) @implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object): class TailboneAuthorizationPolicy(object):

View file

@ -338,7 +338,7 @@ class Form(object):
assume_local_times=False, renderers=None, renderer_kwargs={}, assume_local_times=False, renderers=None, renderer_kwargs={},
hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None, hidden={}, widgets={}, defaults={}, validators={}, required={}, helptext={}, focus_spec=None,
action_url=None, cancel_url=None, component='tailbone-form', 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! # TODO: ugh this is getting out hand!
can_edit_help=False, edit_help_url=None, route_prefix=None, can_edit_help=False, edit_help_url=None, route_prefix=None,
): ):
@ -379,6 +379,7 @@ class Form(object):
self.action_url = action_url self.action_url = action_url
self.cancel_url = cancel_url self.cancel_url = cancel_url
self.component = component self.component = component
self.vuejs_component_kwargs = vuejs_component_kwargs or {}
self.vuejs_field_converters = vuejs_field_converters or {} self.vuejs_field_converters = vuejs_field_converters or {}
self.can_edit_help = can_edit_help self.can_edit_help = can_edit_help
self.edit_help_url = edit_help_url self.edit_help_url = edit_help_url
@ -913,6 +914,25 @@ class Form(object):
return False return False
return True 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
<tailbone-form :configure-fields-help="configureFieldsHelp">
</tailbone-form>
"""
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={}): def render_buefy_field(self, fieldname, bfield_attrs={}):
""" """
Render the given field in a Buefy-compatible way. Note that Render the given field in a Buefy-compatible way. Note that

View file

@ -1613,6 +1613,23 @@ class GridAction(object):
""" """
Represents an action available to a grid. This is used to construct the Represents an action available to a grid. This is used to construct the
'actions' column when rendering the grid. '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, def __init__(self, key, label=None, url='#', icon=None, target=None,

View file

@ -11,12 +11,7 @@
<%def name="render_buefy_form()"> <%def name="render_buefy_form()">
<div class="form"> <div class="form">
<${form.component} ${form.render_vuejs_component()}
% if can_edit_help:
:configure-fields-help="configureFieldsHelp"
% endif
>
</${form.component}>
</div> </div>
</%def> </%def>

View file

@ -32,7 +32,7 @@
let ThisPage = { let ThisPage = {
template: '#this-page-template', template: '#this-page-template',
mixins: [FormPosterMixin], mixins: [SimpleRequestMixin],
props: { props: {
configureFieldsHelp: Boolean, configureFieldsHelp: Boolean,
}, },

View file

@ -21,5 +21,125 @@
% endif % endif
</%def> </%def>
<%def name="render_this_page()">
${parent.render_this_page()}
% if master.has_perm('manage_api_tokens'):
<b-modal :active.sync="apiNewTokenShowDialog"
has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
New API Token
</p>
</header>
<section class="modal-card-body">
<div v-if="!apiNewTokenSaved">
<b-field label="Description"
:type="{'is-danger': !apiNewTokenDescription}">
<b-input v-model.trim="apiNewTokenDescription"
ref="apiNewTokenDescription">
</b-input>
</b-field>
</div>
<div v-if="apiNewTokenSaved">
<p class="block">
Your new API token is shown below.
</p>
<p class="block">
IMPORTANT:&nbsp; You must record this token elsewhere
for later reference.&nbsp; You will NOT be able to
recover the value if you lose it.
</p>
<b-field horizontal label="API Token">
{{ apiNewTokenRaw }}
</b-field>
<b-field horizontal label="Description">
{{ apiNewTokenDescription }}
</b-field>
</div>
</section>
<footer class="modal-card-foot">
<b-button @click="apiNewTokenShowDialog = false">
{{ apiNewTokenSaved ? "Close" : "Cancel" }}
</b-button>
<b-button v-if="!apiNewTokenSaved"
type="is-primary"
icon-pack="fas"
icon-left="save"
@click="apiNewTokenSave()"
:disabled="!apiNewTokenDescription || apiNewTokenSaving">
Save
</b-button>
</footer>
</div>
</b-modal>
% endif
</%def>
<%def name="modify_this_page_vars()">
${parent.modify_this_page_vars()}
% if master.has_perm('manage_api_tokens'):
<script type="text/javascript">
${form.component_studly}.props.apiTokens = null
ThisPageData.apiTokens = ${json.dumps(api_tokens_data)|n}
ThisPageData.apiNewTokenShowDialog = false
ThisPageData.apiNewTokenDescription = null
ThisPage.methods.apiNewToken = function() {
this.apiNewTokenDescription = null
this.apiNewTokenSaved = false
this.apiNewTokenShowDialog = true
this.$nextTick(() => {
this.$refs.apiNewTokenDescription.focus()
})
}
ThisPageData.apiNewTokenSaving = false
ThisPageData.apiNewTokenSaved = false
ThisPageData.apiNewTokenRaw = null
ThisPage.methods.apiNewTokenSave = function() {
this.apiNewTokenSaving = true
let url = '${master.get_action_url('add_api_token', instance)}'
let params = {
description: this.apiNewTokenDescription,
}
this.simplePOST(url, params, response => {
this.apiTokens = response.data.tokens
this.apiNewTokenSaving = false
this.apiNewTokenRaw = response.data.raw_token
this.apiNewTokenSaved = true
}, response => {
this.apiNewTokenSaving = false
})
}
ThisPage.methods.apiTokenDelete = function(token) {
if (!confirm("Really delete this API token?")) {
return
}
let url = '${master.get_action_url('delete_api_token', instance)}'
let params = {uuid: token.uuid}
this.simplePOST(url, params, response => {
this.apiTokens = response.data.tokens
})
}
</script>
% endif
</%def>
${parent.body()} ${parent.body()}

View file

@ -2282,9 +2282,15 @@ class MasterView(View):
if info and info.markdown_text: if info and info.markdown_text:
return 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): def edit_help(self):
if (not self.has_perm('edit_help') if not self.can_edit_help():
and not self.request.has_perm('common.edit_help')):
raise self.forbidden() raise self.forbidden()
model = self.model model = self.model
@ -2317,8 +2323,7 @@ class MasterView(View):
return {'ok': True} return {'ok': True}
def edit_field_help(self): def edit_field_help(self):
if (not self.has_perm('edit_help') if not self.can_edit_help():
and not self.request.has_perm('common.edit_help')):
raise self.forbidden() raise self.forbidden()
model = self.model model = self.model
@ -2371,8 +2376,7 @@ class MasterView(View):
'grid_index': self.grid_index, 'grid_index': self.grid_index,
'help_url': self.get_help_url(), 'help_url': self.get_help_url(),
'help_markdown': self.get_help_markdown(), 'help_markdown': self.get_help_markdown(),
'can_edit_help': (self.has_perm('edit_help') 'can_edit_help': self.can_edit_help(),
or self.request.has_perm('common.edit_help')),
'quickie': None, 'quickie': None,
} }
@ -2638,16 +2642,16 @@ class MasterView(View):
elif is_primary: elif is_primary:
btn_kw['type'] = 'is-primary' btn_kw['type'] = 'is-primary'
if url:
btn_kw['href'] = url
if icon_left: if icon_left:
btn_kw['icon_left'] = icon_left btn_kw['icon_left'] = icon_left
elif is_external: elif is_external:
btn_kw['icon_left'] = 'external-link-alt' btn_kw['icon_left'] = 'external-link-alt'
else: elif url:
btn_kw['icon_left'] = 'eye' btn_kw['icon_left'] = 'eye'
if url:
btn_kw['href'] = url
if target: if target:
btn_kw['target'] = target btn_kw['target'] = target
elif is_external: elif is_external:
@ -4017,8 +4021,7 @@ class MasterView(View):
'action_url': self.request.current_route_url(_query=None), 'action_url': self.request.current_route_url(_query=None),
'assume_local_times': self.has_local_times, 'assume_local_times': self.has_local_times,
'route_prefix': route_prefix, 'route_prefix': route_prefix,
'can_edit_help': (self.has_perm('edit_help') 'can_edit_help': self.can_edit_help(),
or self.request.has_perm('common.edit_help')),
} }
if defaults['can_edit_help']: if defaults['can_edit_help']:

View file

@ -38,6 +38,7 @@ from webhelpers2.html import HTML, tags
from tailbone import forms from tailbone import forms
from tailbone.views import MasterView, View from tailbone.views import MasterView, View
from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
from tailbone.util import raw_datetime
class UserView(PrincipalMasterView): class UserView(PrincipalMasterView):
@ -51,6 +52,10 @@ class UserView(PrincipalMasterView):
touchable = True touchable = True
mergeable = True mergeable = True
labels = {
'api_tokens': "API Tokens",
}
grid_columns = [ grid_columns = [
'username', 'username',
'person', 'person',
@ -68,6 +73,7 @@ class UserView(PrincipalMasterView):
'active_sticky', 'active_sticky',
'set_password', 'set_password',
'prevent_password_change', 'prevent_password_change',
'api_tokens',
'roles', 'roles',
'permissions', 'permissions',
] ]
@ -218,6 +224,17 @@ class UserView(PrincipalMasterView):
# if self.creating: # if self.creating:
# f.set_required('password') # 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 # roles
f.set_renderer('roles', self.render_roles) f.set_renderer('roles', self.render_roles)
if self.creating or self.editing: if self.creating or self.editing:
@ -260,6 +277,75 @@ class UserView(PrincipalMasterView):
if self.viewing or self.deleting: if self.viewing or self.deleting:
f.remove('set_password') 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): def get_possible_roles(self):
model = self.model model = self.model
@ -554,6 +640,25 @@ class UserView(PrincipalMasterView):
config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix), config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix),
"Edit the Roles to which a {} belongs".format(model_title)) "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 # edit preferences for any user
config.add_tailbone_permission(permission_prefix, config.add_tailbone_permission(permission_prefix,
'{}.preferences'.format(permission_prefix), '{}.preferences'.format(permission_prefix),

View file

@ -28,10 +28,9 @@ import simplejson
from cornice.renderer import CorniceRenderer from cornice.renderer import CorniceRenderer
from pyramid.config import Configurator from pyramid.config import Configurator
from pyramid.authentication import SessionAuthenticationPolicy
from tailbone import app from tailbone import app
from tailbone.auth import TailboneAuthorizationPolicy from tailbone.auth import TailboneAuthenticationPolicy, TailboneAuthorizationPolicy
from tailbone.providers import get_all_providers 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) pyramid_config = Configurator(settings=settings, root_factory=app.Root)
# configure user authorization / authentication # configure user authorization / authentication
pyramid_config.set_authentication_policy(TailboneAuthenticationPolicy())
pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy()) pyramid_config.set_authorization_policy(TailboneAuthorizationPolicy())
pyramid_config.set_authentication_policy(SessionAuthenticationPolicy())
# always require CSRF token protection # always require CSRF token protection
pyramid_config.set_default_csrf_options(require_csrf=True, pyramid_config.set_default_csrf_options(require_csrf=True,