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:
parent
85947878c4
commit
c002d3d182
|
@ -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):
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
<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={}):
|
||||
"""
|
||||
Render the given field in a Buefy-compatible way. Note that
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -11,12 +11,7 @@
|
|||
|
||||
<%def name="render_buefy_form()">
|
||||
<div class="form">
|
||||
<${form.component}
|
||||
% if can_edit_help:
|
||||
:configure-fields-help="configureFieldsHelp"
|
||||
% endif
|
||||
>
|
||||
</${form.component}>
|
||||
${form.render_vuejs_component()}
|
||||
</div>
|
||||
</%def>
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
let ThisPage = {
|
||||
template: '#this-page-template',
|
||||
mixins: [FormPosterMixin],
|
||||
mixins: [SimpleRequestMixin],
|
||||
props: {
|
||||
configureFieldsHelp: Boolean,
|
||||
},
|
||||
|
|
|
@ -21,5 +21,125 @@
|
|||
% endif
|
||||
</%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: You must record this token elsewhere
|
||||
for later reference. 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()}
|
||||
|
|
|
@ -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']:
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue