Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
CIRCLE
/
cloud
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
94
Merge Requests
10
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
905f484d
authored
Sep 08, 2016
by
Kálmán Viktor
Browse files
Options
Browse Files
Download
Plain Diff
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
Changes
16
Pipelines
1
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
393 additions
and
13 deletions
+393
-13
circle/circle/settings/base.py
+2
-0
circle/circle/urls.py
+8
-2
circle/dashboard/forms.py
+32
-1
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
+0
-0
circle/dashboard/views/util.py
+71
-4
circle/templates/registration/two-factor-login.html
+27
-0
requirements/base.txt
+1
-0
No files found.
circle/circle/settings/base.py
View file @
905f484d
...
...
@@ -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 @
905f484d
...
...
@@ -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 @
905f484d
...
...
@@ -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
...
...
@@ -1650,3 +1660,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 @
905f484d
# -*- 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 @
905f484d
...
...
@@ -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 @
905f484d
...
...
@@ -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 @
905f484d
...
...
@@ -22,6 +22,5 @@ $(function() {
}
});
});
});
circle/dashboard/templates/dashboard/disable-two-factor.html
0 → 100644
View file @
905f484d
{% 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 @
905f484d
{% 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 @
905f484d
...
...
@@ -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 @
905f484d
...
...
@@ -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 @
905f484d
...
...
@@ -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 @
905f484d
This diff is collapsed.
Click to expand it.
circle/dashboard/views/util.py
View file @
905f484d
...
...
@@ -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
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
...
...
@@ -747,3 +756,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 @
905f484d
{% 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 @
905f484d
...
...
@@ -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
...
...
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