Skip to content

Commit

Permalink
Merge pull request #22 from getyoti/AML
Browse files Browse the repository at this point in the history
[SDK-250]: AML Requests
  • Loading branch information
echarrod authored Mar 12, 2018
2 parents 88da24c + ffd33da commit 496c220
Show file tree
Hide file tree
Showing 15 changed files with 448 additions and 37 deletions.
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Entry point explanation
1) [Handling Users](#handling-users) -
How to manage users

1) [AML Integration](#aml-integration) -
How to integrate with Yoti's AML (Anti Money Laundering) service

1) [Running the examples](#running-the-examples) -
How to retrieve a Yoti profile using the token

Expand Down Expand Up @@ -128,6 +131,63 @@ gender = user_profile.get('gender')
nationality = user_profile.get('nationality')
```

## AML Integration

Yoti provides an AML (Anti Money Laundering) check service to allow a deeper KYC process to prevent fraud. This is a chargeable service, so please contact [[email protected]](mailto:[email protected]) for more information.

Yoti will provide a boolean result on the following checks:

* PEP list - Verify against Politically Exposed Persons list
* Fraud list - Verify against US Social Security Administration Fraud (SSN Fraud) list
* Watch list - Verify against watch lists from the Office of Foreign Assets Control

To use this functionality you must ensure your application is assigned to your Organisation in the Yoti Dashboard - please see here for further information.

For the AML check you will need to provide the following:

* Data provided by Yoti (please ensure you have selected the Given name(s) and Family name attributes from the Data tab in the Yoti Dashboard)
* Given name(s)
* Family name
* Data that must be collected from the user:
* Country of residence (must be an ISO 3166 3-letter code)
* Social Security Number (US citizens only)
* Postcode/Zip code (US citizens only)

### Consent

Performing an AML check on a person *requires* their consent.
**You must ensure you have user consent *before* using this service.**

### Code Example

Given a YotiClient initialised with your SDK ID and KeyPair (see [Client Initialisation](#client-initialisation)) performing an AML check is a straightforward case of providing basic profile data.

```python
from yoti_python_sdk import aml
from yoti_python_sdk import Client

client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH)
given_names = "Edward Richard George"
family_name = "Heath"

aml_address = aml.AmlAddress(country="GBR")
aml_profile = aml.AmlProfile(
given_names,
family_name,
aml_address
)


aml_result = client.perform_aml_check(aml_profile)

print("AML Result for {1} {2}:", given_names, family_name)
print("On PEP list: " + str(aml_result.on_pep_list))
print("On fraud list: " + str(aml_result.on_fraud_list))
print("On watchlist: " + str(aml_result.on_watch_list))
```

Additionally an [example AML application](/examples/aml/app.py) is provided in the examples folder.

## Running the Examples

The callback URL for both example projects will be `http://localhost:5000/yoti/auth/`
Expand Down Expand Up @@ -166,6 +226,12 @@ Both example applications utilise the env variables described in [Configuration]
1. Run: `python manage.py runserver 0.0.0.0:5000`
1. Navigate to http://localhost:5000

#### AML Example

1. Change directories to the AML folder: `cd examples/aml`
1. Install requirements with `pip install -r requirements.txt`
1. Run: `python app.py`

### Plugins ###

Plugins for both Django and Flask are in the `plugins/` dir. Their purpose is to make it as easy as possible to use the Yoti SDK with those frameworks. See the [Django](/plugins/django_yoti/README.md) and [Flask](/plugins/flask_yoti/README.md) README files for further details.
Expand Down
2 changes: 2 additions & 0 deletions examples/aml/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
YOTI_CLIENT_SDK_ID=yourClientSdkId
YOTI_KEY_FILE_PATH=yourKeyFilePath
43 changes: 43 additions & 0 deletions examples/aml/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import sys
from os import environ
from os.path import join, dirname

from dotenv import load_dotenv

from yoti_python_sdk import Client
from yoti_python_sdk import aml

dotenv_path = join(dirname(__file__), '.env')
load_dotenv(dotenv_path)

YOTI_CLIENT_SDK_ID = environ.get('YOTI_CLIENT_SDK_ID')
YOTI_KEY_FILE_PATH = environ.get('YOTI_KEY_FILE_PATH')


# The following exits cleanly on Ctrl-C,
# while treating other exceptions as before.
def cli_exception(exception_type, value, tb):
if not issubclass(exception_type, KeyboardInterrupt):
sys.__excepthook__(exception_type, value, tb)


given_names = "Edward Richard George"
family_name = "Heath"

aml_address = aml.AmlAddress(country="GBR")
aml_profile = aml.AmlProfile(
given_names,
family_name,
aml_address
)

if sys.stdin.isatty():
sys.excepthook = cli_exception

client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH)

aml_result = client.perform_aml_check(aml_profile)
print("AML Result for {0} {1}:".format(given_names, family_name))
print("On PEP list: " + str(aml_result.on_pep_list))
print("On fraud list: " + str(aml_result.on_fraud_list))
print("On watchlist: " + str(aml_result.on_watch_list))
2 changes: 2 additions & 0 deletions examples/aml/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
yoti
python-dotenv>=0.7.1
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from setuptools import setup, find_packages

VERSION = '2.0.4'
VERSION = '2.1.0'
long_description = 'This package contains the tools you need to quickly ' \
'integrate your Python back-end with Yoti, so that your ' \
'users can share their identity details with your ' \
Expand Down
45 changes: 45 additions & 0 deletions yoti_python_sdk/aml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import json


class AmlResult:
def __init__(self, response_text):
if not response_text:
raise ValueError("AML Response is not valid")

try:
self.on_pep_list = json.loads(response_text).get('on_pep_list')
self.on_fraud_list = json.loads(response_text).get('on_fraud_list')
self.on_watch_list = json.loads(response_text).get('on_watch_list')

except (AttributeError, IOError, TypeError, OSError) as exc:
error = 'Could not parse AML result from response: "{0}"'.format(response_text)
exception = '{0}: {1}'.format(type(exc).__name__, exc)
raise RuntimeError('{0}: {1}'.format(error, exception))

self.__check_for_none_values(self.on_pep_list)
self.__check_for_none_values(self.on_fraud_list)
self.__check_for_none_values(self.on_watch_list)

@staticmethod
def __check_for_none_values(arg):
if arg is None:
raise TypeError(str.format("{0} argument was unable to be retrieved from the response", arg))

def __iter__(self):
yield 'on_pep_list', self.on_pep_list
yield 'on_fraud_list', self.on_fraud_list
yield 'on_watch_list', self.on_watch_list


class AmlAddress:
def __init__(self, country, postcode=None):
self.country = country
self.post_code = postcode


class AmlProfile:
def __init__(self, given_names, family_name, address, ssn=None):
self.given_names = given_names
self.family_name = family_name
self.address = address.__dict__
self.ssn = ssn
70 changes: 54 additions & 16 deletions yoti_python_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@
from __future__ import unicode_literals

import json
import time
import uuid
from os import environ
from os.path import isfile, expanduser

import requests
from cryptography.fernet import base64
from past.builtins import basestring

import yoti_python_sdk
from .config import SDK_IDENTIFIER
from yoti_python_sdk import aml
from yoti_python_sdk.activity_details import ActivityDetails
from yoti_python_sdk.crypto import Crypto
from yoti_python_sdk.endpoint import Endpoint
from yoti_python_sdk.protobuf.v1 import protobuf
from .config import SDK_IDENTIFIER

NO_KEY_FILE_SPECIFIED_ERROR = 'Please specify the correct private key file ' \
'in Client(pem_file_path=...)\nor by setting ' \
'the "YOTI_KEY_FILE_PATH" environment variable'
HTTP_SUPPORTED_METHODS = ['POST', 'PUT', 'PATCH', 'GET', 'DELETE']


class Client(object):
Expand All @@ -36,6 +38,7 @@ def __init__(self, sdk_id=None, pem_file_path=None):
raise RuntimeError(NO_KEY_FILE_SPECIFIED_ERROR)

self.__crypto = Crypto(pem)
self.__endpoint = Endpoint(sdk_id)

@staticmethod
def __read_pem_file(key_file_path, error_source):
Expand All @@ -52,7 +55,9 @@ def __read_pem_file(key_file_path, error_source):
raise RuntimeError('{0}: {1}'.format(error, exception))

def get_activity_details(self, encrypted_request_token):
response = self.__make_request(encrypted_request_token)
http_method = 'GET'
content = None
response = self.__make_activity_details_request(encrypted_request_token, http_method, content)
receipt = json.loads(response.text).get('receipt')

encrypted_data = protobuf.Protobuf().current_user(receipt)
Expand All @@ -69,31 +74,64 @@ def get_activity_details(self, encrypted_request_token):
attribute_list = protobuf.Protobuf().attribute_list(decrypted_data)
return ActivityDetails(receipt, attribute_list)

def __make_request(self, encrypted_request_token):
path = self.__get_request_path(encrypted_request_token)
def perform_aml_check(self, aml_profile):
if aml_profile is None:
raise TypeError("aml_profile not set")

http_method = 'POST'

response = self.__make_aml_check_request(http_method, aml_profile)

return aml.AmlResult(response.text)

def __make_activity_details_request(self, encrypted_request_token, http_method, content):
decrypted_token = self.__crypto.decrypt_token(encrypted_request_token).decode('utf-8')
path = self.__endpoint.get_activity_details_request_path(decrypted_token)
url = yoti_python_sdk.YOTI_API_ENDPOINT + path
headers = self.__get_request_headers(path)
headers = self.__get_request_headers(path, http_method, content)
response = requests.get(url=url, headers=headers)

if not response.status_code == 200:
raise RuntimeError('Unsuccessful Yoti API call: {0}'.format(response.text))

return response

def __get_request_path(self, encrypted_request_token):
token = self.__crypto.decrypt_token(encrypted_request_token).decode('utf-8')
nonce = uuid.uuid4()
timestamp = int(time.time() * 1000)
def __make_aml_check_request(self, http_method, aml_profile):
aml_profile_json = json.dumps(aml_profile.__dict__)
aml_profile_bytes = aml_profile_json.encode()
path = self.__endpoint.get_aml_request_url()
url = yoti_python_sdk.YOTI_API_ENDPOINT + path
headers = self.__get_request_headers(path, http_method, aml_profile_bytes)

return '/profile/{0}?nonce={1}&timestamp={2}&appId={3}'.format(
token, nonce, timestamp, self.sdk_id
)
response = requests.post(url=url, headers=headers, data=aml_profile_bytes)

if not response.status_code == 200:
raise RuntimeError('Unsuccessful Yoti API call: {0}'.format(response.text))

return response

def __get_request_headers(self, path, http_method, content):
request = self.__create_request(http_method, path, content)

def __get_request_headers(self, path):
return {
'X-Yoti-Auth-Key': self.__crypto.get_public_key(),
'X-Yoti-Auth-Digest': self.__crypto.sign('GET&' + path),
'X-Yoti-Auth-Digest': self.__crypto.sign(request),
'X-Yoti-SDK': SDK_IDENTIFIER,
'Content-Type': 'application/json',
'Accept': 'application/json'
}

@staticmethod
def __create_request(http_method, path, content):
if http_method not in HTTP_SUPPORTED_METHODS:
raise ValueError(
"{} is not in the list of supported methods: {}".format(http_method, HTTP_SUPPORTED_METHODS))

request = "{}&{}".format(http_method, path)

if content is not None:
b64encoded = base64.b64encode(content)
b64ascii = b64encoded.decode('ascii')
request += "&" + b64ascii

return request
29 changes: 29 additions & 0 deletions yoti_python_sdk/endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import time
import uuid


class Endpoint(object):
def __init__(self, sdk_id):
self.sdk_id = sdk_id

def get_activity_details_request_path(self, decrypted_request_token):
return '/profile/{0}?nonce={1}&timestamp={2}&appId={3}'.format(
decrypted_request_token,
self.__create_nonce(),
self.__create_timestamp(),
self.sdk_id
)

def get_aml_request_url(self):
return '/aml-check?appId={0}&timestamp={1}&nonce={2}'.format(
self.sdk_id,
self.__create_timestamp(),
self.__create_nonce())

@staticmethod
def __create_nonce():
return uuid.uuid4()

@staticmethod
def __create_timestamp():
return int(time.time() * 1000)
13 changes: 10 additions & 3 deletions yoti_python_sdk/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
PEM_FILE_PATH = join(FIXTURES_DIR, 'sdk-test.pem')
ENCRYPTED_TOKEN_FILE_PATH = join(FIXTURES_DIR, 'encrypted_yoti_token.txt')
AUTH_KEY_FILE_PATH = join(FIXTURES_DIR, 'auth_key.txt')
AUTH_DIGEST_FILE_PATH = join(FIXTURES_DIR, 'auth_digest.txt')
AUTH_DIGEST_GET_FILE_PATH = join(FIXTURES_DIR, 'auth_digest_get.txt')
AUTH_DIGEST_POST_FILE_PATH = join(FIXTURES_DIR, 'auth_digest_post.txt')

YOTI_CLIENT_SDK_ID = '737204aa-d54e-49a4-8bde-26ddbe6d880c'

Expand Down Expand Up @@ -45,6 +46,12 @@ def x_yoti_auth_key():


@pytest.fixture(scope='module')
def x_yoti_auth_digest():
with open(AUTH_DIGEST_FILE_PATH, 'r') as auth_digest_file:
def x_yoti_auth_digest_get():
with open(AUTH_DIGEST_GET_FILE_PATH, 'r') as auth_digest_file:
return auth_digest_file.read()


@pytest.fixture(scope='module')
def x_yoti_auth_digest_post():
with open(AUTH_DIGEST_POST_FILE_PATH, 'r') as auth_digest_file:
return auth_digest_file.read()
1 change: 1 addition & 0 deletions yoti_python_sdk/tests/fixtures/aml_response.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"on_fraud_list":false,"on_pep_list":true,"on_watch_list":false}
1 change: 1 addition & 0 deletions yoti_python_sdk/tests/fixtures/auth_digest_post.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NDem1ujFPEEb4cP/YWp6oGm7KFTVaxyWxLmRSNNH1hJhw3JejHwGKThlWPFdvtmsJuDVxvqTI4zbiNKlo9x9QKlNim0nUO6CFrH/PlwjJ3TAYE0c6BGoZOMp1LyAshl3QSVlNHC6/QuyyJIVJcekoH3Z3UkU8KYUB7xw+WR+OmQFO/U4JqjcqWDArPZtI5EwzFJBGpd3/7OJjpEbDHnAfU44p3yTnB9ySaxbsJ3V7zzBEMHt+b4NOEbKxboTt7hfz5N5wPRHLg2cg6gAFCq1Z7OhHsGdfmwgIqGG5Pmx6qlbBFhR7boRygK0HGDtkm25tbdLEJ9fiX40DFgbWzQF9g==
10 changes: 10 additions & 0 deletions yoti_python_sdk/tests/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ def mocked_requests_get(*args, **kwargs):
return MockResponse(status_code=200, text=response)


def mocked_requests_post_aml_profile(*args, **kwargs):
with open('yoti_python_sdk/tests/fixtures/aml_response.txt', 'r') as f:
response = f.read()
return MockResponse(status_code=200, text=response)


def mocked_requests_post_aml_profile_not_found(*args, **kwargs):
return MockResponse(status_code=404, text="Not Found")


def mocked_requests_get_null_profile(*args, **kwargs):
with open('yoti_python_sdk/tests/fixtures/response_null_profile.txt', 'r') as f:
response = f.read()
Expand Down
Loading

0 comments on commit 496c220

Please sign in to comment.