Skip to content

Commit

Permalink
use Vonage JWT generator instead of PyJWT for requests (#262)
Browse files Browse the repository at this point in the history
* use Vonage JWT generator instead of PyJWT for requests

* adding new test for multiple workflows in verify v2 (#263)

* updating the changelog for new release

* Bump version: 3.5.1 → 3.5.2

* updating the changelog for new release

* internal refactoring to check for new Client._jwt_client object

* removing superseded check

* removing potentially misleading message
  • Loading branch information
maxkahan committed Jun 6, 2023
1 parent 896f990 commit 867d386
Show file tree
Hide file tree
Showing 16 changed files with 163 additions and 100 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 3.5.1
current_version = 3.5.2
commit = True
tag = False

Expand Down
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 3.5.2
- Using the [Vonage JWT Generator](https://github.com/Vonage/vonage-python-jwt) instead of `PyJWT` for generating JWTs.
- Other internal refactoring and enhancements

# 3.5.1
- Updating the internal use of the `fraud_check` parameter in the Vonage Verify V2 API

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ coverage:
coverage html

test:
pytest -v
pytest -vv --disable-warnings

clean:
rm -rf dist build
Expand Down
23 changes: 10 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

[![PyPI version](https://badge.fury.io/py/vonage.svg)](https://badge.fury.io/py/vonage)
[![Build Status](https://github.com/Vonage/vonage-python-sdk/workflows/Build/badge.svg)](https://github.com/Vonage/vonage-python-sdk/actions)
[![codecov](https://codecov.io/gh/Vonage/vonage-python-sdk/branch/master/graph/badge.svg)](https://codecov.io/gh/Vonage/vonage-python-sdk)
[![Python versions supported](https://img.shields.io/pypi/pyversions/vonage.svg)](https://pypi.python.org/pypi/vonage)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)

Expand Down Expand Up @@ -728,34 +727,32 @@ your account before you can validate webhook signatures.

## JWT parameters

By default, the library generates short-lived tokens for JWT authentication.
By default, the library generates 15-minute tokens for JWT authentication.

Use the auth method to specify parameters for a longer life token or to
specify a different token identifier:
Use the `auth` method of the client class to specify custom parameters:

```python
client.auth(nbf=nbf, exp=exp, jti=jti)
# OR
client.auth({'nbf': nbf, 'exp': exp, 'jti': jti})
```

## Overriding API Attributes

In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client for Sms/Voice Classes. This means that if you make a change on a client instance this will be available for the Sms class.
In order to rewrite/get the value of variables used across all the Vonage classes Python uses `Call by Object Reference` that allows you to create a single client to use with all API classes.

An example using setters/getters with `Object references`:

```python
from vonage import Client, Sms
from vonage import Client

#Defines the client
# Define the client
client = Client(key='YOUR_API_KEY', secret='YOUR_API_SECRET')
print(client.host()) # using getter for host -- value returned: rest.nexmo.com

#Define the sms instance
sms = Sms(client)

#Change the value in client
client.host('mio.nexmo.com') #Change host to mio.nexmo.com - this change will be available for sms

# Change the value in client
client.host('mio.nexmo.com') # Change host to mio.nexmo.com - this change will be available for sms
client.sms.send_message(params) # Sends an SMS to the host above
```

### Overriding API Host / Host Attributes
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

setup(
name="vonage",
version="3.5.1",
version="3.5.2",
description="Vonage Server SDK for Python",
long_description=long_description,
long_description_content_type="text/markdown",
Expand All @@ -23,8 +23,8 @@
package_dir={"": "src"},
platforms=["any"],
install_requires=[
"vonage-jwt>=1.0.0",
"requests>=2.4.2",
"PyJWT[crypto]>=1.6.4",
"pytz>=2018.5",
"Deprecated",
"pydantic>=1.10.2",
Expand Down
2 changes: 1 addition & 1 deletion src/vonage/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .client import *
from .ncco_builder.ncco import *

__version__ = "3.5.1"
__version__ = "3.5.2"
14 changes: 14 additions & 0 deletions src/vonage/_internal.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from vonage import Client


def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"):
"""
Utility function to convert datetime values to strings.
Expand All @@ -12,3 +19,10 @@ def _format_date_param(params, key, format="%Y-%m-%d %H:%M:%S"):
param = params[key]
if hasattr(param, "strftime"):
params[key] = param.strftime(format)


def set_auth_type(client: Client) -> str:
if hasattr(client, '_jwt_client'):
return 'jwt'
else:
return 'header'
41 changes: 11 additions & 30 deletions src/vonage/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import vonage
from vonage_jwt.jwt import JwtClient

from .account import Account
from .application import ApplicationV2, Application
Expand All @@ -20,11 +21,8 @@
import base64
import hashlib
import hmac
import jwt
import os
import time
import re
from uuid import uuid4

from requests import Response
from requests.adapters import HTTPAdapter
Expand Down Expand Up @@ -95,16 +93,10 @@ def __init__(
if self.signature_method in {"md5", "sha1", "sha256", "sha512"}:
self.signature_method = getattr(hashlib, signature_method)

self._jwt_auth_params = {}

if private_key is not None and application_id is not None:
self._application_id = application_id
self._private_key = private_key

if isinstance(self._private_key, string_types) and re.search("[.][a-zA-Z0-9_]+$", self._private_key):
with open(self._private_key, "rb") as key_file:
self._private_key = key_file.read()
self._jwt_client = JwtClient(application_id, private_key)

self._jwt_claims = {}
self._host = "rest.nexmo.com"
self._api_host = "api.nexmo.com"

Expand Down Expand Up @@ -149,7 +141,7 @@ def api_host(self, value=None):
self._api_host = value

def auth(self, params=None, **kwargs):
self._jwt_auth_params = params or kwargs
self._jwt_claims = params or kwargs

def check_signature(self, params):
params = dict(params)
Expand Down Expand Up @@ -275,7 +267,7 @@ def delete(self, host, request_uri, auth_type=None):
def parse(self, host, response: Response):
logger.debug(f"Response headers {repr(response.headers)}")
if response.status_code == 401:
raise AuthenticationError("Authentication failed. Check you're using a valid authentication method.")
raise AuthenticationError("Authentication failed.")
elif response.status_code == 204:
return None
elif 200 <= response.status_code < 300:
Expand Down Expand Up @@ -312,21 +304,10 @@ def parse(self, host, response: Response):
raise ServerError(message)

def _add_jwt_to_request_headers(self):
return dict(self.headers, Authorization=b"Bearer " + self._generate_application_jwt())

return dict(
self.headers,
Authorization=b"Bearer " + self._generate_application_jwt()
)

def _generate_application_jwt(self):
iat = int(time.time())

payload = dict(self._jwt_auth_params)
payload.setdefault("application_id", self._application_id)
payload.setdefault("iat", iat)
payload.setdefault("exp", iat + 60)
payload.setdefault("jti", str(uuid4()))

token = jwt.encode(payload, self._private_key, algorithm="RS256")

# If token is string transform it to byte type
if type(token) is str:
token = bytes(token, 'utf-8')

return token
return self._jwt_client.generate_application_jwt(self._jwt_claims)
33 changes: 24 additions & 9 deletions src/vonage/messages.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ._internal import set_auth_type
from .errors import MessagesError

import re
Expand All @@ -15,13 +16,11 @@ class Messages:

def __init__(self, client):
self._client = client
self._auth_type = 'jwt'
self._auth_type = set_auth_type(self._client)

def send_message(self, params: dict):
self.validate_send_message_input(params)

if not hasattr(self._client, '_application_id'):
self._auth_type = 'header'
return self._client.post(
self._client.api_host(),
"/v1/messages",
Expand All @@ -40,7 +39,9 @@ def validate_send_message_input(self, params):

def _check_input_is_dict(self, params):
if type(params) is not dict:
raise MessagesError('Parameters to the send_message method must be specified as a dictionary.')
raise MessagesError(
'Parameters to the send_message method must be specified as a dictionary.'
)

def _check_valid_message_channel(self, params):
if params['channel'] not in Messages.valid_message_channels:
Expand All @@ -64,9 +65,13 @@ def _check_valid_recipient(self, params):
if not isinstance(params['to'], str):
raise MessagesError(f'Message recipient ("to={params["to"]}") not in a valid format.')
elif params['channel'] != 'messenger' and not re.search(r'^[1-9]\d{6,14}$', params['to']):
raise MessagesError(f'Message recipient number ("to={params["to"]}") not in a valid format.')
raise MessagesError(
f'Message recipient number ("to={params["to"]}") not in a valid format.'
)
elif params['channel'] == 'messenger' and not 0 < len(params['to']) < 50:
raise MessagesError(f'Message recipient ID ("to={params["to"]}") not in a valid format.')
raise MessagesError(
f'Message recipient ID ("to={params["to"]}") not in a valid format.'
)

def _check_valid_sender(self, params):
if not isinstance(params['from'], str) or params['from'] == "":
Expand All @@ -76,8 +81,16 @@ def _check_valid_sender(self, params):

def _channel_specific_checks(self, params):
if (
(params['channel'] == 'whatsapp' and params['message_type'] == 'template' and 'whatsapp' not in params)
or (params['channel'] == 'whatsapp' and params['message_type'] == 'sticker' and 'sticker' not in params)
(
params['channel'] == 'whatsapp'
and params['message_type'] == 'template'
and 'whatsapp' not in params
)
or (
params['channel'] == 'whatsapp'
and params['message_type'] == 'sticker'
and 'sticker' not in params
)
or (params['channel'] == 'viber_service' and 'viber_service' not in params)
):
raise MessagesError(
Expand All @@ -95,4 +108,6 @@ def _check_valid_client_ref(self, params):

def _check_valid_whatsapp_sticker(self, sticker):
if ('id' not in sticker and 'url' not in sticker) or ('id' in sticker and 'url' in sticker):
raise MessagesError('Must specify one, and only one, of "id" or "url" in the "sticker" field.')
raise MessagesError(
'Must specify one, and only one, of "id" or "url" in the "sticker" field.'
)
32 changes: 18 additions & 14 deletions src/vonage/verify2.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from vonage import Client

from pydantic import BaseModel, ValidationError, validator, conint, constr
from typing import Optional, List

import copy
import re

from ._internal import set_auth_type
from .errors import Verify2Error


Expand All @@ -17,9 +24,9 @@ class Verify2:
'silent_auth',
]

def __init__(self, client):
def __init__(self, client: Client):
self._client = client
self._auth_type = 'jwt'
self._auth_type = set_auth_type(self._client)

def new_request(self, params: dict):
self._remove_unnecessary_fraud_check(params)
Expand All @@ -29,9 +36,6 @@ def new_request(self, params: dict):
except (ValidationError, Verify2Error) as err:
raise err

if not hasattr(self._client, '_application_id'):
self._auth_type = 'header'

return self._client.post(
self._client.api_host(),
'/v2/verify',
Expand All @@ -42,9 +46,6 @@ def new_request(self, params: dict):
def check_code(self, request_id: str, code: str):
params = {'code': str(code)}

if not hasattr(self._client, '_application_id'):
self._auth_type = 'header'

return self._client.post(
self._client.api_host(),
f'/v2/verify/{request_id}',
Expand All @@ -53,9 +54,6 @@ def check_code(self, request_id: str, code: str):
)

def cancel_verification(self, request_id: str):
if not hasattr(self._client, '_application_id'):
self._auth_type = 'header'

return self._client.delete(
self._client.api_host(),
f'/v2/verify/{request_id}',
Expand All @@ -74,7 +72,9 @@ class VerifyRequest(BaseModel):
client_ref: Optional[str]
code_length: Optional[conint(ge=4, le=10)]
fraud_check: Optional[bool]
code: Optional[constr(min_length=4, max_length=10, regex='^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$')]
code: Optional[
constr(min_length=4, max_length=10, regex='^(?=[a-zA-Z0-9]{4,10}$)[a-zA-Z0-9]*$')
]

@validator('workflow')
def check_valid_workflow(cls, v):
Expand All @@ -95,7 +95,9 @@ def _check_valid_recipient(workflow):
if 'to' not in workflow or (
workflow['channel'] != 'email' and not re.search(r'^[1-9]\d{6,14}$', workflow['to'])
):
raise Verify2Error(f'You must specify a valid "to" value for channel "{workflow["channel"]}"')
raise Verify2Error(
f'You must specify a valid "to" value for channel "{workflow["channel"]}"'
)

def _check_app_hash(workflow):
if workflow['channel'] == 'sms' and 'app_hash' in workflow:
Expand All @@ -105,7 +107,9 @@ def _check_app_hash(workflow):
it must be passed as a string and contain exactly 11 characters.'
)
elif workflow['channel'] != 'sms' and 'app_hash' in workflow:
raise Verify2Error('Cannot specify a value for "app_hash" unless using SMS for authentication.')
raise Verify2Error(
'Cannot specify a value for "app_hash" unless using SMS for authentication.'
)

def _check_whatsapp_sender(workflow):
if not re.search(r'^[1-9]\d{6,14}$', workflow['from']):
Expand Down
Loading

0 comments on commit 867d386

Please sign in to comment.