diff --git a/README.md b/README.md index 1d949bb..b77714f 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,8 @@ apimanager/ │   ├── requirements.txt │   └── supervisor.apimanager.conf ├── db.sqlite3 -├── logs [error opening dir] +├── logs ├── static-collected -│   ├── admin -│   ├── consumers -│   ├── css -│   ├── img -│   ├── js -│   └── users └── venv ├── bin └── lib @@ -54,7 +48,7 @@ Edit `apimanager/apimanager/local_settings.py`: # Used internally by Django, can be anything of your choice SECRET_KEY = '' # API hostname, e.g. https://api.openbankproject.com -OAUTH_API = '' +API_HOST = '' # Consumer key + secret to authenticate the _app_ against the API OAUTH_CONSUMER_KEY = '' OAUTH_CONSUMER_SECRET = '' diff --git a/apimanager/apimanager/settings.py b/apimanager/apimanager/settings.py index 05fb9f9..fe9b637 100644 --- a/apimanager/apimanager/settings.py +++ b/apimanager/apimanager/settings.py @@ -45,7 +45,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'base', - 'oauth', + 'obp', 'consumers', 'users', 'customers', @@ -187,13 +187,18 @@ API_DATETIMEFORMAT = '%Y-%m-%dT%H:%M:%SZ' API_DATEFORMAT = '%Y-%m-%d' -OAUTH_API = 'http://127.0.0.1:8080' +API_HOST = 'http://127.0.0.1:8080' +API_BASE_PATH = '/obp/v3.0.0' +# For some reason, swagger is not available at the latest API version +API_SWAGGER_BASE_PATH = '/obp/v1.4.0' + + +# Always save session$ +SESSION_SAVE_EVERY_REQUEST = True + OAUTH_TOKEN_PATH = '/oauth/initiate' OAUTH_AUTHORIZATION_PATH = '/oauth/authorize' OAUTH_ACCESS_TOKEN_PATH = '/oauth/token' -OAUTH_API_BASE_PATH = '/obp/v3.0.0' - - # Set OAuth client key/secret in apimanager/local_settings.py OAUTH_CONSUMER_KEY = None diff --git a/apimanager/apimanager/urls.py b/apimanager/apimanager/urls.py index b50e381..abf2257 100644 --- a/apimanager/apimanager/urls.py +++ b/apimanager/apimanager/urls.py @@ -6,11 +6,19 @@ URLs for apimanager from django.conf.urls import url, include from base.views import HomeView +from obp.views import OAuthInitiateView, OAuthAuthorizeView, LogoutView urlpatterns = [ url(r'^$', HomeView.as_view(), name="home"), - url(r'^oauth/', include('oauth.urls')), + # Defining authentication URLs here and not including oauth.urls for + # backward compatibility + url(r'^oauth/initiate$', + OAuthInitiateView.as_view(), name='oauth-initiate'), + url(r'^oauth/authorize$', + OAuthAuthorizeView.as_view(), name='oauth-authorize'), + url(r'^logout$', + LogoutView.as_view(), name='oauth-logout'), url(r'^consumers/', include('consumers.urls')), url(r'^users/', include('users.urls')), url(r'^customers/', include('customers.urls')), diff --git a/apimanager/base/api.py b/apimanager/base/api.py deleted file mode 100644 index 529bd68..0000000 --- a/apimanager/base/api.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Module to handle the OBP API - -It instantiates a convenience api object for imports, e.g.: -from base.api import api -""" - -from datetime import datetime -import logging -import time - -from requests.exceptions import ConnectionError -from requests_oauthlib import OAuth1Session - -from django.conf import settings -from django.contrib.auth import logout - - -DATE_FORMAT = '%d/%b/%Y %H:%M:%S' -LOGGER = logging.getLogger(__name__) - - -def log(level, message): - """Logs a given message on a given level to log facility""" - now = datetime.now().strftime(DATE_FORMAT) - msg = '[{}] API: {}'.format(now, message) - LOGGER.log(level, msg) - - -class APIError(Exception): - """Exception class for API errors""" - pass - - -class API(object): - """Implements an interface to the OBP API""" - - def get(self, request, urlpath=''): - """Gets data from the API""" - return self.call(request, 'GET', urlpath) - - def delete(self, request, urlpath): - """Deletes data from the API""" - return self.call(request, 'DELETE', urlpath) - - def post(self, request, urlpath, payload): - """Posts data to the API""" - return self.call(request, 'POST', urlpath, payload) - - def put(self, request, urlpath, payload): - """Puts data onto the API""" - return self.call(request, 'PUT', urlpath, payload) - - def handle_response_404(self, response, prefix): - # Stripping HTML body ... - if response.text.find('body'): - msg = response.text.split('')[1].split('')[0] - msg = '{} {}: {}'.format( - prefix, response.status_code, msg) - log(logging.ERROR, msg) - raise APIError(msg) - - def handle_response_500(self, response, prefix): - msg = '{} {}: {}'.format( - prefix, response.status_code, response.text) - log(logging.ERROR, msg) - raise APIError(msg) - - def handle_response_error(self, request, prefix, error): - if 'Invalid or expired access token' in error: - logout(request) - msg = '{} {}'.format(prefix, error) - raise APIError(msg) - - def handle_response(self, request, response): - """Handles the response, e.g. errors or conversion to JSON""" - prefix = 'APIError' - if response.status_code == 404: - self.handle_response_404(response, prefix) - elif response.status_code == 500: - self.handle_response_500(response, prefix) - elif response.status_code in [204]: - return response.text - else: - data = response.json() - if 'error' in data: - self.handle_response_error(request, prefix, data['error']) - return data - - def call(self, request, method='GET', urlpath='', payload=None): - """Workhorse which actually calls the API""" - url = settings.OAUTH_API + settings.OAUTH_API_BASE_PATH + urlpath - log(logging.INFO, '{} {}'.format(method, url)) - if payload: - log(logging.INFO, 'Payload: {}'.format(payload)) - if not hasattr(request, 'api'): - request.api = OAuth1Session( - settings.OAUTH_CONSUMER_KEY, - client_secret=settings.OAUTH_CONSUMER_SECRET, - resource_owner_key=request.session['oauth_token'], - resource_owner_secret=request.session['oauth_secret'] - ) - time_start = time.time() - try: - if payload: - response = request.api.request(method, url, json=payload) - else: - response = request.api.request(method, url) - except ConnectionError as err: - raise APIError(err) - time_end = time.time() - elapsed = int((time_end - time_start) * 1000) - log(logging.INFO, 'Took {} ms'.format(elapsed)) - return self.handle_response(request, response) -api = API() diff --git a/apimanager/base/api_helper.py b/apimanager/base/api_helper.py deleted file mode 100644 index 61b8be2..0000000 --- a/apimanager/base/api_helper.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Helpers to reuse common API calls -""" - -from django.contrib import messages - -from .api import api, APIError - - -def get_bank_id_choices(request): - """Gets a list of bank ids and bank ids as used by form choices""" - choices = [('', 'Choose ...')] - try: - result = api.get(request, '/banks') - for bank in result['banks']: - choices.append((bank['id'], bank['id'])) - except APIError as err: - messages.error(request, err) - return choices - - -def get_user_id_choices(request): - """Gets a list of user ids and usernames as used by form choices""" - choices = [('', 'Choose ...')] - try: - result = api.get(request, '/users') - for user in result['users']: - choices.append((user['user_id'], user['username'])) - except APIError as err: - messages.error(request, err) - return choices diff --git a/apimanager/base/context_processors.py b/apimanager/base/context_processors.py index 7de663a..d094014 100644 --- a/apimanager/base/context_processors.py +++ b/apimanager/base/context_processors.py @@ -6,13 +6,13 @@ Context processors for base app from django.conf import settings from django.contrib import messages -from base.api import api, APIError +from obp.api import API, APIError def api_root(request): """Returns the configured API_ROOT""" - return {'API_ROOT': settings.OAUTH_API + settings.OAUTH_API_BASE_PATH} + return {'API_ROOT': settings.API_HOST + settings.API_BASE_PATH} @@ -21,7 +21,8 @@ def api_username(request): username = 'not authenticated' if request.user.is_authenticated: try: - data = api.get(request, '/users/current') + api = API(request.session.get('obp')) + data = api.get('/users/current') username = data['username'] except APIError as err: messages.error(request, err) diff --git a/apimanager/base/templates/home.html b/apimanager/base/templates/home.html index 456e1ba..8e55ed7 100644 --- a/apimanager/base/templates/home.html +++ b/apimanager/base/templates/home.html @@ -6,7 +6,7 @@

- This app gives you access to management functionality for the sandbox at {{ OAUTH_API }}. You have to register an account before being able to proceed. The logged-in user needs to have specific roles granted to use the functionality. + This app gives you access to management functionality for the sandbox at {{ API_HOST }}. You have to register an account before being able to proceed. The logged-in user needs to have specific roles granted to use the functionality.

diff --git a/apimanager/base/views.py b/apimanager/base/views.py index 1c924e6..f9f6a3b 100644 --- a/apimanager/base/views.py +++ b/apimanager/base/views.py @@ -13,5 +13,5 @@ class HomeView(TemplateView): def get_context_data(self, **kwargs): context = super(HomeView, self).get_context_data(**kwargs) - context['OAUTH_API'] = settings.OAUTH_API + context['API_HOST'] = settings.API_HOST return context diff --git a/apimanager/config/views.py b/apimanager/config/views.py index 9563921..65f3c42 100644 --- a/apimanager/config/views.py +++ b/apimanager/config/views.py @@ -9,9 +9,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import FormView, TemplateView, View -from base.api import api, APIError - - +from obp.api import API, APIError class IndexView(LoginRequiredMixin, TemplateView): @@ -20,9 +18,10 @@ class IndexView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super(IndexView, self).get_context_data(**kwargs) + api = API(self.request.session.get('obp')) try: urlpath = '/config' - config = api.get(self.request, urlpath) + config = api.get(urlpath) except APIError as err: messages.error(self.request, err) config = {} diff --git a/apimanager/consumers/views.py b/apimanager/consumers/views.py index e9dd006..99a7593 100644 --- a/apimanager/consumers/views.py +++ b/apimanager/consumers/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse from django.views.generic import TemplateView, RedirectView -from base.api import api, APIError +from obp.api import API, APIError from base.filters import BaseFilter, FilterTime @@ -68,10 +68,10 @@ class IndexView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super(IndexView, self).get_context_data(**kwargs) consumers = [] - + api = API(self.request.session.get('obp')) try: urlpath = '/management/consumers' - consumers = api.get(self.request, urlpath) + consumers = api.get(urlpath) consumers = FilterEnabled(context, self.request.GET)\ .apply(consumers['list']) consumers = FilterAppType(context, self.request.GET)\ @@ -98,10 +98,11 @@ class DetailView(LoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): context = super(DetailView, self).get_context_data(**kwargs) + api = API(self.request.session.get('obp')) try: urlpath = '/management/consumers/{}'.format(kwargs['consumer_id']) - consumer = api.get(self.request, urlpath) + consumer = api.get(urlpath) consumer['created'] = datetime.strptime( consumer['created'], settings.API_DATETIMEFORMAT) except APIError as err: @@ -120,10 +121,11 @@ class EnableDisableView(LoginRequiredMixin, RedirectView): success = None def get_redirect_url(self, *args, **kwargs): + api = API(self.request.session.get('obp')) try: urlpath = '/management/consumers/{}'.format(kwargs['consumer_id']) payload = {'enabled': self.enabled} - api.put(self.request, urlpath, payload) + api.put(urlpath, payload) messages.success(self.request, self.success) except APIError as err: messages.error(self.request, err) diff --git a/apimanager/customers/views.py b/apimanager/customers/views.py index 3ec0b85..e4cf2b0 100644 --- a/apimanager/customers/views.py +++ b/apimanager/customers/views.py @@ -12,8 +12,7 @@ from django.http import HttpResponseRedirect from django.urls import reverse_lazy from django.views.generic import FormView -from base.api import api, APIError -from base.api_helper import get_bank_id_choices, get_user_id_choices +from obp.api import API, APIError from .forms import CreateCustomerForm @@ -24,53 +23,56 @@ class CreateView(LoginRequiredMixin, FormView): template_name = 'customers/create.html' success_url = reverse_lazy('customers-create') + def dispatch(self, request, *args, **kwargs): + self.api = API(request.session.get('obp')) + return super(CreateView, self).dispatch(request, *args,**kwargs) + def get_form(self, *args, **kwargs): form = super(CreateView, self).get_form(*args, **kwargs) fields = form.fields - fields['bank_id'].choices = get_bank_id_choices(self.request) - fields['user_id'].choices = get_user_id_choices(self.request) + fields['bank_id'].choices = self.api.get_bank_id_choices() + fields['user_id'].choices = self.api.get_user_id_choices() fields['last_ok_date'].initial =\ datetime.datetime.now().strftime(settings.API_DATETIMEFORMAT) return form def form_valid(self, form): + data = form.cleaned_data + urlpath = '/banks/{}/customers'.format(data['bank_id']) + payload = { + 'user_id': data['user_id'], + 'customer_number': data['customer_number'], + 'legal_name': data['legal_name'], + 'mobile_phone_number': data['mobile_phone_number'], + 'email': data['email'], + 'face_image': { + 'url': data['face_image_url'], + 'date': data['face_image_date'], + }, + 'date_of_birth': data['date_of_birth'], + 'relationship_status': data['relationship_status'], + 'dependants': data['dependants'], + 'dob_of_dependants': data['dob_of_dependants'], + 'credit_rating': { + 'rating': data['credit_rating_rating'], + 'source': data['credit_rating_source'], + }, + 'credit_limit': { + 'currency': data['credit_limit_currency'], + 'amount': data['credit_limit_amount'], + }, + 'highest_education_attained': + data['highest_education_attained'], + 'employment_status': data['employment_status'], + 'kyc_status': data['kyc_status'], + 'last_ok_date': + data['last_ok_date'].strftime(settings.API_DATETIMEFORMAT), + } try: - data = form.cleaned_data - urlpath = '/banks/{}/customers'.format(data['bank_id']) - payload = { - 'user_id': data['user_id'], - 'customer_number': data['customer_number'], - 'legal_name': data['legal_name'], - 'mobile_phone_number': data['mobile_phone_number'], - 'email': data['email'], - 'face_image': { - 'url': data['face_image_url'], - 'date': data['face_image_date'], - }, - 'date_of_birth': data['date_of_birth'], - 'relationship_status': data['relationship_status'], - 'dependants': data['dependants'], - 'dob_of_dependants': data['dob_of_dependants'], - 'credit_rating': { - 'rating': data['credit_rating_rating'], - 'source': data['credit_rating_source'], - }, - 'credit_limit': { - 'currency': data['credit_limit_currency'], - 'amount': data['credit_limit_amount'], - }, - 'highest_education_attained': - data['highest_education_attained'], - 'employment_status': data['employment_status'], - 'kyc_status': data['kyc_status'], - 'last_ok_date': - data['last_ok_date'].strftime(settings.API_DATETIMEFORMAT), - } - result = api.post(self.request, urlpath, payload=payload) + result = self.api.post(urlpath, payload=payload) except APIError as err: messages.error(self.request, err) return super(CreateView, self).form_invalid(form) - msg = 'Customer number {} has been created successfully!'.format( result['customer_number']) messages.success(self.request, msg) diff --git a/apimanager/metrics/views.py b/apimanager/metrics/views.py index 56060e8..126151d 100644 --- a/apimanager/metrics/views.py +++ b/apimanager/metrics/views.py @@ -15,7 +15,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic import FormView, TemplateView from django.utils.http import urlquote -from base.api import api, APIError +from obp.api import API, APIError from .forms import APIMetricsForm, ConnectorMetricsForm @@ -117,8 +117,9 @@ class MetricsView(LoginRequiredMixin, TemplateView): metrics = [] params = self.to_api(cleaned_data) urlpath = '{}?{}'.format(self.api_urlpath, params) + api = API(self.request.session.get('obp')) try: - metrics = api.get(self.request, urlpath) + metrics = api.get(urlpath) metrics = self.to_django(metrics['metrics']) except APIError as err: messages.error(self.request, err) diff --git a/apimanager/oauth/apps.py b/apimanager/oauth/apps.py deleted file mode 100644 index a1f4f0c..0000000 --- a/apimanager/oauth/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -""" -App config for OAuth 1 app -""" - -from django.apps import AppConfig - - -class OauthConfig(AppConfig): - """Config for OAuth 1""" - name = 'oauth' diff --git a/apimanager/oauth/urls.py b/apimanager/oauth/urls.py deleted file mode 100644 index b16356c..0000000 --- a/apimanager/oauth/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -""" -URLs for OAuth 1 app -""" - -from django.conf.urls import url - -from .views import InitiateView, AuthorizeView, LogoutView - -urlpatterns = [ - url(r'^initiate$', InitiateView.as_view(), name='oauth-initiate'), - url(r'^authorize$', AuthorizeView.as_view(), name='oauth-authorize'), - url(r'^logout$', LogoutView.as_view(), name='oauth-logout'), -] diff --git a/apimanager/oauth/views.py b/apimanager/oauth/views.py deleted file mode 100644 index 4bc0843..0000000 --- a/apimanager/oauth/views.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Views for OAuth 1 app -""" - -import hashlib - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth import login, logout -from django.contrib.auth.models import User -from django.urls import reverse -from django.views.generic import RedirectView - -from requests_oauthlib import OAuth1Session -from requests_oauthlib.oauth1_session import TokenRequestDenied - -from base.api import api - - -class InitiateView(RedirectView): - """View to initiate OAuth 1 session""" - - def get_callback_uri(self, request): - """ - Gets the callback URI to where the user shall be returned after - authorization at OAuth 1 server - """ - base_url = '{}://{}'.format( - request.scheme, request.environ['HTTP_HOST']) - uri = base_url + reverse('oauth-authorize') - if 'next' in request.GET: - uri = '{}?next={}'.format(uri, request.GET['next']) - return uri - - def get_redirect_url(self, *args, **kwargs): - callback_uri = self.get_callback_uri(self.request) - session = OAuth1Session( - settings.OAUTH_CONSUMER_KEY, - client_secret=settings.OAUTH_CONSUMER_SECRET, - callback_uri=callback_uri, - ) - - try: - url = settings.OAUTH_API + settings.OAUTH_TOKEN_PATH - response = session.fetch_request_token(url) - except (ValueError, TokenRequestDenied) as err: - messages.error(self.request, err) - return reverse('home') - - url = settings.OAUTH_API + settings.OAUTH_AUTHORIZATION_PATH - authorization_url = session.authorization_url(url) - self.request.session['oauth_token'] = response.get('oauth_token') - self.request.session['oauth_secret'] = response.get('oauth_token_secret') - self.request.session.modified = True - return authorization_url - - -class AuthorizeView(RedirectView): - """View to authorize user""" - - def login_to_django(self): - """ - Logs the user into Django - Kind of faking it to establish if a user is authenticated later on - """ - data = api.get(self.request, '/users/current') - userid = data['user_id'] or data['email'] - username = hashlib.sha256(userid.encode('utf-8')).hexdigest() - password = username - user, _ = User.objects.get_or_create( - username=username, password=password, - ) - login(self.request, user) - - def get_redirect_url(self, *args, **kwargs): - session = OAuth1Session( - settings.OAUTH_CONSUMER_KEY, - settings.OAUTH_CONSUMER_SECRET, - resource_owner_key=self.request.session.get('oauth_token'), - resource_owner_secret=self.request.session.get('oauth_secret') - ) - session.parse_authorization_response(self.request.build_absolute_uri()) - url = settings.OAUTH_API + settings.OAUTH_ACCESS_TOKEN_PATH - try: - response = session.fetch_access_token(url) - self.request.session['oauth_token'] = response.get('oauth_token') - self.request.session['oauth_secret'] = response.get('oauth_token_secret') - self.request.session.modified = True - self.login_to_django() - except TokenRequestDenied as err: - messages.error(self.request, err) - redirect_url = self.request.GET.get('next', reverse('home')) - return redirect_url - - -class LogoutView(RedirectView): - """View to logout""" - - def get_redirect_url(self, *args, **kwargs): - logout(self.request) - return reverse('home') diff --git a/apimanager/oauth/__init__.py b/apimanager/obp/__init__.py similarity index 100% rename from apimanager/oauth/__init__.py rename to apimanager/obp/__init__.py diff --git a/apimanager/obp/api.py b/apimanager/obp/api.py new file mode 100644 index 0000000..c97b513 --- /dev/null +++ b/apimanager/obp/api.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +""" +Module to handle the OBP API + +It instantiates a convenience api object for imports, e.g.: +from obp.api import api +""" + +from datetime import datetime +import importlib +import logging +import time + +import requests +from requests.exceptions import ConnectionError + +from django.conf import settings + + +DATE_FORMAT = '%d/%b/%Y %H:%M:%S' +LOGGER = logging.getLogger(__name__) + + +def log(level, message): + """Logs a given message on a given level to log facility""" + now = datetime.now().strftime(DATE_FORMAT) + msg = '[{}] API: {}'.format(now, message) + LOGGER.log(level, msg) + + +class APIError(Exception): + """Exception class for API errors""" + pass + + +class API(object): + """Implements an interface to the OBP API""" + session = None + swagger = None + + def __init__(self, session_data=None): + self.set_base_path() + if session_data: + self.start_session(session_data) + self.session_data = session_data + + def set_base_path(self, base_path=None): + """Sets the basepath for API calls""" + if base_path: + self.base_path = settings.API_HOST + base_path + else: + self.base_path = settings.API_HOST + settings.API_BASE_PATH + + def call(self, method='GET', urlpath='', payload=None): + """Workhorse which actually calls the API""" + url = self.base_path + urlpath + log(logging.INFO, '{} {}'.format(method, url)) + if payload: + log(logging.INFO, 'Payload: {}'.format(payload)) + # use `requests` if no session has been started + session = self.session or requests + time_start = time.time() + try: + if payload: + response = session.request(method, url, json=payload) + else: + response = session.request(method, url) + except ConnectionError as err: + raise APIError(err) + time_end = time.time() + elapsed = int((time_end - time_start) * 1000) + log(logging.INFO, 'Took {} ms'.format(elapsed)) + response.execution_time = elapsed + return response + + def get(self, urlpath=''): + """Gets data from the API""" + response = self.call('GET', urlpath) + return self.handle_response(response) + + def delete(self, urlpath): + """Deletes data from the API""" + response = self.call('DELETE', urlpath) + return self.handle_response(response) + + def post(self, urlpath, payload): + """Posts data to the API""" + response = self.call('POST', urlpath, payload) + return self.handle_response(response) + + def put(self, urlpath, payload): + """Puts data onto the API""" + response = self.call('PUT', urlpath, payload) + return self.handle_response(response) + + def handle_response_404(self, response, prefix): + # Stripping HTML body ... + if response.text.find('body'): + msg = response.text.split('')[1].split('')[0] + msg = '{} {}: {}'.format( + prefix, response.status_code, msg) + log(logging.ERROR, msg) + raise APIError(msg) + + def handle_response_500(self, response, prefix): + msg = '{} {}: {}'.format( + prefix, response.status_code, response.text) + log(logging.ERROR, msg) + raise APIError(msg) + + def handle_response_error(self, prefix, error): + if 'Invalid or expired access token' in error: + raise APIError(error) + msg = '{} {}'.format(prefix, error) + raise APIError(msg) + + def handle_response(self, response): + """Handles the response, e.g. errors or conversion to JSON""" + prefix = 'APIError' + if response.status_code == 404: + self.handle_response_404(response, prefix) + elif response.status_code == 500: + self.handle_response_500(response, prefix) + elif response.status_code in [204]: + return response.text + else: + data = response.json() + if 'error' in data: + self.handle_response_error(prefix, data['error']) + return data + + def start_session(self, session_data): + """ + Starts a session with given session_data: + - Authenticator class name (e.g. obp.oauth.OAuthAuthenticator) + - Token data + for subsequent requests to the API + """ + if 'authenticator' in session_data and\ + 'authenticator_kwargs' in session_data: + mod_name, cls_name = session_data['authenticator'].rsplit('.', 1) + log(logging.INFO, 'Authenticator {}'.format(cls_name)) + cls = getattr(importlib.import_module(mod_name), cls_name) + authenticator = cls(**session_data['authenticator_kwargs']) + self.session = authenticator.get_session() + return self.session + else: + return None + + def get_swagger(self): + """Gets the swagger definition from the API""" + if not self.session_data.get('swagger'): + self.set_base_path(settings.API_SWAGGER_BASE_PATH) + urlpath = '/resource-docs/v3.0.0/swagger' + response = self.get(urlpath) + # Set base path back + self.set_base_path() + self.session_data['swagger'] = response + return self.session_data.get('swagger') + + def get_bank_id_choices(self): + """Gets a list of bank ids and bank ids as used by form choices""" + choices = [('', 'Choose ...')] + result = self.get('/banks') + for bank in result['banks']: + choices.append((bank['id'], bank['id'])) + return choices + + def get_user_id_choices(self): + """Gets a list of user ids and usernames as used by form choices""" + choices = [('', 'Choose ...')] + result = self.get('/users') + for user in result['users']: + choices.append((user['user_id'], user['username'])) + return choices diff --git a/apimanager/obp/apps.py b/apimanager/obp/apps.py new file mode 100644 index 0000000..44ec17d --- /dev/null +++ b/apimanager/obp/apps.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +""" +App config for OBP app +""" + +from django.apps import AppConfig + + +class OBPConfig(AppConfig): + """Config for OBP""" + name = 'obp' diff --git a/apimanager/obp/authenticator.py b/apimanager/obp/authenticator.py new file mode 100644 index 0000000..ca9abc3 --- /dev/null +++ b/apimanager/obp/authenticator.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +""" +Base authenticator for OBP app +""" + +import hashlib + +from django.contrib import messages +from django.contrib.auth import login +from django.contrib.auth.models import User + + +class AuthenticatorError(Exception): + """Exception class for Authenticator errors""" + pass + + +class Authenticator(object): + """Generic authenticator to the API""" + pass diff --git a/apimanager/obp/directlogin.py b/apimanager/obp/directlogin.py new file mode 100644 index 0000000..8025564 --- /dev/null +++ b/apimanager/obp/directlogin.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +DirectLogin authenticator for OBP app +""" + + +import requests + +from django.conf import settings + +from .authenticator import Authenticator, AuthenticatorError + + +class DirectLoginAuthenticator(Authenticator): + """Implements a DirectLogin authenticator to the API""" + + token = None + + def __init__(self, token=None): + self.token = token + + def login_to_api(self, data): + """ + Logs into the API and returns the token + + data is a dict which contains keys username, password and consumer_key + """ + url = settings.API_HOST + settings.DIRECTLOGIN_PATH + authorization = 'DirectLogin username="{}",password="{}",consumer_key="{}"'.format( # noqa + data['username'], + data['password'], + data['consumer_key']) + headers = {'Authorization': authorization} + + try: + response = requests.get(url, headers=headers) + except requests.exceptions.ConnectionError as err: + raise AuthenticatorError(err) + + result = response.json() + if response.status_code != 200: + raise AuthenticatorError(result['error']) + else: + self.token = result['token'] + + def get_session(self): + """Returns a session object to make authenticated requests""" + headers = {'Authorization': 'DirectLogin token={}'.format(self.token)} + session = requests.Session() + session.headers.update(headers) + return session diff --git a/apimanager/obp/forms.py b/apimanager/obp/forms.py new file mode 100644 index 0000000..45bdc63 --- /dev/null +++ b/apimanager/obp/forms.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" +Forms for OBP app +""" + +from django import forms + +from .authenticator import AuthenticatorError +from .directlogin import DirectLoginAuthenticator +from .gatewaylogin import GatewayLoginAuthenticator + + +class DirectLoginForm(forms.Form): + username = forms.CharField(widget=forms.TextInput( + attrs={'class': 'form-control'})) + password = forms.CharField(widget=forms.PasswordInput( + attrs={'class': 'form-control'})) + consumer_key = forms.CharField(widget=forms.TextInput( + attrs={'class': 'form-control'})) + + def clean(self): + """ + Stores an authenticator in cleaned_data after successful login to API + """ + cleaned_data = super(DirectLoginForm, self).clean() + authenticator = DirectLoginAuthenticator() + try: + authenticator.login_to_api(cleaned_data) + cleaned_data['authenticator'] = authenticator + except AuthenticatorError as err: + raise forms.ValidationError(err) + return cleaned_data + + +class GatewayLoginForm(forms.Form): + username = forms.CharField(widget=forms.TextInput( + attrs={'class': 'form-control'})) + secret = forms.CharField(widget=forms.PasswordInput( + attrs={'class': 'form-control'})) + + def clean(self): + """ + Stores an authenticator in cleaned_data after successful login to API + """ + cleaned_data = super(GatewayLoginForm, self).clean() + authenticator = GatewayLoginAuthenticator() + try: + authenticator.login_to_api(cleaned_data) + cleaned_data['authenticator'] = authenticator + except AuthenticatorError as err: + raise forms.ValidationError(err) + return cleaned_data + diff --git a/apimanager/obp/gatewaylogin.py b/apimanager/obp/gatewaylogin.py new file mode 100644 index 0000000..0cbfef8 --- /dev/null +++ b/apimanager/obp/gatewaylogin.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +""" +GatewayLogin authenticator for OBP app +""" + + +import jwt +import requests + +from django.conf import settings + +from .authenticator import Authenticator, AuthenticatorError + + +class GatewayLoginAuthenticator(Authenticator): + """Implements a GatewayLogin authenticator to the API""" + + token = None + + def __init__(self, token=None): + self.token = token + + def create_jwt(self, data): + """ + Creates a JWT used for future requests tothe API + data is a dict which contains keys username, secret + """ + url = settings.API_HOST + settings.DIRECTLOGIN_PATH + message = { + 'username': data['username'], + 'timestamp': 'unused', + 'consumer_id': '', # Do not create new consumer + 'consumer_name': '', # Do not create new consumer + } + if settings.GATEWAYLOGIN_HAS_CBS: + # Not sure if that is the right thing to do + message['is_first'] = True + else: + # Fake when there is no core banking system + message.update({ + 'is_first': False, + 'CBS_auth_token': 'dummy', + }) + token = jwt.encode(message, data['secret'], 'HS256') + self.token = token.decode('utf-8') + return self.token + + def login_to_api(self, data): + token = self.create_jwt(data) + # Make a test call to see if the token works + url = settings.API_HOST + settings.API_BASE_PATH + '/users/current' + api = self.get_session() + try: + response = api.get(url) + except requests.exceptions.ConnectionError as err: + raise AuthenticationError(err) + if response.status_code != 200: + raise AuthenticatorError(response.json()['error']) + else: + return token + + def get_session(self): + """Returns a session object to make authenticated requests""" + headers = {'Authorization': 'GatewayLogin token="{}"'.format(self.token)} + session = requests.Session() + session.headers.update(headers) + return session diff --git a/apimanager/obp/oauth.py b/apimanager/obp/oauth.py new file mode 100644 index 0000000..6f7bdd6 --- /dev/null +++ b/apimanager/obp/oauth.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +""" +OAuth authenticator for OBP app +""" + +import logging +import time + +from django.conf import settings + +from requests.exceptions import ConnectionError +from requests_oauthlib import OAuth1Session +from requests_oauthlib.oauth1_session import TokenRequestDenied + +from .authenticator import Authenticator, AuthenticatorError + + +LOGGER = logging.getLogger(__name__) + + +class OAuthAuthenticator(Authenticator): + """Implements an OAuth authenticator to the API""" + + token = None + secret = None + + def __init__(self, token=None, secret=None): + self.token = token + self.secret = secret + + def get_authorization_url(self, callback_uri): + session = OAuth1Session( + settings.OAUTH_CONSUMER_KEY, + client_secret=settings.OAUTH_CONSUMER_SECRET, + callback_uri=callback_uri, + ) + try: + url = settings.API_HOST + settings.OAUTH_TOKEN_PATH + response = session.fetch_request_token(url) + except (ValueError, TokenRequestDenied, ConnectionError) as err: + raise AuthenticatorError(err) + else: + self.token = response.get('oauth_token') + self.secret = response.get('oauth_token_secret') + url = settings.API_HOST + settings.OAUTH_AUTHORIZATION_PATH + authorization_url = session.authorization_url(url) + LOGGER.log(logging.INFO, 'Initial token {}, secret {}'.format( + self.token, self.secret)) + return authorization_url + + def set_access_token(self, authorization_url): + session = OAuth1Session( + settings.OAUTH_CONSUMER_KEY, + settings.OAUTH_CONSUMER_SECRET, + resource_owner_key=self.token, + resource_owner_secret=self.secret, + ) + session.parse_authorization_response(authorization_url) + url = settings.API_HOST + settings.OAUTH_ACCESS_TOKEN_PATH + try: + response = session.fetch_access_token(url) + except (TokenRequestDenied, ConnectionError) as err: + raise AuthenticatorError(err) + else: + self.token = response.get('oauth_token') + self.secret = response.get('oauth_token_secret') + LOGGER.log(logging.INFO, 'Updated token {}, secret {}'.format( + self.token, self.secret)) + + def get_session(self): + session = OAuth1Session( + settings.OAUTH_CONSUMER_KEY, + client_secret=settings.OAUTH_CONSUMER_SECRET, + resource_owner_key=self.token, + resource_owner_secret=self.secret, + ) + return session diff --git a/apimanager/obp/templates/obp/directlogin.html b/apimanager/obp/templates/obp/directlogin.html new file mode 100644 index 0000000..b7df24b --- /dev/null +++ b/apimanager/obp/templates/obp/directlogin.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block content %} +

DirectLogin

+
+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {% csrf_token %} +
+ {% if form.username.errors %}
{{ form.username.errors }}
{% endif %} + + {{ form.username }} +
+
+ {% if form.password.errors %}
{{ form.password.errors }}
{% endif %} + + {{ form.password }} +
+
+ {% if form.consumer_key.errors %}
{{ form.consumer_key.errors }}
{% endif %} + + {{ form.consumer_key }} +
+ +
+{% endblock content %} diff --git a/apimanager/obp/templates/obp/gatewaylogin.html b/apimanager/obp/templates/obp/gatewaylogin.html new file mode 100644 index 0000000..159d8c1 --- /dev/null +++ b/apimanager/obp/templates/obp/gatewaylogin.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block content %} +

GatewayLogin

+
+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + {% csrf_token %} +
+ {% if form.username.errors %}
{{ form.username.errors }}
{% endif %} + + {{ form.username }} +
+
+ {% if form.secret.errors %}
{{ form.secret.errors }}
{% endif %} + + {{ form.secret }} +
+ +
+{% endblock content %} diff --git a/apimanager/obp/urls.py b/apimanager/obp/urls.py new file mode 100644 index 0000000..097d448 --- /dev/null +++ b/apimanager/obp/urls.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +""" +URLs for OBP app +""" + +from django.conf.urls import url + +from .views import ( + OAuthInitiateView, OAuthAuthorizeView, + DirectLoginView, + GatewayLoginView, + LogoutView, +) + + +urlpatterns = [ + url(r'^oauth/initiate$', + OAuthInitiateView.as_view(), name='oauth-initiate'), + url(r'^oauth/authorize$', + OAuthAuthorizeView.as_view(), name='oauth-authorize'), + url(r'^directlogin$', + DirectLoginView.as_view(), name='directlogin'), + url(r'^gatewaylogin$', + GatewayLoginView.as_view(), name='gatewaylogin'), + url(r'^logout$', + LogoutView.as_view(), name='oauth-logout'), +] diff --git a/apimanager/obp/views.py b/apimanager/obp/views.py new file mode 100644 index 0000000..38d7f41 --- /dev/null +++ b/apimanager/obp/views.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +""" +Views for OBP app +""" + + +import hashlib + +from django.contrib import messages +from django.contrib.auth import login, logout +from django.contrib.auth.models import User +from django.urls import reverse +from django.views.generic import RedirectView, FormView + +from .api import API, APIError +from .authenticator import AuthenticatorError +from .forms import DirectLoginForm, GatewayLoginForm +from .oauth import OAuthAuthenticator + + +class LoginToDjangoMixin(object): + """Mixin to login to Django from views.""" + def login_to_django(self): + """ + Logs the user into Django + Kind of faking it to establish if a user is authenticated later on + """ + api = API(self.request.session.get('obp')) + try: + data = api.get('/users/current') + except APIError as err: + messages.error(self.request, err) + return False + else: + userid = data['user_id'] or data['email'] + username = hashlib.sha256(userid.encode('utf-8')).hexdigest() + password = username + user, _ = User.objects.get_or_create( + username=username, password=password, + ) + login(self.request, user) + return True + + +class OAuthInitiateView(RedirectView): + """View to initiate OAuth session""" + + def get_callback_uri(self, request): + """ + Gets the callback URI to where the user shall be returned after + initiation at OAuth server + """ + base_url = '{}://{}'.format( + request.scheme, request.environ['HTTP_HOST']) + uri = base_url + reverse('oauth-authorize') + if 'next' in request.GET: + uri = '{}?next={}'.format(uri, request.GET['next']) + return uri + + def get_redirect_url(self, *args, **kwargs): + callback_uri = self.get_callback_uri(self.request) + try: + authenticator = OAuthAuthenticator() + authorization_url = authenticator.get_authorization_url( + callback_uri) + except AuthenticatorError as err: + messages.error(self.request, err) + return reverse('home') + else: + self.request.session['obp'] = { + 'authenticator': 'obp.oauth.OAuthAuthenticator', + 'authenticator_kwargs': { + 'token': authenticator.token, + 'secret': authenticator.secret, + } + } + return authorization_url + + +class OAuthAuthorizeView(RedirectView, LoginToDjangoMixin): + """View to authorize user after OAuth 1 initiation""" + + def get_redirect_url(self, *args, **kwargs): + session_data = self.request.session.get('obp') + authenticator_kwargs = session_data.get('authenticator_kwargs') + authenticator = OAuthAuthenticator(**authenticator_kwargs) + authorization_url = self.request.build_absolute_uri() + try: + authenticator.set_access_token(authorization_url) + except AuthenticatorError as err: + messages.error(self.request, err) + else: + session_data['authenticator_kwargs'] = { + 'token': authenticator.token, + 'secret': authenticator.secret, + } + self.login_to_django() + messages.success(self.request, 'OAuth login successful!') + redirect_url = self.request.GET.get('next', reverse('home')) + return redirect_url + + +class DirectLoginView(FormView, LoginToDjangoMixin): + """View to login via DirectLogin""" + form_class = DirectLoginForm + template_name = 'obp/directlogin.html' + + def get_success_url(self): + messages.success(self.request, 'DirectLogin successful!') + return reverse('runtests-index') + + def form_valid(self, form): + """ + Stores a DirectLogin token in the request's session for use in + future requests. It also logs in to Django. + """ + authenticator = form.cleaned_data['authenticator'] + self.request.session['obp'] = { + 'authenticator': 'obp.directlogin.DirectLoginAuthenticator', + 'authenticator_kwargs': { + 'token': authenticator.token, + } + } + self.login_to_django() + return super(DirectLoginView, self).form_valid(form) + + +class GatewayLoginView(FormView, LoginToDjangoMixin): + """View to login via GatewayLogin""" + form_class = GatewayLoginForm + template_name = 'obp/gatewaylogin.html' + + def get_success_url(self): + messages.success(self.request, 'GatewayLogin successful!') + return reverse('runtests-index') + + def form_valid(self, form): + """ + Stores a GatewayLogin token in the request's session for use in + future requests. It also logs in to Django. + """ + authenticator = form.cleaned_data['authenticator'] + self.request.session['obp'] = { + 'authenticator': 'obp.gatewaylogin.GatewayLoginAuthenticator', + 'authenticator_kwargs': { + 'token': authenticator.token, + } + } + self.login_to_django() + return super(GatewayLoginView, self).form_valid(form) + + +class LogoutView(RedirectView): + """View to logout""" + + def get_redirect_url(self, *args, **kwargs): + logout(self.request) + if 'obp' in self.request.session: + del self.request.session['obp'] + return reverse('home') diff --git a/apimanager/users/views.py b/apimanager/users/views.py index 2e4e3f8..b024710 100644 --- a/apimanager/users/views.py +++ b/apimanager/users/views.py @@ -10,8 +10,7 @@ from django.urls import reverse, reverse_lazy from django.views.generic import FormView, TemplateView, View from base.filters import BaseFilter -from base.api import api, APIError -from base.api_helper import get_bank_id_choices +from obp.api import API, APIError from .forms import AddEntitlementForm @@ -42,9 +41,10 @@ class IndexView(LoginRequiredMixin, TemplateView): def get_users_rolenames(self, context): users = [] + api = API(self.request.session.get('obp')) try: urlpath = '/users' - users = api.get(self.request, urlpath) + users = api.get(urlpath) except APIError as err: messages.error(self.request, err) return [], [] @@ -85,9 +85,13 @@ class DetailView(LoginRequiredMixin, FormView): form_class = AddEntitlementForm template_name = 'users/detail.html' + def dispatch(self, request, *args, **kwargs): + self.api = API(request.session.get('obp')) + return super(DetailView, self).dispatch(request, *args,**kwargs) + def get_form(self, *args, **kwargs): form = super(DetailView, self).get_form(*args, **kwargs) - form.fields['bank_id'].choices = get_bank_id_choices(self.request) + form.fields['bank_id'].choices = self.api.get_bank_id_choices() return form def form_valid(self, form): @@ -99,7 +103,7 @@ class DetailView(LoginRequiredMixin, FormView): 'bank_id': data['bank_id'], 'role_name': data['role_name'], } - entitlement = api.post(self.request, urlpath, payload=payload) + entitlement = self.api.post(urlpath, payload=payload) except APIError as err: messages.error(self.request, err) return super(DetailView, self).form_invalid(form) @@ -112,13 +116,12 @@ class DetailView(LoginRequiredMixin, FormView): def get_context_data(self, **kwargs): context = super(DetailView, self).get_context_data(**kwargs) - # NOTE: assuming there is just one user with that email address # The API needs a call 'get user by id'! user = {} try: urlpath = '/users/user_id/{}'.format(self.kwargs['user_id']) - user = api.get(self.request, urlpath) + user = self.api.get(urlpath) context['form'].fields['user_id'].initial = user['user_id'] except APIError as err: messages.error(self.request, err) @@ -134,10 +137,11 @@ class DeleteEntitlementView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): """Deletes entitlement from API""" + api = API(self.request.session.get('obp')) try: urlpath = '/users/{}/entitlement/{}'.format( kwargs['user_id'], kwargs['entitlement_id']) - api.delete(request, urlpath) + api.delete(urlpath) msg = 'Entitlement with role {} has been deleted.'.format( request.POST.get('role_name', '')) messages.success(request, msg) diff --git a/requirements.txt b/requirements.txt index 77a6060..be9b557 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ Django==1.11.5 oauthlib==2.0.0 requests==2.11.1 requests-oauthlib==0.6.2 +PyJWT==1.5.3 gunicorn==19.6.0