Skip to content

Commit

Permalink
Merge 168f490 into 24240af
Browse files Browse the repository at this point in the history
  • Loading branch information
teleyinex committed Aug 3, 2016
2 parents 24240af + 168f490 commit 837395d
Show file tree
Hide file tree
Showing 23 changed files with 881 additions and 48 deletions.
28 changes: 28 additions & 0 deletions alembic/versions/8ce9b3da799e_add_user_external_id.py
@@ -0,0 +1,28 @@
"""Add user external ID
Revision ID: 8ce9b3da799e
Revises: 4f12d8650050
Create Date: 2016-07-27 12:12:46.392252
"""

# revision identifiers, used by Alembic.
revision = '8ce9b3da799e'
down_revision = '4f12d8650050'

from alembic import op
import sqlalchemy as sa

field = 'external_uid'


def upgrade():
op.add_column('task_run', sa.Column(field, sa.String))
op.add_column('project', sa.Column('secret_key', sa.String))
query = 'update project set secret_key=md5(random()::text);'
op.execute(query)


def downgrade():
op.drop_column('task_run', field)
op.drop_column('project', 'secret_key')
56 changes: 54 additions & 2 deletions doc/api.rst
Expand Up @@ -407,8 +407,60 @@ desired::
Where 'provider' will be any of the third parties supported, i.e. 'twitter',
'facebook' or 'google'.

Example Usage
-------------
Using your own user database
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Since version v2.3.0 PYBOSSA supports external User IDs. This means that you can
easily use your own database of users without having to registering them in the
PYBOSSA server. As a benefit, you will be able to track your own users within the
PYBOSSA server providing a very simple and easy experience for them.

A typical case for this would be for example a native phone app (Android, iOS or Windows).

Usually phone apps have their own user base. With this in mind, you can add a crowdsourcing
feature to your phone app by just using PYBOSSA in the following way.

First, create a project. When you create a project in PYBOSSA the system will create for
you a *secret key*. This secret key will be used by your phone app to authenticate all
the requests and avoid other users to send data to your project via external user API.


.. note::

We highly recommend using SSL on your server to secure all the process. You can use
Let's Encrypt certificates for free. Check their `documentation. <https://certbot.eff.org/>`_

Now your phone app will have to authenticate to the server to get tasks and post task runs.

To do it, all you have to do is to create an HTTP Request with an Authorization Header like this::

HEADERS Authorization: project.secret_key
GET http://{pybossa-site-url}/api/auth/project/short_name/token

That request will return a JWT token for you. With that token, you will be able to start
requesting tasks for your user base passing again an authorization header. Imagine a user
from your database is identified like this: '1xa'::

HEADERS Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
GET http://{pybossa-site-url}/api/{project.id}/newtask?external_uid=1xa


That will return a task for the user ID 1xa that belongs to your database but not to
PYBOSSA. Then, once the user has completed the task you will be able to submit it like
this::

HEADERS Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
POST http://{pybossa-site-url}/api/taskrun


.. note::
The TaskRun object needs to have the external_uid field filled with 1xa.

As simple as that!


Command line Example Usage of the API
-------------------------------------

Create a Project object:

Expand Down
2 changes: 2 additions & 0 deletions doc/changelog/index.rst
@@ -1,6 +1,7 @@
Changelog
=========

* v2.3.0_
* v2.2.1_
* v2.2.0_
* v2.1.0_
Expand All @@ -22,6 +23,7 @@ Changelog
* v1.1.0_
* v0.2.3_

.. _v2.3.0: v2.3.0.html
.. _v2.2.1: v2.2.1.html
.. _v2.2.0: v2.2.0.html
.. _v2.1.0: v2.1.0.html
Expand Down
22 changes: 22 additions & 0 deletions doc/changelog/v2.3.0.rst
@@ -0,0 +1,22 @@
================
Changelog v2.3.0
================


This new version adds a few cool features to PYBOSSA. Basically, it allows to use
PYBOSSA backend as your crowdsourcing engine for native iOS and Android phone apps.

The idea is that those apps, usually have their own user base, with their own IDs.

As a result, you don't want to force your user base to register again in another
service just to help you with your crowdsourcing research. Therefore, PYBOSSA comes
to the rescue allowing you to login those users in a PYBOSSA project using a secure
token (JWT).

The process is really simple, you create a PYBOSSA project, you copy the secret key
created by PYBOSSA for your project and you use it to authenticate your requests. Then
when a user sends a Task Run you pass your authentication token and your internal user
ID. As simple as that. PYBOSSA will handle everything as usual.

* Add support for external User IDs.
* Add JWT authentication for projects.
40 changes: 40 additions & 0 deletions pybossa/api/__init__.py
Expand Up @@ -30,6 +30,7 @@
"""

import json
import jwt
from flask import Blueprint, request, abort, Response, make_response
from flask.ext.login import current_user
from werkzeug.exceptions import NotFound
Expand All @@ -52,6 +53,7 @@
from result import ResultAPI
from pybossa.core import project_repo, task_repo
from pybossa.contributions_guard import ContributionsGuard
from pybossa.auth import jwt_authorize_project

blueprint = Blueprint('api', __name__)

Expand Down Expand Up @@ -110,6 +112,10 @@ def new_task(project_id):
# Check if the request has an arg:
try:
task = _retrieve_new_task(project_id)

if type(task) is Response:
return task

# If there is a task for the user, return it
if task is not None:
guard = ContributionsGuard(sentinel.master)
Expand All @@ -123,23 +129,35 @@ def new_task(project_id):


def _retrieve_new_task(project_id):

project = project_repo.get(project_id)

if project is None:
raise NotFound

if not project.allow_anonymous_contributors and current_user.is_anonymous():
info = dict(
error="This project does not allow anonymous contributors")
error = model.task.Task(info=info)
return error

if request.args.get('external_uid'):
resp = jwt_authorize_project(project,
request.headers.get('Authorization'))
if resp != True:
return resp

if request.args.get('offset'):
offset = int(request.args.get('offset'))
else:
offset = 0
user_id = None if current_user.is_anonymous() else current_user.id
user_ip = request.remote_addr if current_user.is_anonymous() else None
external_uid = request.args.get('external_uid')
task = sched.new_task(project_id, project.info.get('sched'),
user_id,
user_ip,
external_uid,
offset)
return task

Expand Down Expand Up @@ -182,3 +200,25 @@ def user_progress(project_id=None, short_name=None):
return abort(404)
else: # pragma: no cover
return abort(404)


@jsonpify
@blueprint.route('/auth/project/<short_name>/token')
@crossdomain(origin='*', headers=cors_headers)
@ratelimit(limit=ratelimits.get('LIMIT'), per=ratelimits.get('PER'))
def auth_jwt_project(short_name):
"""Create a JWT for a project via its secret KEY."""
project_secret_key = None
if 'Authorization' in request.headers:
project_secret_key = request.headers.get('Authorization')
if project_secret_key:
project = project_repo.get_by_shortname(short_name)
if project and project.secret_key == project_secret_key:
token = jwt.encode({'short_name': short_name,
'project_id': project.id},
project.secret_key, algorithm='HS256')
return token
else:
return abort(404)
else:
return abort(403)
9 changes: 8 additions & 1 deletion pybossa/api/project.py
Expand Up @@ -45,7 +45,8 @@ class ProjectAPI(APIBase):

__class__ = Project
reserved_keys = set(['id', 'created', 'updated', 'completed', 'contacted',
'published'])
'published', 'secret_key'])
private_keys = set(['secret_key'])

def _create_instance_from_request(self, data):
inst = super(ProjectAPI, self)._create_instance_from_request(data)
Expand All @@ -71,3 +72,9 @@ def _forbidden_attributes(self, data):
if key == 'published':
raise Forbidden('You cannot publish a project via the API')
raise BadRequest("Reserved keys in payload")

def _select_attributes(self, data):
for key in self.private_keys:
if data.get(key):
del data[key]
return data
9 changes: 8 additions & 1 deletion pybossa/api/task_run.py
Expand Up @@ -23,7 +23,7 @@
"""
import json
from flask import request
from flask import request, Response
from flask.ext.login import current_user
from pybossa.model.task_run import TaskRun
from werkzeug.exceptions import Forbidden, BadRequest
Expand All @@ -32,6 +32,7 @@
from pybossa.util import get_user_id_or_ip
from pybossa.core import task_repo, sentinel
from pybossa.contributions_guard import ContributionsGuard
from pybossa.auth import jwt_authorize_project


class TaskRunAPI(APIBase):
Expand Down Expand Up @@ -61,6 +62,12 @@ def _validate_project_and_task(self, taskrun, task):
raise Forbidden('Invalid task_id')
if (task.project_id != taskrun.project_id):
raise Forbidden('Invalid project_id')
if taskrun.external_uid:
resp = jwt_authorize_project(task.project,
request.headers.get('Authorization'))
if type(resp) == Response:
msg = json.loads(resp.data)['description']
raise Forbidden(msg)

def _ensure_task_was_requested(self, task, guard):
if not guard.check_task_stamped(task, get_user_id_or_ip()):
Expand Down
38 changes: 38 additions & 0 deletions pybossa/auth/__init__.py
Expand Up @@ -20,6 +20,11 @@
from flask import abort
from flask.ext.login import current_user
from pybossa.core import task_repo, project_repo, result_repo
from pybossa.auth.errcodes import *

import jwt
from flask import jsonify
from jwt import exceptions

import project
import task
Expand Down Expand Up @@ -89,3 +94,36 @@ def _authorizer_for(resource_name):
if resource_name in ('project', 'task', 'taskrun'):
kwargs.update({'result_repo': result_repo})
return _auth_classes[resource_name](**kwargs)


def handle_error(error):
"""Return authentication error in JSON."""
resp = jsonify(error)
resp.status_code = 401
return resp


def jwt_authorize_project(project, payload):
"""Authorize the project for the payload."""
try:
if payload is None:
return handle_error(INVALID_HEADER_MISSING)
parts = payload.split()

if parts[0].lower() != 'bearer':
return handle_error(INVALID_HEADER_BEARER)
elif len(parts) == 1:
return handle_error(INVALID_HEADER_TOKEN)
elif len(parts) > 2:
return handle_error(INVALID_HEADER_BEARER_TOKEN)

data = jwt.decode(parts[1],
project.secret_key,
'H256')
if (data['project_id'] == project.id
and data['short_name'] == project.short_name):
return True
else:
return handle_error(WRONG_PROJECT_SIGNATURE)
except exceptions.DecodeError:
return handle_error(DECODE_ERROR_SIGNATURE)
37 changes: 37 additions & 0 deletions pybossa/auth/errcodes.py
@@ -0,0 +1,37 @@
# -*- coding: utf8 -*-
# This file is part of PyBossa.
#
# Copyright (C) 2016 SciFabric LTD.
#
# PyBossa is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PyBossa is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with PyBossa. If not, see <http://www.gnu.org/licenses/>.

INVALID_HEADER_MISSING = {'code': 'invalid_header',
'description': 'Missing Authorization header'}

INVALID_HEADER_BEARER = {'code': 'invalid_header',
'description': 'Authorization header \
must start with Bearer'}

INVALID_HEADER_TOKEN = {'code': 'invalid_header',
'description': 'Token not found'}

INVALID_HEADER_BEARER_TOKEN = {'code': 'invalid_header',
'description': 'Authorization header must \
be Bearer + \\s + token'}

WRONG_PROJECT_SIGNATURE = {'code': 'Wrong project',
'description': 'Signature verification failed'}

DECODE_ERROR_SIGNATURE = {'code': 'Decode error',
'description': 'Signature verification failed'}
4 changes: 3 additions & 1 deletion pybossa/model/project.py
Expand Up @@ -23,7 +23,7 @@
from sqlalchemy.ext.mutable import MutableDict

from pybossa.core import db, signer
from pybossa.model import DomainObject, make_timestamp
from pybossa.model import DomainObject, make_timestamp, make_uuid
from pybossa.model.task import Task
from pybossa.model.task_run import TaskRun
from pybossa.model.category import Category
Expand Down Expand Up @@ -58,6 +58,8 @@ class Project(db.Model, DomainObject):
published = Column(Boolean, nullable=False, default=False)
# If the project is featured
featured = Column(Boolean, nullable=False, default=False)
# Secret key for project
secret_key = Column(Text, default=make_uuid)
# If the project owner has been emailed
contacted = Column(Boolean, nullable=False, default=False)
#: Project owner_id
Expand Down
2 changes: 2 additions & 0 deletions pybossa/model/task_run.py
Expand Up @@ -47,6 +47,8 @@ class TaskRun(db.Model, DomainObject):
finish_time = Column(Text, default=make_timestamp)
timeout = Column(Integer)
calibration = Column(Integer)
#: External User ID
external_uid = Column(Text)
#: Value of the answer.
info = Column(JSON)
'''General writable field that should be used by clients to record results\
Expand Down

0 comments on commit 837395d

Please sign in to comment.