Show More
@@ -48,15 +48,33 b' log = logging.getLogger(__name__)' | |||
|
48 | 48 | |
|
49 | 49 | class LdapSettingsController(BaseController): |
|
50 | 50 | |
|
51 | search_scope_choices = [('BASE', _('BASE'),), | |
|
52 | ('ONELEVEL', _('ONELEVEL'),), | |
|
53 | ('SUBTREE', _('SUBTREE'),), | |
|
54 | ] | |
|
55 | search_scope_default = 'SUBTREE' | |
|
56 | ||
|
57 | tls_reqcert_choices = [('NEVER', _('NEVER'),), | |
|
58 | ('ALLOW', _('ALLOW'),), | |
|
59 | ('TRY', _('TRY'),), | |
|
60 | ('DEMAND', _('DEMAND'),), | |
|
61 | ('HARD', _('HARD'),), | |
|
62 | ] | |
|
63 | tls_reqcert_default = 'DEMAND' | |
|
64 | ||
|
51 | 65 | @LoginRequired() |
|
52 | 66 | @HasPermissionAllDecorator('hg.admin') |
|
53 | 67 | def __before__(self): |
|
54 | 68 | c.admin_user = session.get('admin_user') |
|
55 | 69 | c.admin_username = session.get('admin_username') |
|
70 | c.search_scope_choices = self.search_scope_choices | |
|
71 | c.tls_reqcert_choices = self.tls_reqcert_choices | |
|
56 | 72 | super(LdapSettingsController, self).__before__() |
|
57 | 73 | |
|
58 | 74 | def index(self): |
|
59 | 75 | defaults = SettingsModel().get_ldap_settings() |
|
76 | c.search_scope_cur = defaults.get('ldap_search_scope') | |
|
77 | c.tls_reqcert_cur = defaults.get('ldap_tls_reqcert') | |
|
60 | 78 | |
|
61 | 79 | return htmlfill.render( |
|
62 | 80 | render('admin/ldap/ldap.html'), |
@@ -68,7 +86,8 b' class LdapSettingsController(BaseControl' | |||
|
68 | 86 | """POST ldap create and store ldap settings""" |
|
69 | 87 | |
|
70 | 88 | settings_model = SettingsModel() |
|
71 |
_form = LdapSettingsForm( |
|
|
89 | _form = LdapSettingsForm([x[0] for x in self.tls_reqcert_choices], | |
|
90 | [x[0] for x in self.search_scope_choices])() | |
|
72 | 91 | |
|
73 | 92 | try: |
|
74 | 93 | form_result = _form.to_python(dict(request.POST)) |
@@ -91,6 +110,9 b' class LdapSettingsController(BaseControl' | |||
|
91 | 110 | |
|
92 | 111 | except formencode.Invalid, errors: |
|
93 | 112 | |
|
113 | c.search_scope_cur = self.search_scope_default | |
|
114 | c.tls_reqcert_cur = self.search_scope_default | |
|
115 | ||
|
94 | 116 | return htmlfill.render( |
|
95 | 117 | render('admin/ldap/ldap.html'), |
|
96 | 118 | defaults=errors.value, |
@@ -103,7 +103,7 b' def authenticate(username, password):' | |||
|
103 | 103 | user = user_model.get_by_username(username, cache=False) |
|
104 | 104 | |
|
105 | 105 | log.debug('Authenticating user using RhodeCode account') |
|
106 |
if user is not None and user. |
|
|
106 | if user is not None and not user.ldap_dn: | |
|
107 | 107 | if user.active: |
|
108 | 108 | |
|
109 | 109 | if user.username == 'default' and user.active: |
@@ -122,7 +122,7 b' def authenticate(username, password):' | |||
|
122 | 122 | user_obj = user_model.get_by_username(username, cache=False, |
|
123 | 123 | case_insensitive=True) |
|
124 | 124 | |
|
125 |
if user_obj is not None and user_obj. |
|
|
125 | if user_obj is not None and not user_obj.ldap_dn: | |
|
126 | 126 | log.debug('this user already exists as non ldap') |
|
127 | 127 | return False |
|
128 | 128 | |
@@ -141,15 +141,25 b' def authenticate(username, password):' | |||
|
141 | 141 | 'bind_dn':ldap_settings.get('ldap_dn_user'), |
|
142 | 142 | 'bind_pass':ldap_settings.get('ldap_dn_pass'), |
|
143 | 143 | 'use_ldaps':ldap_settings.get('ldap_ldaps'), |
|
144 | 'tls_reqcert':ldap_settings.get('ldap_tls_reqcert'), | |
|
145 | 'ldap_filter':ldap_settings.get('ldap_filter'), | |
|
146 | 'search_scope':ldap_settings.get('ldap_search_scope'), | |
|
147 | 'attr_login':ldap_settings.get('ldap_attr_login'), | |
|
144 | 148 | 'ldap_version':3, |
|
145 | 149 | } |
|
146 | 150 | log.debug('Checking for ldap authentication') |
|
147 | 151 | try: |
|
148 | 152 | aldap = AuthLdap(**kwargs) |
|
149 |
|
|
|
150 |
log.debug('Got ldap response %s', |
|
|
153 | (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password) | |
|
154 | log.debug('Got ldap DN response %s', user_dn) | |
|
151 | 155 | |
|
152 | if user_model.create_ldap(username, password): | |
|
156 | user_attrs = { | |
|
157 | 'name' : ldap_attrs[ldap_settings.get('ldap_attr_firstname')][0], | |
|
158 | 'lastname' : ldap_attrs[ldap_settings.get('ldap_attr_lastname')][0], | |
|
159 | 'email' : ldap_attrs[ldap_settings.get('ldap_attr_email')][0], | |
|
160 | } | |
|
161 | ||
|
162 | if user_model.create_ldap(username, password, user_dn, user_attrs): | |
|
153 | 163 | log.info('created new ldap user') |
|
154 | 164 | |
|
155 | 165 | return True |
@@ -36,11 +36,15 b' except ImportError:' | |||
|
36 | 36 | class AuthLdap(object): |
|
37 | 37 | |
|
38 | 38 | def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='', |
|
39 |
use_ldaps=False, ldap_version=3 |
|
|
39 | use_ldaps=False, tls_reqcert='DEMAND', ldap_version=3, | |
|
40 | ldap_filter='(&(objectClass=user)(!(objectClass=computer)))', | |
|
41 | search_scope='SUBTREE', | |
|
42 | attr_login='uid'): | |
|
40 | 43 | self.ldap_version = ldap_version |
|
41 | 44 | if use_ldaps: |
|
42 | 45 | port = port or 689 |
|
43 | 46 | self.LDAP_USE_LDAPS = use_ldaps |
|
47 | self.TLS_REQCERT = ldap.__dict__['OPT_X_TLS_' + tls_reqcert] | |
|
44 | 48 | self.LDAP_SERVER_ADDRESS = server |
|
45 | 49 | self.LDAP_SERVER_PORT = port |
|
46 | 50 | |
@@ -55,6 +59,10 b' class AuthLdap(object):' | |||
|
55 | 59 | self.LDAP_SERVER_PORT) |
|
56 | 60 | |
|
57 | 61 | self.BASE_DN = base_dn |
|
62 | self.LDAP_FILTER = ldap_filter | |
|
63 | self.SEARCH_SCOPE = ldap.__dict__['SCOPE_' + search_scope] | |
|
64 | self.attr_login = attr_login | |
|
65 | ||
|
58 | 66 | |
|
59 | 67 | def authenticate_ldap(self, username, password): |
|
60 | 68 | """Authenticate a user via LDAP and return his/her LDAP properties. |
@@ -74,7 +82,13 b' class AuthLdap(object):' | |||
|
74 | 82 | raise LdapUsernameError("invalid character in username: ,") |
|
75 | 83 | try: |
|
76 | 84 | ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, '/etc/openldap/cacerts') |
|
85 | ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF) | |
|
86 | ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON) | |
|
87 | ldap.set_option(ldap.OPT_TIMEOUT, 20) | |
|
77 | 88 | ldap.set_option(ldap.OPT_NETWORK_TIMEOUT, 10) |
|
89 | ldap.set_option(ldap.OPT_TIMELIMIT, 15) | |
|
90 | if self.LDAP_USE_LDAPS: | |
|
91 | ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT) | |
|
78 | 92 | server = ldap.initialize(self.LDAP_SERVER) |
|
79 | 93 | if self.ldap_version == 2: |
|
80 | 94 | server.protocol = ldap.VERSION2 |
@@ -84,21 +98,29 b' class AuthLdap(object):' | |||
|
84 | 98 | if self.LDAP_BIND_DN and self.LDAP_BIND_PASS: |
|
85 | 99 | server.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS) |
|
86 | 100 | |
|
87 | dn = self.BASE_DN % {'user':uid} | |
|
88 |
log.debug("Authenticating %r at %s", |
|
|
89 | server.simple_bind_s(dn, password) | |
|
101 | filt = '(&%s(%s=%s))' % (self.LDAP_FILTER, self.attr_login, username) | |
|
102 | log.debug("Authenticating %r filt %s at %s", self.BASE_DN, filt, self.LDAP_SERVER) | |
|
103 | lobjects = server.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filt) | |
|
104 | ||
|
105 | if not lobjects: | |
|
106 | raise ldap.NO_SUCH_OBJECT() | |
|
90 | 107 | |
|
91 | properties = server.search_s(dn, ldap.SCOPE_SUBTREE) | |
|
92 |
|
|
|
93 | raise ldap.NO_SUCH_OBJECT() | |
|
108 | for (dn, attrs) in lobjects: | |
|
109 | try: | |
|
110 | server.simple_bind_s(dn, password) | |
|
111 | break | |
|
112 | ||
|
113 | except ldap.INVALID_CREDENTIALS, e: | |
|
114 | log.debug("LDAP rejected password for user '%s' (%s): %s", uid, username, dn) | |
|
115 | ||
|
116 | else: | |
|
117 | log.debug("No matching LDAP objecs for authentication of '%s' (%s)", uid, username) | |
|
118 | raise LdapPasswordError() | |
|
119 | ||
|
94 | 120 | except ldap.NO_SUCH_OBJECT, e: |
|
95 | 121 | log.debug("LDAP says no such user '%s' (%s)", uid, username) |
|
96 | 122 | raise LdapUsernameError() |
|
97 | except ldap.INVALID_CREDENTIALS, e: | |
|
98 | log.debug("LDAP rejected password for user '%s' (%s)", uid, username) | |
|
99 | raise LdapPasswordError() | |
|
100 | 123 | except ldap.SERVER_DOWN, e: |
|
101 | 124 | raise LdapConnectionError("LDAP can't access authentication server") |
|
102 | 125 | |
|
103 |
return |
|
|
104 | ||
|
126 | return (dn, attrs) |
@@ -336,7 +336,10 b' class DbManage(object):' | |||
|
336 | 336 | |
|
337 | 337 | try: |
|
338 | 338 | for k in ['ldap_active', 'ldap_host', 'ldap_port', 'ldap_ldaps', |
|
339 |
'ldap_dn_user', 'ldap_dn_pass', |
|
|
339 | 'ldap_tls_reqcert', 'ldap_dn_user', 'ldap_dn_pass', | |
|
340 | 'ldap_base_dn', 'ldap_filter', 'ldap_search_scope', | |
|
341 | 'ldap_attr_login', 'ldap_attr_firstname', 'ldap_attr_lastname', | |
|
342 | 'ldap_attr_email']: | |
|
340 | 343 | |
|
341 | 344 | setting = RhodeCodeSettings(k, '') |
|
342 | 345 | self.sa.add(setting) |
@@ -105,7 +105,7 b' class User(Base, BaseModel):' | |||
|
105 | 105 | lastname = Column("lastname", String(length=None, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) |
|
106 | 106 | email = Column("email", String(length=None, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) |
|
107 | 107 | last_login = Column("last_login", DateTime(timezone=False), nullable=True, unique=None, default=None) |
|
108 |
|
|
|
108 | ldap_dn = Column("ldap_dn", String(length=None, convert_unicode=False, assert_unicode=None), nullable=True, unique=None, default=None) | |
|
109 | 109 | |
|
110 | 110 | user_log = relationship('UserLog', cascade='all') |
|
111 | 111 | user_perms = relationship('UserToPerm', primaryjoin="User.user_id==UserToPerm.user_id", cascade='all') |
@@ -334,23 +334,14 b' class LdapLibValidator(formencode.valida' | |||
|
334 | 334 | raise LdapImportError |
|
335 | 335 | return value |
|
336 | 336 | |
|
337 |
class |
|
|
337 | class AttrLoginValidator(formencode.validators.FancyValidator): | |
|
338 | 338 | |
|
339 | 339 | def to_python(self, value, state): |
|
340 | 340 | |
|
341 | try: | |
|
342 | value % {'user':'valid'} | |
|
343 | ||
|
344 | if value.find('%(user)s') == -1: | |
|
345 | raise formencode.Invalid(_("You need to specify %(user)s in " | |
|
346 | "template for example uid=%(user)s " | |
|
347 | ",dc=company...") , | |
|
348 | value, state) | |
|
349 | ||
|
350 | except KeyError: | |
|
351 | raise formencode.Invalid(_("Wrong template used, only %(user)s " | |
|
352 | "is an valid entry") , | |
|
353 | value, state) | |
|
341 | if not value or not isinstance(value, (str, unicode)): | |
|
342 | raise formencode.Invalid(_("The LDAP Login attribute of the CN must be specified " | |
|
343 | "- this is the name of the attribute that is equivalent to 'username'"), | |
|
344 | value, state) | |
|
354 | 345 | |
|
355 | 346 | return value |
|
356 | 347 | |
@@ -521,7 +512,7 b' def DefaultPermissionsForm(perms_choices' | |||
|
521 | 512 | return _DefaultPermissionsForm |
|
522 | 513 | |
|
523 | 514 | |
|
524 | def LdapSettingsForm(): | |
|
515 | def LdapSettingsForm(tls_reqcert_choices, search_scope_choices): | |
|
525 | 516 | class _LdapSettingsForm(formencode.Schema): |
|
526 | 517 | allow_extra_fields = True |
|
527 | 518 | filter_extra_fields = True |
@@ -530,8 +521,15 b' def LdapSettingsForm():' | |||
|
530 | 521 | ldap_host = UnicodeString(strip=True,) |
|
531 | 522 | ldap_port = Number(strip=True,) |
|
532 | 523 | ldap_ldaps = StringBoolean(if_missing=False) |
|
524 | ldap_tls_reqcert = OneOf(tls_reqcert_choices) | |
|
533 | 525 | ldap_dn_user = UnicodeString(strip=True,) |
|
534 | 526 | ldap_dn_pass = UnicodeString(strip=True,) |
|
535 |
ldap_base_dn = |
|
|
527 | ldap_base_dn = UnicodeString(strip=True,) | |
|
528 | ldap_filter = UnicodeString(strip=True,) | |
|
529 | ldap_search_scope = OneOf(search_scope_choices) | |
|
530 | ldap_attr_login = All(AttrLoginValidator, UnicodeString(strip=True,)) | |
|
531 | ldap_attr_firstname = UnicodeString(strip=True,) | |
|
532 | ldap_attr_lastname = UnicodeString(strip=True,) | |
|
533 | ldap_attr_email = UnicodeString(strip=True,) | |
|
536 | 534 | |
|
537 | 535 | return _LdapSettingsForm |
@@ -72,10 +72,18 b' class SettingsModel(BaseModel):' | |||
|
72 | 72 | ldap_host |
|
73 | 73 | ldap_port |
|
74 | 74 | ldap_ldaps |
|
75 | ldap_tls_reqcert | |
|
75 | 76 | ldap_dn_user |
|
76 | 77 | ldap_dn_pass |
|
77 | 78 | ldap_base_dn |
|
79 | ldap_filter | |
|
80 | ldap_search_scope | |
|
81 | ldap_attr_login | |
|
82 | ldap_attr_firstname | |
|
83 | ldap_attr_lastname | |
|
84 | ldap_attr_email | |
|
78 | 85 | """ |
|
86 | # ldap_search_scope | |
|
79 | 87 | |
|
80 | 88 | r = self.sa.query(RhodeCodeSettings)\ |
|
81 | 89 | .filter(RhodeCodeSettings.app_settings_name\ |
@@ -75,25 +75,27 b' class UserModel(BaseModel):' | |||
|
75 | 75 | self.sa.rollback() |
|
76 | 76 | raise |
|
77 | 77 | |
|
78 | def create_ldap(self, username, password): | |
|
78 | def create_ldap(self, username, password, user_dn, attrs): | |
|
79 | 79 | """ |
|
80 | 80 | Checks if user is in database, if not creates this user marked |
|
81 | 81 | as ldap user |
|
82 | 82 | :param username: |
|
83 | 83 | :param password: |
|
84 | :param user_dn: | |
|
85 | :param attrs: | |
|
84 | 86 | """ |
|
85 | 87 | from rhodecode.lib.auth import get_crypt_password |
|
86 | 88 | log.debug('Checking for such ldap account in RhodeCode database') |
|
87 | 89 | if self.get_by_username(username, case_insensitive=True) is None: |
|
88 | 90 | try: |
|
89 | 91 | new_user = User() |
|
90 | new_user.username = username.lower()#add ldap account always lowercase | |
|
92 | new_user.username = username.lower() # add ldap account always lowercase | |
|
91 | 93 | new_user.password = get_crypt_password(password) |
|
92 |
new_user.email = ' |
|
|
94 | new_user.email = attrs['email'] | |
|
93 | 95 | new_user.active = True |
|
94 |
new_user. |
|
|
95 |
new_user.name = ' |
|
|
96 | new_user.lastname = '' | |
|
96 | new_user.ldap_dn = user_dn | |
|
97 | new_user.name = attrs['name'] | |
|
98 | new_user.lastname = attrs['lastname'] | |
|
97 | 99 | |
|
98 | 100 | |
|
99 | 101 | self.sa.add(new_user) |
@@ -21,13 +21,13 b'' | |||
|
21 | 21 | <div class="title"> |
|
22 | 22 | ${self.breadcrumbs()} |
|
23 | 23 | </div> |
|
24 | <h3>${_('LDAP administration')}</h3> | |
|
25 | 24 | ${h.form(url('ldap_settings'))} |
|
26 | 25 | <div class="form"> |
|
27 | 26 | <div class="fields"> |
|
28 | 27 | |
|
28 | <h3>${_('Connection settings')}</h3> | |
|
29 | 29 | <div class="field"> |
|
30 |
<div class="label label-checkbox"><label for="ldap_active">${_('Enable |
|
|
30 | <div class="label label-checkbox"><label for="ldap_active">${_('Enable LDAP')}</label></div> | |
|
31 | 31 | <div class="checkboxes"><div class="checkbox">${h.checkbox('ldap_active',True,class_='small')}</div></div> |
|
32 | 32 | </div> |
|
33 | 33 | <div class="field"> |
@@ -39,10 +39,6 b'' | |||
|
39 | 39 | <div class="input">${h.text('ldap_port',class_='small')}</div> |
|
40 | 40 | </div> |
|
41 | 41 | <div class="field"> |
|
42 | <div class="label label-checkbox"><label for="ldap_ldaps">${_('Enable LDAPS')}</label></div> | |
|
43 | <div class="checkboxes"><div class="checkbox">${h.checkbox('ldap_ldaps',True,class_='small')}</div></div> | |
|
44 | </div> | |
|
45 | <div class="field"> | |
|
46 | 42 | <div class="label"><label for="ldap_dn_user">${_('Account')}</label></div> |
|
47 | 43 | <div class="input">${h.text('ldap_dn_user',class_='small')}</div> |
|
48 | 44 | </div> |
@@ -51,9 +47,43 b'' | |||
|
51 | 47 | <div class="input">${h.password('ldap_dn_pass',class_='small')}</div> |
|
52 | 48 | </div> |
|
53 | 49 | <div class="field"> |
|
50 | <div class="label label-checkbox"><label for="ldap_ldaps">${_('Enable LDAPS')}</label></div> | |
|
51 | <div class="checkboxes"><div class="checkbox">${h.checkbox('ldap_ldaps',True,class_='small')}</div></div> | |
|
52 | </div> | |
|
53 | <div class="field"> | |
|
54 | <div class="label"><label for="ldap_tls_reqcert">${_('Certificate Checks')}</label></div> | |
|
55 | <div class="select">${h.select('ldap_tls_reqcert',c.tls_reqcert_cur,c.tls_reqcert_choices,class_='small')}</div> | |
|
56 | </div> | |
|
57 | <h3>${_('Search settings')}</h3> | |
|
58 | <div class="field"> | |
|
54 | 59 | <div class="label"><label for="ldap_base_dn">${_('Base DN')}</label></div> |
|
55 | 60 | <div class="input">${h.text('ldap_base_dn',class_='small')}</div> |
|
56 | 61 | </div> |
|
62 | <div class="field"> | |
|
63 | <div class="label"><label for="ldap_filter">${_('LDAP Filter')}</label></div> | |
|
64 | <div class="input">${h.text('ldap_filter',class_='small')}</div> | |
|
65 | </div> | |
|
66 | <div class="field"> | |
|
67 | <div class="label"><label for="ldap_search_scope">${_('LDAP Search Scope')}</label></div> | |
|
68 | <div class="select">${h.select('ldap_search_scope',c.search_scope_cur,c.search_scope_choices,class_='small')}</div> | |
|
69 | </div> | |
|
70 | <h3>${_('Attribute mappings')}</h3> | |
|
71 | <div class="field"> | |
|
72 | <div class="label"><label for="ldap_attr_login">${_('Login Attribute')}</label></div> | |
|
73 | <div class="input">${h.text('ldap_attr_login',class_='small')}</div> | |
|
74 | </div> | |
|
75 | <div class="field"> | |
|
76 | <div class="label"><label for="ldap_attr_firstname">${_('First Name Attribute')}</label></div> | |
|
77 | <div class="input">${h.text('ldap_attr_firstname',class_='small')}</div> | |
|
78 | </div> | |
|
79 | <div class="field"> | |
|
80 | <div class="label"><label for="ldap_attr_lastname">${_('Last Name Attribute')}</label></div> | |
|
81 | <div class="input">${h.text('ldap_attr_lastname',class_='small')}</div> | |
|
82 | </div> | |
|
83 | <div class="field"> | |
|
84 | <div class="label"><label for="ldap_attr_email">${_('E-mail Attribute')}</label></div> | |
|
85 | <div class="input">${h.text('ldap_attr_email',class_='small')}</div> | |
|
86 | </div> | |
|
57 | 87 | |
|
58 | 88 | <div class="buttons"> |
|
59 | 89 | ${h.submit('save','Save',class_="ui-button")} |
@@ -49,6 +49,15 b'' | |||
|
49 | 49 | |
|
50 | 50 | <div class="field"> |
|
51 | 51 | <div class="label"> |
|
52 | <label for="ldap_dn">${_('LDAP DN')}:</label> | |
|
53 | </div> | |
|
54 | <div class="input"> | |
|
55 | ${h.text('ldap_dn',class_='small')} | |
|
56 | </div> | |
|
57 | </div> | |
|
58 | ||
|
59 | <div class="field"> | |
|
60 | <div class="label"> | |
|
52 | 61 | <label for="new_password">${_('New password')}:</label> |
|
53 | 62 | </div> |
|
54 | 63 | <div class="input"> |
@@ -231,4 +240,4 b'' | |||
|
231 | 240 | }); |
|
232 | 241 | </script> |
|
233 | 242 | </div> |
|
234 |
</%def> |
|
|
243 | </%def> |
@@ -49,7 +49,7 b'' | |||
|
49 | 49 | <td>${user.last_login}</td> |
|
50 | 50 | <td>${h.bool2icon(user.active)}</td> |
|
51 | 51 | <td>${h.bool2icon(user.admin)}</td> |
|
52 |
<td>${h.bool2icon(user. |
|
|
52 | <td>${h.bool2icon(bool(user.ldap_dn))}</td> | |
|
53 | 53 | <td> |
|
54 | 54 | ${h.form(url('user', id=user.user_id),method='delete')} |
|
55 | 55 | ${h.submit('remove_','delete',id="remove_user_%s" % user.user_id, |
General Comments 0
You need to be logged in to leave comments.
Login now