Skip to content

Commit 496c220

Browse files
authored
Merge pull request #22 from getyoti/AML
[SDK-250]: AML Requests
2 parents 88da24c + ffd33da commit 496c220

File tree

15 files changed

+448
-37
lines changed

15 files changed

+448
-37
lines changed

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Entry point explanation
2525
1) [Handling Users](#handling-users) -
2626
How to manage users
2727

28+
1) [AML Integration](#aml-integration) -
29+
How to integrate with Yoti's AML (Anti Money Laundering) service
30+
2831
1) [Running the examples](#running-the-examples) -
2932
How to retrieve a Yoti profile using the token
3033

@@ -128,6 +131,63 @@ gender = user_profile.get('gender')
128131
nationality = user_profile.get('nationality')
129132
```
130133

134+
## AML Integration
135+
136+
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 [sdksupport@yoti.com](mailto:sdksupport@yoti.com) for more information.
137+
138+
Yoti will provide a boolean result on the following checks:
139+
140+
* PEP list - Verify against Politically Exposed Persons list
141+
* Fraud list - Verify against US Social Security Administration Fraud (SSN Fraud) list
142+
* Watch list - Verify against watch lists from the Office of Foreign Assets Control
143+
144+
To use this functionality you must ensure your application is assigned to your Organisation in the Yoti Dashboard - please see here for further information.
145+
146+
For the AML check you will need to provide the following:
147+
148+
* 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)
149+
* Given name(s)
150+
* Family name
151+
* Data that must be collected from the user:
152+
* Country of residence (must be an ISO 3166 3-letter code)
153+
* Social Security Number (US citizens only)
154+
* Postcode/Zip code (US citizens only)
155+
156+
### Consent
157+
158+
Performing an AML check on a person *requires* their consent.
159+
**You must ensure you have user consent *before* using this service.**
160+
161+
### Code Example
162+
163+
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.
164+
165+
```python
166+
from yoti_python_sdk import aml
167+
from yoti_python_sdk import Client
168+
169+
client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH)
170+
given_names = "Edward Richard George"
171+
family_name = "Heath"
172+
173+
aml_address = aml.AmlAddress(country="GBR")
174+
aml_profile = aml.AmlProfile(
175+
given_names,
176+
family_name,
177+
aml_address
178+
)
179+
180+
181+
aml_result = client.perform_aml_check(aml_profile)
182+
183+
print("AML Result for {1} {2}:", given_names, family_name)
184+
print("On PEP list: " + str(aml_result.on_pep_list))
185+
print("On fraud list: " + str(aml_result.on_fraud_list))
186+
print("On watchlist: " + str(aml_result.on_watch_list))
187+
```
188+
189+
Additionally an [example AML application](/examples/aml/app.py) is provided in the examples folder.
190+
131191
## Running the Examples
132192

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

229+
#### AML Example
230+
231+
1. Change directories to the AML folder: `cd examples/aml`
232+
1. Install requirements with `pip install -r requirements.txt`
233+
1. Run: `python app.py`
234+
169235
### Plugins ###
170236

171237
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.

examples/aml/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
YOTI_CLIENT_SDK_ID=yourClientSdkId
2+
YOTI_KEY_FILE_PATH=yourKeyFilePath

examples/aml/app.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import sys
2+
from os import environ
3+
from os.path import join, dirname
4+
5+
from dotenv import load_dotenv
6+
7+
from yoti_python_sdk import Client
8+
from yoti_python_sdk import aml
9+
10+
dotenv_path = join(dirname(__file__), '.env')
11+
load_dotenv(dotenv_path)
12+
13+
YOTI_CLIENT_SDK_ID = environ.get('YOTI_CLIENT_SDK_ID')
14+
YOTI_KEY_FILE_PATH = environ.get('YOTI_KEY_FILE_PATH')
15+
16+
17+
# The following exits cleanly on Ctrl-C,
18+
# while treating other exceptions as before.
19+
def cli_exception(exception_type, value, tb):
20+
if not issubclass(exception_type, KeyboardInterrupt):
21+
sys.__excepthook__(exception_type, value, tb)
22+
23+
24+
given_names = "Edward Richard George"
25+
family_name = "Heath"
26+
27+
aml_address = aml.AmlAddress(country="GBR")
28+
aml_profile = aml.AmlProfile(
29+
given_names,
30+
family_name,
31+
aml_address
32+
)
33+
34+
if sys.stdin.isatty():
35+
sys.excepthook = cli_exception
36+
37+
client = Client(YOTI_CLIENT_SDK_ID, YOTI_KEY_FILE_PATH)
38+
39+
aml_result = client.perform_aml_check(aml_profile)
40+
print("AML Result for {0} {1}:".format(given_names, family_name))
41+
print("On PEP list: " + str(aml_result.on_pep_list))
42+
print("On fraud list: " + str(aml_result.on_fraud_list))
43+
print("On watchlist: " + str(aml_result.on_watch_list))

examples/aml/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
yoti
2+
python-dotenv>=0.7.1

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
from setuptools import setup, find_packages
33

4-
VERSION = '2.0.4'
4+
VERSION = '2.1.0'
55
long_description = 'This package contains the tools you need to quickly ' \
66
'integrate your Python back-end with Yoti, so that your ' \
77
'users can share their identity details with your ' \

yoti_python_sdk/aml.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import json
2+
3+
4+
class AmlResult:
5+
def __init__(self, response_text):
6+
if not response_text:
7+
raise ValueError("AML Response is not valid")
8+
9+
try:
10+
self.on_pep_list = json.loads(response_text).get('on_pep_list')
11+
self.on_fraud_list = json.loads(response_text).get('on_fraud_list')
12+
self.on_watch_list = json.loads(response_text).get('on_watch_list')
13+
14+
except (AttributeError, IOError, TypeError, OSError) as exc:
15+
error = 'Could not parse AML result from response: "{0}"'.format(response_text)
16+
exception = '{0}: {1}'.format(type(exc).__name__, exc)
17+
raise RuntimeError('{0}: {1}'.format(error, exception))
18+
19+
self.__check_for_none_values(self.on_pep_list)
20+
self.__check_for_none_values(self.on_fraud_list)
21+
self.__check_for_none_values(self.on_watch_list)
22+
23+
@staticmethod
24+
def __check_for_none_values(arg):
25+
if arg is None:
26+
raise TypeError(str.format("{0} argument was unable to be retrieved from the response", arg))
27+
28+
def __iter__(self):
29+
yield 'on_pep_list', self.on_pep_list
30+
yield 'on_fraud_list', self.on_fraud_list
31+
yield 'on_watch_list', self.on_watch_list
32+
33+
34+
class AmlAddress:
35+
def __init__(self, country, postcode=None):
36+
self.country = country
37+
self.post_code = postcode
38+
39+
40+
class AmlProfile:
41+
def __init__(self, given_names, family_name, address, ssn=None):
42+
self.given_names = given_names
43+
self.family_name = family_name
44+
self.address = address.__dict__
45+
self.ssn = ssn

yoti_python_sdk/client.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,25 @@
22
from __future__ import unicode_literals
33

44
import json
5-
import time
6-
import uuid
75
from os import environ
86
from os.path import isfile, expanduser
97

108
import requests
9+
from cryptography.fernet import base64
1110
from past.builtins import basestring
1211

1312
import yoti_python_sdk
14-
from .config import SDK_IDENTIFIER
13+
from yoti_python_sdk import aml
1514
from yoti_python_sdk.activity_details import ActivityDetails
1615
from yoti_python_sdk.crypto import Crypto
16+
from yoti_python_sdk.endpoint import Endpoint
1717
from yoti_python_sdk.protobuf.v1 import protobuf
18+
from .config import SDK_IDENTIFIER
1819

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

2325

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

3840
self.__crypto = Crypto(pem)
41+
self.__endpoint = Endpoint(sdk_id)
3942

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

5457
def get_activity_details(self, encrypted_request_token):
55-
response = self.__make_request(encrypted_request_token)
58+
http_method = 'GET'
59+
content = None
60+
response = self.__make_activity_details_request(encrypted_request_token, http_method, content)
5661
receipt = json.loads(response.text).get('receipt')
5762

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

72-
def __make_request(self, encrypted_request_token):
73-
path = self.__get_request_path(encrypted_request_token)
77+
def perform_aml_check(self, aml_profile):
78+
if aml_profile is None:
79+
raise TypeError("aml_profile not set")
80+
81+
http_method = 'POST'
82+
83+
response = self.__make_aml_check_request(http_method, aml_profile)
84+
85+
return aml.AmlResult(response.text)
86+
87+
def __make_activity_details_request(self, encrypted_request_token, http_method, content):
88+
decrypted_token = self.__crypto.decrypt_token(encrypted_request_token).decode('utf-8')
89+
path = self.__endpoint.get_activity_details_request_path(decrypted_token)
7490
url = yoti_python_sdk.YOTI_API_ENDPOINT + path
75-
headers = self.__get_request_headers(path)
91+
headers = self.__get_request_headers(path, http_method, content)
7692
response = requests.get(url=url, headers=headers)
7793

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

8197
return response
8298

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

88-
return '/profile/{0}?nonce={1}&timestamp={2}&appId={3}'.format(
89-
token, nonce, timestamp, self.sdk_id
90-
)
106+
response = requests.post(url=url, headers=headers, data=aml_profile_bytes)
107+
108+
if not response.status_code == 200:
109+
raise RuntimeError('Unsuccessful Yoti API call: {0}'.format(response.text))
110+
111+
return response
112+
113+
def __get_request_headers(self, path, http_method, content):
114+
request = self.__create_request(http_method, path, content)
91115

92-
def __get_request_headers(self, path):
93116
return {
94117
'X-Yoti-Auth-Key': self.__crypto.get_public_key(),
95-
'X-Yoti-Auth-Digest': self.__crypto.sign('GET&' + path),
118+
'X-Yoti-Auth-Digest': self.__crypto.sign(request),
96119
'X-Yoti-SDK': SDK_IDENTIFIER,
97120
'Content-Type': 'application/json',
98121
'Accept': 'application/json'
99122
}
123+
124+
@staticmethod
125+
def __create_request(http_method, path, content):
126+
if http_method not in HTTP_SUPPORTED_METHODS:
127+
raise ValueError(
128+
"{} is not in the list of supported methods: {}".format(http_method, HTTP_SUPPORTED_METHODS))
129+
130+
request = "{}&{}".format(http_method, path)
131+
132+
if content is not None:
133+
b64encoded = base64.b64encode(content)
134+
b64ascii = b64encoded.decode('ascii')
135+
request += "&" + b64ascii
136+
137+
return request

yoti_python_sdk/endpoint.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import time
2+
import uuid
3+
4+
5+
class Endpoint(object):
6+
def __init__(self, sdk_id):
7+
self.sdk_id = sdk_id
8+
9+
def get_activity_details_request_path(self, decrypted_request_token):
10+
return '/profile/{0}?nonce={1}&timestamp={2}&appId={3}'.format(
11+
decrypted_request_token,
12+
self.__create_nonce(),
13+
self.__create_timestamp(),
14+
self.sdk_id
15+
)
16+
17+
def get_aml_request_url(self):
18+
return '/aml-check?appId={0}&timestamp={1}&nonce={2}'.format(
19+
self.sdk_id,
20+
self.__create_timestamp(),
21+
self.__create_nonce())
22+
23+
@staticmethod
24+
def __create_nonce():
25+
return uuid.uuid4()
26+
27+
@staticmethod
28+
def __create_timestamp():
29+
return int(time.time() * 1000)

yoti_python_sdk/tests/conftest.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
PEM_FILE_PATH = join(FIXTURES_DIR, 'sdk-test.pem')
1111
ENCRYPTED_TOKEN_FILE_PATH = join(FIXTURES_DIR, 'encrypted_yoti_token.txt')
1212
AUTH_KEY_FILE_PATH = join(FIXTURES_DIR, 'auth_key.txt')
13-
AUTH_DIGEST_FILE_PATH = join(FIXTURES_DIR, 'auth_digest.txt')
13+
AUTH_DIGEST_GET_FILE_PATH = join(FIXTURES_DIR, 'auth_digest_get.txt')
14+
AUTH_DIGEST_POST_FILE_PATH = join(FIXTURES_DIR, 'auth_digest_post.txt')
1415

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

@@ -45,6 +46,12 @@ def x_yoti_auth_key():
4546

4647

4748
@pytest.fixture(scope='module')
48-
def x_yoti_auth_digest():
49-
with open(AUTH_DIGEST_FILE_PATH, 'r') as auth_digest_file:
49+
def x_yoti_auth_digest_get():
50+
with open(AUTH_DIGEST_GET_FILE_PATH, 'r') as auth_digest_file:
51+
return auth_digest_file.read()
52+
53+
54+
@pytest.fixture(scope='module')
55+
def x_yoti_auth_digest_post():
56+
with open(AUTH_DIGEST_POST_FILE_PATH, 'r') as auth_digest_file:
5057
return auth_digest_file.read()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"on_fraud_list":false,"on_pep_list":true,"on_watch_list":false}

0 commit comments

Comments
 (0)