Repository: ulule/django-safety Branch: master Commit: 6fdb56a55ee4 Files: 57 Total size: 53.5 KB Directory structure: gitextract_rc3yltz7/ ├── .coveragerc ├── .editorconfig ├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── Makefile ├── README.rst ├── data/ │ ├── .gitkeep │ ├── geoip/ │ │ └── .gitkeep │ └── geoip2/ │ └── .gitkeep ├── example/ │ ├── __init__.py │ ├── models.py │ ├── settings.py │ ├── templates/ │ │ ├── base.html │ │ ├── home.html │ │ └── safety/ │ │ ├── password_change/ │ │ │ └── base.html │ │ └── session_list.html │ └── urls.py ├── manage.py ├── pytest.ini ├── requirements/ │ ├── base.txt │ ├── development.txt │ ├── test.txt │ └── tox.txt ├── safety/ │ ├── __init__.py │ ├── admin.py │ ├── app_settings.py │ ├── apps.py │ ├── compat.py │ ├── decorators.py │ ├── forms.py │ ├── management/ │ │ ├── __init__.py │ │ └── commands/ │ │ ├── __init__.py │ │ └── clean_safety_sessions.py │ ├── managers.py │ ├── middleware.py │ ├── migrations/ │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── mixins.py │ ├── models.py │ ├── resolvers.py │ ├── templates/ │ │ └── safety/ │ │ └── password_change/ │ │ ├── base.html │ │ ├── done.html │ │ └── form.html │ ├── tests/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── models.py │ │ ├── settings.py │ │ ├── test_models.py │ │ ├── test_views.py │ │ └── urls.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── setup.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] include = safety/models.py safety/utils.py safety/views.py ================================================ FILE: .editorconfig ================================================ # editorconfig.org root = true [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [Makefile] indent_style = tab indent_size = 4 ================================================ FILE: .gitignore ================================================ __pycache__/ *.py[cod] *.so .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg *.manifest *.spec pip-log.txt pip-delete-this-directory.txt htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml *.mo *.pot *.log docs/_build/ target/ .vagrant .venv .env *.sqlite3 *.db *.mmdb *.gz *.dat ================================================ FILE: .travis.yml ================================================ language: python env: - TOXENV=py27-django18 - TOXENV=py34-django18 - TOXENV=py35-django18 - TOXENV=py27-django19 - TOXENV=py34-django19 - TOXENV=py35-django19 before_install: - sudo apt-get -qq update - sudo apt-get install -y libgeoip-dev install: - travis_retry pip install tox script: - travis_retry tox ================================================ FILE: AUTHORS ================================================ Gilles Fabio Louise Grandjonc Florent Messa ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2016 Ulule Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ devenv: virtualenv -p python2.7 `pwd`/.venv . .venv/bin/activate && pip install -r requirements/development.txt clean: @(rm -rvf .venv .tox .coverage build django-safety* *.egg-info) pep8: @(flake8 safety --ignore=E501,E127,E128,E124) test: @(py.test -s --cov-report term --cov-config .coveragerc --cov=safety --color=yes safety/tests) geoip: @(wget http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz) @(wget http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz) @(wget http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz) @(gunzip -d GeoIP.dat.gz && mv GeoIP.dat data/geoip/) @(gunzip -d GeoLite2-City.mmdb.gz && mv GeoLite2-City.mmdb data/geoip2/) @(gunzip -d GeoLiteCity.dat.gz && mv GeoLiteCity.dat data/geoip/) migrate: @(ENV=example python manage.py makemigrations safety) example-clean: @(rm -rf example.db) example-migrate: @(ENV=example python manage.py migrate) example-user: @(ENV=example python manage.py createsuperuser --username='johndoe' --email='johndoe@example.com') example-serve: @(ENV=example python manage.py runserver) delpyc: @(find . -name '*.pyc' -delete) release: @(python setup.py sdist register upload -s) ================================================ FILE: README.rst ================================================ django-safety ============= .. image:: https://secure.travis-ci.org/ulule/django-safety.png?branch=master :alt: Build Status :target: http://travis-ci.org/ulule/django-safety **Generic Django application for safer user accounts.** Features -------- Sessions ~~~~~~~~ * User can see all active sessions * User can disable a given active session * User can disable all active sessions Force password change ~~~~~~~~~~~~~~~~~~~~~ * Administrators can require a password change for any user Workflows --------- Sessions ~~~~~~~~ 1. User logs in 2. We connect the logic to the ``user_logged_in`` signal 3. We create a new ``safety.models.Session`` instance 4. User can see the list of her sessions (with IP, last activity and device information) 5. User can delete a given session in the list 6. We delete both the related ``safety.models.Session`` instance and related session in store 7. User can delete all active sessions excepted the current one 8. We proceed the same way: deleting instances and related sessions from store 9. User logs out 10. We connect the logic to the ``user_logged_out`` signal 11. We delete the related ``safety.models.Session`` instance Force password change ~~~~~~~~~~~~~~~~~~~~~ 1. Administrator creates a ``PasswordChange`` instance and sets ``required`` to ``True`` 2. When user logs in, it will be redirected to password change form 3. Until the user does not change its password, it is not authorized to go elsewhere 4. User changes its password 5. It is now authorized to go elsewhere Installation ------------ Installing prerequisites ~~~~~~~~~~~~~~~~~~~~~~~~ GeoIP library must be installed on your server. On OS X with Homebrew: .. code-block:: bash brew install geoip You also need the GeoIP databases. For Django >= 1.9, download City and Country databases as binary (not CSV): http://dev.maxmind.com/geoip/geoip2/geolite2/ For Django 1.8, download City and Country legacy databases as binary (not CSV): http://dev.maxmind.com/geoip/legacy/geolite/ Create a directory wherever you want and uncompress these archives this directory. Once done, set ``GEOIP_PATH`` setting pointing to this directory: .. code-block:: python GEOIP_PATH = '/absolute/path/to/maxmind/db/directory' Installing django-safety ~~~~~~~~~~~~~~~~~~~~~~~~ Install .. code-block:: bash $ pip install django-safety In your ``settings.py``, add ``safety`` to ``INSTALLED_APPS``: .. code-block:: python INSTALLED_APPS = ( # Your other apps here. 'safety', ) In your ``urls.py``, include ``safety.urls`` under ``safety`` namespace. .. code-block:: python urlpatterns = [ # Your other URLs here. url(r'^security/', include('safety.urls', namespace='safety')), ] Synchronize the database: .. code-block:: bash $ python manage.py migrate safety Great. The session feature is ready. If you want to enable the "force password change" feature, read the next. Enabling "force password change" feature ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To enable this feature, you have two choices: * You want to protect only specific views? Use ``password_change_required()`` decorator * You want to protect your whole application? Use ``PasswordChangeMiddleware`` middleware The decorator works as any Django view decorator. .. code-block:: python # # In your urls.py # from safety.decorators import password_change_required from .views import protect_me urlpatterns = [ # Other URLs here. url(r'^protect-me/$', password_change_required(protect_me)), ] # # Or in your views.py (it's up to you) # from django.shortcuts import render from safety.decorators import password_change_required @password_change_required def protect_me(request): return render(request, 'protect_me.html') The middleware works as any Django middleware. Add ``safety.middleware.PasswordChangeMiddleware`` middleware in your ``settings.py``: .. code-block:: python MIDDLEWARE_CLASSES = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'safety.middleware.PasswordChangeMiddleware', ] Done. Settings -------- +-------------------------------------------+---------------------------------------------------------------------+ | Setting | Description | +===========================================+=====================================================================+ | ``SAFETY_LOGIN_REQUIRED_MIXIN_CLASS`` | The Python path to your own "login required" mixin class. | | | Defaults to ``safety.mixins.LoginRequiredMixin``. | +-------------------------------------------+---------------------------------------------------------------------+ | ``SAFETY_IP_RESOLVER`` | The Python path to your own IP resolver callable. | | | Defaults to ``safety.resolvers.remote_addr_ip``. | +-------------------------------------------+---------------------------------------------------------------------+ | ``SAFETY_DEVICE_RESOLVER`` | The Python path to your own device resolver callable. | | | Defaults to ``safety.resolvers.device``. | +-------------------------------------------+---------------------------------------------------------------------+ | ``SAFETY_LOCATION_RESOLVER`` | The Python path to your own location resolver callable. | |  | Defaults to ``safety.resolvers.location``. | +-------------------------------------------+---------------------------------------------------------------------+ Development ----------- .. code-block:: bash # Install pip and virtualenv $ sudo easy_install pip $ sudo pip install virtualenv # Clone repository $ git clone https://github.com/ulule/django-safety.git # Setup your development environment $ cd django-safety $ make devenv $ source .venv/bin/activate # Download GeoIP databases $ make geoip # Launch test suite $ make test # Launch test suite with tox to check compatibility $ tox # Run the example project (default user username is "johndoe") $ make example-migrate $ make example-user $ make example-serve Contribute ---------- 1. Create an issue (**before** submitting pull requests) 2. Submit your bug or feature request 3. You want to fix or code it yourself? Great! Fork the project 4. Create a branch, always add tests and make sure they all pass with ``tox`` 5. Submit a pull request Compatibility ------------- - python 2.7: Django 1.8, 1.9 - Python 3.4: Django 1.8, 1.9 - Python 3.5: Django 1.8, 1.9 ================================================ FILE: data/.gitkeep ================================================ ================================================ FILE: data/geoip/.gitkeep ================================================ ================================================ FILE: data/geoip2/.gitkeep ================================================ ================================================ FILE: example/__init__.py ================================================ ================================================ FILE: example/models.py ================================================ ================================================ FILE: example/settings.py ================================================ import os import django BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY = 'zn=kw41f9nhe1lse8minnu0s-@7b+q(exccs5d-1vil$^ees&#' DEBUG = True ALLOWED_HOSTS = [] INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'example', 'safety', ] MIDDLEWARE_CLASSES = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'safety.middleware.PasswordChangeMiddleware', ] ROOT_URLCONF = 'example.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'example.db'), } } AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_L10N = True USE_TZ = True STATIC_URL = '/static/' AUTH_USER_MODEL = 'auth.User' LOGIN_URL = '/admin/login/' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' GEOIP_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip') GEOIP2_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip2') GEOIP_PATH = GEOIP2_DIR_PATH if django.VERSION >= (1, 9) else GEOIP_DIR_PATH ================================================ FILE: example/templates/base.html ================================================ {% block title %}{% endblock %} {% block content_wrapper %}

Hello, {{ user }}!

home — {% if request.user.is_authenticated %}update password —{% endif %} adminactive sessions


{% block content %}{% endblock %}
{% endblock %} ================================================ FILE: example/templates/home.html ================================================ {% extends "base.html" %} {% block content %}

I'm the homepage.

{% endblock content %} ================================================ FILE: example/templates/safety/password_change/base.html ================================================ {% extends "base.html" %} ================================================ FILE: example/templates/safety/session_list.html ================================================ {% extends "base.html" %} {% load i18n %} {% block content %}

{% trans "Active Sessions" %}

{% for object in object_list %} {% endfor %}
{% trans "Last Activity" %} {% trans "Location" %} {% trans "Device" %} {% trans "End Session" %}
{% if object.session_key == request.session.session_key %} {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %} {% else %} {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %} {% endif %} {{ object.location }} {{ object.device }}
{% csrf_token %} {% if object.session_key == request.session.session_key %} {% else %} {% endif %}
{% if object_list.count > 1 %}
{% csrf_token %}

{% blocktrans %}You can also end all other sessions but the current. This will log you out on all other devices.{% endblocktrans %}

{% endif %} {% endblock %} ================================================ FILE: example/urls.py ================================================ # -*- coding: utf-8 -*- from django.conf.urls import url, include from django.contrib import admin from django.core.urlresolvers import reverse from django.shortcuts import redirect, render from safety.models import PasswordChange def home(request): return render(request, 'home.html') def update_password(request): if request.user.is_authenticated(): pr, created = PasswordChange.objects.get_or_create_for_user(request.user) pr.required = True pr.save() return redirect(reverse('home')) urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^account/', include('django.contrib.auth.urls')), url(r'^security/', include('safety.urls', namespace='safety')), url(r'^update-password/$', update_password, name='update-password'), url(r'^$', home, name='home'), ] ================================================ FILE: manage.py ================================================ #!/usr/bin/env python import os import sys SUPPORTED_ENVS = ( 'test', 'example', ) SETTINGS_MODULES = { 'test': 'safety.tests.settings', 'example': 'example.settings' } ENV = os.environ.get('ENV', 'test') ENV = ENV.lower() if ENV not in SUPPORTED_ENVS: raise Exception('Unsupported environment: %s' % ENV) if __name__ == "__main__": os.environ.setdefault('DJANGO_SETTINGS_MODULE', SETTINGS_MODULES[ENV]) from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) ================================================ FILE: pytest.ini ================================================ [pytest] DJANGO_SETTINGS_MODULE=safety.tests.settings ================================================ FILE: requirements/base.txt ================================================ six ua-parser geoip2 ================================================ FILE: requirements/development.txt ================================================ -r base.txt -r test.txt Django flake8 tox ================================================ FILE: requirements/test.txt ================================================ pytest pytest-cov pytest-django exam ================================================ FILE: requirements/tox.txt ================================================ six ua-parser -r test.txt ================================================ FILE: safety/__init__.py ================================================ # -*- coding: utf-8 -*- version = (0, 1, 0) __version__ = '.'.join(map(str, version)) default_app_config = 'safety.apps.SafetyConfig' __all__ = [ 'default_app_config', 'version', ] ================================================ FILE: safety/admin.py ================================================ # -*- coding: utf-8 -*- from django.contrib import admin from django.utils.timezone import now from .models import ( PasswordChange, Session, ) class PasswordChangeAdmin(admin.ModelAdmin): list_display = ('user', 'last_change_date', 'required') raw_id_fields = ('user',) class SessionAdmin(admin.ModelAdmin): list_display = ('user', 'ip', 'last_activity', 'location', 'device', 'is_valid') raw_id_fields = ('user',) def is_valid(self, obj): return obj.expiration_date > now() is_valid.boolean = True admin.site.register(PasswordChange, PasswordChangeAdmin) admin.site.register(Session, SessionAdmin) ================================================ FILE: safety/app_settings.py ================================================ # -*- coding: utf-8 -*- from django.conf import settings # The Python path to the login_required mixin class applied to views. LOGIN_REQUIRED_MIXIN_CLASS = getattr( settings, 'SAFETY_LOGIN_REQUIRED_MIXIN_CLASS', 'safety.mixins.LoginRequiredMixin') # Path to callable that handles the IP resolving. # Takes a django.http.HttpRequest instance and returns a string. IP_RESOLVER = getattr( settings, 'SAFETY_IP_RESOLVER', 'safety.resolvers.remote_addr_ip') # Path to callable that handles the device resolving. # Takes a django.http.HttpRequest instance and returns a string. DEVICE_RESOLVER = getattr( settings, 'SAFETY_DEVICE_RESOLVER', 'safety.resolvers.device') # Path to callable that handles the location resolving. # Takes a django.http.HttpRequest instance and returns a string. LOCATION_RESOLVER = getattr( settings, 'SAFETY_LOCATION_RESOLVER', 'safety.resolvers.location') ================================================ FILE: safety/apps.py ================================================ # -*- coding: utf-8 -*- from django.apps import AppConfig class SafetyConfig(AppConfig): name = 'safety' verbose_name = 'Safety' def ready(self): super(SafetyConfig, self).ready() ================================================ FILE: safety/compat.py ================================================ # -*- coding: utf-8 -*- import django if django.VERSION >= (1, 9): from importlib import import_module # noqa from django.contrib.gis.geoip2 import HAS_GEOIP2 if HAS_GEOIP2: from django.contrib.gis.geoip2 import GeoIP2 as GeoIP # noqa else: from django.contrib.gis.geoip import GeoIP # noqa else: from django.utils.importlib import import_module # noqa from django.contrib.gis.geoip import GeoIP # noqa ================================================ FILE: safety/decorators.py ================================================ # -*- coding: utf-8 -*- from functools import wraps from django.core.urlresolvers import reverse from django.shortcuts import redirect from .models import ( PasswordChange, Session, ) def password_change_required(func): @wraps(func) def inner(request, *args, **kwargs): if not request.user.is_authenticated(): return func(request, *args, **kwargs) required = PasswordChange.objects.is_required_for_user(request.user) url = reverse('safety:password_change') is_excluded_url = request.path.startswith(url) if required and not is_excluded_url: return redirect(url) return func(request, *args, **kwargs) return inner ================================================ FILE: safety/forms.py ================================================ # -*- coding: utf-8 -*- from django import forms from django.contrib.auth.forms import PasswordChangeForm as BasePasswordChangeForm from django.utils.translation import ugettext_lazy as _ from .models import PasswordChange class PasswordChangeForm(BasePasswordChangeForm): error_messages = dict(BasePasswordChangeForm.error_messages, **{ 'same_password': _("Your old password and the new one are the same. You must use a different password."), }) def clean_new_password2(self): password2 = super(PasswordChangeForm, self).clean_new_password2() if self.user.check_password(password2): raise forms.ValidationError( self.error_messages['same_password'], code='same_password') return password2 def save(self, *args, **kwargs): pc, created = PasswordChange.objects.get_or_create_for_user(self.user) pc.required = False pc.save() super(PasswordChangeForm, self).save(*args, **kwargs) ================================================ FILE: safety/management/__init__.py ================================================ ================================================ FILE: safety/management/commands/__init__.py ================================================ ================================================ FILE: safety/management/commands/clean_safety_sessions.py ================================================ # -*- coding: utf-8 -*- from django.core.management.base import BaseCommand from django.utils.timezone import now from safety.models import Session class Command(BaseCommand): def handle(self, *args, **options): Session.objects.filter(expiration_date__lt=now()).delete() self.stdout.write(self.style.SUCCESS('Deleted expired sessions')) ================================================ FILE: safety/managers.py ================================================ # -*- coding: utf-8 -*- from django.db import models, transaction, IntegrityError from django.utils.timezone import now from . import app_settings from . import utils class PasswordChangeManager(models.Manager): def get_or_create_for_user(self, user): return self.get_or_create(user=user) def is_required_for_user(self, user): obj, created = self.get_or_create_for_user(user=user) return obj.required class SessionManager(models.Manager): def active(self, user=None): qs = self.filter(expiration_date__gt=now()) if user is not None: qs = qs.filter(user=user) return qs.order_by('-last_activity') def create_session(self, request, user): ip = utils.resolve(app_settings.IP_RESOLVER, request) device = utils.resolve(app_settings.DEVICE_RESOLVER, request) location = utils.resolve(app_settings.LOCATION_RESOLVER, request) user_agent = request.META.get('HTTP_USER_AGENT', '') user_agent = user_agent[:200] if user_agent else user_agent try: with transaction.atomic(): obj = self.create( user=user, session_key=request.session.session_key, ip=ip, user_agent=user_agent, device=device, location=location, expiration_date=request.session.get_expiry_date(), last_activity=now()) except IntegrityError: obj = self.get( user=user, session_key=request.session.session_key) obj.last_activity = now() obj.save() return obj ================================================ FILE: safety/middleware.py ================================================ # -*- coding: utf-8 -*- from .decorators import password_change_required class PasswordChangeMiddleware(object): def process_view(self, request, view_func, view_args, view_kwargs): return password_change_required(view_func)(request, *view_args, **view_kwargs) ================================================ FILE: safety/migrations/0001_initial.py ================================================ # -*- coding: utf-8 -*- # Generated by Django 1.9.2 on 2016-03-16 12:51 from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='PasswordChange', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('required', models.BooleanField(db_index=True, default=False, verbose_name='required')), ('last_change_date', models.DateTimeField(blank=True, null=True, verbose_name='last change date')), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='safety_password_change', to=settings.AUTH_USER_MODEL, verbose_name='user')), ], options={ 'verbose_name': 'password change', 'verbose_name_plural': 'password changes', }, ), migrations.CreateModel( name='Session', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('session_key', models.CharField(max_length=40, unique=True, verbose_name='session key')), ('ip', models.GenericIPAddressField(verbose_name='IP')), ('user_agent', models.CharField(max_length=200, verbose_name='user agent')), ('location', models.CharField(max_length=255, verbose_name='location')), ('device', models.CharField(max_length=200, verbose_name='device')), ('expiration_date', models.DateTimeField(db_index=True, verbose_name='expiration date')), ('last_activity', models.DateTimeField(verbose_name='last activity')), ('active', models.BooleanField(default=True)), ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), ], options={ 'verbose_name': 'session', 'verbose_name_plural': 'sessions', }, ), ] ================================================ FILE: safety/migrations/__init__.py ================================================ ================================================ FILE: safety/mixins.py ================================================ # -*- coding: utf-8 -*- from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from .models import Session class SessionMixin(object): def get_queryset(self): return Session.objects.active(user=self.request.user) class LoginRequiredMixin(object): @method_decorator(login_required) def dispatch(self, request, *args, **kwargs): return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs) ================================================ FILE: safety/models.py ================================================ # -*- coding: utf-8 -*- from django.conf import settings from django.contrib.auth.signals import ( user_logged_in, user_logged_out, ) from django.db.models.signals import post_delete from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from . import managers @python_2_unicode_compatible class PasswordChange(models.Model): user = models.OneToOneField( getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), verbose_name=_('user'), related_name='safety_password_change') required = models.BooleanField(verbose_name=_('required'), db_index=True, default=False) last_change_date = models.DateTimeField(verbose_name=_('last change date'), null=True, blank=True) objects = managers.PasswordChangeManager() class Meta: verbose_name = _('password change') verbose_name_plural = _('password changes') def __str__(self): return '%s - %s' % (self.user, self.last_change_date) @python_2_unicode_compatible class Session(models.Model): user = models.ForeignKey(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), verbose_name=_('user'), null=True) session_key = models.CharField(verbose_name=_('session key'), max_length=40, unique=True) ip = models.GenericIPAddressField(verbose_name=_('IP')) user_agent = models.CharField(verbose_name=_('user agent'), max_length=200) location = models.CharField(verbose_name=_('location'), max_length=255) device = models.CharField(verbose_name=_('device'), max_length=200) expiration_date = models.DateTimeField(verbose_name=_('expiration date'), db_index=True) last_activity = models.DateTimeField(verbose_name=_('last activity')) active = models.BooleanField(default=True) objects = managers.SessionManager() class Meta: verbose_name = _('session') verbose_name_plural = _('sessions') def __str__(self): return '%s (%s)' % (self.user, self.device) def delete_store_session(self): from .utils import get_session_store store = get_session_store() if store.exists(session_key=self.session_key): store.delete(session_key=self.session_key) # ----------------------------------------------------------------------------- # Signal handlers # ----------------------------------------------------------------------------- # Connected to user_logged_in def create_session(sender, request, user, **kwargs): Session.objects.create_session(request, user) # Connected to user_logged_out def deactivate_session(sender, request, user, **kwargs): try: key = request.session.session_key instance = Session.objects.get(user=user, session_key=key) instance.active = False instance.save() except Session.DoesNotExist: pass # Connected to post_delete for Session model def post_delete_session(sender, instance, **kwargs): instance.delete_store_session() user_logged_in.connect(create_session) user_logged_out.connect(deactivate_session) post_delete.connect(post_delete_session, sender=Session) ================================================ FILE: safety/resolvers.py ================================================ # -*- coding: utf-8 -*- from django.utils.translation import ugettext from ua_parser import user_agent_parser from . import app_settings from . import utils from .compat import GeoIP def remote_addr_ip(request): """ This is for the development setup. """ return request.META.get('REMOTE_ADDR') or None def x_forwarded_ip(request): """ Amazon ELB stores the IP in the HTTP_X_FORWARDED_FOR META attribute. It is realiably the first one of the IP adresses sent and can be trusted (eg.: cannot be spoofed) Warning: This might not be true for other load balancers This function assumes that your Nginx is configured with: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; """ ip_address_list = request.META.get('HTTP_X_FORWARDED_FOR') if ip_address_list: ip_address_list = ip_address_list.split(',') return ip_address_list[0] def real_ip(request): """ Behind a Wsgi (Nginx) server. """ return request.META.get('HTTP_X_REAL_IP') or None def device(request): """ Default device resolver using ua-parser. """ ua = request.META.get('HTTP_USER_AGENT', '') parsed = user_agent_parser.Parse(ua) ua_parsed = (ugettext('unknown browser'), parsed['user_agent']) os_parsed = (ugettext('unknown system'), parsed['os']) infos = [] for unknown, dct in (ua_parsed, os_parsed): d = dct.copy() family = d.pop('family') if family is None or family == 'Other': infos.append(unknown) continue version = '.'.join([d.get(k) for k in ('major', 'minor', 'patch') if d.get(k) is not None]) if version: family = '%s %s' % (family, version) infos.append(family) return ' - '. join(infos) def location(request): """ Transform an IP address into an approximate location. """ ip = utils.resolve(app_settings.IP_RESOLVER, request) try: location = GeoIP() and GeoIP().city(ip) except: # Handle 127.0.0.1 and not found IPs return ugettext('unknown') if location and location['country_name']: if location['city']: return '%s, %s' % (location['city'], location['country_name']) else: return location['country_name'] return ugettext('unknown') ================================================ FILE: safety/templates/safety/password_change/base.html ================================================ {% block title %} {{ title }} {% endblock %} {% block content %} {% endblock %} ================================================ FILE: safety/templates/safety/password_change/done.html ================================================ {% extends "safety/password_change/base.html" %} {% load i18n %} {% block title %}{{ title }}{% endblock %} {% block content %}

{% trans 'Your password was changed.' %}

{% endblock %} ================================================ FILE: safety/templates/safety/password_change/form.html ================================================ {% extends "safety/password_change/base.html" %} {% load i18n %} {% block title %}{{ title }}{% endblock %} {% block content %}
{% csrf_token %}
{% if form.errors %}

{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}

{% endif %}

{% trans "Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly." %}

{{ form.old_password.errors }} {{ form.old_password.label_tag }} {{ form.old_password }}
{{ form.new_password1.errors }} {{ form.new_password1.label_tag }} {{ form.new_password1 }} {% if form.new_password1.help_text %}

{{ form.new_password1.help_text|safe }}

{% endif %}
{{ form.new_password2.errors }} {{ form.new_password2.label_tag }} {{ form.new_password2 }} {% if form.new_password2.help_text %}

{{ form.new_password2.help_text|safe }}

{% endif %}
{% endblock %} ================================================ FILE: safety/tests/__init__.py ================================================ ================================================ FILE: safety/tests/base.py ================================================ # -*- coding: utf-8 -*- from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.test import TestCase from django.utils.timezone import now from exam.cases import Exam from exam.decorators import fixture from safety.models import Session from safety.utils import get_session_store class Fixtures(Exam): UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) ' 'AppleWebKit/536.26.17 (KHTML, like Gecko) Version/6.0.2 ' 'Safari/536.26.17') DEVICE = 'Safari 6.0.2 - Mac OS X 10.8.2' LOCATION = 'Mountain View, United States' REMOTE_ADDR = '66.249.64.0' USER_PASSWORD = 'secret' @fixture def user(self): return User.objects.create_superuser( username='johndoe', email='johndoe@example.com', password=self.USER_PASSWORD) def reload(self): self.user = User.objects.get(pk=self.user.pk) class BaseTestCase(Fixtures, TestCase): def login_user(self, password=None): admin_login_url = reverse('admin:login') password = password or self.USER_PASSWORD self.client.post( admin_login_url, data={ 'username': self.user.username, 'password': password, 'next': '/admin/', }, REMOTE_ADDR=self.REMOTE_ADDR, HTTP_USER_AGENT=self.UA) def create_fake_sessions(self): store = get_session_store() sessions = [] for x in range(10): store.create() session = Session.objects.create( user=self.user, session_key=store.session_key, ip=self.REMOTE_ADDR, location=self.LOCATION, device=self.DEVICE, user_agent=self.UA, last_activity=now(), expiration_date=store.get_expiry_date()) sessions.append(session) return sessions ================================================ FILE: safety/tests/models.py ================================================ ================================================ FILE: safety/tests/settings.py ================================================ import os import django BASE_DIR = os.path.dirname(os.path.dirname((os.path.dirname(os.path.abspath(__file__))))) SECRET_KEY = 'zn=kw41f9nhe1lse8minnu0s-@7b+q(exccs5d-1vil$^ees&#' DEBUG = True ALLOWED_HOSTS = [] INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'example', 'safety', 'safety.tests', ] MIDDLEWARE_CLASSES = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'safety.middleware.PasswordChangeMiddleware', ] ROOT_URLCONF = 'safety.tests.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'test.db'), } } AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_L10N = True USE_TZ = True STATIC_URL = '/static/' AUTH_USER_MODEL = 'auth.User' LOGIN_URL = '/admin/login/' GEOIP_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip') GEOIP2_DIR_PATH = os.path.join(BASE_DIR, 'data', 'geoip2') GEOIP_PATH = os.getenv('GEOIP_PATH', GEOIP2_DIR_PATH) if django.VERSION >= (1, 9) else GEOIP_DIR_PATH ================================================ FILE: safety/tests/test_models.py ================================================ # -*- coding: utf-8 -*- from .base import BaseTestCase from safety.models import ( PasswordChange, Session, ) class ModelsTest(BaseTestCase): def test_session(self): self.assertEqual(Session.objects.count(), 0) self.login_user() self.assertEqual(Session.objects.count(), 1) session = Session.objects.first() self.assertTrue(session.active) self.client.logout() self.assertEqual(Session.objects.count(), 1) session = Session.objects.first() self.assertFalse(session.active) def test_password_change(self): obj, created = PasswordChange.objects.get_or_create_for_user(self.user) self.assertTrue(created) self.assertEqual(obj.user, self.user) self.assertIsNone(obj.last_change_date) self.assertFalse(obj.required) ================================================ FILE: safety/tests/test_views.py ================================================ # -*- coding: utf-8 -*- from django.core.urlresolvers import reverse from safety.forms import PasswordChangeForm from safety.models import PasswordChange, Session from .base import BaseTestCase class SessionsViewsTest(BaseTestCase): def tearDown(self, *args, **kwargs): self.client.logout() def test_session_list_view(self): self.login_user() r = self.client.get(reverse('safety:session_list')) self.assertEqual(r.status_code, 200) objects = r.context['object_list'] obj = r.context['object_list'][0] self.assertEqual(len(objects), 1) self.assertEqual(obj.user, self.user) self.assertTrue(obj.session_key) self.assertTrue(obj.active) self.assertEqual(obj.ip, self.REMOTE_ADDR) self.assertEqual(obj.device, self.DEVICE) self.assertEqual(obj.location, self.LOCATION) def test_session_delete_view(self): admin_login_url = reverse('admin:login') session_list_url = reverse('safety:session_list') # Let's delete our current session. # We must be redirected to login page. self.login_user() session = Session.objects.first() r = self.client.post(reverse('safety:session_delete', kwargs={'pk': session.pk}), follow=True) self.assertRedirects(r, '%s?next=%s' % (admin_login_url, session_list_url)) self.assertEqual(Session.objects.count(), 0) self.client.logout() # Let's delete an other session, not our current one. # We must be redirected to session list. fake_sessions = self.create_fake_sessions() self.assertEqual(Session.objects.count(), 10) self.login_user() sessions_count = Session.objects.count() self.assertEqual(sessions_count, 11) for session in fake_sessions: r = self.client.post(reverse('safety:session_delete', kwargs={'pk': session.pk}), follow=True) self.assertRedirects(r, session_list_url) self.assertEqual(Session.objects.count(), 1) def test_session_delete_other_view(self): session_list_url = reverse('safety:session_list') fake_sessions = self.create_fake_sessions() self.assertEqual(Session.objects.count(), 10) self.login_user() self.assertEqual(Session.objects.count(), 11) r = self.client.post(reverse('safety:session_delete_other'), follow=True) self.assertRedirects(r, session_list_url) self.assertEqual(Session.objects.count(), 1) current_session = Session.objects.get(session_key=self.client.session.session_key) r = self.client.get(reverse('safety:session_list')) self.assertEqual(r.status_code, 200) self.assertTrue(self.client.session.exists(session_key=current_session.session_key)) for session in fake_sessions: self.assertFalse(self.client.session.exists(session_key=session.session_key)) class PasswordChangeViewsTest(BaseTestCase): def test_password_change(self): self.login_user() r = self.client.get(reverse('safety:password_change')) self.assertTemplateUsed(r, 'safety/password_change/form.html') self.assertEqual(r.status_code, 200) self.client.logout() def test_password_change_done(self): self.login_user() r = self.client.get(reverse('safety:password_change_done')) self.assertTemplateUsed(r, 'safety/password_change/done.html') self.assertEqual(r.status_code, 200) self.client.logout() def test_workflow(self): change_form_url = reverse('safety:password_change') # We don't have any PasswordChange instance yet. # Because we just plugged the app. self.assertEqual(PasswordChange.objects.count(), 0) # Let's login user. self.login_user() # We still have no instance. self.assertEqual(PasswordChange.objects.count(), 0) # Let's create an instance for a given user. # By default, "required" field is set to False. # So nothing changes. pr, created = PasswordChange.objects.get_or_create_for_user(self.user) self.assertTrue(created) self.assertFalse(pr.required) # Let's logout user and login again, than go elsewhere # on the site. As we don't set required to True yet, # nothing changes again. self.client.logout() self.login_user() r = self.client.get(reverse('home')) self.assertEqual(r.status_code, 200) r = self.client.get(reverse('admin:index')) self.assertEqual(r.status_code, 200) # Time to force user to reset its password. pr.required = True pr.save() # Now, it should be effictive because we use the decorator # (the middleware or the decorator provides the same behavior). # So we must be redirected to password change form. No other choice. r = self.client.get(reverse('home')) self.assertRedirects(r, change_form_url) r = self.client.get(reverse('admin:index')) self.assertRedirects(r, change_form_url) # Now, ok. User is stuck. Time to change password. # Let's change password! r = self.client.post( reverse('safety:password_change'), data={ 'old_password': self.USER_PASSWORD, 'new_password1': 'superpassword', 'new_password2': 'superpassword', }) self.assertRedirects(r, reverse('safety:password_change_done')) # Done. New password. # # A few days later, we required user to change its password again. # But! We do not accept the old one again. User must provide a # different new password. Let's check it. pr, created = PasswordChange.objects.get_or_create_for_user(self.user) self.assertFalse(created) # As we changed our last password, required field must be now set to False. self.assertFalse(pr.required) # Let's switch. pr.required = True pr.save() # Login with new password. self.login_user(password='superpassword') # Boom! Redirected. r = self.client.get(reverse('home')) self.assertRedirects(r, change_form_url) # Let's reload our fixture. self.reload() # Let's try to reset our password with the same as old one. r = self.client.post( reverse('safety:password_change'), data={ 'old_password': 'superpassword', 'new_password1': 'superpassword', 'new_password2': 'superpassword', }) # Boom! Error. Not authorized. self.assertFormError(r, 'form', 'new_password2', PasswordChangeForm.error_messages['same_password']) # OK. Choose a new one. r = self.client.post( reverse('safety:password_change'), data={ 'old_password': 'superpassword', 'new_password1': 'ournewpassword', 'new_password2': 'ournewpassword', }) # Everything is OK. self.assertRedirects(r, reverse('safety:password_change_done')) # Login with new password. self.login_user(password='ournewpassword') # No redirect. Everything is OK. r = self.client.get(reverse('home')) self.assertEqual(r.status_code, 200) ================================================ FILE: safety/tests/urls.py ================================================ # -*- coding: utf-8 -*- from django.conf.urls import include, url from django.contrib import admin from django.core.urlresolvers import reverse from django.shortcuts import render, redirect from safety.models import PasswordChange def home(request): return render(request, 'home.html') def update_password(request): if request.user.is_authenticated(): pr, created = PasswordChange.objects.get_or_create_for_user(request.user) pr.required = True pr.save() return redirect(reverse('home')) urlpatterns = [ url(r'^admin/', include(admin.site.urls)), url(r'^account/', include('django.contrib.auth.urls')), url(r'^security/', include('safety.urls', namespace='safety')), url(r'^update-password/$', update_password, name='update-password'), url(r'^$', home, name='home'), ] ================================================ FILE: safety/urls.py ================================================ # -*- coding: utf-8 -*- from django.conf.urls import url from . import views urlpatterns = [ url( r'^sessions/$', views.SessionListView.as_view(), name='session_list'), url( r'^sessions/other/delete/$', views.SessionDeleteOtherView.as_view(), name='session_delete_other'), url( r'^sessions/(?P\w+)/delete/$', views.SessionDeleteView.as_view(), name='session_delete'), url( r'^password-change/$', views.password_change, name='password_change'), url( r'^password-change/done/$', views.password_change_done, name='password_change_done'), ] ================================================ FILE: safety/utils.py ================================================ # -*- coding: utf-8 -*- from django.conf import settings from .compat import import_module def get_session_store(): mod = getattr(settings, 'SESSION_ENGINE', 'django.contrib.sessions.backends.db') engine = import_module(mod) store = engine.SessionStore() return store def resolve(path, request): obj = import_from_path(path) return obj(request) def import_from_path(path): try: module_path, attr = path.rsplit('.', 1) module = import_module(module_path) obj = getattr(module, attr) except Exception as e: raise e return obj ================================================ FILE: safety/views.py ================================================ # -*- coding: utf-8 -*- try: from urllib.parse import urlparse, urlunparse except ImportError: # pragma: no cover # Python 2 fallback from urlparse import urlparse, urlunparse # noqa from django.contrib.auth import views as auth_views from django.contrib.auth.decorators import login_required from django.contrib.auth.tokens import default_token_generator from django.core.urlresolvers import reverse, reverse_lazy from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_protect from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import ListView, DeleteView, View from django.views.generic.edit import DeletionMixin from . import app_settings from . import forms from . import utils from .mixins import SessionMixin LoginRequiredMixin = utils.import_from_path( app_settings.LOGIN_REQUIRED_MIXIN_CLASS) # ----------------------------------------------------------------------------- # Sessions # ----------------------------------------------------------------------------- class SessionListView(LoginRequiredMixin, SessionMixin, ListView): pass class SessionDeleteView(LoginRequiredMixin, SessionMixin, DeleteView): def get_success_url(self): return str(reverse_lazy('safety:session_list')) class SessionDeleteOtherView(LoginRequiredMixin, SessionMixin, DeletionMixin, View): def get_object(self): qs = super(SessionDeleteOtherView, self).get_queryset() qs = qs.exclude(session_key=self.request.session.session_key) return qs def get_success_url(self): return str(reverse_lazy('safety:session_list')) # ----------------------------------------------------------------------------- # Password Change # ----------------------------------------------------------------------------- @sensitive_post_parameters() @csrf_protect @login_required def password_change(request): return auth_views.password_change( request=request, template_name='safety/password_change/form.html', post_change_redirect=reverse('safety:password_change_done'), password_change_form=forms.PasswordChangeForm) @login_required def password_change_done(request): return auth_views.password_change_done( request=request, template_name='safety/password_change/done.html') ================================================ FILE: setup.py ================================================ # -*- coding: utf-8 -*- import os import sys from setuptools import setup, find_packages root = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(root, 'README.rst')) as f: readme = f.read() version = __import__('safety').__version__ install_requires = [ 'six', 'ua-parser', 'geoip2' ] setup( name='django-safety', version=version, description='Generic Django application for safer user accounts', long_description=readme, author='Gilles Fabio', author_email='gilles.fabio@gmail.com', url='http://github.com/ulule/django-safety', packages=find_packages(), zip_safe=False, include_package_data=True, install_requires=install_requires, classifiers=[ 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', ] ) ================================================ FILE: tox.ini ================================================ [tox] skipsdist = True envlist = {py27,py34,py35}-django{18,19} {py27,py34,py35}-django19nogeoip2 [testenv] basepython = py27: python2.7 py34: python3.4 py35: python3.5 deps = -r{toxinidir}/requirements/tox.txt {py27,py34,py35}-django18: Django>=1.8,<1.9 {py27,py34,py35}-django19: Django>=1.9,<1.10 {py27,py34,py35}-django19: geoip2 {py27,py34,py35}-django19nogeoip2: Django>=1.9,<1.10 setenv = PYTHONPATH = {toxinidir} whitelist_externals = make changedir = {toxinidir} commands = make geoip make test [testenv:py27-django19nogeoip2] setenv = GEOIP_PATH = {toxinidir}/data/geoip [testenv:py34-django19nogeoip2] setenv = GEOIP_PATH = {toxinidir}/data/geoip [testenv:py35-django19nogeoip2] setenv = GEOIP_PATH = {toxinidir}/data/geoip