##// END OF EJS Templates
authentication: introduce login restriction option for builtin rhodecode plugin.
marcink -
r3387:8a62bda2 default
parent child Browse files
Show More
@@ -1,559 +1,569 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import urlparse
22 22
23 23 import mock
24 24 import pytest
25 25
26 26 from rhodecode.tests import (
27 27 assert_session_flash, HG_REPO, TEST_USER_ADMIN_LOGIN,
28 28 no_newline_id_generator)
29 29 from rhodecode.tests.fixture import Fixture
30 30 from rhodecode.lib.auth import check_password
31 31 from rhodecode.lib import helpers as h
32 32 from rhodecode.model.auth_token import AuthTokenModel
33 33 from rhodecode.model.db import User, Notification, UserApiKeys
34 34 from rhodecode.model.meta import Session
35 35
36 36 fixture = Fixture()
37 37
38 38 whitelist_view = ['RepoCommitsView:repo_commit_raw']
39 39
40 40
41 41 def route_path(name, params=None, **kwargs):
42 42 import urllib
43 43 from rhodecode.apps._base import ADMIN_PREFIX
44 44
45 45 base_url = {
46 46 'login': ADMIN_PREFIX + '/login',
47 47 'logout': ADMIN_PREFIX + '/logout',
48 48 'register': ADMIN_PREFIX + '/register',
49 49 'reset_password':
50 50 ADMIN_PREFIX + '/password_reset',
51 51 'reset_password_confirmation':
52 52 ADMIN_PREFIX + '/password_reset_confirmation',
53 53
54 54 'admin_permissions_application':
55 55 ADMIN_PREFIX + '/permissions/application',
56 56 'admin_permissions_application_update':
57 57 ADMIN_PREFIX + '/permissions/application/update',
58 58
59 59 'repo_commit_raw': '/{repo_name}/raw-changeset/{commit_id}'
60 60
61 61 }[name].format(**kwargs)
62 62
63 63 if params:
64 64 base_url = '{}?{}'.format(base_url, urllib.urlencode(params))
65 65 return base_url
66 66
67 67
68 68 @pytest.mark.usefixtures('app')
69 69 class TestLoginController(object):
70 70 destroy_users = set()
71 71
72 72 @classmethod
73 73 def teardown_class(cls):
74 74 fixture.destroy_users(cls.destroy_users)
75 75
76 76 def teardown_method(self, method):
77 77 for n in Notification.query().all():
78 78 Session().delete(n)
79 79
80 80 Session().commit()
81 81 assert Notification.query().all() == []
82 82
83 83 def test_index(self):
84 84 response = self.app.get(route_path('login'))
85 85 assert response.status == '200 OK'
86 86 # Test response...
87 87
88 88 def test_login_admin_ok(self):
89 89 response = self.app.post(route_path('login'),
90 90 {'username': 'test_admin',
91 91 'password': 'test12'}, status=302)
92 92 response = response.follow()
93 93 session = response.get_session_from_response()
94 94 username = session['rhodecode_user'].get('username')
95 95 assert username == 'test_admin'
96 96 response.mustcontain('/%s' % HG_REPO)
97 97
98 98 def test_login_regular_ok(self):
99 99 response = self.app.post(route_path('login'),
100 100 {'username': 'test_regular',
101 101 'password': 'test12'}, status=302)
102 102
103 103 response = response.follow()
104 104 session = response.get_session_from_response()
105 105 username = session['rhodecode_user'].get('username')
106 106 assert username == 'test_regular'
107 107
108 108 response.mustcontain('/%s' % HG_REPO)
109 109
110 def test_login_regular_forbidden_when_super_admin_restriction(self):
111 from rhodecode.authentication.plugins.auth_rhodecode import RhodeCodeAuthPlugin
112 with fixture.login_restriction(RhodeCodeAuthPlugin.LOGIN_RESTRICTION_SUPER_ADMIN):
113 response = self.app.post(route_path('login'),
114 {'username': 'test_regular',
115 'password': 'test12'})
116
117 response.mustcontain('invalid user name')
118 response.mustcontain('invalid password')
119
110 120 def test_login_ok_came_from(self):
111 121 test_came_from = '/_admin/users?branch=stable'
112 122 _url = '{}?came_from={}'.format(route_path('login'), test_came_from)
113 123 response = self.app.post(
114 124 _url, {'username': 'test_admin', 'password': 'test12'}, status=302)
115 125
116 126 assert 'branch=stable' in response.location
117 127 response = response.follow()
118 128
119 129 assert response.status == '200 OK'
120 130 response.mustcontain('Users administration')
121 131
122 132 def test_redirect_to_login_with_get_args(self):
123 133 with fixture.anon_access(False):
124 134 kwargs = {'branch': 'stable'}
125 135 response = self.app.get(
126 136 h.route_path('repo_summary', repo_name=HG_REPO, _query=kwargs),
127 137 status=302)
128 138
129 139 response_query = urlparse.parse_qsl(response.location)
130 140 assert 'branch=stable' in response_query[0][1]
131 141
132 142 def test_login_form_with_get_args(self):
133 143 _url = '{}?came_from=/_admin/users,branch=stable'.format(route_path('login'))
134 144 response = self.app.get(_url)
135 145 assert 'branch%3Dstable' in response.form.action
136 146
137 147 @pytest.mark.parametrize("url_came_from", [
138 148 'data:text/html,<script>window.alert("xss")</script>',
139 149 'mailto:test@rhodecode.org',
140 150 'file:///etc/passwd',
141 151 'ftp://some.ftp.server',
142 152 'http://other.domain',
143 153 '/\r\nX-Forwarded-Host: http://example.org',
144 154 ], ids=no_newline_id_generator)
145 155 def test_login_bad_came_froms(self, url_came_from):
146 156 _url = '{}?came_from={}'.format(route_path('login'), url_came_from)
147 157 response = self.app.post(
148 158 _url,
149 159 {'username': 'test_admin', 'password': 'test12'})
150 160 assert response.status == '302 Found'
151 161 response = response.follow()
152 162 assert response.status == '200 OK'
153 163 assert response.request.path == '/'
154 164
155 165 def test_login_short_password(self):
156 166 response = self.app.post(route_path('login'),
157 167 {'username': 'test_admin',
158 168 'password': 'as'})
159 169 assert response.status == '200 OK'
160 170
161 171 response.mustcontain('Enter 3 characters or more')
162 172
163 173 def test_login_wrong_non_ascii_password(self, user_regular):
164 174 response = self.app.post(
165 175 route_path('login'),
166 176 {'username': user_regular.username,
167 177 'password': u'invalid-non-asci\xe4'.encode('utf8')})
168 178
169 179 response.mustcontain('invalid user name')
170 180 response.mustcontain('invalid password')
171 181
172 182 def test_login_with_non_ascii_password(self, user_util):
173 183 password = u'valid-non-ascii\xe4'
174 184 user = user_util.create_user(password=password)
175 185 response = self.app.post(
176 186 route_path('login'),
177 187 {'username': user.username,
178 188 'password': password.encode('utf-8')})
179 189 assert response.status_code == 302
180 190
181 191 def test_login_wrong_username_password(self):
182 192 response = self.app.post(route_path('login'),
183 193 {'username': 'error',
184 194 'password': 'test12'})
185 195
186 196 response.mustcontain('invalid user name')
187 197 response.mustcontain('invalid password')
188 198
189 199 def test_login_admin_ok_password_migration(self, real_crypto_backend):
190 200 from rhodecode.lib import auth
191 201
192 202 # create new user, with sha256 password
193 203 temp_user = 'test_admin_sha256'
194 204 user = fixture.create_user(temp_user)
195 205 user.password = auth._RhodeCodeCryptoSha256().hash_create(
196 206 b'test123')
197 207 Session().add(user)
198 208 Session().commit()
199 209 self.destroy_users.add(temp_user)
200 210 response = self.app.post(route_path('login'),
201 211 {'username': temp_user,
202 212 'password': 'test123'}, status=302)
203 213
204 214 response = response.follow()
205 215 session = response.get_session_from_response()
206 216 username = session['rhodecode_user'].get('username')
207 217 assert username == temp_user
208 218 response.mustcontain('/%s' % HG_REPO)
209 219
210 220 # new password should be bcrypted, after log-in and transfer
211 221 user = User.get_by_username(temp_user)
212 222 assert user.password.startswith('$')
213 223
214 224 # REGISTRATIONS
215 225 def test_register(self):
216 226 response = self.app.get(route_path('register'))
217 227 response.mustcontain('Create an Account')
218 228
219 229 def test_register_err_same_username(self):
220 230 uname = 'test_admin'
221 231 response = self.app.post(
222 232 route_path('register'),
223 233 {
224 234 'username': uname,
225 235 'password': 'test12',
226 236 'password_confirmation': 'test12',
227 237 'email': 'goodmail@domain.com',
228 238 'firstname': 'test',
229 239 'lastname': 'test'
230 240 }
231 241 )
232 242
233 243 assertr = response.assert_response()
234 244 msg = 'Username "%(username)s" already exists'
235 245 msg = msg % {'username': uname}
236 246 assertr.element_contains('#username+.error-message', msg)
237 247
238 248 def test_register_err_same_email(self):
239 249 response = self.app.post(
240 250 route_path('register'),
241 251 {
242 252 'username': 'test_admin_0',
243 253 'password': 'test12',
244 254 'password_confirmation': 'test12',
245 255 'email': 'test_admin@mail.com',
246 256 'firstname': 'test',
247 257 'lastname': 'test'
248 258 }
249 259 )
250 260
251 261 assertr = response.assert_response()
252 262 msg = u'This e-mail address is already taken'
253 263 assertr.element_contains('#email+.error-message', msg)
254 264
255 265 def test_register_err_same_email_case_sensitive(self):
256 266 response = self.app.post(
257 267 route_path('register'),
258 268 {
259 269 'username': 'test_admin_1',
260 270 'password': 'test12',
261 271 'password_confirmation': 'test12',
262 272 'email': 'TesT_Admin@mail.COM',
263 273 'firstname': 'test',
264 274 'lastname': 'test'
265 275 }
266 276 )
267 277 assertr = response.assert_response()
268 278 msg = u'This e-mail address is already taken'
269 279 assertr.element_contains('#email+.error-message', msg)
270 280
271 281 def test_register_err_wrong_data(self):
272 282 response = self.app.post(
273 283 route_path('register'),
274 284 {
275 285 'username': 'xs',
276 286 'password': 'test',
277 287 'password_confirmation': 'test',
278 288 'email': 'goodmailm',
279 289 'firstname': 'test',
280 290 'lastname': 'test'
281 291 }
282 292 )
283 293 assert response.status == '200 OK'
284 294 response.mustcontain('An email address must contain a single @')
285 295 response.mustcontain('Enter a value 6 characters long or more')
286 296
287 297 def test_register_err_username(self):
288 298 response = self.app.post(
289 299 route_path('register'),
290 300 {
291 301 'username': 'error user',
292 302 'password': 'test12',
293 303 'password_confirmation': 'test12',
294 304 'email': 'goodmailm',
295 305 'firstname': 'test',
296 306 'lastname': 'test'
297 307 }
298 308 )
299 309
300 310 response.mustcontain('An email address must contain a single @')
301 311 response.mustcontain(
302 312 'Username may only contain '
303 313 'alphanumeric characters underscores, '
304 314 'periods or dashes and must begin with '
305 315 'alphanumeric character')
306 316
307 317 def test_register_err_case_sensitive(self):
308 318 usr = 'Test_Admin'
309 319 response = self.app.post(
310 320 route_path('register'),
311 321 {
312 322 'username': usr,
313 323 'password': 'test12',
314 324 'password_confirmation': 'test12',
315 325 'email': 'goodmailm',
316 326 'firstname': 'test',
317 327 'lastname': 'test'
318 328 }
319 329 )
320 330
321 331 assertr = response.assert_response()
322 332 msg = u'Username "%(username)s" already exists'
323 333 msg = msg % {'username': usr}
324 334 assertr.element_contains('#username+.error-message', msg)
325 335
326 336 def test_register_special_chars(self):
327 337 response = self.app.post(
328 338 route_path('register'),
329 339 {
330 340 'username': 'xxxaxn',
331 341 'password': 'Δ…Δ‡ΕΊΕΌΔ…Ε›Ε›Ε›Ε›',
332 342 'password_confirmation': 'Δ…Δ‡ΕΊΕΌΔ…Ε›Ε›Ε›Ε›',
333 343 'email': 'goodmailm@test.plx',
334 344 'firstname': 'test',
335 345 'lastname': 'test'
336 346 }
337 347 )
338 348
339 349 msg = u'Invalid characters (non-ascii) in password'
340 350 response.mustcontain(msg)
341 351
342 352 def test_register_password_mismatch(self):
343 353 response = self.app.post(
344 354 route_path('register'),
345 355 {
346 356 'username': 'xs',
347 357 'password': '123qwe',
348 358 'password_confirmation': 'qwe123',
349 359 'email': 'goodmailm@test.plxa',
350 360 'firstname': 'test',
351 361 'lastname': 'test'
352 362 }
353 363 )
354 364 msg = u'Passwords do not match'
355 365 response.mustcontain(msg)
356 366
357 367 def test_register_ok(self):
358 368 username = 'test_regular4'
359 369 password = 'qweqwe'
360 370 email = 'marcin@test.com'
361 371 name = 'testname'
362 372 lastname = 'testlastname'
363 373
364 374 # this initializes a session
365 375 response = self.app.get(route_path('register'))
366 376 response.mustcontain('Create an Account')
367 377
368 378
369 379 response = self.app.post(
370 380 route_path('register'),
371 381 {
372 382 'username': username,
373 383 'password': password,
374 384 'password_confirmation': password,
375 385 'email': email,
376 386 'firstname': name,
377 387 'lastname': lastname,
378 388 'admin': True
379 389 },
380 390 status=302
381 391 ) # This should be overridden
382 392
383 393 assert_session_flash(
384 394 response, 'You have successfully registered with RhodeCode')
385 395
386 396 ret = Session().query(User).filter(
387 397 User.username == 'test_regular4').one()
388 398 assert ret.username == username
389 399 assert check_password(password, ret.password)
390 400 assert ret.email == email
391 401 assert ret.name == name
392 402 assert ret.lastname == lastname
393 403 assert ret.auth_tokens is not None
394 404 assert not ret.admin
395 405
396 406 def test_forgot_password_wrong_mail(self):
397 407 bad_email = 'marcin@wrongmail.org'
398 408 # this initializes a session
399 409 self.app.get(route_path('reset_password'))
400 410
401 411 response = self.app.post(
402 412 route_path('reset_password'), {'email': bad_email, }
403 413 )
404 414 assert_session_flash(response,
405 415 'If such email exists, a password reset link was sent to it.')
406 416
407 417 def test_forgot_password(self, user_util):
408 418 # this initializes a session
409 419 self.app.get(route_path('reset_password'))
410 420
411 421 user = user_util.create_user()
412 422 user_id = user.user_id
413 423 email = user.email
414 424
415 425 response = self.app.post(route_path('reset_password'), {'email': email, })
416 426
417 427 assert_session_flash(response,
418 428 'If such email exists, a password reset link was sent to it.')
419 429
420 430 # BAD KEY
421 431 confirm_url = '{}?key={}'.format(route_path('reset_password_confirmation'), 'badkey')
422 432 response = self.app.get(confirm_url, status=302)
423 433 assert response.location.endswith(route_path('reset_password'))
424 434 assert_session_flash(response, 'Given reset token is invalid')
425 435
426 436 response.follow() # cleanup flash
427 437
428 438 # GOOD KEY
429 439 key = UserApiKeys.query()\
430 440 .filter(UserApiKeys.user_id == user_id)\
431 441 .filter(UserApiKeys.role == UserApiKeys.ROLE_PASSWORD_RESET)\
432 442 .first()
433 443
434 444 assert key
435 445
436 446 confirm_url = '{}?key={}'.format(route_path('reset_password_confirmation'), key.api_key)
437 447 response = self.app.get(confirm_url)
438 448 assert response.status == '302 Found'
439 449 assert response.location.endswith(route_path('login'))
440 450
441 451 assert_session_flash(
442 452 response,
443 453 'Your password reset was successful, '
444 454 'a new password has been sent to your email')
445 455
446 456 response.follow()
447 457
448 458 def _get_api_whitelist(self, values=None):
449 459 config = {'api_access_controllers_whitelist': values or []}
450 460 return config
451 461
452 462 @pytest.mark.parametrize("test_name, auth_token", [
453 463 ('none', None),
454 464 ('empty_string', ''),
455 465 ('fake_number', '123456'),
456 466 ('proper_auth_token', None)
457 467 ])
458 468 def test_access_not_whitelisted_page_via_auth_token(
459 469 self, test_name, auth_token, user_admin):
460 470
461 471 whitelist = self._get_api_whitelist([])
462 472 with mock.patch.dict('rhodecode.CONFIG', whitelist):
463 473 assert [] == whitelist['api_access_controllers_whitelist']
464 474 if test_name == 'proper_auth_token':
465 475 # use builtin if api_key is None
466 476 auth_token = user_admin.api_key
467 477
468 478 with fixture.anon_access(False):
469 479 self.app.get(
470 480 route_path('repo_commit_raw',
471 481 repo_name=HG_REPO, commit_id='tip',
472 482 params=dict(api_key=auth_token)),
473 483 status=302)
474 484
475 485 @pytest.mark.parametrize("test_name, auth_token, code", [
476 486 ('none', None, 302),
477 487 ('empty_string', '', 302),
478 488 ('fake_number', '123456', 302),
479 489 ('proper_auth_token', None, 200)
480 490 ])
481 491 def test_access_whitelisted_page_via_auth_token(
482 492 self, test_name, auth_token, code, user_admin):
483 493
484 494 whitelist = self._get_api_whitelist(whitelist_view)
485 495
486 496 with mock.patch.dict('rhodecode.CONFIG', whitelist):
487 497 assert whitelist_view == whitelist['api_access_controllers_whitelist']
488 498
489 499 if test_name == 'proper_auth_token':
490 500 auth_token = user_admin.api_key
491 501 assert auth_token
492 502
493 503 with fixture.anon_access(False):
494 504 self.app.get(
495 505 route_path('repo_commit_raw',
496 506 repo_name=HG_REPO, commit_id='tip',
497 507 params=dict(api_key=auth_token)),
498 508 status=code)
499 509
500 510 @pytest.mark.parametrize("test_name, auth_token, code", [
501 511 ('proper_auth_token', None, 200),
502 512 ('wrong_auth_token', '123456', 302),
503 513 ])
504 514 def test_access_whitelisted_page_via_auth_token_bound_to_token(
505 515 self, test_name, auth_token, code, user_admin):
506 516
507 517 expected_token = auth_token
508 518 if test_name == 'proper_auth_token':
509 519 auth_token = user_admin.api_key
510 520 expected_token = auth_token
511 521 assert auth_token
512 522
513 523 whitelist = self._get_api_whitelist([
514 524 'RepoCommitsView:repo_commit_raw@{}'.format(expected_token)])
515 525
516 526 with mock.patch.dict('rhodecode.CONFIG', whitelist):
517 527
518 528 with fixture.anon_access(False):
519 529 self.app.get(
520 530 route_path('repo_commit_raw',
521 531 repo_name=HG_REPO, commit_id='tip',
522 532 params=dict(api_key=auth_token)),
523 533 status=code)
524 534
525 535 def test_access_page_via_extra_auth_token(self):
526 536 whitelist = self._get_api_whitelist(whitelist_view)
527 537 with mock.patch.dict('rhodecode.CONFIG', whitelist):
528 538 assert whitelist_view == \
529 539 whitelist['api_access_controllers_whitelist']
530 540
531 541 new_auth_token = AuthTokenModel().create(
532 542 TEST_USER_ADMIN_LOGIN, 'test')
533 543 Session().commit()
534 544 with fixture.anon_access(False):
535 545 self.app.get(
536 546 route_path('repo_commit_raw',
537 547 repo_name=HG_REPO, commit_id='tip',
538 548 params=dict(api_key=new_auth_token.api_key)),
539 549 status=200)
540 550
541 551 def test_access_page_via_expired_auth_token(self):
542 552 whitelist = self._get_api_whitelist(whitelist_view)
543 553 with mock.patch.dict('rhodecode.CONFIG', whitelist):
544 554 assert whitelist_view == \
545 555 whitelist['api_access_controllers_whitelist']
546 556
547 557 new_auth_token = AuthTokenModel().create(
548 558 TEST_USER_ADMIN_LOGIN, 'test')
549 559 Session().commit()
550 560 # patch the api key and make it expired
551 561 new_auth_token.expires = 0
552 562 Session().add(new_auth_token)
553 563 Session().commit()
554 564 with fixture.anon_access(False):
555 565 self.app.get(
556 566 route_path('repo_commit_raw',
557 567 repo_name=HG_REPO, commit_id='tip',
558 568 params=dict(api_key=new_auth_token.api_key)),
559 569 status=302)
@@ -1,171 +1,186 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for built in internal auth
23 23 """
24 24
25 25 import logging
26 26
27 27 import colander
28 28
29 29 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
30 30 from rhodecode.translation import _
31 31
32 32 from rhodecode.authentication.base import RhodeCodeAuthPluginBase, hybrid_property
33 33 from rhodecode.authentication.routes import AuthnPluginResourceBase
34 34 from rhodecode.lib.utils2 import safe_str
35 35 from rhodecode.model.db import User
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 def plugin_factory(plugin_id, *args, **kwargs):
41 41 plugin = RhodeCodeAuthPlugin(plugin_id)
42 42 return plugin
43 43
44 44
45 45 class RhodecodeAuthnResource(AuthnPluginResourceBase):
46 46 pass
47 47
48 48
49 class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase):
50
51 superadmin_restriction = colander.SchemaNode(
52 colander.Bool(),
53 default=False,
54 description=_('Only allow super-admins to log-in using this plugin.'),
55 missing=False,
56 title=_('Enabled'),
57 widget='bool',
58 )
59
60
61 49 class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
62 50 uid = 'rhodecode'
51 LOGIN_RESTRICTION_NONE = 'none'
52 LOGIN_RESTRICTION_SUPER_ADMIN = 'super_admin'
63 53
64 54 def includeme(self, config):
65 55 config.add_authn_plugin(self)
66 56 config.add_authn_resource(self.get_id(), RhodecodeAuthnResource(self))
67 57 config.add_view(
68 58 'rhodecode.authentication.views.AuthnPluginViewBase',
69 59 attr='settings_get',
70 60 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
71 61 request_method='GET',
72 62 route_name='auth_home',
73 63 context=RhodecodeAuthnResource)
74 64 config.add_view(
75 65 'rhodecode.authentication.views.AuthnPluginViewBase',
76 66 attr='settings_post',
77 67 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
78 68 request_method='POST',
79 69 route_name='auth_home',
80 70 context=RhodecodeAuthnResource)
81 71
82 72 def get_settings_schema(self):
83 73 return RhodeCodeSettingsSchema()
84 74
85 75 def get_display_name(self):
86 76 return _('RhodeCode Internal')
87 77
88 78 @classmethod
89 79 def docs(cls):
90 80 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth.html"
91 81
92 82 @hybrid_property
93 83 def name(self):
94 84 return u"rhodecode"
95 85
96 86 def user_activation_state(self):
97 87 def_user_perms = User.get_default_user().AuthUser().permissions['global']
98 88 return 'hg.register.auto_activate' in def_user_perms
99 89
100 90 def allows_authentication_from(
101 91 self, user, allows_non_existing_user=True,
102 92 allowed_auth_plugins=None, allowed_auth_sources=None):
103 93 """
104 94 Custom method for this auth that doesn't accept non existing users.
105 95 We know that user exists in our database.
106 96 """
107 97 allows_non_existing_user = False
108 98 return super(RhodeCodeAuthPlugin, self).allows_authentication_from(
109 99 user, allows_non_existing_user=allows_non_existing_user)
110 100
111 101 def auth(self, userobj, username, password, settings, **kwargs):
112 102 if not userobj:
113 103 log.debug('userobj was:%s skipping', userobj)
114 104 return None
105
115 106 if userobj.extern_type != self.name:
116 107 log.warning(
117 108 "userobj:%s extern_type mismatch got:`%s` expected:`%s`",
118 109 userobj, userobj.extern_type, self.name)
119 110 return None
120 111
112 login_restriction = settings.get('login_restriction', '')
113 if login_restriction == self.LOGIN_RESTRICTION_SUPER_ADMIN and userobj.admin is False:
114 log.info(
115 "userobj:%s is not super-admin and login restriction is set to %s",
116 userobj, login_restriction)
117 return None
118
121 119 user_attrs = {
122 120 "username": userobj.username,
123 121 "firstname": userobj.firstname,
124 122 "lastname": userobj.lastname,
125 123 "groups": [],
126 124 'user_group_sync': False,
127 125 "email": userobj.email,
128 126 "admin": userobj.admin,
129 127 "active": userobj.active,
130 128 "active_from_extern": userobj.active,
131 129 "extern_name": userobj.user_id,
132 130 "extern_type": userobj.extern_type,
133 131 }
134 132
135 133 log.debug("User attributes:%s", user_attrs)
136 134 if userobj.active:
137 135 from rhodecode.lib import auth
138 136 crypto_backend = auth.crypto_backend()
139 137 password_encoded = safe_str(password)
140 138 password_match, new_hash = crypto_backend.hash_check_with_upgrade(
141 139 password_encoded, userobj.password or '')
142 140
143 141 if password_match and new_hash:
144 142 log.debug('user %s properly authenticated, but '
145 143 'requires hash change to bcrypt', userobj)
146 144 # if password match, and we use OLD deprecated hash,
147 145 # we should migrate this user hash password to the new hash
148 146 # we store the new returned by hash_check_with_upgrade function
149 147 user_attrs['_hash_migrate'] = new_hash
150 148
151 149 if userobj.username == User.DEFAULT_USER and userobj.active:
152 log.info(
153 'user `%s` authenticated correctly as anonymous user', userobj.username)
150 log.info('user `%s` authenticated correctly as anonymous user',
151 userobj.username)
154 152 return user_attrs
155 153
156 154 elif userobj.username == username and password_match:
157 155 log.info('user `%s` authenticated correctly', userobj.username)
158 156 return user_attrs
159 157 log.warn("user `%s` used a wrong password when "
160 158 "authenticating on this plugin", userobj.username)
161 159 return None
162 160 else:
163 161 log.warning(
164 162 'user `%s` failed to authenticate via %s, reason: account not '
165 163 'active.', username, self.name)
166 164 return None
167 165
168 166
167 class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase):
168 login_restriction_choices = [
169 (RhodeCodeAuthPlugin.LOGIN_RESTRICTION_NONE, 'All users'),
170 (RhodeCodeAuthPlugin.LOGIN_RESTRICTION_SUPER_ADMIN, 'Super admins only')
171 ]
172
173 login_restriction = colander.SchemaNode(
174 colander.String(),
175 default=login_restriction_choices[0],
176 description=_('Choose login restrition for users.'),
177 title=_('Login restriction'),
178 validator=colander.OneOf([x[0] for x in login_restriction_choices]),
179 widget='select_with_labels',
180 choices=login_restriction_choices
181 )
182
183
169 184 def includeme(config):
170 185 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
171 186 plugin_factory(plugin_id).includeme(config)
@@ -1,132 +1,134 b''
1 1 ## -*- coding: utf-8 -*-
2 2 <%inherit file="/base/base.mako"/>
3 3
4 4 <%def name="title()">
5 5 ${_('Authentication Settings')}
6 6 %if c.rhodecode_name:
7 7 &middot; ${h.branding(c.rhodecode_name)}}
8 8 %endif
9 9 </%def>
10 10
11 11 <%def name="breadcrumbs_links()">
12 12 ${h.link_to(_('Admin'),h.route_path('admin_home'))}
13 13 &raquo;
14 14 ${h.link_to(_('Authentication Plugins'),request.resource_path(resource.__parent__, route_name='auth_home'))}
15 15 &raquo;
16 16 ${resource.display_name}
17 17 </%def>
18 18
19 19 <%def name="menu_bar_nav()">
20 20 ${self.menu_items(active='admin')}
21 21 </%def>
22 22
23 23 <%def name="main()">
24 24 <div class="box">
25 25 <div class="title">
26 26 ${self.breadcrumbs()}
27 27 </div>
28 28 <div class='sidebar-col-wrapper'>
29 29
30 30 ## TODO: This is repeated in the auth root template and should be merged
31 31 ## into a single solution.
32 32 <div class="sidebar">
33 33 <ul class="nav nav-pills nav-stacked">
34 34 % for item in resource.get_root().get_nav_list():
35 35 <li ${'class=active' if item == resource else ''}>
36 36 <a href="${request.resource_path(item, route_name='auth_home')}">${item.display_name}</a>
37 37 </li>
38 38 % endfor
39 39 </ul>
40 40 </div>
41 41
42 42 <div class="main-content-full-width">
43 43 <div class="panel panel-default">
44 44 <div class="panel-heading">
45 45 <h3 class="panel-title">${_('Plugin')}: ${resource.display_name}</h3>
46 46 </div>
47 47 <div class="panel-body">
48 48 <div class="plugin_form">
49 49 <div class="fields">
50 50 ${h.secure_form(request.resource_path(resource, route_name='auth_home'), request=request)}
51 51 <div class="form">
52 52
53 53 %for node in plugin.get_settings_schema():
54 54 <%
55 55 label_to_type = {'label-checkbox': 'bool', 'label-textarea': 'textarea'}
56 56 %>
57 57
58 58 <div class="field">
59 59 <div class="label ${label_to_type.get(node.widget)}"><label for="${node.name}">${node.title}</label></div>
60 60 <div class="input">
61 61 %if node.widget in ["string", "int", "unicode"]:
62 62 ${h.text(node.name, defaults.get(node.name), class_="large")}
63 63 %elif node.widget == "password":
64 64 ${h.password(node.name, defaults.get(node.name), class_="large")}
65 65 %elif node.widget == "bool":
66 66 <div class="checkbox">${h.checkbox(node.name, True, checked=defaults.get(node.name))}</div>
67 67 %elif node.widget == "select":
68 68 ${h.select(node.name, defaults.get(node.name), node.validator.choices, class_="select2AuthSetting")}
69 %elif node.widget == "select_with_labels":
70 ${h.select(node.name, defaults.get(node.name), node.choices, class_="select2AuthSetting")}
69 71 %elif node.widget == "textarea":
70 72 <div class="textarea" style="margin-left: 0px">${h.textarea(node.name, defaults.get(node.name), rows=10)}</div>
71 73 %elif node.widget == "readonly":
72 74 ${node.default}
73 75 %else:
74 76 This field is of type ${node.typ}, which cannot be displayed. Must be one of [string|int|bool|select].
75 77 %endif
76 78
77 79 %if node.name in errors:
78 80 <span class="error-message">${errors.get(node.name)}</span>
79 81 <br />
80 82 %endif
81 83 <p class="help-block pre-formatting">${node.description}</p>
82 84 </div>
83 85 </div>
84 86 %endfor
85 87
86 88 ## Allow derived templates to add something below the form
87 89 ## input fields
88 90 %if hasattr(next, 'below_form_fields'):
89 91 ${next.below_form_fields()}
90 92 %endif
91 93
92 94 <div class="buttons">
93 95 ${h.submit('save',_('Save'),class_="btn")}
94 96 </div>
95 97
96 98 </div>
97 99 ${h.end_form()}
98 100 </div>
99 101 </div>
100 102
101 103 % if request.GET.get('schema'):
102 104 ## this is for development and creation of example configurations for documentation
103 105 <pre>
104 106 % for node in plugin.get_settings_schema():
105 107 *option*: `${node.name}` => `${defaults.get(node.name)}`${'\n # '.join(['']+node.description.splitlines())}
106 108
107 109 % endfor
108 110 </pre>
109 111
110 112 % endif
111 113
112 114 </div>
113 115 </div>
114 116 </div>
115 117
116 118 </div>
117 119 </div>
118 120
119 121
120 122 <script>
121 123 $(document).ready(function() {
122 124 var select2Options = {
123 125 containerCssClass: 'drop-menu',
124 126 dropdownCssClass: 'drop-menu-dropdown',
125 127 dropdownAutoWidth: true,
126 128 minimumResultsForSearch: -1
127 129 };
128 130 $('.select2AuthSetting').select2(select2Options);
129 131
130 132 });
131 133 </script>
132 134 </%def>
@@ -1,349 +1,383 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Helpers for fixture generation
23 23 """
24 24
25 25 import os
26 26 import time
27 27 import tempfile
28 28 import shutil
29 29
30 30 import configobj
31 31
32 32 from rhodecode.tests import *
33 33 from rhodecode.model.db import Repository, User, RepoGroup, UserGroup, Gist, UserEmailMap
34 34 from rhodecode.model.meta import Session
35 35 from rhodecode.model.repo import RepoModel
36 36 from rhodecode.model.user import UserModel
37 37 from rhodecode.model.repo_group import RepoGroupModel
38 38 from rhodecode.model.user_group import UserGroupModel
39 39 from rhodecode.model.gist import GistModel
40 40 from rhodecode.model.auth_token import AuthTokenModel
41 from rhodecode.authentication.plugins.auth_rhodecode import \
42 RhodeCodeAuthPlugin
41 43
42 44 dn = os.path.dirname
43 45 FIXTURES = os.path.join(dn(dn(os.path.abspath(__file__))), 'tests', 'fixtures')
44 46
45 47
46 48 def error_function(*args, **kwargs):
47 49 raise Exception('Total Crash !')
48 50
49 51
50 52 class TestINI(object):
51 53 """
52 54 Allows to create a new test.ini file as a copy of existing one with edited
53 55 data. Example usage::
54 56
55 57 with TestINI('test.ini', [{'section':{'key':val'}]) as new_test_ini_path:
56 58 print('paster server %s' % new_test_ini)
57 59 """
58 60
59 61 def __init__(self, ini_file_path, ini_params, new_file_prefix='DEFAULT',
60 62 destroy=True, dir=None):
61 63 self.ini_file_path = ini_file_path
62 64 self.ini_params = ini_params
63 65 self.new_path = None
64 66 self.new_path_prefix = new_file_prefix
65 67 self._destroy = destroy
66 68 self._dir = dir
67 69
68 70 def __enter__(self):
69 71 return self.create()
70 72
71 73 def __exit__(self, exc_type, exc_val, exc_tb):
72 74 self.destroy()
73 75
74 76 def create(self):
75 77 config = configobj.ConfigObj(
76 78 self.ini_file_path, file_error=True, write_empty_values=True)
77 79
78 80 for data in self.ini_params:
79 81 section, ini_params = data.items()[0]
80 82 for key, val in ini_params.items():
81 83 config[section][key] = val
82 84 with tempfile.NamedTemporaryFile(
83 85 prefix=self.new_path_prefix, suffix='.ini', dir=self._dir,
84 86 delete=False) as new_ini_file:
85 87 config.write(new_ini_file)
86 88 self.new_path = new_ini_file.name
87 89
88 90 return self.new_path
89 91
90 92 def destroy(self):
91 93 if self._destroy:
92 94 os.remove(self.new_path)
93 95
94 96
95 97 class Fixture(object):
96 98
97 99 def anon_access(self, status):
98 100 """
99 101 Context process for disabling anonymous access. use like:
100 102 fixture = Fixture()
101 103 with fixture.anon_access(False):
102 104 #tests
103 105
104 106 after this block anon access will be set to `not status`
105 107 """
106 108
107 109 class context(object):
108 110 def __enter__(self):
109 111 anon = User.get_default_user()
110 112 anon.active = status
111 113 Session().add(anon)
112 114 Session().commit()
113 115 time.sleep(1.5) # must sleep for cache (1s to expire)
114 116
115 117 def __exit__(self, exc_type, exc_val, exc_tb):
116 118 anon = User.get_default_user()
117 119 anon.active = not status
118 120 Session().add(anon)
119 121 Session().commit()
120 122
121 123 return context()
122 124
125 def login_restriction(self, login_restriction):
126 """
127 Context process for changing the builtin rhodecode plugin login restrictions.
128 Use like:
129 fixture = Fixture()
130 with fixture.login_restriction('super_admin'):
131 #tests
132
133 after this block login restriction will be taken off
134 """
135
136 class context(object):
137 def _get_pluing(self):
138 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(
139 RhodeCodeAuthPlugin.uid)
140 plugin = RhodeCodeAuthPlugin(plugin_id)
141 return plugin
142
143 def __enter__(self):
144 plugin = self._get_pluing()
145 plugin.create_or_update_setting(
146 'login_restriction', login_restriction)
147 Session().commit()
148
149 def __exit__(self, exc_type, exc_val, exc_tb):
150 plugin = self._get_pluing()
151 plugin.create_or_update_setting(
152 'login_restriction', RhodeCodeAuthPlugin.LOGIN_RESTRICTION_NONE)
153 Session().commit()
154
155 return context()
156
123 157 def _get_repo_create_params(self, **custom):
124 158 defs = {
125 159 'repo_name': None,
126 160 'repo_type': 'hg',
127 161 'clone_uri': '',
128 162 'push_uri': '',
129 163 'repo_group': '-1',
130 164 'repo_description': 'DESC',
131 165 'repo_private': False,
132 166 'repo_landing_rev': 'rev:tip',
133 167 'repo_copy_permissions': False,
134 168 'repo_state': Repository.STATE_CREATED,
135 169 }
136 170 defs.update(custom)
137 171 if 'repo_name_full' not in custom:
138 172 defs.update({'repo_name_full': defs['repo_name']})
139 173
140 174 # fix the repo name if passed as repo_name_full
141 175 if defs['repo_name']:
142 176 defs['repo_name'] = defs['repo_name'].split('/')[-1]
143 177
144 178 return defs
145 179
146 180 def _get_group_create_params(self, **custom):
147 181 defs = {
148 182 'group_name': None,
149 183 'group_description': 'DESC',
150 184 'perm_updates': [],
151 185 'perm_additions': [],
152 186 'perm_deletions': [],
153 187 'group_parent_id': -1,
154 188 'enable_locking': False,
155 189 'recursive': False,
156 190 }
157 191 defs.update(custom)
158 192
159 193 return defs
160 194
161 195 def _get_user_create_params(self, name, **custom):
162 196 defs = {
163 197 'username': name,
164 198 'password': 'qweqwe',
165 199 'email': '%s+test@rhodecode.org' % name,
166 200 'firstname': 'TestUser',
167 201 'lastname': 'Test',
168 202 'active': True,
169 203 'admin': False,
170 204 'extern_type': 'rhodecode',
171 205 'extern_name': None,
172 206 }
173 207 defs.update(custom)
174 208
175 209 return defs
176 210
177 211 def _get_user_group_create_params(self, name, **custom):
178 212 defs = {
179 213 'users_group_name': name,
180 214 'user_group_description': 'DESC',
181 215 'users_group_active': True,
182 216 'user_group_data': {},
183 217 }
184 218 defs.update(custom)
185 219
186 220 return defs
187 221
188 222 def create_repo(self, name, **kwargs):
189 223 repo_group = kwargs.get('repo_group')
190 224 if isinstance(repo_group, RepoGroup):
191 225 kwargs['repo_group'] = repo_group.group_id
192 226 name = name.split(Repository.NAME_SEP)[-1]
193 227 name = Repository.NAME_SEP.join((repo_group.group_name, name))
194 228
195 229 if 'skip_if_exists' in kwargs:
196 230 del kwargs['skip_if_exists']
197 231 r = Repository.get_by_repo_name(name)
198 232 if r:
199 233 return r
200 234
201 235 form_data = self._get_repo_create_params(repo_name=name, **kwargs)
202 236 cur_user = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
203 237 RepoModel().create(form_data, cur_user)
204 238 Session().commit()
205 239 repo = Repository.get_by_repo_name(name)
206 240 assert repo
207 241 return repo
208 242
209 243 def create_fork(self, repo_to_fork, fork_name, **kwargs):
210 244 repo_to_fork = Repository.get_by_repo_name(repo_to_fork)
211 245
212 246 form_data = self._get_repo_create_params(repo_name=fork_name,
213 247 fork_parent_id=repo_to_fork.repo_id,
214 248 repo_type=repo_to_fork.repo_type,
215 249 **kwargs)
216 250 #TODO: fix it !!
217 251 form_data['description'] = form_data['repo_description']
218 252 form_data['private'] = form_data['repo_private']
219 253 form_data['landing_rev'] = form_data['repo_landing_rev']
220 254
221 255 owner = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
222 256 RepoModel().create_fork(form_data, cur_user=owner)
223 257 Session().commit()
224 258 r = Repository.get_by_repo_name(fork_name)
225 259 assert r
226 260 return r
227 261
228 262 def destroy_repo(self, repo_name, **kwargs):
229 263 RepoModel().delete(repo_name, pull_requests='delete', **kwargs)
230 264 Session().commit()
231 265
232 266 def destroy_repo_on_filesystem(self, repo_name):
233 267 rm_path = os.path.join(RepoModel().repos_path, repo_name)
234 268 if os.path.isdir(rm_path):
235 269 shutil.rmtree(rm_path)
236 270
237 271 def create_repo_group(self, name, **kwargs):
238 272 if 'skip_if_exists' in kwargs:
239 273 del kwargs['skip_if_exists']
240 274 gr = RepoGroup.get_by_group_name(group_name=name)
241 275 if gr:
242 276 return gr
243 277 form_data = self._get_group_create_params(group_name=name, **kwargs)
244 278 owner = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
245 279 gr = RepoGroupModel().create(
246 280 group_name=form_data['group_name'],
247 281 group_description=form_data['group_name'],
248 282 owner=owner)
249 283 Session().commit()
250 284 gr = RepoGroup.get_by_group_name(gr.group_name)
251 285 return gr
252 286
253 287 def destroy_repo_group(self, repogroupid):
254 288 RepoGroupModel().delete(repogroupid)
255 289 Session().commit()
256 290
257 291 def create_user(self, name, **kwargs):
258 292 if 'skip_if_exists' in kwargs:
259 293 del kwargs['skip_if_exists']
260 294 user = User.get_by_username(name)
261 295 if user:
262 296 return user
263 297 form_data = self._get_user_create_params(name, **kwargs)
264 298 user = UserModel().create(form_data)
265 299
266 300 # create token for user
267 301 AuthTokenModel().create(
268 302 user=user, description=u'TEST_USER_TOKEN')
269 303
270 304 Session().commit()
271 305 user = User.get_by_username(user.username)
272 306 return user
273 307
274 308 def destroy_user(self, userid):
275 309 UserModel().delete(userid)
276 310 Session().commit()
277 311
278 312 def create_additional_user_email(self, user, email):
279 313 uem = UserEmailMap()
280 314 uem.user = user
281 315 uem.email = email
282 316 Session().add(uem)
283 317 return uem
284 318
285 319 def destroy_users(self, userid_iter):
286 320 for user_id in userid_iter:
287 321 if User.get_by_username(user_id):
288 322 UserModel().delete(user_id)
289 323 Session().commit()
290 324
291 325 def create_user_group(self, name, **kwargs):
292 326 if 'skip_if_exists' in kwargs:
293 327 del kwargs['skip_if_exists']
294 328 gr = UserGroup.get_by_group_name(group_name=name)
295 329 if gr:
296 330 return gr
297 331 # map active flag to the real attribute. For API consistency of fixtures
298 332 if 'active' in kwargs:
299 333 kwargs['users_group_active'] = kwargs['active']
300 334 del kwargs['active']
301 335 form_data = self._get_user_group_create_params(name, **kwargs)
302 336 owner = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
303 337 user_group = UserGroupModel().create(
304 338 name=form_data['users_group_name'],
305 339 description=form_data['user_group_description'],
306 340 owner=owner, active=form_data['users_group_active'],
307 341 group_data=form_data['user_group_data'])
308 342 Session().commit()
309 343 user_group = UserGroup.get_by_group_name(user_group.users_group_name)
310 344 return user_group
311 345
312 346 def destroy_user_group(self, usergroupid):
313 347 UserGroupModel().delete(user_group=usergroupid, force=True)
314 348 Session().commit()
315 349
316 350 def create_gist(self, **kwargs):
317 351 form_data = {
318 352 'description': 'new-gist',
319 353 'owner': TEST_USER_ADMIN_LOGIN,
320 354 'gist_type': GistModel.cls.GIST_PUBLIC,
321 355 'lifetime': -1,
322 356 'acl_level': Gist.ACL_LEVEL_PUBLIC,
323 357 'gist_mapping': {'filename1.txt': {'content': 'hello world'},}
324 358 }
325 359 form_data.update(kwargs)
326 360 gist = GistModel().create(
327 361 description=form_data['description'], owner=form_data['owner'],
328 362 gist_mapping=form_data['gist_mapping'], gist_type=form_data['gist_type'],
329 363 lifetime=form_data['lifetime'], gist_acl_level=form_data['acl_level']
330 364 )
331 365 Session().commit()
332 366 return gist
333 367
334 368 def destroy_gists(self, gistid=None):
335 369 for g in GistModel.cls.get_all():
336 370 if gistid:
337 371 if gistid == g.gist_access_id:
338 372 GistModel().delete(g)
339 373 else:
340 374 GistModel().delete(g)
341 375 Session().commit()
342 376
343 377 def load_resource(self, resource_name, strip=False):
344 378 with open(os.path.join(FIXTURES, resource_name)) as f:
345 379 source = f.read()
346 380 if strip:
347 381 source = source.strip()
348 382
349 383 return source
General Comments 0
You need to be logged in to leave comments. Login now