diff --git a/readme.md b/readme.md index ccf5b24c9408192a70bef30efb01cc3f952f3108..22d11dd3d5f75b4968213ae977ea8759f98d8cf0 100644 --- a/readme.md +++ b/readme.md @@ -187,6 +187,13 @@ SHELL=/bin/bash ## Operations +### Disable two steps authentication for a specific user + + +``` +./manage.py two_factor_disable ${USERNAME} +``` + ### Public holidays to import public holidays run: diff --git a/requirements.txt b/requirements.txt index 0dcc0503db9fadbba8cf9f7f203abd80d3926b62..fa5a7c14be038ab020a18cf2c7e4201737d76b11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,5 @@ lxml==3.7.3 python-docx==0.8.6 django-cleanup==0.4.2 django_cron==0.5.0 +django-two-factor-auth==1.6.1 +nexmo \ No newline at end of file diff --git a/smash/smash/local_settings.py.template b/smash/smash/local_settings.py.template index a2411b28e1d8c80401b5741a608faeccdedaf80e..bb8abb8c205e9b027f8af35341958a1cacc28274 100644 --- a/smash/smash/local_settings.py.template +++ b/smash/smash/local_settings.py.template @@ -39,3 +39,7 @@ STATIC_ROOT = '/tmp/static' # Warning! `/tmp` directory can be flushed in any m MEDIA_ROOT = '/tmp/media' # Warning! `/tmp` directory can be flushed in any moment; use a persistent one, e.g. ~/tmp/media STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' + +NEXMO_API_KEY = 'API_KEY' +NEXMO_API_SECRET = 'API_SECRET' +NEXMO_DEFAULT_FROM = 'Scheduling' # the sender of the message (phone number or text) diff --git a/smash/smash/settings.py b/smash/smash/settings.py index 12e9ac3d4f1c53eeffaa2ca17162c4de3dc9c529..fbdd6e22c2d3175ec692b63332269d03b3ba4e29 100644 --- a/smash/smash/settings.py +++ b/smash/smash/settings.py @@ -14,7 +14,7 @@ import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - +PROJECT_PATH = os.path.abspath(os.path.dirname(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ @@ -31,8 +31,13 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django_cleanup', 'django_cron', - 'debug_toolbar', - 'web' + 'django_otp', + 'django_otp.plugins.otp_static', + 'django_otp.plugins.otp_totp', + 'two_factor', + 'web', + + 'debug_toolbar' ] MIDDLEWARE = [ @@ -42,6 +47,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -51,7 +57,7 @@ ROOT_URLCONF = 'smash.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(PROJECT_PATH, '../web', 'templates')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -112,7 +118,9 @@ STATIC_URL = '/static/' MEDIA_URL = '/media/' # Used for @login_required ecosystem -LOGIN_URL = '/login' -LOGOUT_URL = '/logout' +# LOGIN_URL = '/login' +LOGIN_URL = 'two_factor:login' +LOGIN_REDIRECT_URL = 'web.views.appointments' +LOGOUT_REDIRECT_URL = 'web.views.appointments' from local_settings import * diff --git a/smash/smash/urls.py b/smash/smash/urls.py index e2a8cc8f902b3a586d0b958bbb56e116b4b1a799..bf0f4e881de1772f73ad99303e29e04b9950beb7 100644 --- a/smash/smash/urls.py +++ b/smash/smash/urls.py @@ -14,16 +14,17 @@ Including another URLconf 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url, include -from django.contrib import admin from django.conf import settings +from django.conf.urls import url, include from django.conf.urls.static import static +from django.contrib import admin -import web.urls -import web.api_urls +from web import api_urls +from web import urls urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'', include(web.urls)), - url(r'^api/', include(web.api_urls)) -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + url(r'^admin/', admin.site.urls), + url(r'', include(urls)), + url(r'^api/', include(api_urls)), + url(r'', include('two_factor.urls', 'two_factor')) + ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/smash/web/models/worker.py b/smash/web/models/worker.py index fe6fc96e2e3e07f3290b7010c96c20a6d215b5fe..e962487c74522ec5a5ccd94e7373772e14e6f51e 100644 --- a/smash/web/models/worker.py +++ b/smash/web/models/worker.py @@ -1,7 +1,7 @@ # coding=utf-8 import datetime -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser from django.db import models @@ -68,6 +68,8 @@ class Worker(models.Model): return None elif isinstance(the_user, Worker): return the_user + elif isinstance(the_user, AnonymousUser): + return None elif the_user is not None: raise TypeError("Unknown class type: " + the_user.__class__.__name__) else: diff --git a/smash/web/nexmo_gateway.py b/smash/web/nexmo_gateway.py new file mode 100644 index 0000000000000000000000000000000000000000..0515c732b1ce3a96e8a414eb9bc6fb712054efcd --- /dev/null +++ b/smash/web/nexmo_gateway.py @@ -0,0 +1,34 @@ +import nexmo +from django.conf import settings + + +class Nexmo: + """ + Gateway for sending text messages and making phone calls using Nexmo_. + + All you need is your Nexmo Account API and Secret, as shown in your Nexmo + account dashboard. + + ``NEXMO_API_KEY`` + Should be set to your account's API Key. + + ``NEXMO_API_SECRET`` + Should be set to your account's secret. + + ``NEXMO_DEFAULT_FROM`` + Should be set to a phone number or name. + + .. _Nexmo: http://www.nexmo.com/ + """ + + def __init__(self): + self.client = nexmo.Client(key=getattr(settings, 'NEXMO_API_KEY'), secret=getattr(settings, 'NEXMO_API_SECRET')) + self.default_from = getattr(settings, 'NEXMO_DEFAULT_FROM') + + def make_call(self, device, token): + pass + + def send_sms(self, device, token): + body = 'Your authentication token is %s' % token + phone_number = device.number.as_e164 + self.client.send_message({'to': phone_number, 'from': self.default_from, 'text': body}) diff --git a/smash/web/templates/_base.html b/smash/web/templates/_base.html index b561c6457d753953f82aee5d3b413f0f26bf94a4..69834e355309c677d548c53b4ab70c5af80cdcae 100644 --- a/smash/web/templates/_base.html +++ b/smash/web/templates/_base.html @@ -194,9 +194,13 @@ desired effect <a href="#" class="btn btn-default btn-flat"><i class="fa fa-user"></i> Profile</a> </div>--> <div class="pull-right"> - <a href="{% url 'web.views.logout' %}" class="btn btn-default btn-flat"><i + <a href="{% url 'logout' %}" class="btn btn-default btn-flat"><i class="fa fa-sign-out"></i> Sign out</a> </div> + <div class="pull-left"> + <a href="{% url 'two_factor:profile' %}" class="btn btn-default btn-flat"><i + class="fa fa-lock"></i> Security</a> + </div> </li> </ul> </li> diff --git a/smash/web/templates/appointments/index.html b/smash/web/templates/appointments/index.html index 6e14a48c9ef9ff4c68afcb9ac3275b5f4628e36b..2915da109d4e7ba085f71f7adc38394f439f9a83 100644 --- a/smash/web/templates/appointments/index.html +++ b/smash/web/templates/appointments/index.html @@ -118,7 +118,7 @@ eventRender: function (event, element) { var content = "<ul>"; if (event.phone_number) { - content += '<li>Phone number: ' + event.phone_number + '</li>' + content += '<li>Phone numbers: ' + event.phone_number + '</li>' } if (event.screening_number) { content += '<li>Screening number: ' + event.screening_number + '</li>' diff --git a/smash/web/templates/login.html b/smash/web/templates/login.html index e02c5705f25549909a6de2ea5a2968915cdec322..e3ba68154ec303856c01c9e10ccad9f7671528b6 100644 --- a/smash/web/templates/login.html +++ b/smash/web/templates/login.html @@ -50,60 +50,62 @@ <h5 class="login-logo-h5">(Smart Scheduling)</h5> </div> <!-- /.login-logo --> + {% block content %} + {% if state == "logout" %} + <div class="callout callout-info"> + <h4>Success!</h4> - {% if state == "logout" %} - <div class="callout callout-info"> - <h4>Success!</h4> - - <p>You have logged out of the Scheduling System</p> - </div> - {% elif state == "logout_failed" %} - <div class="callout callout-danger"> - <h4>Error!</h4> - - <p>You cannot log out, if you are not logged in!</p> - </div> - {% elif state == "login_failed" %} - <div class="callout callout-danger"> - <h4>Error!</h4> - - <p>Username does not exist, or the password is incorrect!</p> - </div> - {% else %} - <hr/> - {% endif %} - - <p class="login-box-msg">Please sign in</p> - - <form action="{% url 'web.views.login' %}" method="post"> - {% csrf_token %} - {% if next %} - <input type="hidden" name="next" value="{{ next }}"/> + <p>You have logged out of the Scheduling System</p> + </div> + {% elif state == "logout_failed" %} + <div class="callout callout-danger"> + <h4>Error!</h4> + + <p>You cannot log out, if you are not logged in!</p> + </div> + {% elif state == "login_failed" %} + <div class="callout callout-danger"> + <h4>Error!</h4> + + <p>Username does not exist, or the password is incorrect!</p> + </div> + {% else %} + <hr/> {% endif %} - <div class="form-group has-feedback"> - <input type="text" name="username" class="form-control" placeholder="Login"> - <span class="glyphicon glyphicon-envelope form-control-feedback"></span> - </div> - <div class="form-group has-feedback"> - <input type="password" name="password" class="form-control" placeholder="Password"> - <span class="glyphicon glyphicon-lock form-control-feedback"></span> - </div> - <div class="row"> - <div class="col-xs-8"> - <div class="checkbox icheck"> - <label> - <input type="checkbox"> Remember Me - </label> - </div> + <p class="login-box-msg">Please sign in</p> + + <form action="{% url 'web.views.login' %}" method="post"> + {% csrf_token %} + {% if next %} + <input type="hidden" name="next" value="{{ next }}"/> + {% endif %} + + <div class="form-group has-feedback"> + <input type="text" name="username" class="form-control" placeholder="Login"> + <span class="glyphicon glyphicon-envelope form-control-feedback"></span> + </div> + <div class="form-group has-feedback"> + <input type="password" name="password" class="form-control" placeholder="Password"> + <span class="glyphicon glyphicon-lock form-control-feedback"></span> </div> - <!-- /.col --> - <div class="col-xs-4"> - <button type="submit" class="btn btn-primary btn-block btn-flat">Sign In</button> + <div class="row"> + <div class="col-xs-8"> + <div class="checkbox icheck"> + <label> + <input type="checkbox"> Remember Me + </label> + </div> + </div> + <!-- /.col --> + <div class="col-xs-4"> + <button type="submit" class="btn btn-primary btn-block btn-flat">Sign In</button> + </div> + <!-- /.col --> </div> - <!-- /.col --> - </div> - </form> + </form> + {% endblock content %} + <hr/> diff --git a/smash/web/templates/two_factor/_base.html b/smash/web/templates/two_factor/_base.html new file mode 100644 index 0000000000000000000000000000000000000000..97f9798d0998abf247c9137aa315e0bfc25f070a --- /dev/null +++ b/smash/web/templates/two_factor/_base.html @@ -0,0 +1,6 @@ +{% extends "_base.html" %} + +{% block maincontent %} + {% block content %} + {% endblock content %} +{% endblock maincontent %} \ No newline at end of file diff --git a/smash/web/templates/two_factor/core/login.html b/smash/web/templates/two_factor/core/login.html new file mode 100644 index 0000000000000000000000000000000000000000..318bedb1824713dd1d2c33ba6d0def425f32c7ce --- /dev/null +++ b/smash/web/templates/two_factor/core/login.html @@ -0,0 +1,56 @@ +{% extends "login.html" %} +{% load i18n two_factor %} + +{% block content %} + + + + + + + {% if wizard.steps.current == 'auth' %} + <p class="login-box-msg">Please sign in</p> + {% elif wizard.steps.current == 'token' %} + {% if device.method == 'call' %} + <p class="login-box-msg">{% blocktrans %}We are calling your phone right now, please enter the + digits you hear.{% endblocktrans %}</p> + {% elif device.method == 'sms' %} + <p class="login-box-msg">{% blocktrans %}We sent you a text message, please enter the tokens we + sent.{% endblocktrans %}</p> + {% else %} + <p class="login-box-msg">{% blocktrans %}Please enter the tokens generated by your token + generator.{% endblocktrans %}</p> + {% endif %} + {% elif wizard.steps.current == 'backup' %} + <p class="login-box-msg">{% blocktrans %}Use this form for entering backup tokens for logging in. + These tokens have been generated for you to print and keep safe. Please + enter one of these backup tokens to login to your account.{% endblocktrans %}</p> + {% endif %} + + <form action="" method="post">{% csrf_token %} + {% include "two_factor/_wizard_forms.html" %} + + {# hidden submit button to enable [enter] key #} + <div style="margin-left: -9999px"><input type="submit" value=""/></div> + + {% if other_devices %} + <p>{% trans "Or, alternatively, use one of your backup phones:" %}</p> + <p> + {% for other in other_devices %} + <button name="challenge_device" value="{{ other.persistent_id }}" + class="btn btn-default btn-block" type="submit"> + {{ other|device_action }} + </button> + {% endfor %}</p> + {% endif %} + {% if backup_tokens %} + <p>{% trans "As a last resort, you can use a backup token:" %}</p> + <p> + <button name="wizard_goto_step" type="submit" value="backup" + class="btn btn-default btn-block">{% trans "Use Backup Token" %}</button> + </p> + {% endif %} + + {% include "two_factor/_wizard_actions.html" %} + </form> +{% endblock %} \ No newline at end of file diff --git a/smash/web/templates/two_factor/profile/profile.html b/smash/web/templates/two_factor/profile/profile.html new file mode 100644 index 0000000000000000000000000000000000000000..bbfeda8d13dd5aac4a5ebb187f2430633816c10f --- /dev/null +++ b/smash/web/templates/two_factor/profile/profile.html @@ -0,0 +1,59 @@ +{% extends "two_factor/_base.html" %} +{% load i18n two_factor %} + +{% block content %} + <h1>{% block title %}{% trans "Account Security" %}{% endblock %}</h1> + + {% if default_device %} + {% if default_device_type == 'TOTPDevice' %} + <p>{% trans "Tokens will be generated by your token generator." %}</p> + {% elif default_device_type == 'PhoneDevice' %} + <p>{% blocktrans with primary=default_device|device_action %}Primary method: + {{ primary }}{% endblocktrans %}</p> + {% elif default_device_type == 'RemoteYubikeyDevice' %} + <p>{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}</p> + {% endif %} + + <h2>{% trans "Backup Phone Numbers" %}</h2> + <p>{% blocktrans %}If your primary method is not available, we are able to + send backup tokens to the phone numbers listed below.{% endblocktrans %}</p> + <ul> + {% for phone in backup_phones %} + <li> + {{ phone|device_action }} + <form method="post" action="{% url 'two_factor:phone_delete' phone.id %}" + onsubmit="return confirm('Are you sure?')"> + {% csrf_token %} + <button class="btn btn-xs btn-warning" + type="submit">{% trans "Unregister" %}</button> + </form> + </li> + {% endfor %} + </ul> + {% if available_phone_methods %} + <p><a href="{% url 'two_factor:phone_create' %}" + class="btn btn-info">{% trans "Add Phone Number" %}</a></p> + {% endif %} + + <h2>{% trans "Backup Tokens" %}</h2> + <p> + {% blocktrans %}If you don't have any device with you, you can access + your account using backup tokens.{% endblocktrans %} + {% blocktrans count counter=backup_tokens %} + You have only one backup token remaining. + {% plural %} + You have {{ counter }} backup tokens remaining. + {% endblocktrans %} + </p> + <p><a href="{% url 'two_factor:backup_tokens' %}" + class="btn btn-info">{% trans "Show Codes" %}</a></p> + + {% else %} + <p>{% blocktrans %}Two-factor authentication is not enabled for your + account. Enable two-factor authentication for enhanced account + security.{% endblocktrans %}</p> + <p><a href="{% url 'two_factor:setup' %}" class="btn btn-primary"> + {% trans "Enable Two-Factor Authentication" %}</a> + </p> + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/smash/web/tests/test_view_login.py b/smash/web/tests/test_view_login.py index 4e662d9de2085340c9998676081c7f4dcece35f2..f3fa9b05d3152c6fc7a6542e03bbaf4a8937c0d1 100644 --- a/smash/web/tests/test_view_login.py +++ b/smash/web/tests/test_view_login.py @@ -1,4 +1,5 @@ # coding=utf-8 +from django.conf import settings from django.contrib import auth as django_auth from django.test import Client from django.test import TestCase @@ -14,29 +15,31 @@ class TestLoginView(TestCase): user = create_user() password = 'top_secret' username = user.username - login_url = reverse('web.views.login') + login_url = reverse(settings.LOGIN_URL) self.assertFalse(django_auth.get_user(self.client).is_authenticated()) - response = self.client.post(login_url, data={'username': username, 'password': password}, follow=True) + form_data = {'auth-username': username, 'auth-password': password, 'login_view-current_step': 'auth'} + response = self.client.post(login_url, data=form_data, follow=True) self.assertEqual(200, response.status_code) self.assertTrue(django_auth.get_user(self.client).is_authenticated()) worker = Worker.get_by_user(user) self.assertIsNotNone(worker) worker.last_name = 'Grouès' worker.save() - response = self.client.post(login_url, data={'username': username, 'password': password}, follow=True) + response = self.client.post(login_url, data=form_data, follow=True) self.assertEqual(200, response.status_code) def test_login_failed(self): self.client = Client() user = create_user() username = user.username - login_url = reverse('web.views.login') - response = self.client.post(login_url, data={'username': username, 'password': 'wrong_password'}, follow=False) - self.assertEqual(302, response.status_code) - self.assertEqual('/login?error=login_failed', response.url) + login_url = reverse(settings.LOGIN_URL) + response = self.client.post(login_url, data={'auth-username': username, 'auth-password': 'wrong-password', + 'login_view-current_step': 'auth'}, follow=False) + self.assertContains(response, 'Please enter a correct') + self.assertContains(response, 'and password.') self.assertFalse(django_auth.get_user(self.client).is_authenticated()) def test_logout(self): self.test_login() - self.client.get(reverse('web.views.logout')) + self.client.get(reverse('logout')) self.assertFalse(django_auth.get_user(self.client).is_authenticated()) diff --git a/smash/web/urls.py b/smash/web/urls.py index 137dd4f057f70f914fdcf71b684b27271fce9e15..9c50298a34d5ce746718a97beb8d5c379615048b 100644 --- a/smash/web/urls.py +++ b/smash/web/urls.py @@ -17,12 +17,22 @@ from django.conf import settings from django.conf.urls import include from django.conf.urls import url from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import logout +from django.views.defaults import page_not_found from django.views.generic import TemplateView from web import views urlpatterns = [ + # make sure that users cannot disable two factors authentication + + url( + r'^account/two_factor/disable/$', + page_not_found, + {'exception': Exception('Not Found')} + ), + #################### # APPOINTMENTS # #################### @@ -118,7 +128,8 @@ urlpatterns = [ #################### - url(r'^daily_planning$', login_required(TemplateView.as_view(template_name='daily_planning.html')), name='web.views.daily_planning'), + url(r'^daily_planning$', login_required(TemplateView.as_view(template_name='daily_planning.html')), + name='web.views.daily_planning'), #################### # LANGUAGES # @@ -154,8 +165,9 @@ urlpatterns = [ # AUTH # #################### - url(r'^login$', views.auth.login, name='web.views.login'), - url(r'^logout$', views.auth.logout, name='web.views.logout'), + # url(r'^login$', views.auth.login, name='web.views.login'), + # url(r'^logout$', views.auth.logout, name='web.views.logout'), + url(r'^logout$', logout, name='logout'), url(r'^$', views.index, name='web.views.index') ] diff --git a/smash/web/views/__init__.py b/smash/web/views/__init__.py index 4322cb370c651c5e613bf6f708f68a559aa7c315..49fc1dceaf3ea3695f54ad8066803bc41dab7d9a 100644 --- a/smash/web/views/__init__.py +++ b/smash/web/views/__init__.py @@ -1,5 +1,5 @@ # coding=utf-8 - +from django.conf import settings from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from django.views.generic.base import ContextMixin @@ -16,7 +16,7 @@ handler400 = 'web.views.e400_bad_request' def index(request): if request.user.is_authenticated(): return redirect('web.views.appointments') - return redirect('web.views.login') + return redirect(getattr(settings, "LOGIN_URL")) def e404_page_not_found(request, context=None): diff --git a/smash/web/views/auth.py b/smash/web/views/auth.py index 2e095773c321d1a6b201d3c50d582f36e37e5e99..1a293f4ae4c379610fbf69b959f93dc79dcf4060 100644 --- a/smash/web/views/auth.py +++ b/smash/web/views/auth.py @@ -1,8 +1,11 @@ # coding=utf-8 +from django.conf import settings from django.shortcuts import redirect, render from ..auth import do_login, do_logout +login_url = getattr(settings, "LOGIN_URL") + def login(request): context = { @@ -22,9 +25,9 @@ def login(request): # FIXME: risk of phishing attacks # see https://www.owasp.org/index.php/Unvalidated_Redirects_and_Forwards_Cheat_Sheet else: - return redirect('web.views.appointments') + return redirect(login_url) else: - response = redirect('web.views.login') + response = redirect(login_url) response['Location'] += "?error={}".format(message) return response return render(request, "login.html", context) @@ -32,6 +35,6 @@ def login(request): def logout(request): state, message = do_logout(request) - response = redirect('web.views.login') + response = redirect(login_url) response['Location'] += "?error={}".format(message) return response