├── .editorconfig ├── .gitignore ├── .travis.yml ├── .trigger_deploy.sh ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── auth ├── VERSION ├── __init__.py ├── blueprint.py ├── controllers.py ├── credentials.py ├── extensions.py ├── lib.py ├── models.py └── permissions.py ├── pylama.ini ├── requirements.dev.txt ├── requirements.txt ├── sample_extension └── __init__.py ├── sample_permission_provider └── __init__.py ├── server.py ├── setup.py ├── tests ├── __init__.py ├── test_extensions_controllers.py ├── test_permission_controllers.py └── test_user_controllers.py ├── tools └── generate_key_pair.sh └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.js] 2 | indent_style = space 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *,cover 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # Node 59 | node_modules/ 60 | 61 | # Virtualenv 62 | venv/ 63 | 64 | # Shippable 65 | shippable/ 66 | 67 | # IntelliJ 68 | /.idea/ 69 | *.iml 70 | 71 | # flask 72 | flask_session/ 73 | 74 | # Keys 75 | *.pem 76 | 77 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: 2 | python 3 | 4 | python: 5 | - 3.6 6 | 7 | services: 8 | - docker 9 | 10 | install: 11 | - ./tools/generate_key_pair.sh 12 | - make install 13 | 14 | script: 15 | - make test 16 | 17 | after_success: 18 | - coveralls 19 | 20 | before_deploy: 21 | - curl -s https://raw.githubusercontent.com/datahq/deploy/master/apps_travis_script.sh > .travis.sh 22 | - bash .travis.sh script 23 | 24 | deploy: 25 | skip_cleanup: true 26 | provider: script 27 | script: bash .trigger_deploy.sh 28 | on: 29 | branch: master 30 | -------------------------------------------------------------------------------- /.trigger_deploy.sh: -------------------------------------------------------------------------------- 1 | SCRIPT_PATH=".travis.sh" 2 | 3 | bash "$SCRIPT_PATH" push_to_docker 4 | bash "$SCRIPT_PATH" trigger datahub-auth 5 | bash "$SCRIPT_PATH" trigger specstore 6 | bash "$SCRIPT_PATH" trigger bitstore 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM codexfons/gunicorn 2 | 3 | USER root 4 | RUN apk --update --no-cache add libpq postgresql-dev libffi libffi-dev build-base python3-dev ca-certificates 5 | RUN update-ca-certificates 6 | RUN mkdir /tmp/sessions && chown $GUNICORN_USER /tmp/sessions 7 | 8 | ADD requirements.txt $APP_PATH/requirements.txt 9 | RUN pip3 install -r $APP_PATH/requirements.txt 10 | 11 | ADD . $APP_PATH 12 | 13 | USER $GUNICORN_USER -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 OpenSpending 4 | Copyright (c) 2017 DHQ 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-include *.json 2 | global-include *.yml 3 | global-include *.txt 4 | global-include VERSION 5 | include LICENSE.md 6 | include Makefile 7 | include pylama.ini 8 | include pytest.ini 9 | include README.md 10 | include tox.ini -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all install list test version 2 | 3 | 4 | PACKAGE := $(shell grep '^PACKAGE =' setup.py | cut -d "'" -f2) 5 | VERSION := $(shell head -n 1 $(PACKAGE)/VERSION) 6 | 7 | all: list 8 | 9 | install: 10 | pip install --upgrade -e .[develop] 11 | 12 | list: 13 | @grep '^\.PHONY' Makefile | cut -d' ' -f2- | tr ' ' '\n' 14 | 15 | test: 16 | bash ./tools/generate_key_pair.sh 17 | pylama $(PACKAGE) 18 | PRIVATE_KEY=`cat private.pem` tox 19 | 20 | version: 21 | @echo $(VERSION) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DataHQ auth service 2 | 3 | [![Build Status](https://travis-ci.org/datahq/auth.svg?branch=master)](https://travis-ci.org/datahq/auth) 4 | 5 | A generic OAuth2 authentication service and user permission manager. 6 | 7 | ## Quick start 8 | 9 | ### Clone the repo and install 10 | 11 | `make install` 12 | 13 | ### Run tests 14 | 15 | `make test` 16 | 17 | ### Run server 18 | 19 | `python server.py` 20 | 21 | ## Env Vars 22 | - `PRIVATE_KEY` & `PUBLIC_KEY` an RSA key-pair in PEM format. 23 | See `tools/generate_key_pair.sh` for more info. 24 | - `GOOGLE_KEY` & `GOOGLE_SECRET`: OAuth credentials for authenticating with Google 25 | - `GITHUB_KEY` & `GITHUB_SECRET`: OAuth credentials for authenticating with Github 26 | - `DATABASE_URL`: A SQLAlchemy compatible database connection string (where user data is stored) 27 | - `EXTERNAL_ADDRESS`: The hostname where this service is located on 28 | - `ALLOWED_SERVICES`: 29 | Which permissions providers are available. A `;` delimited list of provider identifiers. 30 | Each provider identifier takes the form of `[alias:]provider`, where `provider` is the name of a Python module 31 | which exports a `get_permissions(service, userid)` function. 32 | - `INSTALLED_EXTENSIONS`: 33 | List of installed extensions. A `;` delimited list of `extension` - the name of a Python modules which exports one or all of these functions 34 | - `on_new_user(user_info)` 35 | - `on_user_login(user_info)` 36 | - `on_user_logout(user_info)` 37 | 38 | 39 | ## API 40 | 41 | ### Check an authentication token's validity 42 | `/auth/check` 43 | 44 | **Method:** `GET` 45 | 46 | **Query Parameters:** 47 | 48 | - `jwt` - authentication token 49 | - `next` - URL to redirect to when finished authentication 50 | 51 | **Returns:** 52 | 53 | If authenticated: 54 | 55 | ```json 56 | { 57 | "authenticated": true, 58 | "profile": { 59 | "id": "", 60 | "name": "", 61 | "email": "", 62 | "avatar_url": "", 63 | "idhash": "", 64 | "username": "" # If user has a username 65 | } 66 | } 67 | ``` 68 | 69 | If not: 70 | 71 | ```json 72 | { 73 | "authenticated": false, 74 | "providers": { 75 | "google": { 76 | "url": "" 77 | }, 78 | "github": { 79 | "url": "" 80 | }, 81 | } 82 | } 83 | ``` 84 | 85 | When the authentication flow is finished, the caller will be redirected to the `next` URL with an extra query parameter 86 | `jwt` which contains the authentication token. The caller should cache this token for further interactions with the API. 87 | 88 | ### Get permission for a service 89 | `/auth/authorize` 90 | 91 | **Method:** `GET` 92 | 93 | **Query Parameters:** 94 | 95 | - `jwt` - user token (received from `/user/check`) 96 | - `service` - the relevant service (e.g. `storage-service`) 97 | 98 | **Returns:** 99 | 100 | ```json 101 | { 102 | "token": "" 103 | "userid": "", 104 | "permissions": { 105 | "permission-x": true, 106 | "permission-y": false 107 | }, 108 | "service": "" 109 | } 110 | ``` 111 | 112 | ### Change the username 113 | `/auth/update` 114 | 115 | **Method:** `POST` 116 | 117 | **Query Parameters:** 118 | 119 | - `jwt` - authentication token (received from `/user/check`) 120 | - `username` - A new username for the user profile (this action is only allowed once) 121 | 122 | **Returns:** 123 | 124 | ```json 125 | { 126 | "success": true, 127 | "error": "" 128 | } 129 | ``` 130 | 131 | __Note__: trying to update other user profile fields like `email` will fail silently and return 132 | 133 | ```json 134 | { 135 | "success": true 136 | } 137 | ``` 138 | 139 | ### Receive authorization public key 140 | `/auth/public-key` 141 | 142 | **Method:** `GET` 143 | 144 | **Returns:** 145 | 146 | The service's public key in PEM format. 147 | 148 | Can be used by services to validate that the permission token is authentic. 149 | -------------------------------------------------------------------------------- /auth/VERSION: -------------------------------------------------------------------------------- 1 | 0.1.6 2 | -------------------------------------------------------------------------------- /auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .blueprint import make_blueprint 2 | from .lib import Verifyer -------------------------------------------------------------------------------- /auth/blueprint.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Blueprint, request, url_for, session 4 | from flask import redirect 5 | from flask_jsonpify import jsonpify 6 | 7 | from .controllers import authenticate, authorize, update, oauth_callback, setup_oauth_apps, resolve_username, get_profile_by_username 8 | from .models import setup_engine 9 | from .credentials import \ 10 | google_key, google_secret, \ 11 | github_key, github_secret, \ 12 | private_key, public_key, \ 13 | db_connection_string 14 | 15 | 16 | def make_blueprint(external_address): 17 | """Create blueprint. 18 | """ 19 | 20 | setup_oauth_apps(google_key=google_key, 21 | google_secret=google_secret, 22 | github_key=github_key, 23 | github_secret=github_secret) 24 | setup_engine(db_connection_string) 25 | 26 | # Create instance 27 | blueprint = Blueprint('auth', 'auth') 28 | 29 | # Controller Proxies 30 | authenticate_controller = authenticate 31 | update_controller = update 32 | authorize_controller = authorize 33 | oauth_callback_controller = oauth_callback 34 | resolve_username_controller = resolve_username 35 | get_profile_by_username_controller = get_profile_by_username 36 | 37 | def callback_url(): 38 | if external_address.startswith('http'): 39 | return external_address+url_for('auth.oauth_callback') 40 | else: 41 | return 'https://'+external_address+url_for('auth.oauth_callback') 42 | 43 | def authorize_(): 44 | token = request.headers.get('auth-token') or request.values.get('jwt') 45 | service = request.values.get('service') 46 | return jsonpify(authorize_controller(token, service, private_key)) 47 | 48 | def check_(): 49 | token = request.headers.get('auth-token') or request.values.get('jwt') 50 | next_url = request.args.get('next', 'http://example.com') 51 | return jsonpify(authenticate_controller(token, next_url, callback_url(), private_key)) 52 | 53 | def update_(): 54 | token = request.headers.get('auth-token') or request.values.get('jwt') 55 | username = request.values.get('username') 56 | return jsonpify(update_controller(token, username, private_key)) 57 | 58 | def oauth_callback_(): 59 | state = request.args.get('state') 60 | 61 | def set_session(k, v): 62 | session[k] = v 63 | 64 | return redirect(oauth_callback_controller(state, callback_url(), private_key, set_session)) 65 | 66 | def public_key_(): 67 | return public_key 68 | 69 | def resolve_username_(): 70 | username = request.values.get('username') 71 | return jsonpify(resolve_username_controller(username)) 72 | 73 | def get_profile_(): 74 | username = request.values.get('username') 75 | return jsonpify(get_profile_by_username_controller(username)) 76 | 77 | # Register routes 78 | blueprint.add_url_rule( 79 | 'check', 'check', check_, methods=['GET']) 80 | blueprint.add_url_rule( 81 | 'update', 'update', update_, methods=['POST']) 82 | blueprint.add_url_rule( 83 | 'authorize', 'authorize', authorize_, methods=['GET']) 84 | blueprint.add_url_rule( 85 | 'public-key', 'public-key', public_key_, methods=['GET']) 86 | blueprint.add_url_rule( 87 | 'oauth_callback', 'oauth_callback', oauth_callback_, methods=['GET']) 88 | blueprint.add_url_rule( 89 | 'resolve', 'resolve', resolve_username_, methods=['GET']) 90 | blueprint.add_url_rule( 91 | 'get_profile', 'get_profile', get_profile_, methods=['GET']) 92 | 93 | # Return blueprint 94 | return blueprint 95 | -------------------------------------------------------------------------------- /auth/controllers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | import urllib.parse as urlparse 5 | 6 | import jwt 7 | import requests 8 | 9 | from flask_oauthlib.client import OAuth, OAuthException 10 | 11 | from .extensions import on_new_user, on_user_login, on_user_logout 12 | from .models import get_user, create_or_get_user, save_user, get_user_by_username, hash_email 13 | from .permissions import get_token 14 | 15 | 16 | oauth = OAuth() 17 | remote_apps = {} 18 | 19 | 20 | def setup_oauth_apps(*, 21 | google_key=None, 22 | google_secret=None, 23 | github_key=None, 24 | github_secret=None): 25 | # Google 26 | if all([google_key, google_secret]): 27 | oauth.remote_app( 28 | 'google', 29 | base_url='https://www.googleapis.com/oauth2/v1/', 30 | authorize_url='https://accounts.google.com/o/oauth2/auth', 31 | request_token_url=None, 32 | request_token_params={ 33 | 'scope': 'email profile', 34 | }, 35 | access_token_url='https://accounts.google.com/o/oauth2/token', 36 | access_token_method='POST', 37 | consumer_key=google_key, 38 | consumer_secret=google_secret) 39 | remote_apps['google'] = { 40 | 'app': oauth.google, 41 | 'get_profile': 'https://www.googleapis.com/oauth2/v1/userinfo', 42 | 'auth_header_prefix': 'OAuth ' 43 | } 44 | 45 | # GitHub 46 | if all([github_key, github_secret]): 47 | oauth.remote_app( 48 | 'github', 49 | base_url='https://api.github.com/', 50 | authorize_url='https://github.com/login/oauth/authorize', 51 | request_token_url=None, 52 | request_token_params={ 53 | 'scope': 'user:email', 54 | }, 55 | access_token_url='https://github.com/login/oauth/access_token', 56 | access_token_method='POST', 57 | consumer_key=github_key, 58 | consumer_secret=github_secret) 59 | remote_apps['github'] = { 60 | 'app': oauth.github, 61 | 'get_profile': 'https://api.github.com/user', 62 | 'auth_header_prefix': 'token ' 63 | } 64 | 65 | 66 | def _get_user_profile(provider, access_token): 67 | if access_token is None: 68 | return None 69 | remote_app = remote_apps[provider] 70 | headers = {'Authorization': '{}{}'.format(remote_app['auth_header_prefix'], access_token)} 71 | response = requests.get(remote_app['get_profile'], 72 | headers=headers) 73 | 74 | if response.status_code == 401: 75 | return None 76 | 77 | response = response.json() 78 | # Make sure we have private Emails from github. 79 | # Also make sure we don't have user registered with other email than primary 80 | if provider == 'github': 81 | emails_resp = requests.get(remote_app['get_profile'] + '/emails', headers=headers) 82 | for email in emails_resp.json(): 83 | id_ = hash_email(email['email']) 84 | user = get_user(id_) 85 | if user is not None: 86 | response['email'] = email['email'] 87 | break 88 | if email.get('primary'): 89 | response['email'] = email['email'] 90 | return response 91 | 92 | 93 | def authenticate(token, next, callback_url, private_key): 94 | """Check if user is authenticated 95 | """ 96 | if token is not None: 97 | try: 98 | token = jwt.decode(token, private_key) 99 | except jwt.InvalidTokenError: 100 | token = None 101 | 102 | if token is not None: 103 | userid = token['userid'] 104 | user = get_user(userid) 105 | if user is not None: 106 | ret = { 107 | 'authenticated': True, 108 | 'profile': user 109 | } 110 | return ret 111 | 112 | # Otherwise - not authenticated 113 | providers = {} 114 | for provider, params in remote_apps.items(): 115 | state = { 116 | 'next': next, 117 | 'provider': provider, 118 | 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=10), 119 | 'nbf': datetime.datetime.utcnow() 120 | } 121 | state = jwt.encode(state, private_key) 122 | login_url = params['app'] \ 123 | .authorize(callback=callback_url, state=state).headers['Location'] 124 | providers[provider] = {'url': login_url} 125 | ret = { 126 | 'authenticated': False, 127 | 'providers': providers 128 | } 129 | 130 | return ret 131 | 132 | 133 | def _update_next_url(next_url, client_token): 134 | if client_token is None: 135 | return next_url 136 | 137 | url_parts = list(urlparse.urlparse(next_url)) 138 | query = dict(urlparse.parse_qsl(url_parts[4])) 139 | query.update({'jwt': client_token}) 140 | 141 | url_parts[4] = urlparse.urlencode(query) 142 | 143 | next_url = urlparse.urlunparse(url_parts) 144 | return next_url 145 | 146 | 147 | def _get_token_from_profile(provider, profile, private_key): 148 | norm_profile = _normilize_profile(provider, profile) 149 | if norm_profile is None: 150 | return None 151 | userid = norm_profile['userid'] 152 | name = norm_profile['name'] 153 | username = norm_profile['username'] 154 | email = norm_profile['email'] 155 | avatar_url = norm_profile['avatar_url'] 156 | user = create_or_get_user(userid, name, username, email, avatar_url) 157 | if user.get('new'): 158 | on_new_user(user) 159 | logging.info('Got USER %r', user) 160 | token = { 161 | 'userid': user['id'], 162 | 'exp': (datetime.datetime.utcnow() + 163 | datetime.timedelta(days=14)) 164 | } 165 | client_token = jwt.encode(token, private_key) 166 | return client_token 167 | 168 | 169 | def _normilize_profile(provider, profile): 170 | if profile is None: 171 | return None 172 | provider_id = profile['id'] 173 | name = profile['name'] 174 | email = profile.get('email') 175 | if email is None: 176 | return None 177 | username = email.split('@')[0] 178 | if provider == 'github': 179 | username = profile.get('login') 180 | fixed_username = username 181 | suffix = 1 182 | while get_user_by_username(username) is not None: 183 | username = '{}{}'.format(fixed_username, suffix) 184 | suffix += 1 185 | avatar_url = profile.get('picture', profile.get('avatar_url')) 186 | userid = '%s:%s' % (provider, provider_id) 187 | normilized_profile = dict( 188 | provider_id=provider_id, 189 | name=name, 190 | email=email, 191 | username=username, 192 | avatar_url=avatar_url, 193 | userid=userid 194 | ) 195 | return normilized_profile 196 | 197 | 198 | def oauth_callback(state, callback_url, private_key, 199 | set_session=lambda k, v: None): 200 | """Callback from OAuth 201 | """ 202 | try: 203 | state = jwt.decode(state, private_key) 204 | except jwt.InvalidTokenError: 205 | state = {} 206 | 207 | resp = None 208 | 209 | provider = state.get('provider') 210 | if provider is not None: 211 | try: 212 | app = remote_apps[provider]['app'] 213 | set_session('%s_oauthredir' % app.name, callback_url) 214 | resp = app.authorized_response() 215 | except OAuthException as e: 216 | resp = e 217 | if isinstance(resp, OAuthException): 218 | logging.error("OAuthException: %r", resp.data, exc_info=resp) 219 | resp = None 220 | 221 | next_url = '/' 222 | provider = state.get('provider') 223 | next_url = state.get('next', next_url) 224 | if resp is not None and provider is not None: 225 | access_token = resp.get('access_token') 226 | logging.info('Got ACCES TOKEN %r', access_token) 227 | profile = _get_user_profile(provider, access_token) 228 | logging.info('Got PROFILE %r', profile) 229 | client_token = _get_token_from_profile(provider, profile, private_key) 230 | # Add client token to redirect url 231 | next_url = _update_next_url(next_url, client_token) 232 | 233 | return next_url 234 | 235 | 236 | def update(token, username, private_key): 237 | """Update a user 238 | """ 239 | err = None 240 | if token is not None: 241 | try: 242 | token = jwt.decode(token, private_key) 243 | except jwt.InvalidTokenError: 244 | token = None 245 | err = 'Not authenticated' 246 | else: 247 | err = 'No token' 248 | 249 | if token is not None: 250 | userid = token['userid'] 251 | user = get_user(userid) 252 | 253 | if user is not None: 254 | dirty = False 255 | if username is not None: 256 | if user.get('username') is None: 257 | user['username'] = username 258 | dirty = True 259 | else: 260 | err = 'Cannot modify username, already set' 261 | if dirty: 262 | save_user(user) 263 | else: 264 | err = 'Unknown User' 265 | 266 | ret = {'success': err is None} 267 | if err is not None: 268 | ret['error'] = err 269 | 270 | return ret 271 | 272 | 273 | def authorize(token, service, private_key): 274 | """Return user authorization for a service 275 | """ 276 | if token is not None and service is not None: 277 | try: 278 | token = jwt.decode(token, private_key) 279 | except jwt.InvalidTokenError: 280 | token = None 281 | 282 | if token is not None: 283 | userid = token['userid'] 284 | permissions = get_token(service, userid) 285 | ret = { 286 | 'userid': userid, 287 | 'permissions': permissions, 288 | 'service': service 289 | } 290 | token = jwt.encode(ret, private_key, algorithm='RS256')\ 291 | .decode('ascii') 292 | ret['token'] = token 293 | return ret 294 | 295 | ret = { 296 | 'permissions': {} 297 | } 298 | return ret 299 | 300 | 301 | def resolve_username(username): 302 | """Return userid for given username. If not exist, return None. 303 | """ 304 | ret = {'userid': None} 305 | user = get_user_by_username(username) 306 | if user is not None: 307 | ret['userid'] = user['id'] 308 | return ret 309 | 310 | 311 | def get_profile_by_username(username): 312 | """Return user profile for given username. If not exist, return None. 313 | """ 314 | ret = {'found': False, 'profile': None} 315 | user = get_user_by_username(username) 316 | if user is not None: 317 | ret['found'] = True 318 | ret['profile'] = { 319 | 'id': user['id'], 320 | 'name': user['name'], 321 | 'join_date': user['join_date'], 322 | 'avatar_url': user['avatar_url'], 323 | 'gravatar': hash_email(user['email']) 324 | } 325 | return ret 326 | -------------------------------------------------------------------------------- /auth/credentials.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Private/Public key pair 4 | # -- use tools/generate_key_pair.sh to generate a key pair 5 | private_key = os.environ.get('PRIVATE_KEY') 6 | public_key = os.environ.get('PUBLIC_KEY') 7 | 8 | # Google Secrets 9 | google_key = os.environ.get('GOOGLE_KEY') 10 | google_secret = os.environ.get('GOOGLE_SECRET') 11 | 12 | # Github Secrets 13 | github_key = os.environ.get('GITHUB_KEY') 14 | github_secret = os.environ.get('GITHUB_SECRET') 15 | 16 | # Database connection string 17 | db_connection_string = os.environ.get('DATABASE_URL') 18 | 19 | # Allowed services 20 | allowed_services = os.environ.get('ALLOWED_SERVICES', '').split(';') 21 | if '' in allowed_services: 22 | allowed_services.remove('') 23 | allowed_services = dict( 24 | (p[0], p[1] if len(p) > 1 else p[0]) 25 | for p in map( 26 | lambda s: s.split(':', 1), 27 | allowed_services 28 | ) 29 | ) 30 | 31 | # installed extentsions 32 | installed_extensions = os.environ.get('INSTALLED_EXTENSIONS', '').split(';') 33 | if '' in installed_extensions: 34 | installed_extensions.remove('') 35 | -------------------------------------------------------------------------------- /auth/extensions.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | import logging 4 | 5 | from .credentials import installed_extensions 6 | 7 | installed_modules = [ 8 | import_module(module_name) 9 | for module_name in installed_extensions 10 | ] 11 | 12 | 13 | def on_new_user(user_info={}): 14 | for module in installed_modules: 15 | try: 16 | module.on_new_user(user_info) 17 | except AttributeError: 18 | pass 19 | 20 | 21 | def on_user_login(user_info={}): 22 | for module in installed_modules: 23 | try: 24 | module.on_user_login(user_info) 25 | except AttributeError: 26 | pass 27 | 28 | 29 | def on_user_logout(user_info={}): 30 | for module in installed_modules: 31 | try: 32 | module.on_user_logout(user_info) 33 | except AttributeError: 34 | pass 35 | -------------------------------------------------------------------------------- /auth/lib.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin 2 | 3 | import jwt 4 | import requests 5 | 6 | 7 | class Verifyer: 8 | 9 | def __init__(self, *, auth_endpoint=None, public_key=None): 10 | if public_key is None: 11 | if auth_endpoint is not None: 12 | public_key = requests.get(urljoin(auth_endpoint, 'public-key')).text 13 | assert public_key is not None 14 | self.public_key = public_key 15 | 16 | def extract_permissions(self, auth_token): 17 | if not auth_token: 18 | return False 19 | try: 20 | token = jwt.decode(auth_token.encode('ascii'), 21 | self.public_key, 22 | algorithm='RS256') 23 | return token 24 | except jwt.InvalidTokenError: 25 | return False 26 | 27 | 28 | -------------------------------------------------------------------------------- /auth/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import datetime 4 | from hashlib import md5 5 | 6 | from contextlib import contextmanager 7 | 8 | from sqlalchemy import DateTime 9 | from sqlalchemy import inspect 10 | from sqlalchemy.ext.declarative import declarative_base 11 | 12 | from sqlalchemy import Column, Unicode, String, create_engine, func 13 | from sqlalchemy.orm import sessionmaker 14 | 15 | # ## SQL DB 16 | Base = declarative_base() 17 | 18 | _sql_engine = None 19 | _sql_session = None 20 | 21 | 22 | def setup_engine(connection_string): 23 | global _sql_engine 24 | _sql_engine = create_engine(connection_string) 25 | Base.metadata.create_all(_sql_engine) 26 | 27 | 28 | @contextmanager 29 | def session_scope(): 30 | """Provide a transactional scope around a series of operations.""" 31 | global _sql_session 32 | if _sql_session is None: 33 | assert _sql_engine is not None, "No database defined, please set your DATABASE_URL environment variable" 34 | _sql_session = sessionmaker(bind=_sql_engine) 35 | session = _sql_session() 36 | try: 37 | yield session 38 | session.commit() 39 | except Exception: 40 | session.rollback() 41 | raise 42 | finally: 43 | session.expunge_all() 44 | session.close() 45 | 46 | 47 | def object_as_dict(obj): 48 | return {c.key: getattr(obj, c.key) 49 | for c in inspect(obj).mapper.column_attrs} 50 | 51 | # ## USERS 52 | 53 | 54 | class User(Base): 55 | __tablename__ = 'users' 56 | id = Column(String(128), primary_key=True) 57 | provider_id = Column(String(128)) 58 | username = Column(Unicode, unique=True, nullable=False) 59 | name = Column(Unicode) 60 | email = Column(Unicode) 61 | avatar_url = Column(String(512)) 62 | join_date = Column(DateTime) 63 | 64 | 65 | def get_user(id_): 66 | with session_scope() as session: 67 | ret = session.query(User).filter_by(id=id_).first() 68 | if ret is not None: 69 | return object_as_dict(ret) 70 | return None 71 | 72 | def delete_user(id_): 73 | with session_scope() as session: 74 | ret = session.query(User).filter_by(id=id_).first() 75 | if ret is not None: 76 | session.delete(ret) 77 | return True 78 | return False 79 | 80 | def get_users(): 81 | with session_scope() as session: 82 | ret = session.query(User) 83 | return [object_as_dict(item) for item in ret] 84 | return None 85 | 86 | def get_user_by_username(username_): 87 | with session_scope() as session: 88 | ret = session.query(User).filter(func.lower(User.username) == func.lower(username_)).first() 89 | if ret is not None: 90 | return object_as_dict(ret) 91 | return None 92 | 93 | def hash_email(email): 94 | return md5(email.encode('utf8')).hexdigest() 95 | 96 | 97 | def save_user(user): 98 | with session_scope() as session: 99 | user = User(**user) 100 | session.add(user) 101 | 102 | 103 | def create_or_get_user(provider_id, name, username, email, avatar_url): 104 | id_ = hash_email(email) 105 | with session_scope() as session: 106 | user = session.query(User).filter_by(id=id_).first() 107 | if user is None: 108 | params = { 109 | 'id': id_, 110 | 'provider_id': provider_id, 111 | 'username': username.lower(), 112 | 'name': name, 113 | 'email': email, 114 | 'avatar_url': avatar_url, 115 | 'join_date': datetime.datetime.now() 116 | } 117 | user = User(**params) 118 | session.add(user) 119 | params['new'] = True 120 | return params 121 | else: 122 | params = { 123 | 'provider_id': provider_id, 124 | 'username': username.lower(), 125 | 'name': name, 126 | 'avatar_url': avatar_url, 127 | } 128 | for k, v in params.items(): 129 | setattr(user, k, v) 130 | return object_as_dict(user) 131 | -------------------------------------------------------------------------------- /auth/permissions.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from importlib.util import find_spec 3 | 4 | import logging 5 | 6 | from .credentials import allowed_services 7 | 8 | 9 | def get_token(service, userid): 10 | if service not in allowed_services: 11 | return {} 12 | 13 | module_name = allowed_services[service] 14 | 15 | if not find_spec(module_name): 16 | return {} 17 | 18 | module = import_module(module_name) 19 | 20 | try: 21 | get_permissions = module.get_permissions 22 | except AttributeError: 23 | logging.warning("Can't find 'get_permissions' function in %s", module_name) 24 | return {} 25 | 26 | return get_permissions(service, userid) 27 | 28 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama] 2 | linters = pyflakes,mccabe 3 | ignore = W0611 4 | 5 | [mccabe] 6 | ignore = C901 7 | 8 | [pylama] 9 | skip = *__init__.py* 10 | ignore = W0611 11 | 12 | [pylama:pycodestyle] 13 | max_line_length = 120 14 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pylama 3 | coverage 4 | coveralls 5 | pytest 6 | pytest-cov 7 | requests-mock==1.3.0 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-cors 3 | flask-session 4 | flask-jsonpify 5 | flask-oauthlib 6 | pyjwt 7 | sqlalchemy 8 | psycopg2 9 | cryptography 10 | werkzeug<1 -------------------------------------------------------------------------------- /sample_extension/__init__.py: -------------------------------------------------------------------------------- 1 | def on_new_user(user_info={}): 2 | user_info['id'] = 'new-user' 3 | 4 | 5 | def on_user_login(user_info={}): 6 | user_info['id'] = 'user-login' 7 | 8 | 9 | def on_user_logout(user_info={}): 10 | user_info['id'] = 'user-logout' 11 | -------------------------------------------------------------------------------- /sample_permission_provider/__init__.py: -------------------------------------------------------------------------------- 1 | def get_permissions(service, userid): 2 | return { 3 | 'provider-token': '{}-{}'.format(service, userid) 4 | } -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from flask import Flask 5 | from flask_cors import CORS 6 | from flask_session import Session 7 | 8 | from auth import make_blueprint 9 | 10 | # Create application 11 | app = Flask(__name__, static_folder=None) 12 | 13 | # CORS support 14 | CORS(app, supports_credentials=True) 15 | 16 | # Session 17 | sess = Session() 18 | app.config['SESSION_TYPE'] = 'filesystem' 19 | app.config['SESSION_FILE_DIR'] = '/tmp/sessions' 20 | app.config['SECRET_KEY'] = '-' 21 | sess.init_app(app) 22 | 23 | # Register blueprints 24 | app.register_blueprint(make_blueprint(os.environ.get('EXTERNAL_ADDRESS')), 25 | url_prefix='/auth/') 26 | 27 | 28 | logging.getLogger().setLevel(logging.INFO) 29 | 30 | if __name__=='__main__': 31 | app.run() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | from setuptools import setup, find_packages 4 | 5 | 6 | # Helpers 7 | def read(*paths): 8 | """Read a text file.""" 9 | basedir = os.path.dirname(__file__) 10 | fullpath = os.path.join(basedir, *paths) 11 | contents = io.open(fullpath, encoding='utf-8').read().strip() 12 | return contents 13 | 14 | 15 | # Prepare 16 | PACKAGE = 'auth' 17 | NAME = 'dhq-auth' 18 | INSTALL_REQUIRES = [ 19 | 'flask', 20 | 'flask-cors', 21 | 'flask-jsonpify', 22 | 'flask-session', 23 | 'flask-oauthlib', 24 | 'pyjwt', 25 | 'sqlalchemy', 26 | 'cryptography', 27 | 'psycopg2', 28 | 'requests', 29 | ] 30 | TESTS_REQUIRE = [ 31 | 'pylama', 32 | 'tox', 33 | 'coverage', 34 | 'coveralls', 35 | 'pytest', 36 | 'pytest-cov', 37 | 'requests-mock==1.3.0' 38 | ] 39 | README = read('README.md') 40 | VERSION = read(PACKAGE, 'VERSION') 41 | PACKAGES = find_packages(exclude=['examples', 'tests', 'tools']) 42 | 43 | 44 | # Run 45 | setup( 46 | name=NAME, 47 | version=VERSION, 48 | packages=PACKAGES, 49 | include_package_data=True, 50 | install_requires=INSTALL_REQUIRES, 51 | tests_require=TESTS_REQUIRE, 52 | extras_require={'develop': TESTS_REQUIRE}, 53 | zip_safe=False, 54 | long_description=README, 55 | long_description_content_type='text/markdown', 56 | description='{{ DESCRIPTION }}', 57 | author='Adam Kariv, Open Knowledge (International), Datopian', 58 | url='https://github.com/datahq/auth', 59 | license='MIT', 60 | keywords=[ 61 | 'data', 62 | 'auth' 63 | ], 64 | classifiers=[ 65 | 'Development Status :: 4 - Beta', 66 | 'Environment :: Web Environment', 67 | 'Intended Audience :: Developers', 68 | 'License :: OSI Approved :: MIT License', 69 | 'Operating System :: OS Independent', 70 | 'Programming Language :: Python :: 3.6', 71 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 72 | 'Topic :: Software Development :: Libraries :: Python Modules', 73 | ], 74 | ) 75 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datopian/auth/44a13e0dbaaf2ca65e6e0f9b6b05d26c02e3edf0/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_extensions_controllers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import jwt 3 | 4 | try: 5 | from unittest.mock import Mock, patch 6 | except ImportError: 7 | from mock import Mock, patch 8 | from importlib import import_module 9 | 10 | module = import_module('auth.controllers') 11 | credentials = import_module('auth.credentials') 12 | 13 | 14 | class ExtensionsTestCase(unittest.TestCase): 15 | 16 | def setUp(self): 17 | 18 | # Cleanup 19 | self.addCleanup(patch.stopall) 20 | 21 | # Mock response from models (to make sure it's new user) 22 | module.create_or_get_user = Mock( 23 | return_value={'new': True, 'id': 'test'} 24 | ) 25 | self.private_key = credentials.private_key 26 | 27 | # Tests 28 | 29 | def test___check___on_new_user_is_called(self): 30 | profile = dict(id='test', name='name', email='test@mail.com') 31 | token = module._get_token_from_profile('test_provider', profile, self.private_key) 32 | user_profile = jwt.decode(token, self.private_key) 33 | self.assertEquals(user_profile.get('userid'), 'new-user') 34 | -------------------------------------------------------------------------------- /tests/test_permission_controllers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import jwt 4 | from collections import namedtuple 5 | 6 | try: 7 | from unittest.mock import Mock, patch 8 | except ImportError: 9 | from mock import Mock, patch 10 | from importlib import import_module 11 | 12 | module = import_module('auth.controllers') 13 | credentials = import_module('auth.credentials') 14 | 15 | 16 | def client_token(): 17 | token = { 18 | 'userid': 'userid', 19 | } 20 | return jwt.encode(token, credentials.private_key) 21 | 22 | 23 | class AuthorizationTest(unittest.TestCase): 24 | 25 | # Actions 26 | 27 | def setUp(self): 28 | 29 | # Cleanup 30 | self.addCleanup(patch.stopall) 31 | 32 | goog_provider = namedtuple("resp",['headers'])({'Location':'google'}) 33 | oauth_response = { 34 | 'access_token': 'access_token' 35 | } 36 | module.google_remote_app = Mock( 37 | return_value=namedtuple('google_remote_app', 38 | ['authorize', 'authorized_response']) 39 | (authorize=lambda **kwargs:goog_provider, 40 | authorized_response=lambda **kwargs:oauth_response) 41 | ) 42 | self.private_key = credentials.private_key 43 | 44 | # Tests 45 | 46 | def test___check___no_token(self): 47 | ret = module.authorize(None, 'service', self.private_key) 48 | self.assertEquals(ret.get('permissions'), {}) 49 | 50 | def test___check___no_service(self): 51 | ret = module.authorize('token', None, self.private_key) 52 | self.assertEquals(ret.get('permissions'), {}) 53 | 54 | def test___check___bad_token_not_allowed_service(self): 55 | ret = module.authorize('token', 'servis', self.private_key) 56 | self.assertEquals(ret.get('permissions'), {}) 57 | 58 | def test___check___bad_token(self): 59 | ret = module.authorize('token', 'example', self.private_key) 60 | self.assertEquals(ret.get('permissions'), {}) 61 | 62 | def test___check___good_token_not_allowed_service(self): 63 | ret = module.authorize(client_token(), 'servis', self.private_key) 64 | self.assertEquals(ret.get('permissions'), {}) 65 | 66 | def test___check___good_token_good_service(self): 67 | ret = module.authorize(client_token(), 'example', self.private_key) 68 | self.assertEquals(ret.get('service'), 'example') 69 | self.assertEquals(ret.get('permissions'),{ 70 | 'provider-token': 'example-userid' 71 | }) 72 | 73 | def test___check___good_token_good_service2(self): 74 | ret = module.authorize(client_token(), 'example2', self.private_key) 75 | self.assertEquals(ret.get('service'), 'example2') 76 | self.assertEquals(ret.get('permissions'),{ 77 | 'provider-token': 'example2-userid' 78 | }) 79 | -------------------------------------------------------------------------------- /tests/test_user_controllers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import time 4 | import jwt 5 | import datetime 6 | import requests_mock 7 | from collections import namedtuple 8 | from hashlib import md5 9 | 10 | try: 11 | from unittest.mock import Mock, patch 12 | except ImportError: 13 | from mock import Mock, patch 14 | from importlib import import_module, reload 15 | 16 | models = import_module('auth.models') 17 | credentials = import_module('auth.credentials') 18 | models.setup_engine('sqlite://') 19 | 20 | class UserAdminTest(unittest.TestCase): 21 | 22 | USERID = 'uusseerriidd' 23 | NAME = 'nnaammee' 24 | EMAIL = 'eemmaaiill' 25 | AVATAR_URL = 'aavvaattaarr__uurrll' 26 | USERNAME = 'Usernaaaame' 27 | 28 | # Actions 29 | 30 | def setUp(self): 31 | self.ctrl = import_module('auth.controllers') 32 | self.private_key = credentials.private_key 33 | reload(self.ctrl) 34 | 35 | def test___create_user___success(self): 36 | user = models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 37 | hash_of_email = md5(self.EMAIL.encode('utf8')).hexdigest() 38 | self.assertEquals(user['id'], hash_of_email) 39 | self.assertEquals(user['name'], self.NAME) 40 | self.assertEquals(user['email'], self.EMAIL) 41 | self.assertEquals(user['avatar_url'], self.AVATAR_URL) 42 | self.assertEquals(user['username'], self.USERNAME.lower()) 43 | 44 | def test___update_user___success(self): 45 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 46 | models.create_or_get_user(self.USERID+'2', self.NAME+'2', self.USERNAME+'2', self.EMAIL, self.AVATAR_URL+'2') 47 | hash_of_email = md5(self.EMAIL.encode('utf8')).hexdigest() 48 | user = models.get_user(hash_of_email) 49 | self.assertEquals(user['id'], hash_of_email) 50 | self.assertEquals(user['provider_id'], self.USERID+'2') 51 | self.assertEquals(user['name'], self.NAME+'2') 52 | self.assertEquals(user['email'], self.EMAIL) 53 | self.assertEquals(user['avatar_url'], self.AVATAR_URL+'2') 54 | self.assertEquals(user['username'], self.USERNAME.lower()+'2') 55 | 56 | def test___delete_user___success(self): 57 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 58 | hash_of_email = md5(self.EMAIL.encode('utf8')).hexdigest() 59 | user = models.get_user(hash_of_email) 60 | self.assertEquals(user['email'], self.EMAIL) 61 | models.delete_user(hash_of_email) 62 | user = models.get_user(hash_of_email) 63 | self.assertIsNone(user) 64 | 65 | def test___create__existing_user___success(self): 66 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 67 | user = models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 68 | hash_of_email = md5(self.EMAIL.encode('utf8')).hexdigest() 69 | self.assertEquals(user['id'], hash_of_email) 70 | self.assertEquals(user['name'], self.NAME) 71 | self.assertEquals(user['email'], self.EMAIL) 72 | self.assertEquals(user['avatar_url'], self.AVATAR_URL) 73 | 74 | def test___get__existing_user___success(self): 75 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 76 | hash = models.hash_email(self.EMAIL) 77 | user = models.get_user(hash) 78 | hash_of_email = md5(self.EMAIL.encode('utf8')).hexdigest() 79 | self.assertEquals(user['id'], hash_of_email) 80 | self.assertEquals(user['name'], self.NAME) 81 | self.assertEquals(user['email'], self.EMAIL) 82 | self.assertEquals(user['avatar_url'], self.AVATAR_URL) 83 | 84 | def test___get__nonexisting_user___success(self): 85 | hash = models.hash_email('random@mail.com') 86 | user = models.get_user(hash) 87 | self.assertIs(user, None) 88 | 89 | def test___update___no_jwt(self): 90 | ret = self.ctrl.update(None, 'new_username', self.private_key) 91 | self.assertFalse(ret.get('success')) 92 | self.assertEquals(ret.get('error'), 'No token') 93 | 94 | def test___update___bad_jwt(self): 95 | ret = self.ctrl.update('bla', 'new_username', self.private_key) 96 | self.assertFalse(ret.get('success')) 97 | self.assertEquals(ret.get('error'), 'Not authenticated') 98 | 99 | def test___update___no_such_user(self): 100 | hash = models.hash_email(self.EMAIL+'X') 101 | token = { 102 | 'userid': hash, 103 | 'exp': (datetime.datetime.utcnow() + 104 | datetime.timedelta(days=14)) 105 | } 106 | client_token = jwt.encode(token, self.private_key) 107 | ret = self.ctrl.update(client_token, 'new_username', self.private_key) 108 | self.assertFalse(ret.get('success')) 109 | self.assertEquals(ret.get('error'), 'Unknown User') 110 | 111 | def test___update___new_user(self): 112 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 113 | hash = models.hash_email(self.EMAIL) 114 | token = { 115 | 'userid': hash, 116 | 'exp': (datetime.datetime.utcnow() + 117 | datetime.timedelta(days=14)) 118 | } 119 | client_token = jwt.encode(token, self.private_key) 120 | ret = self.ctrl.update(client_token, 'new_username', self.private_key) 121 | self.assertFalse(ret.get('success')) 122 | self.assertEquals(ret.get('error'), 'Cannot modify username, already set') 123 | 124 | def test___get__user_by_username___success(self): 125 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 126 | # Get user by uppercased username 127 | ret = models.get_user_by_username(self.USERNAME.upper()) 128 | self.assertEquals(ret.get('username'), self.USERNAME.lower()) 129 | 130 | def test___get__users___success(self): 131 | models.create_or_get_user(self.USERID, self.NAME, self.USERNAME, self.EMAIL, self.AVATAR_URL) 132 | # Get user by uppercased username 133 | ret = models.get_users() 134 | self.assertEquals(ret[0].get('username'), self.USERNAME.lower()) 135 | 136 | 137 | class AuthenticationTest(unittest.TestCase): 138 | 139 | USERID = 'userid' 140 | IDHASH = md5(USERID.encode('utf8')).hexdigest() 141 | 142 | # Actions 143 | 144 | def setUp(self): 145 | 146 | self.ctrl = import_module('auth.controllers') 147 | self.private_key = credentials.private_key 148 | reload(self.ctrl) 149 | 150 | # Cleanup 151 | self.addCleanup(patch.stopall) 152 | 153 | self.goog_provider = namedtuple("resp",['headers'])({'Location':'google'}) 154 | self.oauth_response = { 155 | 'access_token': 'access_token' 156 | } 157 | goog = namedtuple('_google_remote_app', 158 | ['authorize', 'authorized_response', 'name'])( 159 | lambda **kwargs: self.goog_provider, 160 | lambda **kwargs: self.oauth_response, 161 | 'google' 162 | ) 163 | self.ctrl.remote_apps['google'] = { 164 | 'app': goog, 165 | 'get_profile': 'https://www.googleapis.com/oauth2/v1/userinfo', 166 | 'auth_header_prefix': 'OAuth ' 167 | } 168 | self.ctrl.get_user = Mock( 169 | return_value=namedtuple('User', 170 | ['name','email','avatar_url']) 171 | ('moshe','email@moshe.com','http://google.com') 172 | ) 173 | self.ctrl._get_user_profile = Mock( 174 | return_value={ 175 | 'id': 'userid', 176 | 'idhash': self.IDHASH, 177 | 'name': 'Moshe', 178 | 'email': 'email@moshe.com', 179 | 'picture': 'http://moshe.com/picture' 180 | } 181 | ) 182 | 183 | # Tests 184 | 185 | def test___check___no_jwt(self): 186 | ret = self.ctrl.authenticate(None, 'next', 'callback', self.private_key) 187 | self.assertFalse(ret.get('authenticated')) 188 | self.assertIsNotNone(ret.get('providers',{}).get('google')) 189 | 190 | def test___check___bad_jwt(self): 191 | ret = self.ctrl.authenticate('bla', 'next', 'callback', self.private_key) 192 | self.assertFalse(ret.get('authenticated')) 193 | self.assertIsNotNone(ret.get('providers',{}).get('google')) 194 | 195 | def test___check___good_jwt_no_such_user(self): 196 | self.ctrl.get_user = Mock( 197 | return_value=None 198 | ) 199 | token = { 200 | 'userid': self.IDHASH, 201 | 'exp': (datetime.datetime.utcnow() + 202 | datetime.timedelta(days=14)) 203 | } 204 | client_token = jwt.encode(token, self.private_key) 205 | ret = self.ctrl.authenticate(client_token, 'next', 'callback', self.private_key) 206 | self.assertFalse(ret.get('authenticated')) 207 | self.assertIsNotNone(ret.get('providers',{}).get('google')) 208 | 209 | def test___check___expired_jwt(self): 210 | token = { 211 | 'userid': self.IDHASH, 212 | 'exp': (datetime.datetime.utcnow() - 213 | datetime.timedelta(days=1)) 214 | } 215 | client_token = jwt.encode(token, self.private_key) 216 | ret = self.ctrl.authenticate(client_token, 'next', 'callback', self.private_key) 217 | self.assertFalse(ret.get('authenticated')) 218 | self.assertIsNotNone(ret.get('providers',{}).get('google')) 219 | 220 | def test___check___good_jwt(self): 221 | token = { 222 | 'userid': self.IDHASH, 223 | 'exp': (datetime.datetime.utcnow() + 224 | datetime.timedelta(days=14)) 225 | } 226 | client_token = jwt.encode(token, self.private_key) 227 | ret = self.ctrl.authenticate(client_token, 'next', 'callback', self.private_key) 228 | self.assertTrue(ret.get('authenticated')) 229 | self.assertIsNotNone(ret.get('profile')) 230 | self.assertEquals(ret['profile'].email,'email@moshe.com') 231 | self.assertEquals(ret['profile'].avatar_url,'http://google.com') 232 | self.assertEquals(ret['profile'].name,'moshe') 233 | 234 | def test___callback___good_response(self): 235 | token = { 236 | 'next': 'http://next.com/', 237 | 'provider': 'google', 238 | 'exp': (datetime.datetime.utcnow() + 239 | datetime.timedelta(days=14)) 240 | } 241 | state = jwt.encode(token, self.private_key) 242 | ret = self.ctrl.oauth_callback(state, 'callback', self.private_key) 243 | self.assertTrue('jwt' in ret) 244 | 245 | def test___callback___good_response_double(self): 246 | token = { 247 | 'next': 'http://next.com/', 248 | 'provider': 'google', 249 | 'exp': (datetime.datetime.utcnow() + 250 | datetime.timedelta(days=14)) 251 | } 252 | state = jwt.encode(token, self.private_key) 253 | ret = self.ctrl.oauth_callback(state, 'callback', self.private_key) 254 | self.assertTrue('jwt' in ret) 255 | ret = self.ctrl.oauth_callback(state, 'callback', self.private_key) 256 | self.assertTrue('jwt' in ret) 257 | 258 | def test___callback___bad_response(self): 259 | self.oauth_response = None 260 | token = { 261 | 'next': 'http://next.com/', 262 | 'provider': 'google', 263 | 'exp': (datetime.datetime.utcnow() + 264 | datetime.timedelta(days=14)) 265 | } 266 | state = jwt.encode(token, self.private_key) 267 | ret = self.ctrl.oauth_callback(state, 'callback', self.private_key) 268 | self.assertFalse('jwt' in ret) 269 | 270 | def test___callback___bad_state(self): 271 | ret = self.ctrl.oauth_callback("das", 'callback', self.private_key) 272 | self.assertFalse('jwt' in ret) 273 | 274 | 275 | class GetUserProfileTest(unittest.TestCase): 276 | 277 | def setUp(self): 278 | 279 | self.ctrl = import_module('auth.controllers') 280 | self.private_key = credentials.private_key 281 | reload(self.ctrl) 282 | 283 | # Cleanup 284 | self.addCleanup(patch.stopall) 285 | 286 | self.goog_provider = namedtuple("resp",['headers'])({'Location':'google'}) 287 | self.git_provider = namedtuple("resp",['headers'])({'Location':'github'}) 288 | self.oauth_response = { 289 | 'access_token': 'access_token' 290 | } 291 | goog = namedtuple('_google_remote_app', 292 | ['authorize', 'authorized_response', 'name'])( 293 | lambda **kwargs: self.goog_provider, 294 | lambda **kwargs: self.oauth_response, 295 | 'google' 296 | ) 297 | git = namedtuple('_github_remote_app', 298 | ['authorize', 'authorized_response', 'name'])( 299 | lambda **kwargs: self.git_provider, 300 | lambda **kwargs: self.oauth_response, 301 | 'github' 302 | ) 303 | self.ctrl.remote_apps['google'] = { 304 | 'app': goog, 305 | 'get_profile': 'https://www.googleapis.com/oauth2/v1/userinfo', 306 | 'auth_header_prefix': 'OAuth ' 307 | } 308 | self.ctrl.remote_apps['github'] = { 309 | 'app': git, 310 | 'get_profile': 'https://api.github.com/user', 311 | 'auth_header_prefix': 'OAuth ' 312 | } 313 | self.mocked_resp = ''' 314 | { 315 | "name": "Moshe", 316 | "email": "email@moshe.com" 317 | } 318 | ''' 319 | 320 | # Tests 321 | 322 | def test___check___getting_none_if_no_token(self): 323 | res = self.ctrl._get_user_profile('google', None) 324 | self.assertIsNone(res) 325 | 326 | def test___check___google_works_fine(self): 327 | with requests_mock.Mocker() as mock: 328 | mock.get('https://www.googleapis.com/oauth2/v1/userinfo', 329 | text=self.mocked_resp) 330 | res = self.ctrl._get_user_profile('google', 'access_token') 331 | self.assertEquals(res['email'], 'email@moshe.com') 332 | self.assertEquals(res['name'], 'Moshe') 333 | 334 | 335 | def test___check___git_works_fine_with_public_email(self): 336 | with requests_mock.Mocker() as mock: 337 | emails_resp = ''' 338 | [{ 339 | "email": "email@moshe.com", 340 | "primary": true, 341 | "verified": true 342 | }] 343 | ''' 344 | mock.get('https://api.github.com/user', text=self.mocked_resp) 345 | mock.get('https://api.github.com/user/emails', text=emails_resp) 346 | res = self.ctrl._get_user_profile('github', 'access_token') 347 | self.assertEquals(res['email'], 'email@moshe.com') 348 | self.assertEquals(res['name'], 'Moshe') 349 | 350 | def test___check___git_works_fine_with_private_email(self): 351 | self.mocked_resp = ''' 352 | { 353 | "name": "Moshe", 354 | "email": null 355 | } 356 | ''' 357 | emails_resp = ''' 358 | [{ 359 | "email": "email@moshe.com", 360 | "primary": true, 361 | "verified": true 362 | }] 363 | ''' 364 | with requests_mock.Mocker() as mock: 365 | mock.get('https://api.github.com/user', text=self.mocked_resp) 366 | mock.get('https://api.github.com/user/emails', text=emails_resp) 367 | res = self.ctrl._get_user_profile('github', 'access_token') 368 | self.assertEquals(res['email'], 'email@moshe.com') 369 | self.assertEquals(res['name'], 'Moshe') 370 | 371 | 372 | def test___check___git_works_fine_if_multiple_emails_and_one_exists(self): 373 | self.mocked_resp = ''' 374 | { 375 | "name": "Moshe", 376 | "email": null 377 | } 378 | ''' 379 | emails_resp = ''' 380 | [{ 381 | "email": "email@newuser.com", 382 | "primary": false, 383 | "verified": true 384 | }, 385 | { 386 | "email": "email@another.com", 387 | "primary": true, 388 | "verified": true 389 | } 390 | ] 391 | ''' 392 | with requests_mock.Mocker() as mock: 393 | models.create_or_get_user('gtihib', '', '', 'email@newuser.com', '') 394 | mock.get('https://api.github.com/user', text=self.mocked_resp) 395 | mock.get('https://api.github.com/user/emails', text=emails_resp) 396 | res = self.ctrl._get_user_profile('github', 'access_token') 397 | self.assertEquals(res['email'], 'email@newuser.com') 398 | self.assertEquals(res['name'], 'Moshe') 399 | 400 | 401 | class NormalizeProfileTestCase(unittest.TestCase): 402 | 403 | def setUp(self): 404 | 405 | self.ctrl = import_module('auth.controllers') 406 | 407 | # Cleanup 408 | self.addCleanup(patch.stopall) 409 | 410 | 411 | def test__normilize_profile_from_github(self): 412 | git_response = { 413 | 'id': 'gituserid', 414 | 'login': 'NotMoshe', 415 | 'name': 'Not Moshe', 416 | 'email': 'git_email@moshe.com' 417 | } 418 | out = self.ctrl._normilize_profile('github', git_response) 419 | self.assertEquals(out['username'], 'NotMoshe') 420 | 421 | def test__normilize_profile_from_google(self): 422 | google_response = { 423 | 'id': 'userid', 424 | 'name': 'Moshe', 425 | 'email': 'google_email@moshe.com' 426 | } 427 | out = self.ctrl._normilize_profile('google', google_response) 428 | self.assertEquals(out['username'], 'google_email') 429 | 430 | def test__adds_number_if_username_already_exists(self): 431 | models.save_user({ 432 | 'id': 'new_userid', 433 | 'provider_id': '', 434 | 'username': 'existing_username', 435 | 'name': 'Moshe', 436 | 'email': 'email@moshe.com', 437 | 'avatar_url': 'http://avatar.com', 438 | 'join_date': datetime.datetime.now() 439 | }) 440 | google_response = { 441 | 'id': 'userid', 442 | 'username': 'existing_username', 443 | 'name': 'Moshe', 444 | 'email': 'existing_username@moshe.com' 445 | } 446 | out = self.ctrl._normilize_profile('google', google_response) 447 | self.assertEquals(out['username'], 'existing_username1') 448 | 449 | 450 | class ResolveUsernameTest(unittest.TestCase): 451 | 452 | def setUp(self): 453 | 454 | self.ctrl = import_module('auth.controllers') 455 | 456 | self.private_key = credentials.private_key 457 | reload(self.ctrl) 458 | 459 | # Cleanup 460 | self.addCleanup(patch.stopall) 461 | 462 | def test___resolve_username___existing_user(self): 463 | self.ctrl.get_user_by_username = Mock( 464 | return_value={'id': 'abc123'} 465 | ) 466 | username = 'existing_user' 467 | ret = self.ctrl.resolve_username(username) 468 | self.assertEquals(ret['userid'], 'abc123') 469 | 470 | def test___resolve_username___nonexisting_user(self): 471 | username = 'nonexisting_user' 472 | ret = self.ctrl.resolve_username(username) 473 | self.assertEquals(ret['userid'], None) 474 | 475 | 476 | class GetUserProfileByUsernameTest(unittest.TestCase): 477 | 478 | def setUp(self): 479 | 480 | self.ctrl = import_module('auth.controllers') 481 | 482 | self.private_key = credentials.private_key 483 | reload(self.ctrl) 484 | 485 | # Cleanup 486 | self.addCleanup(patch.stopall) 487 | 488 | def test___get_profile_by_username___existing_user(self): 489 | return_value = { 490 | 'id': 'abc123', 491 | 'email': 'test@test.com', 492 | 'join_date': 'Mon, 24 Jul 2017 12:17:50 GMT', 493 | 'name': 'Test Test', 494 | 'avatar_url': 'https://avatar.com' 495 | } 496 | self.ctrl.get_user_by_username = Mock( 497 | return_value=return_value 498 | ) 499 | 500 | username = 'existing_user' 501 | ret = self.ctrl.get_profile_by_username(username) 502 | self.assertEquals(ret['profile']['id'], return_value['id']) 503 | self.assertEquals(ret['profile']['name'], return_value['name']) 504 | self.assertEquals(ret['profile']['join_date'], return_value['join_date']) 505 | self.assertEquals(ret['profile']['avatar_url'], return_value['avatar_url']) 506 | self.assertEquals(ret['profile']['gravatar'], models.hash_email(return_value['email'])) 507 | self.assertEquals(ret['found'], True) 508 | 509 | def test___get_profile_by_username___nonexisting_user(self): 510 | username = 'nonexisting_user' 511 | ret = self.ctrl.get_profile_by_username(username) 512 | self.assertEquals(ret['profile'], None) 513 | self.assertEquals(ret['found'], False) 514 | -------------------------------------------------------------------------------- /tools/generate_key_pair.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | openssl genrsa -out .tmpkey 2048 3 | openssl rsa -in .tmpkey -out private.pem -outform pem 4 | openssl rsa -in .tmpkey -out public.pem -outform pem -pubout 5 | rm .tmpkey 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | package=auth 3 | skip_missing_interpreters=true 4 | envlist= 5 | py36 6 | py37 7 | 8 | [testenv] 9 | deps= 10 | pylama 11 | pytest 12 | pytest-cov 13 | coveralls 14 | coverage 15 | requests-mock==1.3.0 16 | -rrequirements.txt 17 | passenv= 18 | CI 19 | TRAVIS 20 | TRAVIS_JOB_ID 21 | TRAVIS_BRANCH 22 | commands= 23 | python -m pytest tests -sv --cov=auth 24 | setenv = 25 | PRIVATE_KEY={env:PRIVATE_KEY} 26 | ALLOWED_SERVICES=example:sample_permission_provider;example2:sample_permission_provider 27 | INSTALLED_EXTENSIONS=sample_extension 28 | --------------------------------------------------------------------------------