Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cb1ce14b | |||
| 4e69083284 | |||
| cc03b5cc08 | |||
| 385e35b322 | |||
| 9c6097a3d4 | |||
| 204eb1478a | |||
| e64b990dc8 | |||
| e358300085 | |||
| b8cb201652 | |||
| cba141724d | |||
| d64651e655 | |||
| 9d5d835910 | |||
| 9eb5f3466b | |||
| d66e636078 | |||
| a381089497 | |||
| aaa18ee0c6 | |||
| a07280975a | |||
| cb0244652d | |||
| 4a56a43bd5 | |||
| 4a366a1b97 |
+2
-1
@@ -1,10 +1,11 @@
|
||||
language: python
|
||||
python:
|
||||
- "2.6"
|
||||
- "2.7"
|
||||
|
||||
install:
|
||||
- "pip install -r requirements.dev.txt"
|
||||
- "[[ $TRAVIS_PYTHON_VERSION = '2.6' ]] && pip install unittest2 || echo"
|
||||
- "pip install ."
|
||||
|
||||
|
||||
script: nosetests
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pydiscourse
|
||||
------------
|
||||
A Python library for the Discourse API.
|
||||
A Python library for working with Discourse.
|
||||
|
||||
Its pretty basic right now but you need to start somewhere.
|
||||
|
||||
Examples
|
||||
@@ -22,6 +23,16 @@ Create a new user::
|
||||
|
||||
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
|
||||
|
||||
Implement SSO for Discourse with your Python server::
|
||||
|
||||
@login_required
|
||||
def discourse_sso_view(request):
|
||||
payload = request.GET.get('sso')
|
||||
signature = request.GET.get('sig')
|
||||
nonce = sso_validate(payload, signature, SECRET)
|
||||
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
|
||||
return redirect('http://discuss.example.com' + url)
|
||||
|
||||
Command line
|
||||
----------------
|
||||
|
||||
|
||||
+62
-9
@@ -44,13 +44,16 @@ class DiscourseClient(object):
|
||||
|
||||
def toggle_gravatar(self, username, state=True, **kwargs):
|
||||
url = '/users/{0}/preferences/avatar/toggle'.format(username)
|
||||
|
||||
if bool(state):
|
||||
kwargs['use_uploaded_avatar'] = 'true'
|
||||
else:
|
||||
kwargs['use_uploaded_avatar'] = 'false'
|
||||
return self._put(url, **kwargs)
|
||||
|
||||
def pick_avatar(self, username, gravatar=True, generated=False, **kwargs):
|
||||
url = '/users/{0}/preferences/avatar/pick'.format(username)
|
||||
return self._put(url, **kwargs)
|
||||
|
||||
def update_email(self, username, email, **kwargs):
|
||||
return self._put('/users/{0}/preferences/email'.format(username), email=email, **kwargs)
|
||||
|
||||
@@ -60,6 +63,12 @@ class DiscourseClient(object):
|
||||
def update_username(self, username, new_username, **kwargs):
|
||||
return self._put('/users/{0}/preferences/username'.format(username), username=new_username, **kwargs)
|
||||
|
||||
def set_preference(self, username=None, **kwargs):
|
||||
if username is None:
|
||||
username = self.api_username
|
||||
|
||||
return self._put(u'/users/{0}'.format(username), **kwargs)
|
||||
|
||||
def generate_api_key(self, userid, **kwargs):
|
||||
return self._post('/admin/users/{0}/generate_api_key'.format(userid), **kwargs)
|
||||
|
||||
@@ -71,6 +80,12 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._delete('/admin/users/{0}.json'.format(userid), **kwargs)
|
||||
|
||||
def users(self, filter=None, **kwargs):
|
||||
if filter is None:
|
||||
filter = 'active'
|
||||
|
||||
return self._get('/admin/users/list/{0}.json'.format(filter), **kwargs)
|
||||
|
||||
def private_messages(self, username=None, **kwargs):
|
||||
if username is None:
|
||||
username = self.api_username
|
||||
@@ -85,8 +100,8 @@ class DiscourseClient(object):
|
||||
def new_topics(self, **kwargs):
|
||||
return self._get('/new.json', **kwargs)
|
||||
|
||||
def topic(self, topic_id, **kwargs):
|
||||
return self._get('/t/{0}.json'.format(topic_id), **kwargs)
|
||||
def topic(self, slug, topic_id, **kwargs):
|
||||
return self._get('/t/{0}/{1}.json'.format(slug, topic_id), **kwargs)
|
||||
|
||||
def post(self, topic_id, post_id, **kwargs):
|
||||
return self._get('/t/{0}/{1}.json'.format(topic_id, post_id), **kwargs)
|
||||
@@ -123,6 +138,11 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._post('/posts', raw=content, **kwargs)
|
||||
|
||||
def update_post(self, post_id, content, edit_reason='', **kwargs):
|
||||
kwargs['post[raw]'] = content
|
||||
kwargs['post[edit_reason]'] = edit_reason
|
||||
return self._put('/posts/{0}'.format(post_id), **kwargs)
|
||||
|
||||
def topics_by(self, username, **kwargs):
|
||||
url = '/topics/created-by/{0}.json'.format(username)
|
||||
return self._get(url, **kwargs)['topic_list']['topics']
|
||||
@@ -138,9 +158,42 @@ class DiscourseClient(object):
|
||||
kwargs['term'] = term
|
||||
return self._get('/search.json', **kwargs)
|
||||
|
||||
def create_category(self, name, color, text_color='FFFFFF', permissions=None, parent=None, **kwargs):
|
||||
""" permissions - dict of 'everyone', 'admins', 'moderators', 'staff' with values of
|
||||
"""
|
||||
|
||||
kwargs['name'] = name
|
||||
kwargs['color'] = color
|
||||
kwargs['text_color'] = text_color
|
||||
|
||||
if permissions is None and 'permissions' not in kwargs:
|
||||
permissions = {'everyone': '1'}
|
||||
|
||||
for key, value in permissions.items():
|
||||
kwargs['permissions[{0}]'.format(key)] = value
|
||||
|
||||
if parent:
|
||||
parent_id = None
|
||||
for category in self.categories():
|
||||
if category['name'] == parent:
|
||||
parent_id = category['id']
|
||||
continue
|
||||
|
||||
if not parent_id:
|
||||
raise DiscourseClientError(u'{0} not found'.format(parent))
|
||||
kwargs['parent_category_id'] = parent_id
|
||||
|
||||
return self._post('/categories', **kwargs)
|
||||
|
||||
def categories(self, **kwargs):
|
||||
return self._get('/categories.json', **kwargs)['category_list']['categories']
|
||||
|
||||
def category(self, name, parent=None, **kwargs):
|
||||
if parent:
|
||||
name = u'{0}/{1}'.format(parent, name)
|
||||
|
||||
return self._get(u'/category/{0}.json'.format(name), **kwargs)
|
||||
|
||||
def site_settings(self, **kwargs):
|
||||
for setting, value in kwargs.items():
|
||||
setting = setting.replace(' ', '_')
|
||||
@@ -168,12 +221,12 @@ class DiscourseClient(object):
|
||||
|
||||
log.debug('response %s: %s', response.status_code, repr(response.text))
|
||||
if not response.ok:
|
||||
if response.reason:
|
||||
msg = response.reason
|
||||
else:
|
||||
try:
|
||||
msg = u','.join(response.json()['errors'])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
try:
|
||||
msg = u','.join(response.json()['errors'])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
if response.reason:
|
||||
msg = response.reason
|
||||
else:
|
||||
msg = u'{0}: {1}'.format(response.status_code, response.text)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
|
||||
+8
-2
@@ -30,7 +30,7 @@ class DiscourseCmd(cmd.Cmd):
|
||||
try:
|
||||
return method(*args, **kwargs)
|
||||
except DiscourseError as e:
|
||||
print e, e.response.text
|
||||
print (e, e.response.text)
|
||||
return e.response
|
||||
return wrapper
|
||||
|
||||
@@ -57,8 +57,14 @@ def main():
|
||||
op.add_option('--api-user', default='system')
|
||||
op.add_option('-v', '--verbose', action='store_true')
|
||||
|
||||
api_key = os.environ['DISCOURSE_API_KEY']
|
||||
options, args = op.parse_args()
|
||||
if not options.host.startswith('http'):
|
||||
op.error('host must include protocol, eg http://')
|
||||
|
||||
api_key = os.environ.get('DISCOURSE_API_KEY')
|
||||
if not api_key:
|
||||
op.error('please set DISCOURSE_API_KEY')
|
||||
|
||||
client = DiscourseClient(options.host, options.api_user, api_key)
|
||||
|
||||
if options.verbose:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Utilities to implement Single Sign On for Discourse with a Python managed authentication DB
|
||||
|
||||
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
|
||||
|
||||
Thanks to James Potter for the heavy lifting, detailed at https://meta.discourse.org/t/sso-example-for-django/14258
|
||||
|
||||
A SSO request handler might look something like
|
||||
|
||||
@login_required
|
||||
def discourse_sso_view(request):
|
||||
payload = request.GET.get('sso')
|
||||
signature = request.GET.get('sig')
|
||||
try:
|
||||
nonce = sso_validate(payload, signature, SECRET)
|
||||
except DiscourseError as e:
|
||||
return HTTP400(e.args[0])
|
||||
|
||||
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
|
||||
return redirect('http://discuss.example.com' + url)
|
||||
"""
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
try: # py3
|
||||
from urllib.parse import unquote, urlencode
|
||||
except ImportError:
|
||||
from urllib import unquote, urlencode
|
||||
|
||||
|
||||
from pydiscourse.exceptions import DiscourseError
|
||||
|
||||
|
||||
def sso_validate(payload, signature, secret):
|
||||
"""
|
||||
payload: provided by Discourse HTTP call to your SSO endpoint as sso GET param
|
||||
signature: provided by Discourse HTTP call to your SSO endpoint as sig GET param
|
||||
secret: the secret key you entered into Discourse sso secret
|
||||
|
||||
return value: The nonce used by discourse to validate the redirect URL
|
||||
"""
|
||||
if None in [payload, signature]:
|
||||
raise DiscourseError('No SSO payload or signature.')
|
||||
|
||||
if not secret:
|
||||
raise DiscourseError('Invalid secret..')
|
||||
|
||||
payload = unquote(payload)
|
||||
if not payload:
|
||||
raise DiscourseError('Invalid payload..')
|
||||
|
||||
decoded = base64.decodestring(payload)
|
||||
if 'nonce' not in decoded:
|
||||
raise DiscourseError('Invalid payload..')
|
||||
|
||||
h = hmac.new(secret, payload, digestmod=hashlib.sha256)
|
||||
this_signature = h.hexdigest()
|
||||
|
||||
if this_signature != signature:
|
||||
raise DiscourseError('Payload does not match signature.')
|
||||
|
||||
nonce = decoded.split('=')[1]
|
||||
|
||||
return nonce
|
||||
|
||||
|
||||
def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
|
||||
"""
|
||||
nonce: returned by sso_validate()
|
||||
secret: the secret key you entered into Discourse sso secret
|
||||
user_email: email address of the user who logged in
|
||||
user_id: the internal id of the logged in user
|
||||
user_username: username of the logged in user
|
||||
|
||||
return value: URL to redirect users back to discourse, now logged in as user_username
|
||||
"""
|
||||
kwargs.update({
|
||||
'nonce': nonce,
|
||||
'email': email,
|
||||
'external_id': external_id,
|
||||
'username': username
|
||||
})
|
||||
|
||||
return_payload = base64.encodestring(urlencode(kwargs))
|
||||
h = hmac.new(secret, return_payload, digestmod=hashlib.sha256)
|
||||
query_string = urlencode({'sso': return_payload, 'sig': h.hexdigest()})
|
||||
|
||||
return '/session/sso_login?%s' % query_string
|
||||
@@ -1,3 +1,3 @@
|
||||
requests
|
||||
-r requirements.txt
|
||||
nose
|
||||
mock
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
requests
|
||||
@@ -5,7 +5,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
|
||||
def read(fname):
|
||||
return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read()
|
||||
return codecs.open(os.path.join(os.path.dirname(__file__), fname), 'rt').read()
|
||||
|
||||
|
||||
# Provided as an attribute, so you can append to these instead
|
||||
@@ -47,6 +47,8 @@ setup(
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 2.6",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
],
|
||||
zip_safe=False,
|
||||
)
|
||||
|
||||
@@ -82,8 +82,8 @@ class TestTopics(ClientBaseTestCase):
|
||||
|
||||
def test_topic(self, request):
|
||||
prepare_response(request)
|
||||
self.client.topic(22)
|
||||
self.assertRequestCalled(request, 'GET', '/t/22.json')
|
||||
self.client.topic('some-test-slug', 22)
|
||||
self.assertRequestCalled(request, 'GET', '/t/some-test-slug/22.json')
|
||||
|
||||
def test_topics_by(self, request):
|
||||
prepare_response(request)
|
||||
@@ -111,3 +111,8 @@ class MiscellaneousTests(ClientBaseTestCase):
|
||||
r = self.client.categories()
|
||||
self.assertRequestCalled(request, 'GET', '/categories.json')
|
||||
self.assertEqual(r, request().json()['category_list']['categories'])
|
||||
|
||||
def test_users(self, request):
|
||||
prepare_response(request)
|
||||
r = self.client.users()
|
||||
self.assertRequestCalled(request, 'GET', '/admin/users/list/active.json')
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import base64
|
||||
|
||||
try: # py26
|
||||
import unittest2 as unittest
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
|
||||
try: # py3
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
except ImportError:
|
||||
from urlparse import urlparse, parse_qs
|
||||
from urllib import unquote
|
||||
|
||||
|
||||
from pydiscourse import sso
|
||||
from pydiscourse.exceptions import DiscourseError
|
||||
|
||||
|
||||
class SSOTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# values from https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
|
||||
self.secret = 'd836444a9e4084d5b224a60c208dce14'
|
||||
self.nonce = 'cb68251eefb5211e58c00ff1395f0c0b'
|
||||
self.payload = 'bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A'
|
||||
self.signature = '2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56'
|
||||
|
||||
self.name = u'sam'
|
||||
self.username = u'samsam'
|
||||
self.external_id = u'hello123'
|
||||
self.email = u'test@test.com'
|
||||
self.redirect_url = u'/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b'
|
||||
|
||||
|
||||
class Test_sso_validate(SSOTestCase):
|
||||
def test_missing_args(self):
|
||||
with self.assertRaises(DiscourseError):
|
||||
sso.sso_validate(None, self.signature, self.secret)
|
||||
|
||||
with self.assertRaises(DiscourseError):
|
||||
sso.sso_validate('', self.signature, self.secret)
|
||||
|
||||
with self.assertRaises(DiscourseError):
|
||||
sso.sso_validate(self.payload, None, self.secret)
|
||||
|
||||
def test_invalid_signature(self):
|
||||
with self.assertRaises(DiscourseError):
|
||||
sso.sso_validate(self.payload, 'notavalidsignature', self.secret)
|
||||
|
||||
def test_valid_nonce(self):
|
||||
nonce = sso.sso_validate(self.payload, self.signature, self.secret)
|
||||
self.assertEqual(nonce, self.nonce)
|
||||
|
||||
|
||||
class Test_sso_redirect_url(SSOTestCase):
|
||||
def test_valid_redirect_url(self):
|
||||
url = sso.sso_redirect_url(self.nonce, self.secret, self.email, self.external_id, self.username, name='sam')
|
||||
|
||||
self.assertIn('/session/sso_login', url[:20])
|
||||
|
||||
# check its valid, using our own handy validator
|
||||
params = parse_qs(urlparse(url).query)
|
||||
payload = params['sso'][0]
|
||||
sso.sso_validate(payload, params['sig'][0], self.secret)
|
||||
|
||||
# check the params have all the data we expect
|
||||
payload = base64.decodestring(payload)
|
||||
payload = unquote(payload)
|
||||
payload = dict((p.split('=') for p in payload.split('&')))
|
||||
|
||||
self.assertEqual(payload, {
|
||||
'username': self.username,
|
||||
'nonce': self.nonce,
|
||||
'external_id': self.external_id,
|
||||
'name': self.name,
|
||||
'email': self.email
|
||||
})
|
||||
Reference in New Issue
Block a user