##// END OF EJS Templates
feat(2fa): refactor logic arround validation/recoverycodes and workflows of configuration of 2fa...
super-admin -
r5367:a11e6ff3 default
parent child Browse files
Show More
@@ -178,9 +178,7 b' class BaseAppView(object):'
178 178 if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode':
179 179 return
180 180
181 if (user_obj.has_enabled_2fa
182 and not self.user_data.get('secret_2fa')) \
183 and view_name != self.SETUP_2FA_VIEW:
181 if user_obj.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW:
184 182 h.flash(
185 183 "You are required to configure 2FA",
186 184 "warning",
@@ -195,7 +193,7 b' class BaseAppView(object):'
195 193 if not user_obj:
196 194 return
197 195
198 if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW:
196 if user_obj.has_check_2fa_flag and view_name != self.VERIFY_2FA_VIEW:
199 197 raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW))
200 198
201 199 def _log_creation_exception(self, e, repo_name):
@@ -33,7 +33,7 b' class Test2FA(object):'
33 33 def test_redirect_to_2fa_check_if_2fa_configured(self, user_util):
34 34 user = user_util.create_user(password=self.password)
35 35 user.has_enabled_2fa = True
36 user.secret_2fa
36 user.init_secret_2fa()
37 37 Session().add(user)
38 38 Session().commit()
39 39 self.app.post(
@@ -47,8 +47,8 b' class Test2FA(object):'
47 47 def test_2fa_recovery_codes_works_only_once(self, user_util):
48 48 user = user_util.create_user(password=self.password)
49 49 user.has_enabled_2fa = True
50 user.secret_2fa
51 recovery_cod_to_check = user.get_2fa_recovery_codes()[0]
50 user.init_secret_2fa()
51 recovery_cod_to_check = user.init_2fa_recovery_codes()[0]
52 52 Session().add(user)
53 53 Session().commit()
54 54 self.app.post(
@@ -188,7 +188,8 b' class LoginView(BaseAppView):'
188 188 # form checks for username/password, now we're authenticated
189 189 username = form_result['username']
190 190 if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa:
191 user.update_userdata(check_2fa=True)
191 user.has_check_2fa_flag = True
192
192 193 headers = store_user_in_session(
193 194 self.session,
194 195 user_identifier=username,
@@ -489,23 +490,32 b' class LoginView(BaseAppView):'
489 490 form = TOTPForm(_, user_instance)()
490 491 render_ctx = {}
491 492 if self.request.method == 'POST':
493 post_items = dict(self.request.POST)
494
492 495 try:
493 form.to_python(dict(self.request.POST))
496 form_details = form.to_python(post_items)
497 secret = form_details['secret_totp']
498
499 user_instance.init_2fa_recovery_codes(persist=True, force=True)
500 user_instance.set_2fa_secret(secret)
501
494 502 Session().commit()
495 raise HTTPFound(c.came_from)
503 raise HTTPFound(self.request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1}))
496 504 except formencode.Invalid as errors:
497 505 defaults = errors.value
498 506 render_ctx = {
499 507 'errors': errors.error_dict,
500 508 'defaults': defaults,
501 509 }
510
511 # NOTE: here we DO NOT persist the secret 2FA, since this is only for setup, once a setup is completed
512 # only then we should persist it
513 secret = user_instance.init_secret_2fa(persist=False)
514
515 totp_name = f'RhodeCode token ({self.request.user.username})'
516
502 517 qr = qrcode.QRCode(version=1, box_size=10, border=5)
503 secret = user_instance.secret_2fa
504 Session().flush()
505 recovery_codes = user_instance.get_2fa_recovery_codes()
506 Session().commit()
507 qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(
508 name=self.request.user.name))
518 qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_name))
509 519 qr.make(fit=True)
510 520 img = qr.make_image(fill_color='black', back_color='white')
511 521 buffered = BytesIO()
@@ -513,8 +523,8 b' class LoginView(BaseAppView):'
513 523 return self._get_template_context(
514 524 c,
515 525 qr=b64encode(buffered.getvalue()).decode("utf-8"),
516 key=secret, recovery_codes=json.dumps(recovery_codes),
517 codes_viewed=not bool(recovery_codes),
526 key=secret,
527 totp_name=totp_name,
518 528 ** render_ctx
519 529 )
520 530
@@ -527,9 +537,12 b' class LoginView(BaseAppView):'
527 537 user_instance = self._rhodecode_db_user
528 538 totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
529 539 if self.request.method == 'POST':
540 post_items = dict(self.request.POST)
541 # NOTE: inject secret, as it's a post configured saved item.
542 post_items['secret_totp'] = user_instance.get_secret_2fa()
530 543 try:
531 totp_form.to_python(dict(self.request.POST))
532 user_instance.update_userdata(check_2fa=False)
544 totp_form.to_python(post_items)
545 user_instance.has_check_2fa_flag = False
533 546 Session().commit()
534 547 raise HTTPFound(c.came_from)
535 548 except formencode.Invalid as errors:
@@ -83,24 +83,35 b' def includeme(config):'
83 83 attr='my_account_2fa',
84 84 route_name='my_account_enable_2fa', request_method='GET',
85 85 renderer='rhodecode:templates/admin/my_account/my_account.mako')
86
86 # my account 2fa save
87 87 config.add_route(
88 name='my_account_configure_2fa',
89 pattern=ADMIN_PREFIX + '/my_account/configure_2fa')
88 name='my_account_enable_2fa_save',
89 pattern=ADMIN_PREFIX + '/my_account/enable_2fa_save')
90 90 config.add_view(
91 91 MyAccountView,
92 attr='my_account_2fa_configure',
93 route_name='my_account_configure_2fa', request_method='POST', xhr=True,
92 attr='my_account_2fa_update',
93 route_name='my_account_enable_2fa_save', request_method='POST',
94 renderer='rhodecode:templates/admin/my_account/my_account.mako')
95
96 # my account 2fa recovery code-reset
97 config.add_route(
98 name='my_account_show_2fa_recovery_codes',
99 pattern=ADMIN_PREFIX + '/my_account/recovery_codes')
100 config.add_view(
101 MyAccountView,
102 attr='my_account_2fa_show_recovery_codes',
103 route_name='my_account_show_2fa_recovery_codes', request_method='POST', xhr=True,
94 104 renderer='json_ext')
95 105
106 # my account 2fa recovery code-reset
96 107 config.add_route(
97 108 name='my_account_regenerate_2fa_recovery_codes',
98 109 pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes')
99 110 config.add_view(
100 111 MyAccountView,
101 112 attr='my_account_2fa_regenerate_recovery_codes',
102 route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', xhr=True,
103 renderer='json_ext')
113 route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST',
114 renderer='rhodecode:templates/admin/my_account/my_account.mako')
104 115
105 116 # my account tokens
106 117 config.add_route(
@@ -16,6 +16,7 b''
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 import time
19 20 import logging
20 21 import datetime
21 22 import string
@@ -43,6 +44,7 b' from rhodecode.model.db import ('
43 44 IntegrityError, or_, in_filter_generator, select,
44 45 Repository, UserEmailMap, UserApiKeys, UserFollowing,
45 46 PullRequest, UserBookmark, RepoGroup, ChangesetStatus)
47 from rhodecode.model.forms import TOTPForm
46 48 from rhodecode.model.meta import Session
47 49 from rhodecode.model.pull_request import PullRequestModel
48 50 from rhodecode.model.user import UserModel
@@ -207,27 +209,65 b' class MyAccountView(BaseAppView, DataGri'
207 209 def my_account_2fa(self):
208 210 _ = self.request.translate
209 211 c = self.load_default_context()
210 c.active = '2fa'
211 from rhodecode.model.settings import SettingsModel
212 user_instance = self._rhodecode_db_user
212 c.active = '2FA'
213 user_instance = c.auth_user.get_instance()
213 214 locked_by_admin = user_instance.has_forced_2fa
214 215 c.state_of_2fa = user_instance.has_enabled_2fa
216 c.user_seen_2fa_recovery_codes = user_instance.has_seen_2fa_codes
215 217 c.locked_2fa = str2bool(locked_by_admin)
216 218 return self._get_template_context(c)
217 219
218 220 @LoginRequired()
219 221 @NotAnonymous()
220 222 @CSRFRequired()
221 def my_account_2fa_configure(self):
222 state = self.request.POST.get('state')
223 self._rhodecode_db_user.has_enabled_2fa = state
224 return {'state_of_2fa': state}
223 def my_account_2fa_update(self):
224 _ = self.request.translate
225 c = self.load_default_context()
226 c.active = '2FA'
227 user_instance = c.auth_user.get_instance()
228
229 state = self.request.POST.get('2fa_status') == '1'
230 user_instance.has_enabled_2fa = state
231 user_instance.update_userdata(update_2fa=time.time())
232 Session().commit()
233 h.flash(_("Successfully saved 2FA settings"), category='success')
234 raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
235
236 @LoginRequired()
237 @NotAnonymous()
238 @CSRFRequired()
239 def my_account_2fa_show_recovery_codes(self):
240 c = self.load_default_context()
241 user_instance = c.auth_user.get_instance()
242 user_instance.has_seen_2fa_codes = True
243 Session().commit()
244 return {'recovery_codes': user_instance.get_2fa_recovery_codes()}
225 245
226 246 @LoginRequired()
227 247 @NotAnonymous()
228 248 @CSRFRequired()
229 249 def my_account_2fa_regenerate_recovery_codes(self):
230 return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()}
250 _ = self.request.translate
251 c = self.load_default_context()
252 user_instance = c.auth_user.get_instance()
253
254 totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
255
256 post_items = dict(self.request.POST)
257 # NOTE: inject secret, as it's a post configured saved item.
258 post_items['secret_totp'] = user_instance.get_secret_2fa()
259 try:
260 totp_form.to_python(post_items)
261 user_instance.regenerate_2fa_recovery_codes()
262 Session().commit()
263 except formencode.Invalid as errors:
264 h.flash(_("Failed to generate new recovery codes: {}").format(errors), category='error')
265 raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
266 except Exception as e:
267 h.flash(_("Failed to generate new recovery codes: {}").format(e), category='error')
268 raise HTTPFound(self.request.route_path('my_account_enable_2fa'))
269
270 raise HTTPFound(self.request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1}))
231 271
232 272 @LoginRequired()
233 273 @NotAnonymous()
@@ -796,34 +796,13 b' class User(Base, BaseModel):'
796 796 Session.commit()
797 797 return artifact_token.api_key
798 798
799 @hybrid_property
800 def secret_2fa(self):
801 if not self.user_data.get('secret_2fa'):
802 secret = pyotp.random_base32()
803 self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(secret, enc_key=ENCRYPTION_KEY)))
804 return secret
805 return safe_str(
806 enc_utils.decrypt_value(self.user_data['secret_2fa'],
807 enc_key=ENCRYPTION_KEY,
808 strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict',
809 missing=True)
810 )
811 )
812
813 def is_totp_valid(self, received_code):
814 totp = pyotp.TOTP(self.secret_2fa)
799 def is_totp_valid(self, received_code, secret):
800 totp = pyotp.TOTP(secret)
815 801 return totp.verify(received_code)
816 802
817 def is_2fa_recovery_code_valid(self, received_code):
803 def is_2fa_recovery_code_valid(self, received_code, secret):
818 804 encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
819 recovery_codes = list(map(
820 lambda x: safe_str(
821 enc_utils.decrypt_value(
822 x,
823 enc_key=ENCRYPTION_KEY,
824 strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
825 )),
826 encrypted_recovery_codes))
805 recovery_codes = self.get_2fa_recovery_codes()
827 806 if received_code in recovery_codes:
828 807 encrypted_recovery_codes.pop(recovery_codes.index(received_code))
829 808 self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes)
@@ -844,7 +823,7 b' class User(Base, BaseModel):'
844 823 @hybrid_property
845 824 def has_enabled_2fa(self):
846 825 """
847 Checks if 2fa was enabled by user
826 Checks if user enabled 2fa
848 827 """
849 828 if value := self.has_forced_2fa:
850 829 return value
@@ -853,34 +832,109 b' class User(Base, BaseModel):'
853 832 @has_enabled_2fa.setter
854 833 def has_enabled_2fa(self, val):
855 834 val = str2bool(val)
856 self.update_userdata(enabled_2fa=str2bool(val))
835 self.update_userdata(enabled_2fa=val)
857 836 if not val:
858 self.update_userdata(secret_2fa=None, recovery_codes_2fa=[])
837 # NOTE: setting to false we clear the user_data to not store any 2fa artifacts
838 self.update_userdata(secret_2fa=None, recovery_codes_2fa=[], check_2fa=False)
839 Session().commit()
840
841 @hybrid_property
842 def has_check_2fa_flag(self):
843 """
844 Check if check 2fa flag is set for this user
845 """
846 value = self.user_data.get('check_2fa', False)
847 return value
848
849 @has_check_2fa_flag.setter
850 def has_check_2fa_flag(self, val):
851 val = str2bool(val)
852 self.update_userdata(check_2fa=val)
859 853 Session().commit()
860 854
861 def get_2fa_recovery_codes(self):
855 @hybrid_property
856 def has_seen_2fa_codes(self):
857 """
858 get the flag about if user has seen 2fa recovery codes
859 """
860 value = self.user_data.get('recovery_codes_2fa_seen', False)
861 return value
862
863 @has_seen_2fa_codes.setter
864 def has_seen_2fa_codes(self, val):
865 val = str2bool(val)
866 self.update_userdata(recovery_codes_2fa_seen=val)
867 Session().commit()
868
869 @hybrid_property
870 def needs_2fa_configure(self):
871 """
872 Determines if setup2fa has completed for this user. Means he has all needed data for 2fa to work.
873
874 Currently this is 2fa enabled and secret exists
875 """
876 if self.has_enabled_2fa:
877 return not self.user_data.get('secret_2fa')
878 return False
879
880 def init_2fa_recovery_codes(self, persist=True, force=False):
862 881 """
863 882 Creates 2fa recovery codes
864 883 """
865 884 recovery_codes = self.user_data.get('recovery_codes_2fa', [])
866 885 encrypted_codes = []
867 if not recovery_codes:
886 if not recovery_codes or force:
868 887 for _ in range(self.RECOVERY_CODES_COUNT):
869 888 recovery_code = pyotp.random_base32()
870 889 recovery_codes.append(recovery_code)
871 encrypted_codes.append(safe_str(enc_utils.encrypt_value(recovery_code, enc_key=ENCRYPTION_KEY)))
872 self.update_userdata(recovery_codes_2fa=encrypted_codes)
890 encrypted_code = enc_utils.encrypt_value(safe_bytes(recovery_code), enc_key=ENCRYPTION_KEY)
891 encrypted_codes.append(safe_str(encrypted_code))
892 if persist:
893 self.update_userdata(recovery_codes_2fa=encrypted_codes, recovery_codes_2fa_seen=False)
873 894 return recovery_codes
874 895 # User should not check the same recovery codes more than once
875 896 return []
876 897
898 def get_2fa_recovery_codes(self):
899 encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
900 strict_mode = ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
901
902 recovery_codes = list(map(
903 lambda val: safe_str(
904 enc_utils.decrypt_value(
905 val,
906 enc_key=ENCRYPTION_KEY,
907 strict_mode=strict_mode
908 )),
909 encrypted_recovery_codes))
910 return recovery_codes
911
912 def init_secret_2fa(self, persist=True, force=False):
913 secret_2fa = self.user_data.get('secret_2fa')
914 if not secret_2fa or force:
915 secret = pyotp.random_base32()
916 if persist:
917 self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(safe_bytes(secret), enc_key=ENCRYPTION_KEY)))
918 return secret
919 return ''
920
921 def get_secret_2fa(self) -> str:
922 secret_2fa = self.user_data['secret_2fa']
923 if secret_2fa:
924 strict_mode = ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
925 return safe_str(
926 enc_utils.decrypt_value(secret_2fa, enc_key=ENCRYPTION_KEY, strict_mode=strict_mode))
927 return ''
928
929 def set_2fa_secret(self, value):
930 encrypted_value = enc_utils.encrypt_value(safe_bytes(value), enc_key=ENCRYPTION_KEY)
931 self.update_userdata(secret_2fa=safe_str(encrypted_value))
932
877 933 def regenerate_2fa_recovery_codes(self):
878 934 """
879 935 Regenerates 2fa recovery codes upon request
880 936 """
881 self.update_userdata(recovery_codes_2fa=[])
882 Session().flush()
883 new_recovery_codes = self.get_2fa_recovery_codes()
937 new_recovery_codes = self.init_2fa_recovery_codes(force=True)
884 938 Session().commit()
885 939 return new_recovery_codes
886 940
@@ -5021,8 +5075,7 b' class Gist(Base, BaseModel):'
5021 5075 return data
5022 5076
5023 5077 def __json__(self):
5024 data = dict(
5025 )
5078 data = dict()
5026 5079 data.update(self.get_api_data())
5027 5080 return data
5028 5081 # SCM functions
@@ -111,6 +111,7 b' def TOTPForm(localizer, user, allow_reco'
111 111 allow_extra_fields = True
112 112 filter_extra_fields = False
113 113 totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$')
114 secret_totp = v.String()
114 115
115 116 def to_python(self, value, state=None):
116 117 validation_checks = [user.is_totp_valid]
@@ -118,10 +119,12 b' def TOTPForm(localizer, user, allow_reco'
118 119 validation_checks.append(user.is_2fa_recovery_code_valid)
119 120 form_data = super().to_python(value, state)
120 121 received_code = form_data['totp']
121 if not any(map(lambda x: x(received_code), validation_checks)):
122 secret = form_data.get('secret_totp')
123
124 if not any(map(lambda func: func(received_code, secret), validation_checks)):
122 125 error_msg = _('Code is invalid. Try again!')
123 126 raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg})
124 return True
127 return form_data
125 128
126 129 return _TOTPForm
127 130
@@ -95,6 +95,7 b' function registerRCRoutes() {'
95 95 pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []);
96 96 pyroutes.register('channelstream_proxy', '/_channelstream', []);
97 97 pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []);
98 pyroutes.register('check_2fa', '/_admin/check_2fa', []);
98 99 pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']);
99 100 pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']);
100 101 pyroutes.register('debug_style_email_plain_rendered', '/_admin/debug_style/email-rendered/%(email_id)s', ['email_id']);
@@ -218,22 +219,23 b' function registerRCRoutes() {'
218 219 pyroutes.register('my_account_emails', '/_admin/my_account/emails', []);
219 220 pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []);
220 221 pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []);
222 pyroutes.register('my_account_enable_2fa', '/_admin/my_account/enable_2fa', []);
223 pyroutes.register('my_account_enable_2fa_save', '/_admin/my_account/enable_2fa_save', []);
221 224 pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []);
222 225 pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []);
223 226 pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']);
224 227 pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
225 228 pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
226 229 pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
227 pyroutes.register('check_2fa', '/_admin/check_2fa', []);
228 pyroutes.register('my_account_configure_2fa', '/_admin/my_account/configure_2fa', []);
229 pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
230 230 pyroutes.register('my_account_password', '/_admin/my_account/password', []);
231 231 pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
232 232 pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);
233 233 pyroutes.register('my_account_profile', '/_admin/my_account/profile', []);
234 234 pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []);
235 235 pyroutes.register('my_account_pullrequests_data', '/_admin/my_account/pull_requests/data', []);
236 pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
236 237 pyroutes.register('my_account_repos', '/_admin/my_account/repos', []);
238 pyroutes.register('my_account_show_2fa_recovery_codes', '/_admin/my_account/recovery_codes', []);
237 239 pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []);
238 240 pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []);
239 241 pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []);
@@ -382,6 +384,7 b' function registerRCRoutes() {'
382 384 pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']);
383 385 pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']);
384 386 pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']);
387 pyroutes.register('setup_2fa', '/_admin/setup_2fa', []);
385 388 pyroutes.register('store_user_session_value', '/_store_session_attr', []);
386 389 pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']);
387 390 pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']);
@@ -4,6 +4,7 b''
4 4 <div class="panel-heading">
5 5 <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3>
6 6 </div>
7 ${h.secure_form(h.route_path('my_account_enable_2fa_save'), request=request)}
7 8 <div class="panel-body">
8 9 <div class="form">
9 10 <div class="fields">
@@ -12,112 +13,122 b''
12 13 <label>${_('2FA status')}:</label>
13 14 </div>
14 15 <div class="checkboxes">
15
16 <div class="form-check">
17 <label class="form-check-label">
18 <input type="radio" id="2faEnabled" value="1" ${'checked' if c.state_of_2fa else ''}>
19 ${_('Enabled')}
20 </label>
21 <label class="form-check-label">
22 <input type="radio" id="2faDisabled" value="0" ${'checked' if not c.state_of_2fa else ''}>
23 ${_('Disabled')}
24 </label>
25 </div>
26 16 % if c.locked_2fa:
27 17 <span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span>
18
19 % else:
20 <div class="form-check">
21 <input type="radio" id="2faEnabled" name="2fa_status" value="1" ${'checked=1' if c.state_of_2fa else ''}/>
22 <label for="2faEnabled">${_('Enable 2FA')}</label>
23
24 <input type="radio" id="2faDisabled" name="2fa_status" value="0" ${'checked=1' if not c.state_of_2fa else ''} />
25 <label for="2faDisabled">${_('Disable 2FA')}</label>
26 </div>
28 27 % endif
28
29 29 </div>
30 30 </div>
31 31 </div>
32 32 <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button>
33 33 </div>
34 34 </div>
35 ${h.end_form()}
35 36 </div>
36 37
37 38 % if c.state_of_2fa:
39
40
41 % if not c.user_seen_2fa_recovery_codes:
42
43 <div class="panel panel-warning">
44 <div class="panel-heading" id="advanced-archive">
45 <h3 class="panel-title">${_('2FA Recovery codes')} <a class="permalink" href="#advanced-archive"> ΒΆ</a></h3>
46 </div>
47 <div class="panel-body">
48 <p>
49 ${_('You have not seen your 2FA recovery codes yet.')}
50 ${_('Please save them in a safe place, or you will lose access to your account in case of lost access to authenticator app.')}
51 </p>
52 <br/>
53 <a href="${request.route_path('my_account_enable_2fa', _query={'show-recovery-codes': 1})}" class="btn btn-primary">${_('Show recovery codes')}</a>
54 </div>
55 </div>
56 % endif
57
58
59 ${h.secure_form(h.route_path('my_account_regenerate_2fa_recovery_codes'), request=request)}
38 60 <div class="panel panel-default">
39 61 <div class="panel-heading">
40 62 <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3>
41 63 </div>
42 64 <div class="panel-body">
43 65 <form id="2faForm">
44 <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}"
45 style="width: 20%">
46 <button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button>
66 <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" style="width: 20%">
67 <button type="submit" class="btn btn-primary">${_('Verify and generate new codes')}</button>
47 68 </form>
48 <div id="result"></div>
49 69 </div>
50 70
51 71 </div>
72 ${h.end_form()}
73 % endif
52 74
53 % endif
54 75
55 76 <script>
56 function submitForm() {
57 let formData = new FormData(document.getElementById("2faForm"));
58 let xhr = new XMLHttpRequest();
77
78 function showRecoveryCodesPopup() {
59 79
60 let success = function (response) {
61 let recovery_codes = response.recovery_codes;
62 showRecoveryCodesPopup(recovery_codes);
63 }
80 SwalNoAnimation.fire({
81 title: _gettext('2FA recovery codes'),
82 html: '<span>Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you will lose access to your account.</span>',
83 showCancelButton: false,
84 showConfirmButton: true,
85 showLoaderOnConfirm: true,
86 confirmButtonText: _gettext('Show now'),
87 allowOutsideClick: function () {
88 !Swal.isLoading()
89 },
90
91 preConfirm: function () {
64 92
65 xhr.onreadystatechange = function () {
66 if (xhr.readyState == 4 && xhr.status == 200) {
67 let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html");
68 let contentToDisplay = responseDoc.querySelector('#formErrors');
69 if (contentToDisplay) {
70 document.getElementById("result").innerHTML = contentToDisplay.innerHTML;
71 } else {
72 let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes');
73 ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success);
74 }
75 }
93 var postData = {
94 'csrf_token': CSRF_TOKEN
76 95 };
77 let url = pyroutes.url('check_2fa');
78 xhr.open("POST", url, true);
79 xhr.send(formData);
96 return new Promise(function (resolve, reject) {
97 $.ajax({
98 type: 'POST',
99 data: postData,
100 url: pyroutes.url('my_account_show_2fa_recovery_codes'),
101 headers: {'X-PARTIAL-XHR': true}
102 })
103 .done(function (data) {
104 resolve(data);
105 })
106 .fail(function (jqXHR, textStatus, errorThrown) {
107 var message = formatErrorMessage(jqXHR, textStatus, errorThrown);
108 ajaxErrorSwal(message);
109 });
110 })
80 111 }
81 112
82 document.getElementById('2faEnabled').addEventListener('click', function () {
83 document.getElementById('2faDisabled').checked = false;
84 });
85 document.getElementById('2faDisabled').addEventListener('click', function () {
86 document.getElementById('2faEnabled').checked = false;
87 });
88
89 function getStateValue() {
90 if (document.getElementById('2faEnabled').checked) {
91 return '1';
92 } else {
93 return '0';
94 }
95 };
96
97 function saveChanges(state) {
98
99 let post_data = {'state': state, 'csrf_token': CSRF_TOKEN};
100 let url = pyroutes.url('my_account_configure_2fa');
101
102 ajaxPOST(url, post_data, function(){}, function(){})
103 }
104
105 document.getElementById('saveBtn').addEventListener('click', function () {
106 var state = getStateValue();
107 saveChanges(state);
108 });
109
110 function showRecoveryCodesPopup(recoveryCodes) {
111 let funcData = {'recoveryCodes': recoveryCodes}
112 let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData)
113
113 })
114 .then(function (result) {
115 if (result.value) {
116 let funcData = {'recoveryCodes': result.value.recovery_codes}
117 let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData);
114 118 SwalNoAnimation.fire({
115 119 allowOutsideClick: false,
116 120 confirmButtonText: _gettext('I Copied the codes'),
117 121 title: _gettext('2FA Recovery Codes'),
118 122 html: recoveryCodesHtml
123 }).then(function (result) {
124 if (result.isConfirmed) {
125 window.location.reload()
126 }
119 127 })
120
121 128 }
122
129 })
130 }
131 % if request.GET.get('show-recovery-codes') == '1' and not c.user_seen_2fa_recovery_codes:
132 showRecoveryCodesPopup();
133 % endif
123 134 </script>
@@ -1,7 +1,7 b''
1 1 <%inherit file="base/root.mako"/>
2 2
3 3 <%def name="title()">
4 ${_('Setup authenticator app')}
4 ${_('Setup 2FA')}
5 5 %if c.rhodecode_name:
6 6 &middot; ${h.branding(c.rhodecode_name)}
7 7 %endif
@@ -22,31 +22,28 b''
22 22 </div>
23 23
24 24 <div class="loginwrapper">
25 <h1>Setup the authenticator app</h1>
25 <h1>${_('Setup the authenticator app')}</h1>
26
26 27 <p>Authenticator apps like <a href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2' target="_blank" rel="noopener noreferrer">Google Authenticator</a>, etc. generate one-time passwords that are used as a second factor to verify you identity.</p>
27 28 <rhodecode-toast id="notifications"></rhodecode-toast>
28 29
29 30 <div id="setup_2fa">
31 ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
30 32 <div class="sign-in-title">
31 <h1>${_('Scan the QR code')}</h1>
33 <h1>${_('Scan the QR code')}: "${totp_name}"</h1>
32 34 </div>
33 <p>Use an authenticator app to scan.</p>
34 <img src="data:image/png;base64, ${qr}"/>
35 <p>${_('Use an authenticator app to scan.')}</p>
36 <img alt="qr-code" src="data:image/png;base64, ${qr}"/>
37
35 38 <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p>
36 39 <div id="secretDiv" class="hidden">
37 40 <p>${_('Copy and use this code to manually setup an authenticator app')}</p>
38 <input type="text" id="secretField" value=${key}>
39 <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="" title="${_('Copy the secret key')}"></i>
41 <input type="text" class="input-monospace" value="${key}" id="secret_totp" name="secret_totp" style="width: 400px"/>
42 <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${key}" title="${_('Copy the secret key')}"></i>
40 43 </div>
41 <div id="codesPopup" class="modal">
42 <div class="modal-content">
43 <ul id="recoveryCodesList"></ul>
44 <button id="copyAllBtn" class="btn btn-primary">Copy All</button>
45 </div>
46 </div>
47 <br><br>
44
48 45 <div id="verify_2fa">
49 ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
46
50 47 <div class="form mt-4">
51 48 <div class="field">
52 49 <p>
@@ -65,7 +62,7 b''
65 62 %endif
66 63 </div>
67 64 <div class="input-group-append">
68 ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)}
65 ${h.submit('verify_2fa',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
69 66 </div>
70 67 </div>
71 68 </div>
@@ -73,81 +70,18 b''
73 70 </div>
74 71 </div>
75 72 </div>
73 ${h.end_form()}
76 74 </div>
77 75 </div>
78 76 </div>
79 <script>
80 document.addEventListener('DOMContentLoaded', function() {
81 let clipboardIcons = document.querySelectorAll('.clipboard-action');
82 77
83 clipboardIcons.forEach(function(icon) {
84 icon.addEventListener('click', function() {
85 var inputField = document.getElementById('secretField');
86 inputField.select();
87 document.execCommand('copy');
78 <script>
88 79
89 });
90 });
91 });
92 </script>
93 <script>
94 80 document.getElementById('toggleLink').addEventListener('click', function() {
95 81 let hiddenField = document.getElementById('secretDiv');
96 82 if (hiddenField.classList.contains('hidden')) {
97 83 hiddenField.classList.remove('hidden');
98 84 }
99 85 });
86
100 87 </script>
101 <script>
102 const recovery_codes_string = '${recovery_codes}';
103 const cleaned_recovery_codes_string = recovery_codes_string
104 .replace(/&#34;/g, '"')
105 .replace(/&#39;/g, "'");
106
107 const recovery_codes = JSON.parse(cleaned_recovery_codes_string);
108
109 const cleaned_recovery_codes = recovery_codes.map(code => code.replace(/['"]/g, ''));
110
111 function showRecoveryCodesPopup() {
112 const popup = document.getElementById("codesPopup");
113 const codesList = document.getElementById("recoveryCodesList");
114 const verify_btn = document.getElementById('save')
115
116 if (verify_btn.disabled) {
117 codesList.innerHTML = "";
118
119 cleaned_recovery_codes.forEach(code => {
120 const listItem = document.createElement("li");
121 listItem.textContent = code;
122 codesList.appendChild(listItem);
123 });
124
125 popup.style.display = "block";
126 verify_btn.disabled = false;
127 }
128 }
129
130 document.getElementById("save").addEventListener("mouseover", showRecoveryCodesPopup);
131
132 const popup = document.getElementById("codesPopup");
133 const closeButton = document.querySelector(".close");
134 window.onclick = function(event) {
135 if (event.target === popup || event.target === closeButton) {
136 popup.style.display = "none";
137 }
138 }
139
140 document.getElementById("copyAllBtn").addEventListener("click", function() {
141 const codesListItems = document.querySelectorAll("#recoveryCodesList li");
142 const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
143
144 const textarea = document.createElement('textarea');
145 textarea.value = allCodes;
146 document.body.appendChild(textarea);
147
148 textarea.select();
149 document.execCommand('copy');
150
151 document.body.removeChild(textarea);
152 });
153 </script>
@@ -1,37 +1,54 b''
1 <%inherit file="/base/root.mako"/>
1 <%inherit file="base/root.mako"/>
2
2 3 <%def name="title()">
3 ${_('Check 2FA')}
4 ${_('Verify 2FA')}
4 5 %if c.rhodecode_name:
5 6 &middot; ${h.branding(c.rhodecode_name)}
6 7 %endif
7 8 </%def>
9 <style>body{background-color:#eeeeee;}</style>
8 10
9 <div class="box">
10 <div class="verify2FA">
11 <div class="loginbox">
12 <div class="header-account">
13 <div id="header-inner" class="title">
14 <div id="logo">
15 % if c.rhodecode_name:
16 <div class="branding">
17 <a href="${h.route_path('home')}">${h.branding(c.rhodecode_name)}</a>
18 </div>
19 % endif
20 </div>
21 </div>
22 </div>
23
24 <div class="loginwrapper">
25 <rhodecode-toast id="notifications"></rhodecode-toast>
26
27 <div id="register">
28 <div class="sign-in-title">
29 <h1>${_('Verify the code from the app')}</h1>
30 </div>
31 <div class="inner form">
11 32 ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
12 <div class="form mt-4" style="position: relative; margin-left: 35%; margin-top: 20%;">
13 <div class="field">
14 <p>
15 <div class="label">
16 <label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
17 </div>
18 </p>
19 <p>
20 <div>
21 <div class="input-group">
22 ${h.text('totp', class_="form-control", style='width: 38%;')}
23 <div id="formErrors">
33 <label for="totp">${_('Verification code')}:</label>
34 ${h.text('totp', class_="form-control")}
24 35 %if 'totp' in errors:
25 36 <span class="error-message">${errors.get('totp')}</span>
26 37 <br />
27 38 %endif
28 </div>
29 <br />
30 ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
39 <p class="help-block">${_('Enter the code from your two-factor authenticator app. If you\'ve lost your device, you can enter one of your recovery codes.')}</p>
40
41 ${h.submit('send', _('Verify'), class_="btn sign-in")}
42 <p class="help-block pull-right">
43 RhodeCode ${c.rhodecode_edition}
44 </p>
45 ${h.end_form()}
31 46 </div>
32 47 </div>
33 </p>
48
34 49 </div>
35 50 </div>
36 </div>
37 </div>
51
52
53
54
General Comments 0
You need to be logged in to leave comments. Login now