Commit 905f484d by Kálmán Viktor

Merge branch 'feature-2fa' into 'master'

Feature two-factor auth



See merge request !386
parents 99db76d8 da63d852
Pipeline #203 passed with stage
in 0 seconds
...@@ -567,3 +567,5 @@ BLACKLIST_HOOK_URL = get_env_variable("BLACKLIST_HOOK_URL", "") ...@@ -567,3 +567,5 @@ BLACKLIST_HOOK_URL = get_env_variable("BLACKLIST_HOOK_URL", "")
REQUEST_HOOK_URL = get_env_variable("REQUEST_HOOK_URL", "") REQUEST_HOOK_URL = get_env_variable("REQUEST_HOOK_URL", "")
SSHKEY_EMAIL_ADD_KEY = False SSHKEY_EMAIL_ADD_KEY = False
TWO_FACTOR_ISSUER = get_env_variable("TWO_FACTOR_ISSUER", "CIRCLE")
...@@ -25,7 +25,9 @@ from django.shortcuts import redirect ...@@ -25,7 +25,9 @@ from django.shortcuts import redirect
from circle.settings.base import get_env_variable from circle.settings.base import get_env_variable
from dashboard.views import circle_login, HelpView, ResizeHelpView from dashboard.views import (
CircleLoginView, HelpView, ResizeHelpView, TwoFactorLoginView
)
from dashboard.forms import CirclePasswordResetForm, CircleSetPasswordForm from dashboard.forms import CirclePasswordResetForm, CircleSetPasswordForm
from firewall.views import add_blacklist_item from firewall.views import add_blacklist_item
...@@ -52,8 +54,12 @@ urlpatterns = patterns( ...@@ -52,8 +54,12 @@ urlpatterns = patterns(
{'password_reset_form': CirclePasswordResetForm}, {'password_reset_form': CirclePasswordResetForm},
name="accounts.password-reset", name="accounts.password-reset",
), ),
url(r'^accounts/login/?$', circle_login, name="accounts.login"), url(r'^accounts/login/?$', CircleLoginView.as_view(),
name="accounts.login"),
url(r'^accounts/', include('django.contrib.auth.urls')), url(r'^accounts/', include('django.contrib.auth.urls')),
url(r'^two-factor-login/$', TwoFactorLoginView.as_view(),
name="two-factor-login"),
url(r'^info/help/$', HelpView.as_view(template_name="info/help.html"), url(r'^info/help/$', HelpView.as_view(template_name="info/help.html"),
name="info.help"), name="info.help"),
url(r'^info/policy/$', url(r'^info/policy/$',
......
...@@ -20,6 +20,8 @@ from __future__ import absolute_import ...@@ -20,6 +20,8 @@ from __future__ import absolute_import
from datetime import timedelta from datetime import timedelta
from urlparse import urlparse from urlparse import urlparse
import pyotp
from django.forms import ModelForm from django.forms import ModelForm
from django.contrib.auth.forms import ( from django.contrib.auth.forms import (
AuthenticationForm, PasswordResetForm, SetPasswordForm, AuthenticationForm, PasswordResetForm, SetPasswordForm,
...@@ -1298,21 +1300,29 @@ class UserEditForm(forms.ModelForm): ...@@ -1298,21 +1300,29 @@ class UserEditForm(forms.ModelForm):
instance_limit = forms.IntegerField( instance_limit = forms.IntegerField(
label=_('Instance limit'), label=_('Instance limit'),
min_value=0, widget=NumberInput) min_value=0, widget=NumberInput)
two_factor_secret = forms.CharField(
label=_('Two-factor authentication secret'),
help_text=_("Remove the secret key to disable two-factor "
"authentication for this user."), required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(UserEditForm, self).__init__(*args, **kwargs) super(UserEditForm, self).__init__(*args, **kwargs)
self.fields["instance_limit"].initial = ( self.fields["instance_limit"].initial = (
self.instance.profile.instance_limit) self.instance.profile.instance_limit)
self.fields["two_factor_secret"].initial = (
self.instance.profile.two_factor_secret)
class Meta: class Meta:
model = User model = User
fields = ('email', 'first_name', 'last_name', 'instance_limit', fields = ('email', 'first_name', 'last_name', 'instance_limit',
'is_active') 'is_active', "two_factor_secret", )
def save(self, commit=True): def save(self, commit=True):
user = super(UserEditForm, self).save() user = super(UserEditForm, self).save()
user.profile.instance_limit = ( user.profile.instance_limit = (
self.cleaned_data['instance_limit'] or None) self.cleaned_data['instance_limit'] or None)
user.profile.two_factor_secret = (
self.cleaned_data['two_factor_secret'] or None)
user.profile.save() user.profile.save()
return user return user
...@@ -1650,3 +1660,24 @@ class MessageForm(ModelForm): ...@@ -1650,3 +1660,24 @@ class MessageForm(ModelForm):
helper = FormHelper() helper = FormHelper()
helper.add_input(Submit("submit", _("Save"))) helper.add_input(Submit("submit", _("Save")))
return helper return helper
class TwoFactorForm(ModelForm):
class Meta:
model = Profile
fields = ["two_factor_secret", ]
class TwoFactorConfirmationForm(forms.Form):
confirmation_code = forms.CharField(
label=_('Two-factor authentication passcode'),
help_text=_("Get the code from your authenticator."))
def __init__(self, user, *args, **kwargs):
self.user = user
super(TwoFactorConfirmationForm, self).__init__(*args, **kwargs)
def clean_confirmation_code(self):
totp = pyotp.TOTP(self.user.profile.two_factor_secret)
if not totp.verify(self.cleaned_data.get('confirmation_code')):
raise ValidationError(_("Invalid confirmation code."))
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0004_profile_desktop_notifications'),
]
operations = [
migrations.AddField(
model_name='profile',
name='two_factor_secret',
field=models.CharField(max_length=32, null=True, verbose_name='two factor secret key', blank=True),
),
]
...@@ -200,6 +200,10 @@ class Profile(Model): ...@@ -200,6 +200,10 @@ class Profile(Model):
verbose_name=_('disk quota'), verbose_name=_('disk quota'),
default=2048 * 1024 * 1024, default=2048 * 1024 * 1024,
help_text=_('Disk quota in mebibytes.')) help_text=_('Disk quota in mebibytes.'))
two_factor_secret = CharField(
verbose_name=_("two factor secret key"),
max_length=32, null=True, blank=True,
)
def get_connect_commands(self, instance, use_ipv6=False): def get_connect_commands(self, instance, use_ipv6=False):
""" Generate connection command based on template.""" """ Generate connection command based on template."""
...@@ -395,9 +399,6 @@ if hasattr(settings, 'SAML_ORG_ID_ATTRIBUTE'): ...@@ -395,9 +399,6 @@ if hasattr(settings, 'SAML_ORG_ID_ATTRIBUTE'):
pre_user_save.connect(save_org_id) pre_user_save.connect(save_org_id)
else:
logger.debug("Do not register save_org_id to djangosaml2 pre_user_save")
def update_store_profile(sender, **kwargs): def update_store_profile(sender, **kwargs):
profile = kwargs.get('instance') profile = kwargs.get('instance')
......
...@@ -1533,3 +1533,33 @@ textarea[name="new_members"] { ...@@ -1533,3 +1533,33 @@ textarea[name="new_members"] {
#manage-access-select-all { #manage-access-select-all {
cursor: pointer; cursor: pointer;
} }
#two-factor-qr {
text-align: center;
span, small {
display: block;
}
}
#two-factor-confirm {
text-align: center;
button {
margin-left: 15px;
}
}
#two-factor-box {
.help-block {
display: block;
}
h4 {
margin: 0;
}
hr {
margin: 15px 0 2px 0;
}
}
...@@ -22,6 +22,5 @@ $(function() { ...@@ -22,6 +22,5 @@ $(function() {
} }
}); });
}); });
}); });
{% extends "dashboard/base.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin">
<i class="fa fa-unlock"></i>
{% trans "Disable two-factor authentication" %}
</h3>
</div>
<div class="panel-body">
<form action="" method="POST">
{% csrf_token %}
<input type="hidden" value="" name="{{ form.two_factor_secret.name }}"/>
{{ form.confirmation_code|as_crispy_field }}
<button type="submit" class="btn btn-warning">
<i class="fa fa-unlock"></i>
{% trans "Disable" %}
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% extends "dashboard/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin">
<i class="fa fa-lock"></i>
{% trans "Enable two-factor authentication" %}
</h3>
</div>
<div class="panel-body">
{% blocktrans with lang=LANGUAGE_CODE %}
To use two-factor authentication you need to download Google Authenticator
and use the following qr code, secret key or link to set it up.
If you need help with the download or setup check out the
<a href="https://support.google.com/accounts/answer/1066447?hl={{ lang }}">
official help page.
</a>
{% endblocktrans %}
<hr />
<div id="two-factor-qr">
<span>
{% blocktrans with secret=secret %}
Your secret key is: <strong>{{ secret }}</strong>
{% endblocktrans %}
</span>
<img src="//chart.googleapis.com/chart?chs=255x255&chld=L|0&cht=qr&chl={{ uri }}"/>
<small><a href="{{ uri }}">{{ uri }}</a></small>
</div>
<hr />
<div id="two-factor-confirm">
<form action="" method="POST">
{% csrf_token %}
<input type="hidden" value="{{ secret }}" name="{{ form.two_factor_secret.name }}"/>
{% blocktrans %}
If you managed to set up the authenticator click enable to finalize two-factor
authentication for this account.
{% endblocktrans %}
<button type="submit" class="btn btn-success">
<i class="fa fa-lock"></i>
{% trans "Enable" %}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
...@@ -23,6 +23,28 @@ ...@@ -23,6 +23,28 @@
<legend>{% trans "Password change" %}</legend> <legend>{% trans "Password change" %}</legend>
{% crispy forms.change_password %} {% crispy forms.change_password %}
</fieldset> </fieldset>
<fieldset style="margin-top: 25px;">
<legend>{% trans "Two-factor authentication" %}</legend>
{% if profile.two_factor_secret %}
{% blocktrans %}
Two-factor authentication is currently enabled on your account. To disable it
click the button
{% endblocktrans %}
<a href="{% url "dashboard.views.profile-disable-two-factor" %}" class="btn btn-warning btn-xs">
<i class="fa fa-unlock"></i>
{% trans "Disable" %}
</a>
{% else %}
{% blocktrans %}
Two-factor authentication is currently disabled on your account. To enable it
click the button
{% endblocktrans %}
<a href="{% url "dashboard.views.profile-enable-two-factor" %}" class="btn btn-success btn-xs">
<i class="fa fa-lock"></i>
{% trans "Enable" %}
</a>
{% endif %}
</fieldset>
</div> </div>
<div class="col-md-4" style="margin-bottom: 50px;"> <div class="col-md-4" style="margin-bottom: 50px;">
<fieldset> <fieldset>
......
...@@ -17,6 +17,8 @@ ...@@ -17,6 +17,8 @@
import json import json
import pyotp
# from unittest import skip # from unittest import skip
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
...@@ -39,10 +41,12 @@ settings = django.conf.settings.FIREWALL_SETTINGS ...@@ -39,10 +41,12 @@ settings = django.conf.settings.FIREWALL_SETTINGS
class LoginMixin(object): class LoginMixin(object):
def login(self, client, username, password='password'): def login(self, client, username, password='password', follow=False):
response = client.post('/accounts/login/', {'username': username, response = client.post('/accounts/login/', {'username': username,
'password': password}) 'password': password},
follow=follow)
self.assertNotEqual(response.status_code, 403) self.assertNotEqual(response.status_code, 403)
return response
class VmDetailTest(LoginMixin, MockCeleryMixin, TestCase): class VmDetailTest(LoginMixin, MockCeleryMixin, TestCase):
...@@ -1817,3 +1821,86 @@ class LeaseDetailTest(LoginMixin, TestCase): ...@@ -1817,3 +1821,86 @@ class LeaseDetailTest(LoginMixin, TestCase):
# redirect to the login page # redirect to the login page
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertEqual(leases, Lease.objects.count()) self.assertEqual(leases, Lease.objects.count())
class TwoFactorTest(LoginMixin, TestCase):
def setUp(self):
self.u1 = User.objects.create(username='user1', first_name="Bela",
last_name="Akkounter")
self.u1.set_password('password')
self.u1.save()
self.p1 = Profile.objects.create(
user=self.u1, two_factor_secret=pyotp.random_base32())
self.p1.save()
self.u2 = User.objects.create(username='user2', is_staff=True)
self.u2.set_password('password')
self.u2.save()
self.p2 = Profile.objects.create(user=self.u2)
self.p2.save()
def tearDown(self):
super(TwoFactorTest, self).tearDown()
self.u1.delete()
self.u2.delete()
def test_login_wo_2fa_by_redirect(self):
c = Client()
response = self.login(c, 'user2')
self.assertRedirects(response, "/", target_status_code=302)
def test_login_w_2fa_by_redirect(self):
c = Client()
response = self.login(c, 'user1')
self.assertRedirects(response, "/two-factor-login/")
def test_login_wo_2fa_by_content(self):
c = Client()
response = self.login(c, 'user2', follow=True)
self.assertTemplateUsed(response, "dashboard/index.html")
self.assertContains(response, "You have no permission to start "
"or manage virtual machines.")
def test_login_w_2fa_by_conent(self):
c = Client()
r = self.login(c, 'user1', follow=True)
self.assertTemplateUsed(r, "registration/two-factor-login.html")
self.assertContains(r, "Welcome Bela Akkounter (user1)!")
def test_successful_2fa_login(self):
c = Client()
self.login(c, 'user1')
code = pyotp.TOTP(self.p1.two_factor_secret).now()
r = c.post("/two-factor-login/", {'confirmation_code': code},
follow=True)
self.assertContains(r, "You have no permission to start "
"or manage virtual machines.")
def test_unsuccessful_2fa_login(self):
c = Client()
self.login(c, 'user1')
r = c.post("/two-factor-login/", {'confirmation_code': "nudli"})
self.assertTemplateUsed(r, "registration/two-factor-login.html")
self.assertContains(r, "Welcome Bela Akkounter (user1)!")
def test_straight_to_2fa_as_anonymous(self):
c = Client()
response = c.get("/two-factor-login/", follow=True)
self.assertItemsEqual(
response.redirect_chain,
[('http://testserver/', 302),
('http://testserver/dashboard/', 302),
('http://testserver/accounts/login/?next=/dashboard/', 302)]
)
def test_straight_to_2fa_as_user(self):
c = Client()
self.login(c, 'user2')
response = c.get("/two-factor-login/", follow=True)
self.assertItemsEqual(
response.redirect_chain,
[('http://testserver/', 302),
('http://testserver/dashboard/', 302)]
)
...@@ -55,6 +55,7 @@ from .views import ( ...@@ -55,6 +55,7 @@ from .views import (
UserList, UserList,
StorageDetail, DiskDetail, StorageDetail, DiskDetail,
MessageList, MessageDetail, MessageCreate, MessageDelete, MessageList, MessageDetail, MessageCreate, MessageDelete,
EnableTwoFactorView, DisableTwoFactorView,
) )
from .views.vm import vm_ops, vm_mass_ops from .views.vm import vm_ops, vm_mass_ops
from .views.node import node_ops from .views.node import node_ops
...@@ -179,6 +180,10 @@ urlpatterns = patterns( ...@@ -179,6 +180,10 @@ urlpatterns = patterns(
url(r'^profile/(?P<username>[^/]+)/$', ProfileView.as_view(), url(r'^profile/(?P<username>[^/]+)/$', ProfileView.as_view(),
name="dashboard.views.profile"), name="dashboard.views.profile"),
url(r'^profile/(?P<username>[^/]+)/use_gravatar/$', toggle_use_gravatar), url(r'^profile/(?P<username>[^/]+)/use_gravatar/$', toggle_use_gravatar),
url(r'^profile/two-factor/enable/$', EnableTwoFactorView.as_view(),
name="dashboard.views.profile-enable-two-factor"),
url(r'^profile/two-factor/disable/$', DisableTwoFactorView.as_view(),
name="dashboard.views.profile-disable-two-factor"),
url(r'^group/(?P<group_pk>\d+)/remove/user/(?P<member_pk>\d+)/$', url(r'^group/(?P<group_pk>\d+)/remove/user/(?P<member_pk>\d+)/$',
GroupRemoveUserView.as_view(), GroupRemoveUserView.as_view(),
......
...@@ -23,19 +23,28 @@ from collections import OrderedDict ...@@ -23,19 +23,28 @@ from collections import OrderedDict
from urlparse import urljoin from urlparse import urljoin
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth import REDIRECT_FIELD_NAME, login as auth_login
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.contrib.auth.views import redirect_to_login
from django.contrib.sites.shortcuts import get_current_site
from django.core import signing from django.core import signing
from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.contrib import messages
from django.contrib.auth.views import redirect_to_login
from django.db.models import Q from django.db.models import Q
from django.http import ( from django.http import (
HttpResponse, Http404, HttpResponseRedirect, JsonResponse HttpResponse, Http404, HttpResponseRedirect, JsonResponse
) )
from django.shortcuts import redirect, render from django.shortcuts import redirect, render, resolve_url
from django.utils.http import is_safe_url
from django.utils.translation import ugettext_lazy as _, ugettext_noop from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django.views.generic import DetailView, View, DeleteView
from django.utils.decorators import method_decorator
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 DetailView, View, DeleteView, FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from braces.views import LoginRequiredMixin from braces.views import LoginRequiredMixin
...@@ -747,3 +756,61 @@ class DeleteViewBase(LoginRequiredMixin, DeleteView): ...@@ -747,3 +756,61 @@ class DeleteViewBase(LoginRequiredMixin, DeleteView):
else: else:
messages.success(request, self.success_message) messages.success(request, self.success_message)
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
# only in Django 1.9
class LoginView(FormView):
"""
Displays the login form and handles the login action.
"""
form_class = AuthenticationForm
authentication_form = None
redirect_field_name = REDIRECT_FIELD_NAME
template_name = 'registration/login.html'
redirect_authenticated_user = False
extra_context = None
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if (self.redirect_authenticated_user and
self.request.user.is_authenticated):
redirect_to = self.get_success_url()
if redirect_to == self.request.path:
raise ValueError(
"Redirection loop for authenticated user detected. Check "
"your LOGIN_REDIRECT_URL doesn't point to a login page."
)
return HttpResponseRedirect(redirect_to)
return super(LoginView, self).dispatch(request, *args, **kwargs)
def get_success_url(self):
"""Ensure the user-originating redirection URL is safe."""
redirect_to = self.request.POST.get(
self.redirect_field_name,
self.request.GET.get(self.redirect_field_name, '')
)
if not is_safe_url(url=redirect_to, host=self.request.get_host()):
return resolve_url(settings.LOGIN_REDIRECT_URL)
return redirect_to
def get_form_class(self):
return self.authentication_form or self.form_class
def form_valid(self, form):
"""Security check complete. Log the user in."""
auth_login(self.request, form.get_user())
return HttpResponseRedirect(self.get_success_url())
def get_context_data(self, **kwargs):
context = super(LoginView, self).get_context_data(**kwargs)
current_site = get_current_site(self.request)
context.update({
self.redirect_field_name: self.get_success_url(),
'site': current_site,
'site_name': current_site.name,
})
if self.extra_context is not None:
context.update(self.extra_context)
return context
{% extends "registration/base.html" %}
{% load staticfiles %}
{% load i18n %}
{% load crispy_forms_tags %}
{% get_current_language as LANGUAGE_CODE %}
{% block title-page %}{% trans "Two-factor authentication" %}{% endblock %}
{% block content_box %}
<div class="row" id="two-factor-box">
<div class="col-md-12">
<h4>
{% blocktrans with username=user.username full_name=user.get_full_name %}
Welcome {{ full_name }} ({{ username }})!
{% endblocktrans %}
</h4>
<hr/>
<form action="" method="POST">
{% csrf_token %}
{{ form.confirmation_code|as_crispy_field }}
<button type="submit" class="btn btn-success">
{% trans "Confirm" %}
</button>
</form>
</div>
</div>
{% endblock %}
...@@ -29,6 +29,7 @@ Pygments==2.0.2 ...@@ -29,6 +29,7 @@ Pygments==2.0.2
pylibmc==1.4.3 pylibmc==1.4.3
python-dateutil==2.4.2 python-dateutil==2.4.2
pyinotify==0.9.5 pyinotify==0.9.5
pyotp==2.1.1
pytz==2015.4 pytz==2015.4
requests==2.7.0 requests==2.7.0
salt==2014.7.1 salt==2014.7.1
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment