Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
Gelencsér Szabolcs
/
circlestack
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
ca15148f
authored
Sep 12, 2016
by
Czémán Arnold
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'master' into issue_458
Conflicts: circle/dashboard/views/util.py
parents
c894c76d
56975960
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
629 additions
and
37 deletions
+629
-37
circle/circle/settings/base.py
+2
-0
circle/circle/urls.py
+8
-2
circle/dashboard/forms.py
+34
-3
circle/dashboard/migrations/0005_profile_two_factor_secret.py
+19
-0
circle/dashboard/models.py
+4
-3
circle/dashboard/static/dashboard/dashboard.less
+30
-0
circle/dashboard/static/dashboard/profile.js
+0
-1
circle/dashboard/templates/dashboard/disable-two-factor.html
+30
-0
circle/dashboard/templates/dashboard/enable-two-factor.html
+53
-0
circle/dashboard/templates/dashboard/profile_form.html
+22
-0
circle/dashboard/tests/test_views.py
+89
-2
circle/dashboard/urls.py
+5
-0
circle/dashboard/views/user.py
+231
-19
circle/dashboard/views/util.py
+71
-4
circle/templates/registration/two-factor-login.html
+27
-0
requirements/base.txt
+3
-2
requirements/production.txt
+1
-1
No files found.
circle/circle/settings/base.py
View file @
ca15148f
...
...
@@ -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"
)
circle/circle/urls.py
View file @
ca15148f
...
...
@@ -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/$'
,
...
...
circle/dashboard/forms.py
View file @
ca15148f
...
...
@@ -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."
))
circle/dashboard/migrations/0005_profile_two_factor_secret.py
0 → 100644
View file @
ca15148f
# -*- 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
),
),
]
circle/dashboard/models.py
View file @
ca15148f
...
...
@@ -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'
)
...
...
circle/dashboard/static/dashboard/dashboard.less
View file @
ca15148f
...
...
@@ -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;
}
}
circle/dashboard/static/dashboard/profile.js
View file @
ca15148f
...
...
@@ -22,6 +22,5 @@ $(function() {
}
});
});
});
circle/dashboard/templates/dashboard/disable-two-factor.html
0 → 100644
View file @
ca15148f
{% 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 %}
circle/dashboard/templates/dashboard/enable-two-factor.html
0 → 100644
View file @
ca15148f
{% 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 %}
circle/dashboard/templates/dashboard/profile_form.html
View file @
ca15148f
...
...
@@ -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>
...
...
circle/dashboard/tests/test_views.py
View file @
ca15148f
...
...
@@ -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
)]
)
circle/dashboard/urls.py
View file @
ca15148f
...
...
@@ -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
(),
...
...
circle/dashboard/views/user.py
View file @
ca15148f
...
...
@@ -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
circle/dashboard/views/util.py
View file @
ca15148f
...
...
@@ -23,19 +23,28 @@ from collections import OrderedDict
from
urlparse
import
urljoin
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.views
import
redirect_to_login
from
django.contrib.sites.shortcuts
import
get_current_site
from
django.core
import
signing
from
django.core.exceptions
import
PermissionDenied
,
SuspiciousOperation
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
,
Count
,
Sum
from
django.http
import
(
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.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
braces.views
import
LoginRequiredMixin
...
...
@@ -765,3 +774,61 @@ class DeleteViewBase(LoginRequiredMixin, DeleteView):
else
:
messages
.
success
(
request
,
self
.
success_message
)
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
circle/templates/registration/two-factor-login.html
0 → 100644
View file @
ca15148f
{% 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 %}
requirements/base.txt
View file @
ca15148f
amqp==1.4.
6
amqp==1.4.
7
anyjson==0.3.3
arrow==0.7.0
billiard==3.3.0.20
...
...
@@ -19,7 +19,7 @@ django-taggit==0.14.0
docutils==0.12
Jinja2==2.7.3
jsonfield==1.0.3
kombu==3.0.
26
kombu==3.0.
30
logutils==0.3.3
MarkupSafe==0.23
netaddr==0.7.14
...
...
@@ -29,6 +29,7 @@ Pygments==2.0.2
pylibmc==1.4.3
python-dateutil==2.4.2
pyinotify==0.9.5
pyotp==2.1.1
pytz==2015.4
requests==2.7.0
salt==2014.7.1
...
...
requirements/production.txt
View file @
ca15148f
# Pro-tip: Try not to put anything here. There should be no dependency in
# production that isn't in development.
-r base.txt
uWSGI==2.0.1
0
uWSGI==2.0.1
3.1
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment