Commit ca15148f by Czémán Arnold

Merge branch 'master' into issue_458

Conflicts:
	circle/dashboard/views/util.py
parents c894c76d 56975960
Pipeline #212 passed with stage
in 0 seconds
......@@ -567,3 +567,5 @@ BLACKLIST_HOOK_URL = get_env_variable("BLACKLIST_HOOK_URL", "")
REQUEST_HOOK_URL = get_env_variable("REQUEST_HOOK_URL", "")
SSHKEY_EMAIL_ADD_KEY = False
TWO_FACTOR_ISSUER = get_env_variable("TWO_FACTOR_ISSUER", "CIRCLE")
......@@ -25,7 +25,9 @@ from django.shortcuts import redirect
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 firewall.views import add_blacklist_item
......@@ -52,8 +54,12 @@ urlpatterns = patterns(
{'password_reset_form': CirclePasswordResetForm},
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'^two-factor-login/$', TwoFactorLoginView.as_view(),
name="two-factor-login"),
url(r'^info/help/$', HelpView.as_view(template_name="info/help.html"),
name="info.help"),
url(r'^info/policy/$',
......
......@@ -20,6 +20,8 @@ from __future__ import absolute_import
from datetime import timedelta
from urlparse import urlparse
import pyotp
from django.forms import ModelForm
from django.contrib.auth.forms import (
AuthenticationForm, PasswordResetForm, SetPasswordForm,
......@@ -1298,21 +1300,29 @@ class UserEditForm(forms.ModelForm):
instance_limit = forms.IntegerField(
label=_('Instance limit'),
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):
super(UserEditForm, self).__init__(*args, **kwargs)
self.fields["instance_limit"].initial = (
self.instance.profile.instance_limit)
self.fields["two_factor_secret"].initial = (
self.instance.profile.two_factor_secret)
class Meta:
model = User
fields = ('email', 'first_name', 'last_name', 'instance_limit',
'is_active')
'is_active', "two_factor_secret", )
def save(self, commit=True):
user = super(UserEditForm, self).save()
user.profile.instance_limit = (
self.cleaned_data['instance_limit'] or None)
user.profile.two_factor_secret = (
self.cleaned_data['two_factor_secret'] or None)
user.profile.save()
return user
......@@ -1633,9 +1643,9 @@ class MessageForm(ModelForm):
fields = ("message", "enabled", "effect", "start", "end")
help_texts = {
'start': _("Start time of the message in "
"YYYY.DD.MM. hh.mm.ss format."),
"YYYY-MM-DD hh:mm:ss format."),
'end': _("End time of the message in "
"YYYY.DD.MM. hh.mm.ss format."),
"YYYY-MM-DD hh:mm:ss format."),
'effect': _('The color of the message box defined by the '
'respective '
'<a href="http://getbootstrap.com/components/#alerts">'
......@@ -1651,3 +1661,24 @@ class MessageForm(ModelForm):
helper = FormHelper()
helper.add_input(Submit("submit", _("Save")))
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):
verbose_name=_('disk quota'),
default=2048 * 1024 * 1024,
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):
""" Generate connection command based on template."""
......@@ -395,9 +399,6 @@ if hasattr(settings, 'SAML_ORG_ID_ATTRIBUTE'):
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):
profile = kwargs.get('instance')
......
......@@ -1533,3 +1533,33 @@ textarea[name="new_members"] {
#manage-access-select-all {
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() {
}
});
});
});
{% 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 @@
<legend>{% trans "Password change" %}</legend>
{% crispy forms.change_password %}
</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 class="col-md-4" style="margin-bottom: 50px;">
<fieldset>
......
......@@ -17,6 +17,8 @@
import json
import pyotp
# from unittest import skip
from django.test import TestCase
from django.test.client import Client
......@@ -39,10 +41,12 @@ settings = django.conf.settings.FIREWALL_SETTINGS
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,
'password': password})
'password': password},
follow=follow)
self.assertNotEqual(response.status_code, 403)
return response
class VmDetailTest(LoginMixin, MockCeleryMixin, TestCase):
......@@ -1817,3 +1821,86 @@ class LeaseDetailTest(LoginMixin, TestCase):
# redirect to the login page
self.assertEqual(response.status_code, 403)
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 (
UserList,
StorageDetail, DiskDetail,
MessageList, MessageDetail, MessageCreate, MessageDelete,
EnableTwoFactorView, DisableTwoFactorView,
)
from .views.vm import vm_ops, vm_mass_ops
from .views.node import node_ops
......@@ -179,6 +180,10 @@ urlpatterns = patterns(
url(r'^profile/(?P<username>[^/]+)/$', ProfileView.as_view(),
name="dashboard.views.profile"),
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+)/$',
GroupRemoveUserView.as_view(),
......
......@@ -19,16 +19,15 @@ from __future__ import unicode_literals, absolute_import
import json
import logging
import pyotp
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth import login, authenticate
from django.contrib.auth.models import User, Group
from django.contrib.auth.views import login as login_view
from django.contrib.messages.views import SuccessMessageMixin
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, reverse_lazy
from django.core.paginator import Paginator, InvalidPage
from django.db.models import Q
......@@ -38,7 +37,7 @@ from django.templatetags.static import static
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
TemplateView, View, UpdateView, CreateView,
TemplateView, View, UpdateView, CreateView, FormView
)
from django_sshkey.models import UserKey
......@@ -51,19 +50,27 @@ from vm.models import Instance, InstanceTemplate
from ..forms import (
CircleAuthenticationForm, MyProfileForm, UserCreationForm, UnsubscribeForm,
UserKeyForm, CirclePasswordChangeForm, ConnectCommandForm,
UserListSearchForm, UserEditForm,
UserListSearchForm, UserEditForm, TwoFactorForm, TwoFactorConfirmationForm,
)
from ..models import Profile, GroupProfile, ConnectCommand
from ..tables import (
UserKeyListTable, ConnectCommandListTable, UserListTable,
)
from .util import saml_available, DeleteViewBase
from .util import saml_available, DeleteViewBase, LoginView
logger = logging.getLogger(__name__)
def set_session_expiry(request, user):
if user.is_superuser:
messages.info(request, _("You've logged in with an administrator "
"account, your session will expire when "
"the web browser is closed."))
request.session.set_expiry(0)
class NotificationView(LoginRequiredMixin, TemplateView):
def get_template_names(self):
......@@ -98,17 +105,30 @@ class NotificationView(LoginRequiredMixin, TemplateView):
return response
def circle_login(request):
authentication_form = CircleAuthenticationForm
extra_context = {
'saml2': saml_available,
'og_image': (settings.DJANGO_URL.rstrip("/") +
static("dashboard/img/og.png"))
}
response = login_view(request, authentication_form=authentication_form,
extra_context=extra_context)
set_language_cookie(request, response)
return response
class CircleLoginView(LoginView):
form_class = CircleAuthenticationForm
def get_context_data(self, **kwargs):
ctx = super(CircleLoginView, self).get_context_data(**kwargs)
ctx.update({
'saml2': saml_available,
'og_image': (settings.DJANGO_URL.rstrip("/") +
static("dashboard/img/og.png"))
})
return ctx
def form_valid(self, form):
user = form.get_user()
if hasattr(user, "profile") and user.profile.two_factor_secret:
self.request.session['two-fa-user'] = user.pk
self.request.session['two-fa-redirect'] = self.get_success_url()
self.request.session['login-type'] = "password"
return redirect(reverse("two-factor-login"))
else:
response = super(CircleLoginView, self).form_valid(form)
set_language_cookie(self.request, response)
set_session_expiry(self.request, user)
return response
class TokenLogin(View):
......@@ -555,3 +575,195 @@ class UserList(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
qs = qs.filter(filters)
return qs.select_related("profile")
class EnableTwoFactorView(LoginRequiredMixin, UpdateView):
model = Profile
form_class = TwoFactorForm
template_name = "dashboard/enable-two-factor.html"
success_url = reverse_lazy("dashboard.views.profile-preferences")
def dispatch(self, *args, **kwargs):
if self.get_object().two_factor_secret:
messages.info(self.request, _("Two-factor authentication is al"
"ready enabled for your account."))
return redirect(reverse("dashboard.index"))
return super(EnableTwoFactorView, self).dispatch(*args, **kwargs)
def get_object(self, queryset=None):
if self.request.user.is_anonymous():
raise PermissionDenied
return self.request.user.profile
def get_context_data(self, **kwargs):
ctx = super(EnableTwoFactorView, self).get_context_data(**kwargs)
random_base32 = pyotp.random_base32()
ctx['uri'] = pyotp.TOTP(random_base32).provisioning_uri(
self.request.user.username, issuer_name=settings.TWO_FACTOR_ISSUER)
ctx['secret'] = random_base32
return ctx
class DisableTwoFactorView(LoginRequiredMixin, FormView):
form_class = TwoFactorConfirmationForm
template_name = "dashboard/disable-two-factor.html"
success_url = reverse_lazy("dashboard.views.profile-preferences")
def get_profile(self, queryset=None):
if self.request.user.is_anonymous():
raise PermissionDenied
return self.request.user.profile
def get_form_kwargs(self):
kwargs = super(DisableTwoFactorView, self).get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
profile = self.get_profile()
profile.two_factor_secret = ""
profile.save()
return super(DisableTwoFactorView, self).form_valid(form)
class TwoFactorLoginView(FormView):
form_class = TwoFactorConfirmationForm
template_name = "registration/two-factor-login.html"
def dispatch(self, *args, **kwargs):
if not self.request.session.get('two-fa-user'):
return redirect("/")
return super(TwoFactorLoginView, self).dispatch(*args, **kwargs)
def get_user(self):
return User.objects.get(pk=self.request.session['two-fa-user'])
def get_form_kwargs(self):
kwargs = super(TwoFactorLoginView, self).get_form_kwargs()
kwargs['user'] = self.get_user()
return kwargs
def get_context_data(self, **kwargs):
ctx = super(TwoFactorLoginView, self).get_context_data(**kwargs)
ctx['user'] = self.get_user()
return ctx
def form_valid(self, form):
user = self.get_user()
if self.request.session['login-type'] == "saml2":
user.backend = 'common.backends.Saml2Backend'
else:
user.backend = 'django.contrib.auth.backends.ModelBackend'
login(self.request, user)
response = redirect(self.request.session['two-fa-redirect'])
set_language_cookie(self.request, response)
set_session_expiry(self.request, user)
return response
if hasattr(settings, 'SAML_ORG_ID_ATTRIBUTE'):
from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from djangosaml2.cache import IdentityCache, OutstandingQueriesCache
from djangosaml2.conf import get_config
from djangosaml2.signals import post_authenticated
from djangosaml2.utils import get_custom_setting
from saml2.client import Saml2Client
from saml2.ident import code
from saml2 import BINDING_HTTP_POST
@require_POST
@csrf_exempt
def circle_assertion_consumer_service(request,
config_loader_path=None,
attribute_mapping=None,
create_unknown_user=None):
"""SAML Authorization Response endpoint
The IdP will send its response to this view, which
will process it with pysaml2 help and log the user
in using the custom Authorization backend or redirect to 2fa
djangosaml2.backends.Saml2Backend that should be
enabled in the settings.py
"""
attribute_mapping = attribute_mapping or get_custom_setting(
'SAML_ATTRIBUTE_MAPPING', {'uid': ('username', )})
create_unknown_user = create_unknown_user or get_custom_setting(
'SAML_CREATE_UNKNOWN_USER', True)
logger.debug('Assertion Consumer Service started')
conf = get_config(config_loader_path, request)
if 'SAMLResponse' not in request.POST:
return HttpResponseBadRequest(
'Couldn\'t find "SAMLResponse" in POST data.')
xmlstr = request.POST['SAMLResponse']
client = Saml2Client(
conf, identity_cache=IdentityCache(request.session))
oq_cache = OutstandingQueriesCache(request.session)
outstanding_queries = oq_cache.outstanding_queries()
# process the authentication response
response = client.parse_authn_request_response(
xmlstr, BINDING_HTTP_POST, outstanding_queries)
if response is None:
logger.error('SAML response is None')
return HttpResponseBadRequest(
"SAML response has errors. Please check the logs")
session_id = response.session_id()
oq_cache.delete(session_id)
# authenticate the remote user
session_info = response.session_info()
if callable(attribute_mapping):
attribute_mapping = attribute_mapping()
if callable(create_unknown_user):
create_unknown_user = create_unknown_user()
logger.debug('Trying to authenticate the user')
user = authenticate(session_info=session_info,
attribute_mapping=attribute_mapping,
create_unknown_user=create_unknown_user)
if user is None:
logger.error('The user is None')
return HttpResponseForbidden("Permission denied")
# redirect the user to the view where he came from
relay_state = request.POST.get('RelayState', '/')
if not relay_state:
logger.warning('The RelayState parameter exists but is empty')
relay_state = settings.LOGIN_REDIRECT_URL
logger.debug('Redirecting to the RelayState: ' + relay_state)
if hasattr(user, "profile") and user.profile.two_factor_secret:
request.session['two-fa-user'] = user.pk
request.session['two-fa-redirect'] = relay_state
request.session['login-type'] = "saml2"
return redirect(reverse("two-factor-login"))
else:
login(request, user)
set_session_expiry(request, user)
def _set_subject_id(session, subject_id):
session['_saml2_subject_id'] = code(subject_id)
_set_subject_id(request.session, session_info['name_id'])
logger.debug('Sending the post_authenticated signal')
post_authenticated.send_robust(sender=user,
session_info=session_info)
# redirect the user to the view where he came from
return redirect(relay_state)
from djangosaml2 import views as saml2_views
saml2_views.assertion_consumer_service = circle_assertion_consumer_service
......@@ -23,19 +23,28 @@ from collections import OrderedDict
from urlparse import urljoin
from django.conf import settings