Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## master

- [#61](https://github.com/castle/castle-python/pull/61) improve headers and ip extractions, improve ip_headers config, add trusted proxies config, added more events to events list

https://github.com/castle/castle-python/pull/61

## 3.0.0 (2020-02-13)

- [#59](https://github.com/castle/castle-python/pull/59) drop requests min version in ci
Expand All @@ -12,7 +16,7 @@

## 2.4.0 (2019-11-20)

- [#53](https://github.com/castle/castle-python/pull/53) Update whitelisting and blacklisting behavior
- [#53](https://github.com/castle/castle-python/pull/53) update whitelisting and blacklisting behavior

## 2.3.1 (2019-04-05)

Expand Down
19 changes: 16 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,16 @@ import and configure the library with your Castle API secret.
configuration.blacklisted = ['HTTP-X-header']

# Castle needs the original IP of the client, not the IP of your proxy or load balancer.
# If that IP is sent as a header you can configure the SDK to extract it automatically.
# Note that format, it should be prefixed with `HTTP`, capitalized and separated by underscores.
configuration.ip_headers = ["HTTP_X_FORWARDED_FOR"]
# we try to fetch proper ip based on X-Forwarded-For, X-Client-Id or Remote-Addr headers in that order
# but sometimes proper ip may be stored in different header or order could be different.
# SDK can extract ip automatically for you, but you must configure which ip_headers you would like to use
configuration.ip_headers = []
# Additionally to make X-Forwarded-For or X-Client-Id work better discovering client ip address,
# and not the address of a reverse proxy server, you can define trusted proxies
# which will help to fetch proper ip from those headers
configuration.trusted_proxies = []
# *Note: proxies list can be provided as an array of regular expressions
# *Note: default always marked as trusty list is here: Castle::Configuration::TRUSTED_PROXIES

Tracking
--------
Expand Down Expand Up @@ -109,6 +116,12 @@ and use it later in a way
client = Client(context)
client.track(options)

## Events

List of Recognized Events can be found [here](https://github.com/castle/castle-python/tree/master/castle/events.py) or in the [docs](https://docs.castle.io/api_reference/#list-of-recognized-events)



Impersonation mode
------------------

Expand Down
3 changes: 2 additions & 1 deletion castle/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from castle.configuration import configuration
from castle.api import Api
from castle.context.default import ContextDefault
Expand All @@ -9,7 +10,7 @@
from castle.exceptions import InternalServerError, RequestError, ImpersonationFailed
from castle.failover_response import FailoverResponse
from castle.utils import timestamp as generate_timestamp
import warnings


class Client(object):

Expand Down
1 change: 0 additions & 1 deletion castle/commands/identify.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from castle.utils import timestamp
from castle.context.merger import ContextMerger
from castle.context.sanitizer import ContextSanitizer
from castle.validators.present import ValidatorsPresent
from castle.validators.not_supported import ValidatorsNotSupported


Expand Down
35 changes: 29 additions & 6 deletions castle/configuration.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from castle.exceptions import ConfigurationError
from castle.headers_formatter import HeadersFormatter

WHITELISTED = [
DEFAULT_WHITELIST = [
"Accept",
"Accept-Charset",
"Accept-Datetime",
Expand All @@ -25,19 +25,31 @@
# 500 milliseconds
REQUEST_TIMEOUT = 500
FAILOVER_STRATEGIES = ['allow', 'deny', 'challenge', 'throw']
HOST = 'api.castle.io'
PORT = 443
URL_PREFIX = '/v1'
FAILOVER_STRATEGY = 'allow'
TRUSTED_PROXIES = [r"""
\A127\.0\.0\.1\Z|
\A(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|
\A::1\Z|\Afd[0-9a-f]{2}:.+|
\Alocalhost\Z|
\Aunix\Z|
\Aunix:"""]


class Configuration(object):
def __init__(self):
self.api_secret = None
self.host = 'api.castle.io'
self.port = 443
self.url_prefix = '/v1'
self.host = HOST
self.port = PORT
self.url_prefix = URL_PREFIX
self.whitelisted = []
self.blacklisted = []
self.request_timeout = REQUEST_TIMEOUT
self.failover_strategy = 'allow'
self.failover_strategy = FAILOVER_STRATEGY
self.ip_headers = []
self.trusted_proxies = []

@property
def api_secret(self):
Expand Down Expand Up @@ -119,7 +131,18 @@ def ip_headers(self):
@ip_headers.setter
def ip_headers(self, value):
if isinstance(value, list):
self.__ip_headers = value
self.__ip_headers = [HeadersFormatter.call(v) for v in value]
else:
raise ConfigurationError

@property
def trusted_proxies(self):
return self.__trusted_proxies

@trusted_proxies.setter
def trusted_proxies(self, value):
if isinstance(value, list):
self.__trusted_proxies = value
else:
raise ConfigurationError

Expand Down
54 changes: 31 additions & 23 deletions castle/context/default.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from castle.version import VERSION
from castle.headers_filter import HeadersFilter
from castle.extractors.client_id import ExtractorsClientId
from castle.extractors.headers import ExtractorsHeaders
from castle.extractors.ip import ExtractorsIp
Expand All @@ -8,39 +9,46 @@

class ContextDefault(object):
def __init__(self, request, cookies):
used_cookies = self._fetch_cookies(request, cookies)
self.client_id = ExtractorsClientId(
request.environ, used_cookies).call()
self.headers = ExtractorsHeaders(request.environ).call()
self.request_ip = ExtractorsIp(request).call()

def _defaults(self):
return {
'client_id': self.client_id,
self.cookies = self._fetch_cookies(request, cookies)
self.pre_headers = HeadersFilter(request).call()

def call(self):
context = dict({
'client_id': self._client_id(),
'active': True,
'origin': 'web',
'headers': self.headers,
'ip': self.request_ip,
'library': {'name': 'castle-python', 'version': __version__}
}
'headers': self._headers(),
'ip': self._ip(),
'library': {
'name': 'castle-python',
'version': __version__
}
})
context.update(self._optional_defaults())

def _defaults_extra(self):
context = dict()
if 'Accept-Language' in self.headers:
context['locale'] = self.headers['Accept-Language']
if 'User-Agent' in self.headers:
context['user_agent'] = self.headers['User-Agent']
return context

def call(self):
context = dict(self._defaults())
context.update(self._defaults_extra())
def _ip(self):
return ExtractorsIp(self.pre_headers).call()

def _client_id(self):
return ExtractorsClientId(self.pre_headers, self.cookies).call()

def _headers(self):
return ExtractorsHeaders(self.pre_headers).call()

def _optional_defaults(self):
context = dict()
if 'Accept-Language' in self.pre_headers:
context['locale'] = self.pre_headers.get('Accept-Language')
if 'User-Agent' in self.pre_headers:
context['user_agent'] = self.pre_headers.get('User-Agent')
return context

@staticmethod
def _fetch_cookies(request, cookies):
if cookies:
return cookies
elif hasattr(request, 'COOKIES') and request.COOKIES:
if hasattr(request, 'COOKIES') and request.COOKIES:
return request.COOKIES
return None
8 changes: 4 additions & 4 deletions castle/context/sanitizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ def call(cls, context):
return sanitized_context
return dict()

@classmethod
def _sanitize_active_mode(cls, context):
@staticmethod
def _sanitize_active_mode(context):
if context is None:
return None
elif 'active' not in context:
if 'active' not in context:
return context
elif isinstance(context.get('active'), bool):
if isinstance(context.get('active'), bool):
return context

context_copy = context.copy()
Expand Down
5 changes: 5 additions & 0 deletions castle/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@
CHALLENGE_SUCCEEDED = '$challenge.succeeded'
# Record when additional verification failed.
CHALLENGE_FAILED = '$challenge.failed'
# Record when a user attempts an in-app transaction, such as a purchase or withdrawal.
TRANSACTION_ATTEMPTED = '$transaction.attempted'
# Record when a user session is extended, or use any time you want
# to re-authenticate a user mid-session.
SESSION_EXTENDED = '$session.extended'
10 changes: 10 additions & 0 deletions castle/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,46 @@ class CastleError(Exception):
class RequestError(CastleError):
pass


class SecurityError(CastleError):
pass


class ConfigurationError(CastleError):
pass


class ApiError(CastleError):
pass


class InvalidParametersError(ApiError):
pass


class BadRequestError(ApiError):
pass


class UnauthorizedError(ApiError):
pass


class UserUnauthorizedError(ApiError):
pass


class ForbiddenError(ApiError):
pass


class NotFoundError(ApiError):
pass


class InternalServerError(ApiError):
pass


class ImpersonationFailed(ApiError):
pass
6 changes: 3 additions & 3 deletions castle/extractors/client_id.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class ExtractorsClientId(object):
def __init__(self, environ, cookies=None):
self.environ = environ
def __init__(self, headers, cookies=None):
self.headers = headers
self.cookies = cookies or dict()

def call(self):
return self.environ.get('HTTP_X_CASTLE_CLIENT_ID', self.cookies.get('__cid', ''))
return self.headers.get('X-Castle-Client-Id', self.cookies.get('__cid', ''))
43 changes: 23 additions & 20 deletions castle/extractors/headers.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
from castle.headers_formatter import HeadersFormatter
from castle.configuration import configuration

DEFAULT_BLACKLIST = ['Cookie', 'Authorization']
DEFAULT_WHITELIST = ['User-Agent']
ALWAYS_BLACKLISTED = ['Cookie', 'Authorization']
ALWAYS_WHITELISTED = ['User-Agent']


class ExtractorsHeaders(object):
def __init__(self, environ):
self.environ = environ
self.formatter = HeadersFormatter
def __init__(self, headers):
self.headers = headers
self.no_whitelist = len(configuration.whitelisted) == 0

def call(self):
headers = dict()
has_whitelist = len(configuration.whitelisted) > 0

for key, value in self.environ.items():
name = self.formatter.call(key)
if has_whitelist and name not in configuration.whitelisted and name not in DEFAULT_WHITELIST:
headers[name] = True
continue
if name in configuration.blacklisted or name in DEFAULT_BLACKLIST:
headers[name] = True
continue
headers[name] = value

return headers
result = dict()

for name, value in self.headers.items():
result[name] = self._header_value(name, value)

return result

def _header_value(self, name, value):
if name in ALWAYS_BLACKLISTED:
return True
if name in ALWAYS_WHITELISTED:
return value
if name in configuration.blacklisted:
return True
if self.no_whitelist or (name in configuration.whitelisted):
return value

return True
Loading