Skip to content

Commit

Permalink
Merge pull request #30 from Qabel/m/phn
Browse files Browse the repository at this point in the history
phone number handling
  • Loading branch information
audax committed Sep 18, 2016
2 parents ec3f315 + e26dc9e commit 6e97088
Show file tree
Hide file tree
Showing 11 changed files with 444 additions and 24 deletions.
13 changes: 13 additions & 0 deletions defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ qabel:
ALLOWED_HOSTS:
- '*'

# All REST operations on the index server can be made to require authorization.
# The old behaviour is to not require it.
REQUIRE_AUTHORIZATION: False
# If REQUIRE_AUTHORIZATION is set, then the APISECRET and URL must also be set.
# ACCOUNTING_APISECRET: '1234'
# ACCOUNTING_URL: 'https://accs04.shard5.hypercloud.in.4d.example.net/'

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY: '=tmcici-p92_^_jih9ud11#+wb7*i21firlrtcqh$p+d7o*49@'

Expand Down Expand Up @@ -50,6 +57,12 @@ qabel:
# This setting doesn't matter for the dummy backends.
SENDSMS_DEFAULT_FROM_PHONE: '+15005550006'

# Configure countries allowed for phone number registration. Check the applicable charges before
# adding a country code.
# https://en.wikipedia.org/wiki/List_of_country_calling_codes
SMS_ALLOWED_COUNTRIES:
- 49

uwsgi:
processes: 2
http-socket: :9698
10 changes: 6 additions & 4 deletions index_service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
from django.db import models
from django.utils import timezone

from django_prometheus.models import ExportModelOperationsMixin

from .utils import short_id


class Identity(models.Model):
class Identity(ExportModelOperationsMixin('Identity'), models.Model):
"""
An identity, composed of the public key, drop URL and alias.
Expand All @@ -33,7 +35,7 @@ def __repr__(self):
return u'alias: {0} public_key: {1}'.format(self.alias, repr(self.public_key))


class Entry(models.Model):
class Entry(ExportModelOperationsMixin('Entry'), models.Model):
"""
An Entry connects a piece of private data (email, phone, ...) to an identity.
Expand All @@ -57,7 +59,7 @@ class Meta:
index_together = ('field', 'value')


class PendingUpdateRequest(models.Model):
class PendingUpdateRequest(ExportModelOperationsMixin('PendingUpdateRequest'), models.Model):
"""
Pending update request: When additional user-asynchronous authorization is required a request has
to be stored in the database (and all verifications have to complete) before it can be executed.
Expand Down Expand Up @@ -94,7 +96,7 @@ def is_expired(self):
return False


class PendingVerification(models.Model):
class PendingVerification(ExportModelOperationsMixin('PendingVerification'), models.Model):
"""
A pending verification, e.g. a confirmation mail or SMS that has not been acted upon yet.
"""
Expand Down
29 changes: 28 additions & 1 deletion index_service/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
from django.conf import settings

from rest_framework import serializers
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework.validators import UniqueTogetherValidator

from index_service.logic import UpdateRequest, UpdateItem
from index_service.models import Identity, Entry
from index_service.crypto import decode_key
from index_service.utils import normalize_phone_number_localised, parse_phone_number, get_current_cc


FIELD_SCRUBBERS = {
'phone': normalize_phone_number_localised
}


def scrub_field(field, value):
scrubber = FIELD_SCRUBBERS.get(field)
if scrubber:
return scrubber(value)
else:
return value


class IdentitySerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -45,6 +60,18 @@ class UpdateItemSerializer(serializers.Serializer):
field = serializers.ChoiceField(Entry.FIELDS)
value = serializers.CharField()

def validate(self, data):
field = data['field']
try:
data['value'] = scrub_field(field, data['value'])
except ValueError as exc:
raise serializers.ValidationError('Scrubber for %r failed: %s' % (field, exc)) from exc
if field == 'phone':
country_code = parse_phone_number(data['value'], get_current_cc()).country_code
if country_code not in settings.SMS_ALLOWED_COUNTRIES:
raise serializers.ValidationError('This country code (+%d) is not available at this time.' % country_code)
return data

def create(self, validated_data):
return UpdateItem(action=validated_data['action'],
field=validated_data['field'],
Expand Down
120 changes: 120 additions & 0 deletions index_service/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,129 @@

import functools
import logging
import random

from django.conf import settings
from django.http import JsonResponse
from django.utils import translation

import requests

import phonenumbers


def short_id(length):
CHARSET = 'CDEHKMPRSTUWXY2458'
get_character = CHARSET.__getitem__
random_numbers = (random.randrange(len(CHARSET)) for i in range(length))
return ''.join(map(get_character, random_numbers))


def parse_phone_number(phone_number, fallback_cc):
try:
return phonenumbers.parse(phone_number, region=fallback_cc)
except phonenumbers.NumberParseException as exc:
raise ValueError('Unable to parse phone number %r: %s' % (phone_number, exc)) from exc


def normalize_phone_number(phone_number, fallback_cc):
"""
Return (str) *phone_number* (str) normalized to ITU-T E.164.
Apply fallback_CC (str/None) country code, if necessary.
"""
phone_number = parse_phone_number(phone_number, fallback_cc)
return phonenumbers.format_number(phone_number, phonenumbers.PhoneNumberFormat.E164)


def get_current_cc(language=None):
language = language or translation.get_language()
return language.split('-')[-1].upper()


def normalize_phone_number_localised(phone_number):
return normalize_phone_number(phone_number, get_current_cc())


logger = logging.getLogger('index_service.utils.authorization')


def check_authorization(request):
if not settings.REQUIRE_AUTHORIZATION:
reason = 'No authorization required, none checked.'
logger.info('Request is authorized: %s', reason)
return True, reason
auth_header = request.META.get('HTTP_AUTHORIZATION')
if not auth_header:
reason = 'No authorization supplied.'
logger.warning('Request is unauthorized: %s', reason)
return False, reason
acked, reason = AccountingAuthorization().check(auth_header)
if not acked:
logger.warning('Request is unauthorized: %s', reason)
return False, reason
logger.info('Request is authorized: %s', reason)
return True, reason


class AccountingAuthorization:
def endpoint_url(self):
return settings.ACCOUNTING_URL + '/api/v0/internal/user/'

def headers(self):
return {
'APISECRET': settings.ACCOUNTING_APISECRET,
}

def check_response(self, response):
code = response.status_code
if code == 404:
return False, 'User not found.'
if code != 200:
logger.warning('Failed accounting request was: status=%d, response=\n%s', code, response.content)
try:
reason = response.json()['error']
except:
reason = 'Unknown.'
return False, reason

try:
json = response.json()
except ValueError:
logger.exception('Invalid JSON in reponse from accounting server: %s', response.content)
return False, 'Invalid response.'

try:
active = json['active']
user_id = json['user_id']
except KeyError:
logger.exception('Unable to parse accounting server response: %s', json)
return False, 'Invalid response.'

logger.info('Acknowledged token of user ID %s (active=%s)', user_id, active)
if not active:
return False, 'Account is disabled.'
return True, ''

def check(self, authorization, session=requests):
"""Check supplied authorization, return (authorized, public-reason)."""
json = {
'auth': authorization,
}
try:
response = session.post(self.endpoint_url(), headers=self.headers(), json=json)
except requests.RequestException:
logger.exception('Accounting server request failed:')
return False, 'Accounting server unreachable.'
return self.check_response(response)


def authorized_api(view):
"""View decorator that enforces authorization, if enabled."""
@functools.wraps(view)
def wrapper(request, format=None):
authorized, reason = check_authorization(request)
if not authorized:
return JsonResponse({'error': reason}, status=403)
return view(request, format)
return wrapper
12 changes: 11 additions & 1 deletion index_service/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

from django.conf import settings
from django.db import transaction
from django.utils.decorators import method_decorator
from rest_framework.decorators import api_view
from rest_framework.parsers import JSONParser
from rest_framework.response import Response
Expand All @@ -9,8 +10,9 @@

from .crypto import NoiseBoxParser, KeyPair, encode_key
from .models import Identity, Entry, PendingUpdateRequest, PendingVerification
from .serializers import IdentitySerializer, UpdateRequestSerializer
from .serializers import IdentitySerializer, UpdateRequestSerializer, scrub_field
from .verification import execute_if_complete
from .utils import authorized_api


"""
Expand All @@ -28,6 +30,7 @@ def error(description):


@api_view(('GET',))
@authorized_api
def api_root(request, format=None):
"""
Return mapping of API names to API endpoint paths.
Expand All @@ -48,6 +51,7 @@ def root_index(*apis):


@api_view(('GET',))
@authorized_api
def key(request, format=None):
"""
Return the ephemeral server public key.
Expand All @@ -58,6 +62,7 @@ def key(request, format=None):


@api_view(('GET',))
@authorized_api
def search(request, format=None):
"""
Search for identities registered for private data.
Expand All @@ -67,10 +72,15 @@ def search(request, format=None):
if not data or set(data.keys()) > Entry.FIELDS:
return error('No or unknown fields specified: ' + ', '.join(data.keys()))
for field, value in data.items():
try:
value = scrub_field(field, value)
except ValueError as exc:
return error('Failed to parse field %r: %s' % (field, exc))
identities = identities.filter(entry__field=field, entry__value=value)
return Response({'identities': IdentitySerializer(identities, many=True).data})


@method_decorator(authorized_api, 'dispatch')
class UpdateView(APIView):
"""
Atomically create or delete entries in the user directory.
Expand Down
27 changes: 26 additions & 1 deletion qabel_index/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_prometheus',
'mail_templated',
'rest_framework',
'sendsms',
'index_service',
)

MIDDLEWARE_CLASSES = (
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
Expand All @@ -53,6 +55,8 @@
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
)

ROOT_URLCONF = 'qabel_index.urls'
Expand Down Expand Up @@ -105,7 +109,18 @@
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/

LANGUAGE_CODE = 'en-us'

from django.utils.translation import ugettext_lazy as _

LANGUAGE_CODE = 'de-de'
LANGUAGES = (
('de', _('German')),
# The Django docs are wrong. If you want a sublang, you absolutely need to specifiy it in LANGUAGES,
# and the default does not include it. Yikes.
# Note that this cannot be used to control which country codes we allow for phone number registration,
# since this only affects scrubbing of phone numbers passed into the system *without* a country code.
('en-us', _('US English')),
)

TIME_ZONE = 'UTC'

Expand All @@ -124,12 +139,22 @@

# Application configuration

PROMETHEUS_EXPORT_MIGRATIONS = False

REQUIRE_AUTHORIZATION = False
ACCOUNTING_APISECRET = '1234'
ACCOUNTING_URL = 'http://localhost:1234'

# Pending update requests expire after this time interval
PENDING_REQUEST_MAX_AGE = datetime.timedelta(days=3)

SERVER_PRIVATE_KEY = '247a1db50f8747f0e5e1f755c4390a598d36a4c7af202c2234b0613645d9c22a'

SENDSMS_DEFAULT_FROM_PHONE = '+15005550006'

SMS_ALLOWED_COUNTRIES = (
49, 1, 63, 66, 996
)

# Enable shallow verification, i.e. do not confirm via verification mails or SMSes.
FACET_SHALLOW_VERIFICATION = False
3 changes: 3 additions & 0 deletions qabel_index/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django.conf.urls import include, url
from django.contrib import admin

import django_prometheus.urls

from index_service import views
from index_service import verification

Expand All @@ -21,4 +23,5 @@
url(r'^admin/', include(admin.site.urls)),
url(r'^api/v0/', include(rest_urls)),
url(r'^verify/', include(verification_urls)),
url('', include(django_prometheus.urls)),
]
Loading

0 comments on commit 6e97088

Please sign in to comment.