##// END OF EJS Templates
password-reset: strengthten security on password reset logic....
marcink -
r1471:9ea7077d default
parent child Browse files
Show More
@@ -0,0 +1,106 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21 import pytest
22
23 from rhodecode.config.routing import ADMIN_PREFIX
24 from rhodecode.tests import (
25 TestController, clear_all_caches, url,
26 TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
27 from rhodecode.tests.fixture import Fixture
28 from rhodecode.tests.utils import AssertResponse
29
30 fixture = Fixture()
31
32 # Hardcode URLs because we don't have a request object to use
33 # pyramids URL generation methods.
34 index_url = '/'
35 login_url = ADMIN_PREFIX + '/login'
36 logut_url = ADMIN_PREFIX + '/logout'
37 register_url = ADMIN_PREFIX + '/register'
38 pwd_reset_url = ADMIN_PREFIX + '/password_reset'
39 pwd_reset_confirm_url = ADMIN_PREFIX + '/password_reset_confirmation'
40
41
42 class TestPasswordReset(TestController):
43
44 @pytest.mark.parametrize(
45 'pwd_reset_setting, show_link, show_reset', [
46 ('hg.password_reset.enabled', True, True),
47 ('hg.password_reset.hidden', False, True),
48 ('hg.password_reset.disabled', False, False),
49 ])
50 def test_password_reset_settings(
51 self, pwd_reset_setting, show_link, show_reset):
52 clear_all_caches()
53 self.log_user(TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
54 params = {
55 'csrf_token': self.csrf_token,
56 'anonymous': 'True',
57 'default_register': 'hg.register.auto_activate',
58 'default_register_message': '',
59 'default_password_reset': pwd_reset_setting,
60 'default_extern_activate': 'hg.extern_activate.auto',
61 }
62 resp = self.app.post(url('admin_permissions_application'), params=params)
63 self.logout_user()
64
65 login_page = self.app.get(login_url)
66 asr_login = AssertResponse(login_page)
67 index_page = self.app.get(index_url)
68 asr_index = AssertResponse(index_page)
69
70 if show_link:
71 asr_login.one_element_exists('a.pwd_reset')
72 asr_index.one_element_exists('a.pwd_reset')
73 else:
74 asr_login.no_element_exists('a.pwd_reset')
75 asr_index.no_element_exists('a.pwd_reset')
76
77 response = self.app.get(pwd_reset_url)
78
79 assert_response = AssertResponse(response)
80 if show_reset:
81 response.mustcontain('Send password reset email')
82 assert_response.one_element_exists('#email')
83 assert_response.one_element_exists('#send')
84 else:
85 response.mustcontain('Password reset is disabled.')
86 assert_response.no_element_exists('#email')
87 assert_response.no_element_exists('#send')
88
89 def test_password_form_disabled(self):
90 self.log_user(TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
91 params = {
92 'csrf_token': self.csrf_token,
93 'anonymous': 'True',
94 'default_register': 'hg.register.auto_activate',
95 'default_register_message': '',
96 'default_password_reset': 'hg.password_reset.disabled',
97 'default_extern_activate': 'hg.extern_activate.auto',
98 }
99 self.app.post(url('admin_permissions_application'), params=params)
100 self.logout_user()
101
102 response = self.app.post(
103 pwd_reset_url, {'email': 'lisa@rhodecode.com',}
104 )
105 response = response.follow()
106 response.mustcontain('Password reset is disabled.')
@@ -18,6 +18,7 b''
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 import time
21 22 import collections
22 23 import datetime
23 24 import formencode
@@ -37,9 +38,10 b' from rhodecode.lib.auth import ('
37 38 from rhodecode.lib.base import get_ip_addr
38 39 from rhodecode.lib.exceptions import UserCreationError
39 40 from rhodecode.lib.utils2 import safe_str
40 from rhodecode.model.db import User
41 from rhodecode.model.db import User, UserApiKeys
41 42 from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
42 43 from rhodecode.model.meta import Session
44 from rhodecode.model.auth_token import AuthTokenModel
43 45 from rhodecode.model.settings import SettingsModel
44 46 from rhodecode.model.user import UserModel
45 47 from rhodecode.translation import _
@@ -289,17 +291,24 b' class LoginView(object):'
289 291 'errors': {},
290 292 }
291 293
294 # always send implicit message to prevent from discovery of
295 # matching emails
296 msg = _('If such email exists, a password reset link was sent to it.')
297
292 298 if self.request.POST:
299 if h.HasPermissionAny('hg.password_reset.disabled')():
300 _email = self.request.POST.get('email', '')
301 log.error('Failed attempt to reset password for `%s`.', _email)
302 self.session.flash(_('Password reset has been disabled.'),
303 queue='error')
304 return HTTPFound(self.request.route_path('reset_password'))
305
293 306 password_reset_form = PasswordResetForm()()
294 307 try:
295 308 form_result = password_reset_form.to_python(
296 309 self.request.params)
297 if h.HasPermissionAny('hg.password_reset.disabled')():
298 log.error('Failed attempt to reset password for %s.', form_result['email'] )
299 self.session.flash(
300 _('Password reset has been disabled.'),
301 queue='error')
302 return HTTPFound(self.request.route_path('reset_password'))
310 user_email = form_result['email']
311
303 312 if captcha.active:
304 313 response = submit(
305 314 self.request.params.get('recaptcha_challenge_field'),
@@ -310,43 +319,66 b' class LoginView(object):'
310 319 _value = form_result
311 320 _msg = _('Bad captcha')
312 321 error_dict = {'recaptcha_field': _msg}
313 raise formencode.Invalid(_msg, _value, None,
314 error_dict=error_dict)
322 raise formencode.Invalid(
323 _msg, _value, None, error_dict=error_dict)
324 # Generate reset URL and send mail.
325 user = User.get_by_email(user_email)
315 326
316 # Generate reset URL and send mail.
317 user_email = form_result['email']
318 user = User.get_by_email(user_email)
327 # generate password reset token that expires in 10minutes
328 desc = 'Generated token for password reset from {}'.format(
329 datetime.datetime.now().isoformat())
330 reset_token = AuthTokenModel().create(
331 user, lifetime=10,
332 description=desc,
333 role=UserApiKeys.ROLE_PASSWORD_RESET)
334 Session().commit()
335
336 log.debug('Successfully created password recovery token')
319 337 password_reset_url = self.request.route_url(
320 338 'reset_password_confirmation',
321 _query={'key': user.api_key})
339 _query={'key': reset_token.api_key})
322 340 UserModel().reset_password_link(
323 341 form_result, password_reset_url)
324
325 342 # Display success message and redirect.
326 self.session.flash(
327 _('Your password reset link was sent'),
328 queue='success')
329 return HTTPFound(self.request.route_path('login'))
343 self.session.flash(msg, queue='success')
344 return HTTPFound(self.request.route_path('reset_password'))
330 345
331 346 except formencode.Invalid as errors:
332 347 render_ctx.update({
333 348 'defaults': errors.value,
334 'errors': errors.error_dict,
335 349 })
350 log.debug('faking response on invalid password reset')
351 # make this take 2s, to prevent brute forcing.
352 time.sleep(2)
353 self.session.flash(msg, queue='success')
354 return HTTPFound(self.request.route_path('reset_password'))
336 355
337 356 return render_ctx
338 357
339 358 @view_config(route_name='reset_password_confirmation',
340 359 request_method='GET')
341 360 def password_reset_confirmation(self):
361
342 362 if self.request.GET and self.request.GET.get('key'):
363 # make this take 2s, to prevent brute forcing.
364 time.sleep(2)
365
366 token = AuthTokenModel().get_auth_token(
367 self.request.GET.get('key'))
368
369 # verify token is the correct role
370 if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
371 log.debug('Got token with role:%s expected is %s',
372 getattr(token, 'role', 'EMPTY_TOKEN'),
373 UserApiKeys.ROLE_PASSWORD_RESET)
374 self.session.flash(
375 _('Given reset token is invalid'), queue='error')
376 return HTTPFound(self.request.route_path('reset_password'))
377
343 378 try:
344 user = User.get_by_auth_token(self.request.GET.get('key'))
345 password_reset_url = self.request.route_url(
346 'reset_password_confirmation',
347 _query={'key': user.api_key})
348 data = {'email': user.email}
349 UserModel().reset_password(data, password_reset_url)
379 owner = token.user
380 data = {'email': owner.email, 'token': token.api_key}
381 UserModel().reset_password(data)
350 382 self.session.flash(
351 383 _('Your password reset was successful, '
352 384 'a new password has been sent to your email'),
@@ -41,7 +41,7 b' class AuthTokenModel(BaseModel):'
41 41 """
42 42 :param user: user or user_id
43 43 :param description: description of ApiKey
44 :param lifetime: expiration time in seconds
44 :param lifetime: expiration time in minutes
45 45 :param role: role for the apikey
46 46 """
47 47 from rhodecode.lib.auth import generate_auth_token
@@ -85,3 +85,13 b' class AuthTokenModel(BaseModel):'
85 85 .filter(or_(UserApiKeys.expires == -1,
86 86 UserApiKeys.expires >= time.time()))
87 87 return user_auth_tokens
88
89 def get_auth_token(self, auth_token):
90 auth_token = UserApiKeys.query().filter(
91 UserApiKeys.api_key == auth_token)
92 auth_token = auth_token \
93 .filter(or_(UserApiKeys.expires == -1,
94 UserApiKeys.expires >= time.time()))\
95 .first()
96
97 return auth_token
@@ -943,6 +943,8 b' class UserApiKeys(Base, BaseModel):'
943 943 ROLE_VCS = 'token_role_vcs'
944 944 ROLE_API = 'token_role_api'
945 945 ROLE_FEED = 'token_role_feed'
946 ROLE_PASSWORD_RESET = 'token_password_reset'
947
946 948 ROLES = [ROLE_ALL, ROLE_HTTP, ROLE_VCS, ROLE_API, ROLE_FEED]
947 949
948 950 user_api_key_id = Column("user_api_key_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
@@ -538,7 +538,7 b' class UserModel(BaseModel):'
538 538
539 539 return True
540 540
541 def reset_password(self, data, pwd_reset_url):
541 def reset_password(self, data):
542 542 from rhodecode.lib.celerylib import tasks, run_task
543 543 from rhodecode.model.notification import EmailNotificationModel
544 544 from rhodecode.lib import auth
@@ -554,8 +554,15 b' class UserModel(BaseModel):'
554 554 user.update_userdata(force_password_change=True)
555 555
556 556 Session().add(user)
557
558 # now delete the token in question
559 UserApiKeys = AuthTokenModel.cls
560 UserApiKeys().query().filter(
561 UserApiKeys.api_key == data['token']).delete()
562
557 563 Session().commit()
558 564 log.info('successfully reset password for `%s`', user_email)
565
559 566 if new_passwd is None:
560 567 raise Exception('unable to generate new password')
561 568
@@ -563,7 +570,6 b' class UserModel(BaseModel):'
563 570
564 571 email_kwargs = {
565 572 'new_password': new_passwd,
566 'password_reset_url': pwd_reset_url,
567 573 'user': user,
568 574 'email': user_email,
569 575 'date': datetime.datetime.now()
@@ -571,7 +577,8 b' class UserModel(BaseModel):'
571 577
572 578 (subject, headers, email_body,
573 579 email_body_plaintext) = EmailNotificationModel().render_email(
574 EmailNotificationModel.TYPE_PASSWORD_RESET_CONFIRMATION, **email_kwargs)
580 EmailNotificationModel.TYPE_PASSWORD_RESET_CONFIRMATION,
581 **email_kwargs)
575 582
576 583 recipients = [user_email]
577 584
@@ -16,6 +16,7 b' There was a request to reset your passwo'
16 16 You can continue, and generate new password by clicking following URL:
17 17 ${password_reset_url}
18 18
19 This link will be active for 10 minutes.
19 20 ${self.plaintext_footer()}
20 21 </%def>
21 22
@@ -28,4 +29,5 b' There was a request to reset your passwo'
28 29 <strong>If you did not request a password reset, please contact your RhodeCode administrator.</strong>
29 30 </p><p>
30 31 <a href="${password_reset_url}">${_('Generate new password here')}.</a>
32 This link will be active for 10 minutes.
31 33 </p>
@@ -9,12 +9,11 b' Your new RhodeCode password'
9 9 <%def name="body_plaintext()" filter="n,trim">
10 10 Hi ${user.username},
11 11
12 There was a request to reset your password using the email address ${email} on ${h.format_date(date)}
12 Below is your new access password for RhodeCode.
13 13
14 14 *If you didn't do this, please contact your RhodeCode administrator.*
15 15
16 You can continue, and generate new password by clicking following URL:
17 ${password_reset_url}
16 password: ${new_password}
18 17
19 18 ${self.plaintext_footer()}
20 19 </%def>
@@ -27,4 +26,4 b' Below is your new access password for Rh'
27 26 <br/>
28 27 <strong>If you didn't request a new password, please contact your RhodeCode administrator.</strong>
29 28 </p>
30 <p>password: <input value='${new_password}'/></p>
29 <p>password: <pre>${new_password}</pre>
@@ -25,15 +25,13 b' import pytest'
25 25
26 26 from rhodecode.config.routing import ADMIN_PREFIX
27 27 from rhodecode.tests import (
28 TestController, assert_session_flash, clear_all_caches, url,
29 HG_REPO, TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
28 assert_session_flash, url, HG_REPO, TEST_USER_ADMIN_LOGIN)
30 29 from rhodecode.tests.fixture import Fixture
31 30 from rhodecode.tests.utils import AssertResponse, get_session_from_response
32 from rhodecode.lib.auth import check_password, generate_auth_token
33 from rhodecode.lib import helpers as h
31 from rhodecode.lib.auth import check_password
34 32 from rhodecode.model.auth_token import AuthTokenModel
35 33 from rhodecode.model import validators
36 from rhodecode.model.db import User, Notification
34 from rhodecode.model.db import User, Notification, UserApiKeys
37 35 from rhodecode.model.meta import Session
38 36
39 37 fixture = Fixture()
@@ -49,7 +47,7 b" pwd_reset_confirm_url = ADMIN_PREFIX + '"
49 47
50 48
51 49 @pytest.mark.usefixtures('app')
52 class TestLoginController:
50 class TestLoginController(object):
53 51 destroy_users = set()
54 52
55 53 @classmethod
@@ -374,54 +372,42 b' class TestLoginController:'
374 372 def test_forgot_password_wrong_mail(self):
375 373 bad_email = 'marcin@wrongmail.org'
376 374 response = self.app.post(
377 pwd_reset_url,
378 {'email': bad_email, }
375 pwd_reset_url, {'email': bad_email, }
379 376 )
377 assert_session_flash(response,
378 'If such email exists, a password reset link was sent to it.')
380 379
381 msg = validators.ValidSystemEmail()._messages['non_existing_email']
382 msg = h.html_escape(msg % {'email': bad_email})
383 response.mustcontain()
384
385 def test_forgot_password(self):
380 def test_forgot_password(self, user_util):
386 381 response = self.app.get(pwd_reset_url)
387 382 assert response.status == '200 OK'
388 383
389 username = 'test_password_reset_1'
390 password = 'qweqwe'
391 email = 'marcin@python-works.com'
392 name = 'passwd'
393 lastname = 'reset'
384 user = user_util.create_user()
385 user_id = user.user_id
386 email = user.email
394 387
395 new = User()
396 new.username = username
397 new.password = password
398 new.email = email
399 new.name = name
400 new.lastname = lastname
401 new.api_key = generate_auth_token(username)
402 Session().add(new)
403 Session().commit()
388 response = self.app.post(pwd_reset_url, {'email': email, })
404 389
405 response = self.app.post(pwd_reset_url,
406 {'email': email, })
407
408 assert_session_flash(
409 response, 'Your password reset link was sent')
410
411 response = response.follow()
390 assert_session_flash(response,
391 'If such email exists, a password reset link was sent to it.')
412 392
413 393 # BAD KEY
414
415 key = "bad"
416 confirm_url = '{}?key={}'.format(pwd_reset_confirm_url, key)
394 confirm_url = '{}?key={}'.format(pwd_reset_confirm_url, 'badkey')
417 395 response = self.app.get(confirm_url)
418 396 assert response.status == '302 Found'
419 397 assert response.location.endswith(pwd_reset_url)
398 assert_session_flash(response, 'Given reset token is invalid')
399
400 response.follow() # cleanup flash
420 401
421 402 # GOOD KEY
403 key = UserApiKeys.query()\
404 .filter(UserApiKeys.user_id == user_id)\
405 .filter(UserApiKeys.role == UserApiKeys.ROLE_PASSWORD_RESET)\
406 .first()
422 407
423 key = User.get_by_username(username).api_key
424 confirm_url = '{}?key={}'.format(pwd_reset_confirm_url, key)
408 assert key
409
410 confirm_url = '{}?key={}'.format(pwd_reset_confirm_url, key.api_key)
425 411 response = self.app.get(confirm_url)
426 412 assert response.status == '302 Found'
427 413 assert response.location.endswith(login_url)
@@ -431,7 +417,7 b' class TestLoginController:'
431 417 'Your password reset was successful, '
432 418 'a new password has been sent to your email')
433 419
434 response = response.follow()
420 response.follow()
435 421
436 422 def _get_api_whitelist(self, values=None):
437 423 config = {'api_access_controllers_whitelist': values or []}
@@ -522,70 +508,3 b' class TestLoginController:'
522 508 repo_name=HG_REPO, revision='tip',
523 509 api_key=new_auth_token.api_key),
524 510 status=302)
525
526
527 class TestPasswordReset(TestController):
528
529 @pytest.mark.parametrize(
530 'pwd_reset_setting, show_link, show_reset', [
531 ('hg.password_reset.enabled', True, True),
532 ('hg.password_reset.hidden', False, True),
533 ('hg.password_reset.disabled', False, False),
534 ])
535 def test_password_reset_settings(
536 self, pwd_reset_setting, show_link, show_reset):
537 clear_all_caches()
538 self.log_user(TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
539 params = {
540 'csrf_token': self.csrf_token,
541 'anonymous': 'True',
542 'default_register': 'hg.register.auto_activate',
543 'default_register_message': '',
544 'default_password_reset': pwd_reset_setting,
545 'default_extern_activate': 'hg.extern_activate.auto',
546 }
547 resp = self.app.post(url('admin_permissions_application'), params=params)
548 self.logout_user()
549
550 login_page = self.app.get(login_url)
551 asr_login = AssertResponse(login_page)
552 index_page = self.app.get(index_url)
553 asr_index = AssertResponse(index_page)
554
555 if show_link:
556 asr_login.one_element_exists('a.pwd_reset')
557 asr_index.one_element_exists('a.pwd_reset')
558 else:
559 asr_login.no_element_exists('a.pwd_reset')
560 asr_index.no_element_exists('a.pwd_reset')
561
562 pwdreset_page = self.app.get(pwd_reset_url)
563
564 asr_reset = AssertResponse(pwdreset_page)
565 if show_reset:
566 assert 'Send password reset email' in pwdreset_page
567 asr_reset.one_element_exists('#email')
568 asr_reset.one_element_exists('#send')
569 else:
570 assert 'Password reset is disabled.' in pwdreset_page
571 asr_reset.no_element_exists('#email')
572 asr_reset.no_element_exists('#send')
573
574 def test_password_form_disabled(self):
575 self.log_user(TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
576 params = {
577 'csrf_token': self.csrf_token,
578 'anonymous': 'True',
579 'default_register': 'hg.register.auto_activate',
580 'default_register_message': '',
581 'default_password_reset': 'hg.password_reset.disabled',
582 'default_extern_activate': 'hg.extern_activate.auto',
583 }
584 self.app.post(url('admin_permissions_application'), params=params)
585 self.logout_user()
586
587 pwdreset_page = self.app.post(
588 pwd_reset_url,
589 {'email': 'lisa@rhodecode.com',}
590 )
591 assert 'Password reset is disabled.' in pwdreset_page
General Comments 0
You need to be logged in to leave comments. Login now