Show More
@@ -178,9 +178,7 b' class BaseAppView(object):' | |||||
178 | if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode': |
|
178 | if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode': | |
179 | return |
|
179 | return | |
180 |
|
180 | |||
181 | if (user_obj.has_enabled_2fa |
|
181 | if user_obj.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW: | |
182 | and not self.user_data.get('secret_2fa')) \ |
|
|||
183 | and view_name != self.SETUP_2FA_VIEW: |
|
|||
184 | h.flash( |
|
182 | h.flash( | |
185 | "You are required to configure 2FA", |
|
183 | "You are required to configure 2FA", | |
186 | "warning", |
|
184 | "warning", | |
@@ -195,7 +193,7 b' class BaseAppView(object):' | |||||
195 | if not user_obj: |
|
193 | if not user_obj: | |
196 | return |
|
194 | return | |
197 |
|
195 | |||
198 |
if |
|
196 | if user_obj.has_check_2fa_flag and view_name != self.VERIFY_2FA_VIEW: | |
199 | raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW)) |
|
197 | raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW)) | |
200 |
|
198 | |||
201 | def _log_creation_exception(self, e, repo_name): |
|
199 | def _log_creation_exception(self, e, repo_name): |
@@ -33,7 +33,7 b' class Test2FA(object):' | |||||
33 | def test_redirect_to_2fa_check_if_2fa_configured(self, user_util): |
|
33 | def test_redirect_to_2fa_check_if_2fa_configured(self, user_util): | |
34 | user = user_util.create_user(password=self.password) |
|
34 | user = user_util.create_user(password=self.password) | |
35 | user.has_enabled_2fa = True |
|
35 | user.has_enabled_2fa = True | |
36 | user.secret_2fa |
|
36 | user.init_secret_2fa() | |
37 | Session().add(user) |
|
37 | Session().add(user) | |
38 | Session().commit() |
|
38 | Session().commit() | |
39 | self.app.post( |
|
39 | self.app.post( | |
@@ -47,8 +47,8 b' class Test2FA(object):' | |||||
47 | def test_2fa_recovery_codes_works_only_once(self, user_util): |
|
47 | def test_2fa_recovery_codes_works_only_once(self, user_util): | |
48 | user = user_util.create_user(password=self.password) |
|
48 | user = user_util.create_user(password=self.password) | |
49 | user.has_enabled_2fa = True |
|
49 | user.has_enabled_2fa = True | |
50 | user.secret_2fa |
|
50 | user.init_secret_2fa() | |
51 |
recovery_cod_to_check = user. |
|
51 | recovery_cod_to_check = user.init_2fa_recovery_codes()[0] | |
52 | Session().add(user) |
|
52 | Session().add(user) | |
53 | Session().commit() |
|
53 | Session().commit() | |
54 | self.app.post( |
|
54 | self.app.post( |
@@ -188,7 +188,8 b' class LoginView(BaseAppView):' | |||||
188 | # form checks for username/password, now we're authenticated |
|
188 | # form checks for username/password, now we're authenticated | |
189 | username = form_result['username'] |
|
189 | username = form_result['username'] | |
190 | if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa: |
|
190 | if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa: | |
191 |
user. |
|
191 | user.has_check_2fa_flag = True | |
|
192 | ||||
192 | headers = store_user_in_session( |
|
193 | headers = store_user_in_session( | |
193 | self.session, |
|
194 | self.session, | |
194 | user_identifier=username, |
|
195 | user_identifier=username, | |
@@ -489,23 +490,32 b' class LoginView(BaseAppView):' | |||||
489 | form = TOTPForm(_, user_instance)() |
|
490 | form = TOTPForm(_, user_instance)() | |
490 | render_ctx = {} |
|
491 | render_ctx = {} | |
491 | if self.request.method == 'POST': |
|
492 | if self.request.method == 'POST': | |
|
493 | post_items = dict(self.request.POST) | |||
|
494 | ||||
492 | try: |
|
495 | try: | |
493 |
form.to_python( |
|
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 | Session().commit() |
|
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 | except formencode.Invalid as errors: |
|
504 | except formencode.Invalid as errors: | |
497 | defaults = errors.value |
|
505 | defaults = errors.value | |
498 | render_ctx = { |
|
506 | render_ctx = { | |
499 | 'errors': errors.error_dict, |
|
507 | 'errors': errors.error_dict, | |
500 | 'defaults': defaults, |
|
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 | qr = qrcode.QRCode(version=1, box_size=10, border=5) |
|
517 | qr = qrcode.QRCode(version=1, box_size=10, border=5) | |
503 | secret = user_instance.secret_2fa |
|
518 | qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_name)) | |
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)) |
|
|||
509 | qr.make(fit=True) |
|
519 | qr.make(fit=True) | |
510 | img = qr.make_image(fill_color='black', back_color='white') |
|
520 | img = qr.make_image(fill_color='black', back_color='white') | |
511 | buffered = BytesIO() |
|
521 | buffered = BytesIO() | |
@@ -513,8 +523,8 b' class LoginView(BaseAppView):' | |||||
513 | return self._get_template_context( |
|
523 | return self._get_template_context( | |
514 | c, |
|
524 | c, | |
515 | qr=b64encode(buffered.getvalue()).decode("utf-8"), |
|
525 | qr=b64encode(buffered.getvalue()).decode("utf-8"), | |
516 | key=secret, recovery_codes=json.dumps(recovery_codes), |
|
526 | key=secret, | |
517 | codes_viewed=not bool(recovery_codes), |
|
527 | totp_name=totp_name, | |
518 | ** render_ctx |
|
528 | ** render_ctx | |
519 | ) |
|
529 | ) | |
520 |
|
530 | |||
@@ -527,9 +537,12 b' class LoginView(BaseAppView):' | |||||
527 | user_instance = self._rhodecode_db_user |
|
537 | user_instance = self._rhodecode_db_user | |
528 | totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() |
|
538 | totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() | |
529 | if self.request.method == 'POST': |
|
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 | try: |
|
543 | try: | |
531 |
totp_form.to_python( |
|
544 | totp_form.to_python(post_items) | |
532 |
user_instance. |
|
545 | user_instance.has_check_2fa_flag = False | |
533 | Session().commit() |
|
546 | Session().commit() | |
534 | raise HTTPFound(c.came_from) |
|
547 | raise HTTPFound(c.came_from) | |
535 | except formencode.Invalid as errors: |
|
548 | except formencode.Invalid as errors: |
@@ -83,24 +83,35 b' def includeme(config):' | |||||
83 | attr='my_account_2fa', |
|
83 | attr='my_account_2fa', | |
84 | route_name='my_account_enable_2fa', request_method='GET', |
|
84 | route_name='my_account_enable_2fa', request_method='GET', | |
85 | renderer='rhodecode:templates/admin/my_account/my_account.mako') |
|
85 | renderer='rhodecode:templates/admin/my_account/my_account.mako') | |
86 |
|
86 | # my account 2fa save | ||
87 | config.add_route( |
|
87 | config.add_route( | |
88 |
name='my_account_ |
|
88 | name='my_account_enable_2fa_save', | |
89 |
pattern=ADMIN_PREFIX + '/my_account/ |
|
89 | pattern=ADMIN_PREFIX + '/my_account/enable_2fa_save') | |
90 | config.add_view( |
|
90 | config.add_view( | |
91 | MyAccountView, |
|
91 | MyAccountView, | |
92 |
attr='my_account_2fa_ |
|
92 | attr='my_account_2fa_update', | |
93 |
route_name='my_account_ |
|
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 | renderer='json_ext') |
|
104 | renderer='json_ext') | |
95 |
|
105 | |||
|
106 | # my account 2fa recovery code-reset | |||
96 | config.add_route( |
|
107 | config.add_route( | |
97 | name='my_account_regenerate_2fa_recovery_codes', |
|
108 | name='my_account_regenerate_2fa_recovery_codes', | |
98 | pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes') |
|
109 | pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes') | |
99 | config.add_view( |
|
110 | config.add_view( | |
100 | MyAccountView, |
|
111 | MyAccountView, | |
101 | attr='my_account_2fa_regenerate_recovery_codes', |
|
112 | attr='my_account_2fa_regenerate_recovery_codes', | |
102 |
route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', |
|
113 | route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', | |
103 | renderer='json_ext') |
|
114 | renderer='rhodecode:templates/admin/my_account/my_account.mako') | |
104 |
|
115 | |||
105 | # my account tokens |
|
116 | # my account tokens | |
106 | config.add_route( |
|
117 | config.add_route( |
@@ -16,6 +16,7 b'' | |||||
16 | # RhodeCode Enterprise Edition, including its added features, Support services, |
|
16 | # RhodeCode Enterprise Edition, including its added features, Support services, | |
17 | # and proprietary license terms, please see https://rhodecode.com/licenses/ |
|
17 | # and proprietary license terms, please see https://rhodecode.com/licenses/ | |
18 |
|
18 | |||
|
19 | import time | |||
19 | import logging |
|
20 | import logging | |
20 | import datetime |
|
21 | import datetime | |
21 | import string |
|
22 | import string | |
@@ -43,6 +44,7 b' from rhodecode.model.db import (' | |||||
43 | IntegrityError, or_, in_filter_generator, select, |
|
44 | IntegrityError, or_, in_filter_generator, select, | |
44 | Repository, UserEmailMap, UserApiKeys, UserFollowing, |
|
45 | Repository, UserEmailMap, UserApiKeys, UserFollowing, | |
45 | PullRequest, UserBookmark, RepoGroup, ChangesetStatus) |
|
46 | PullRequest, UserBookmark, RepoGroup, ChangesetStatus) | |
|
47 | from rhodecode.model.forms import TOTPForm | |||
46 | from rhodecode.model.meta import Session |
|
48 | from rhodecode.model.meta import Session | |
47 | from rhodecode.model.pull_request import PullRequestModel |
|
49 | from rhodecode.model.pull_request import PullRequestModel | |
48 | from rhodecode.model.user import UserModel |
|
50 | from rhodecode.model.user import UserModel | |
@@ -207,27 +209,65 b' class MyAccountView(BaseAppView, DataGri' | |||||
207 | def my_account_2fa(self): |
|
209 | def my_account_2fa(self): | |
208 | _ = self.request.translate |
|
210 | _ = self.request.translate | |
209 | c = self.load_default_context() |
|
211 | c = self.load_default_context() | |
210 |
c.active = '2 |
|
212 | c.active = '2FA' | |
211 | from rhodecode.model.settings import SettingsModel |
|
213 | user_instance = c.auth_user.get_instance() | |
212 | user_instance = self._rhodecode_db_user |
|
|||
213 | locked_by_admin = user_instance.has_forced_2fa |
|
214 | locked_by_admin = user_instance.has_forced_2fa | |
214 | c.state_of_2fa = user_instance.has_enabled_2fa |
|
215 | c.state_of_2fa = user_instance.has_enabled_2fa | |
|
216 | c.user_seen_2fa_recovery_codes = user_instance.has_seen_2fa_codes | |||
215 | c.locked_2fa = str2bool(locked_by_admin) |
|
217 | c.locked_2fa = str2bool(locked_by_admin) | |
216 | return self._get_template_context(c) |
|
218 | return self._get_template_context(c) | |
217 |
|
219 | |||
218 | @LoginRequired() |
|
220 | @LoginRequired() | |
219 | @NotAnonymous() |
|
221 | @NotAnonymous() | |
220 | @CSRFRequired() |
|
222 | @CSRFRequired() | |
221 |
def my_account_2fa_ |
|
223 | def my_account_2fa_update(self): | |
222 |
|
|
224 | _ = self.request.translate | |
223 | self._rhodecode_db_user.has_enabled_2fa = state |
|
225 | c = self.load_default_context() | |
224 | return {'state_of_2fa': state} |
|
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 | @LoginRequired() |
|
246 | @LoginRequired() | |
227 | @NotAnonymous() |
|
247 | @NotAnonymous() | |
228 | @CSRFRequired() |
|
248 | @CSRFRequired() | |
229 | def my_account_2fa_regenerate_recovery_codes(self): |
|
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 | @LoginRequired() |
|
272 | @LoginRequired() | |
233 | @NotAnonymous() |
|
273 | @NotAnonymous() |
@@ -796,34 +796,13 b' class User(Base, BaseModel):' | |||||
796 | Session.commit() |
|
796 | Session.commit() | |
797 | return artifact_token.api_key |
|
797 | return artifact_token.api_key | |
798 |
|
798 | |||
799 | @hybrid_property |
|
799 | def is_totp_valid(self, received_code, secret): | |
800 | def secret_2fa(self): |
|
800 | totp = pyotp.TOTP(secret) | |
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) |
|
|||
815 | return totp.verify(received_code) |
|
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 | encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', []) |
|
804 | encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', []) | |
819 |
recovery_codes = |
|
805 | recovery_codes = self.get_2fa_recovery_codes() | |
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)) |
|
|||
827 | if received_code in recovery_codes: |
|
806 | if received_code in recovery_codes: | |
828 | encrypted_recovery_codes.pop(recovery_codes.index(received_code)) |
|
807 | encrypted_recovery_codes.pop(recovery_codes.index(received_code)) | |
829 | self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes) |
|
808 | self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes) | |
@@ -844,7 +823,7 b' class User(Base, BaseModel):' | |||||
844 | @hybrid_property |
|
823 | @hybrid_property | |
845 | def has_enabled_2fa(self): |
|
824 | def has_enabled_2fa(self): | |
846 | """ |
|
825 | """ | |
847 |
Checks if |
|
826 | Checks if user enabled 2fa | |
848 | """ |
|
827 | """ | |
849 | if value := self.has_forced_2fa: |
|
828 | if value := self.has_forced_2fa: | |
850 | return value |
|
829 | return value | |
@@ -853,34 +832,109 b' class User(Base, BaseModel):' | |||||
853 | @has_enabled_2fa.setter |
|
832 | @has_enabled_2fa.setter | |
854 | def has_enabled_2fa(self, val): |
|
833 | def has_enabled_2fa(self, val): | |
855 | val = str2bool(val) |
|
834 | val = str2bool(val) | |
856 |
self.update_userdata(enabled_2fa= |
|
835 | self.update_userdata(enabled_2fa=val) | |
857 | if not val: |
|
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 | Session().commit() |
|
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 | Creates 2fa recovery codes |
|
882 | Creates 2fa recovery codes | |
864 | """ |
|
883 | """ | |
865 | recovery_codes = self.user_data.get('recovery_codes_2fa', []) |
|
884 | recovery_codes = self.user_data.get('recovery_codes_2fa', []) | |
866 | encrypted_codes = [] |
|
885 | encrypted_codes = [] | |
867 | if not recovery_codes: |
|
886 | if not recovery_codes or force: | |
868 | for _ in range(self.RECOVERY_CODES_COUNT): |
|
887 | for _ in range(self.RECOVERY_CODES_COUNT): | |
869 | recovery_code = pyotp.random_base32() |
|
888 | recovery_code = pyotp.random_base32() | |
870 | recovery_codes.append(recovery_code) |
|
889 | recovery_codes.append(recovery_code) | |
871 |
encrypted_code |
|
890 | encrypted_code = enc_utils.encrypt_value(safe_bytes(recovery_code), enc_key=ENCRYPTION_KEY) | |
872 | self.update_userdata(recovery_codes_2fa=encrypted_codes) |
|
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 | return recovery_codes |
|
894 | return recovery_codes | |
874 | # User should not check the same recovery codes more than once |
|
895 | # User should not check the same recovery codes more than once | |
875 | return [] |
|
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 | def regenerate_2fa_recovery_codes(self): |
|
933 | def regenerate_2fa_recovery_codes(self): | |
878 | """ |
|
934 | """ | |
879 | Regenerates 2fa recovery codes upon request |
|
935 | Regenerates 2fa recovery codes upon request | |
880 | """ |
|
936 | """ | |
881 | self.update_userdata(recovery_codes_2fa=[]) |
|
937 | new_recovery_codes = self.init_2fa_recovery_codes(force=True) | |
882 | Session().flush() |
|
|||
883 | new_recovery_codes = self.get_2fa_recovery_codes() |
|
|||
884 | Session().commit() |
|
938 | Session().commit() | |
885 | return new_recovery_codes |
|
939 | return new_recovery_codes | |
886 |
|
940 | |||
@@ -5021,8 +5075,7 b' class Gist(Base, BaseModel):' | |||||
5021 | return data |
|
5075 | return data | |
5022 |
|
5076 | |||
5023 | def __json__(self): |
|
5077 | def __json__(self): | |
5024 | data = dict( |
|
5078 | data = dict() | |
5025 | ) |
|
|||
5026 | data.update(self.get_api_data()) |
|
5079 | data.update(self.get_api_data()) | |
5027 | return data |
|
5080 | return data | |
5028 | # SCM functions |
|
5081 | # SCM functions |
@@ -111,6 +111,7 b' def TOTPForm(localizer, user, allow_reco' | |||||
111 | allow_extra_fields = True |
|
111 | allow_extra_fields = True | |
112 | filter_extra_fields = False |
|
112 | filter_extra_fields = False | |
113 | totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$') |
|
113 | totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$') | |
|
114 | secret_totp = v.String() | |||
114 |
|
115 | |||
115 | def to_python(self, value, state=None): |
|
116 | def to_python(self, value, state=None): | |
116 | validation_checks = [user.is_totp_valid] |
|
117 | validation_checks = [user.is_totp_valid] | |
@@ -118,10 +119,12 b' def TOTPForm(localizer, user, allow_reco' | |||||
118 | validation_checks.append(user.is_2fa_recovery_code_valid) |
|
119 | validation_checks.append(user.is_2fa_recovery_code_valid) | |
119 | form_data = super().to_python(value, state) |
|
120 | form_data = super().to_python(value, state) | |
120 | received_code = form_data['totp'] |
|
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 | error_msg = _('Code is invalid. Try again!') |
|
125 | error_msg = _('Code is invalid. Try again!') | |
123 | raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg}) |
|
126 | raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg}) | |
124 |
return |
|
127 | return form_data | |
125 |
|
128 | |||
126 | return _TOTPForm |
|
129 | return _TOTPForm | |
127 |
|
130 |
@@ -95,6 +95,7 b' function registerRCRoutes() {' | |||||
95 | pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []); |
|
95 | pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []); | |
96 | pyroutes.register('channelstream_proxy', '/_channelstream', []); |
|
96 | pyroutes.register('channelstream_proxy', '/_channelstream', []); | |
97 | pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []); |
|
97 | pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []); | |
|
98 | pyroutes.register('check_2fa', '/_admin/check_2fa', []); | |||
98 | pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']); |
|
99 | pyroutes.register('commit_draft_comments_submit', '/%(repo_name)s/changeset/%(commit_id)s/draft_comments_submit', ['repo_name', 'commit_id']); | |
99 | pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']); |
|
100 | pyroutes.register('debug_style_email', '/_admin/debug_style/email/%(email_id)s', ['email_id']); | |
100 | pyroutes.register('debug_style_email_plain_rendered', '/_admin/debug_style/email-rendered/%(email_id)s', ['email_id']); |
|
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 | pyroutes.register('my_account_emails', '/_admin/my_account/emails', []); |
|
219 | pyroutes.register('my_account_emails', '/_admin/my_account/emails', []); | |
219 | pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []); |
|
220 | pyroutes.register('my_account_emails_add', '/_admin/my_account/emails/new', []); | |
220 | pyroutes.register('my_account_emails_delete', '/_admin/my_account/emails/delete', []); |
|
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 | pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []); |
|
224 | pyroutes.register('my_account_external_identity', '/_admin/my_account/external-identity', []); | |
222 | pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []); |
|
225 | pyroutes.register('my_account_external_identity_delete', '/_admin/my_account/external-identity/delete', []); | |
223 | pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']); |
|
226 | pyroutes.register('my_account_goto_bookmark', '/_admin/my_account/bookmark/%(bookmark_id)s', ['bookmark_id']); | |
224 | pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []); |
|
227 | pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []); | |
225 | pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []); |
|
228 | pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []); | |
226 | pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []); |
|
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 | pyroutes.register('my_account_password', '/_admin/my_account/password', []); |
|
230 | pyroutes.register('my_account_password', '/_admin/my_account/password', []); | |
231 | pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []); |
|
231 | pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []); | |
232 | pyroutes.register('my_account_perms', '/_admin/my_account/perms', []); |
|
232 | pyroutes.register('my_account_perms', '/_admin/my_account/perms', []); | |
233 | pyroutes.register('my_account_profile', '/_admin/my_account/profile', []); |
|
233 | pyroutes.register('my_account_profile', '/_admin/my_account/profile', []); | |
234 | pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []); |
|
234 | pyroutes.register('my_account_pullrequests', '/_admin/my_account/pull_requests', []); | |
235 | pyroutes.register('my_account_pullrequests_data', '/_admin/my_account/pull_requests/data', []); |
|
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 | pyroutes.register('my_account_repos', '/_admin/my_account/repos', []); |
|
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 | pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []); |
|
239 | pyroutes.register('my_account_ssh_keys', '/_admin/my_account/ssh_keys', []); | |
238 | pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []); |
|
240 | pyroutes.register('my_account_ssh_keys_add', '/_admin/my_account/ssh_keys/new', []); | |
239 | pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []); |
|
241 | pyroutes.register('my_account_ssh_keys_delete', '/_admin/my_account/ssh_keys/delete', []); | |
@@ -382,6 +384,7 b' function registerRCRoutes() {' | |||||
382 | pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']); |
|
384 | pyroutes.register('search_repo', '/%(repo_name)s/_search', ['repo_name']); | |
383 | pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']); |
|
385 | pyroutes.register('search_repo_alt', '/%(repo_name)s/search', ['repo_name']); | |
384 | pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']); |
|
386 | pyroutes.register('search_repo_group', '/%(repo_group_name)s/_search', ['repo_group_name']); | |
|
387 | pyroutes.register('setup_2fa', '/_admin/setup_2fa', []); | |||
385 | pyroutes.register('store_user_session_value', '/_store_session_attr', []); |
|
388 | pyroutes.register('store_user_session_value', '/_store_session_attr', []); | |
386 | pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']); |
|
389 | pyroutes.register('strip_check', '/%(repo_name)s/settings/strip_check', ['repo_name']); | |
387 | pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']); |
|
390 | pyroutes.register('strip_execute', '/%(repo_name)s/settings/strip_execute', ['repo_name']); |
@@ -4,6 +4,7 b'' | |||||
4 | <div class="panel-heading"> |
|
4 | <div class="panel-heading"> | |
5 | <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3> |
|
5 | <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3> | |
6 | </div> |
|
6 | </div> | |
|
7 | ${h.secure_form(h.route_path('my_account_enable_2fa_save'), request=request)} | |||
7 | <div class="panel-body"> |
|
8 | <div class="panel-body"> | |
8 | <div class="form"> |
|
9 | <div class="form"> | |
9 | <div class="fields"> |
|
10 | <div class="fields"> | |
@@ -12,112 +13,122 b'' | |||||
12 | <label>${_('2FA status')}:</label> |
|
13 | <label>${_('2FA status')}:</label> | |
13 | </div> |
|
14 | </div> | |
14 | <div class="checkboxes"> |
|
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 | % if c.locked_2fa: |
|
16 | % if c.locked_2fa: | |
27 | <span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span> |
|
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 | % endif |
|
27 | % endif | |
|
28 | ||||
29 | </div> |
|
29 | </div> | |
30 | </div> |
|
30 | </div> | |
31 | </div> |
|
31 | </div> | |
32 | <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button> |
|
32 | <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button> | |
33 | </div> |
|
33 | </div> | |
34 | </div> |
|
34 | </div> | |
|
35 | ${h.end_form()} | |||
35 | </div> |
|
36 | </div> | |
36 |
|
37 | |||
37 | % if c.state_of_2fa: |
|
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 | <div class="panel panel-default"> |
|
60 | <div class="panel panel-default"> | |
39 | <div class="panel-heading"> |
|
61 | <div class="panel-heading"> | |
40 | <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3> |
|
62 | <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3> | |
41 | </div> |
|
63 | </div> | |
42 | <div class="panel-body"> |
|
64 | <div class="panel-body"> | |
43 | <form id="2faForm"> |
|
65 | <form id="2faForm"> | |
44 | <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" |
|
66 | <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" style="width: 20%"> | |
45 | style="width: 20%"> |
|
67 | <button type="submit" class="btn btn-primary">${_('Verify and generate new codes')}</button> | |
46 | <button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button> |
|
|||
47 | </form> |
|
68 | </form> | |
48 | <div id="result"></div> |
|
|||
49 | </div> |
|
69 | </div> | |
50 |
|
70 | |||
51 | </div> |
|
71 | </div> | |
|
72 | ${h.end_form()} | |||
|
73 | % endif | |||
52 |
|
|
74 | ||
53 | % endif |
|
|||
54 |
|
75 | |||
55 | <script> |
|
76 | <script> | |
56 | function submitForm() { |
|
77 | ||
57 | let formData = new FormData(document.getElementById("2faForm")); |
|
78 | function showRecoveryCodesPopup() { | |
58 | let xhr = new XMLHttpRequest(); |
|
|||
59 |
|
79 | |||
60 | let success = function (response) { |
|
80 | SwalNoAnimation.fire({ | |
61 | let recovery_codes = response.recovery_codes; |
|
81 | title: _gettext('2FA recovery codes'), | |
62 | showRecoveryCodesPopup(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>', | |
63 | } |
|
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 () { |
|
93 | var postData = { | |
66 | if (xhr.readyState == 4 && xhr.status == 200) { |
|
94 | 'csrf_token': CSRF_TOKEN | |
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 | } |
|
|||
76 | }; |
|
95 | }; | |
77 | let url = pyroutes.url('check_2fa'); |
|
96 | return new Promise(function (resolve, reject) { | |
78 | xhr.open("POST", url, true); |
|
97 | $.ajax({ | |
79 | xhr.send(formData); |
|
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 () { |
|
113 | }) | |
83 | document.getElementById('2faDisabled').checked = false; |
|
114 | .then(function (result) { | |
84 | }); |
|
115 | if (result.value) { | |
85 | document.getElementById('2faDisabled').addEventListener('click', function () { |
|
116 | let funcData = {'recoveryCodes': result.value.recovery_codes} | |
86 | document.getElementById('2faEnabled').checked = false; |
|
117 | let recoveryCodesHtml = renderTemplate('recoveryCodes', funcData); | |
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 |
|
||||
114 | SwalNoAnimation.fire({ |
|
118 | SwalNoAnimation.fire({ | |
115 | allowOutsideClick: false, |
|
119 | allowOutsideClick: false, | |
116 | confirmButtonText: _gettext('I Copied the codes'), |
|
120 | confirmButtonText: _gettext('I Copied the codes'), | |
117 | title: _gettext('2FA Recovery Codes'), |
|
121 | title: _gettext('2FA Recovery Codes'), | |
118 | html: recoveryCodesHtml |
|
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 | </script> |
|
134 | </script> |
@@ -1,7 +1,7 b'' | |||||
1 | <%inherit file="base/root.mako"/> |
|
1 | <%inherit file="base/root.mako"/> | |
2 |
|
2 | |||
3 | <%def name="title()"> |
|
3 | <%def name="title()"> | |
4 |
${_('Setup |
|
4 | ${_('Setup 2FA')} | |
5 | %if c.rhodecode_name: |
|
5 | %if c.rhodecode_name: | |
6 | · ${h.branding(c.rhodecode_name)} |
|
6 | · ${h.branding(c.rhodecode_name)} | |
7 | %endif |
|
7 | %endif | |
@@ -22,31 +22,28 b'' | |||||
22 | </div> |
|
22 | </div> | |
23 |
|
23 | |||
24 | <div class="loginwrapper"> |
|
24 | <div class="loginwrapper"> | |
25 | <h1>Setup the authenticator app</h1> |
|
25 | <h1>${_('Setup the authenticator app')}</h1> | |
|
26 | ||||
26 | <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 | <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 | <rhodecode-toast id="notifications"></rhodecode-toast> |
|
28 | <rhodecode-toast id="notifications"></rhodecode-toast> | |
28 |
|
29 | |||
29 | <div id="setup_2fa"> |
|
30 | <div id="setup_2fa"> | |
|
31 | ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} | |||
30 | <div class="sign-in-title"> |
|
32 | <div class="sign-in-title"> | |
31 | <h1>${_('Scan the QR code')}</h1> |
|
33 | <h1>${_('Scan the QR code')}: "${totp_name}"</h1> | |
32 | </div> |
|
34 | </div> | |
33 | <p>Use an authenticator app to scan.</p> |
|
35 | <p>${_('Use an authenticator app to scan.')}</p> | |
34 |
<img src="data:image/png;base64, |
|
36 | <img alt="qr-code" src="data:image/png;base64, ${qr}"/> | |
|
37 | ||||
35 | <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p> |
|
38 | <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p> | |
36 | <div id="secretDiv" class="hidden"> |
|
39 | <div id="secretDiv" class="hidden"> | |
37 | <p>${_('Copy and use this code to manually setup an authenticator app')}</p> |
|
40 | <p>${_('Copy and use this code to manually set up an authenticator app')}</p> | |
38 | <input type="text" id="secretField" value=${key}> |
|
41 | <input type="text" class="input-monospace" value="${key}" id="secret_totp" name="secret_totp" style="width: 400px"/> | |
39 |
|
|
42 | <i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="${key}" title="${_('Copy the secret key')}"></i> | |
40 | </div> |
|
43 | </div> | |
41 | <div id="codesPopup" class="modal"> |
|
44 | ||
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> |
|
|||
48 | <div id="verify_2fa"> |
|
45 | <div id="verify_2fa"> | |
49 | ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} |
|
46 | ||
50 | <div class="form mt-4"> |
|
47 | <div class="form mt-4"> | |
51 | <div class="field"> |
|
48 | <div class="field"> | |
52 | <p> |
|
49 | <p> | |
@@ -65,7 +62,7 b'' | |||||
65 | %endif |
|
62 | % endif | |
66 | </div> |
|
63 | </div> | |
67 | <div class="input-group-append"> |
|
64 | <div class="input-group-append"> | |
68 |
${h.submit(' |
|
65 | ${h.submit('verify_2fa',_('Verify'),class_="btn btn-primary", style='width: 40%;')} | |
69 | </div> |
|
66 | </div> | |
70 | </div> |
|
67 | </div> | |
71 | </div> |
|
68 | </div> | |
@@ -73,81 +70,18 b'' | |||||
73 | </div> |
|
70 | </div> | |
74 | </div> |
|
71 | </div> | |
75 | </div> |
|
72 | </div> | |
|
73 | ${h.end_form()} | |||
76 | </div> |
|
74 | </div> | |
77 | </div> |
|
75 | </div> | |
78 | </div> |
|
76 | </div> | |
79 | <script> |
|
|||
80 | document.addEventListener('DOMContentLoaded', function() { |
|
|||
81 | let clipboardIcons = document.querySelectorAll('.clipboard-action'); |
|
|||
82 |
|
77 | |||
83 | clipboardIcons.forEach(function(icon) { |
|
78 | <script> | |
84 | icon.addEventListener('click', function() { |
|
|||
85 | var inputField = document.getElementById('secretField'); |
|
|||
86 | inputField.select(); |
|
|||
87 | document.execCommand('copy'); |
|
|||
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 | </script> |
|
87 | </script> | |
101 | <script> |
|
|||
102 | const recovery_codes_string = '${recovery_codes}'; |
|
|||
103 | const cleaned_recovery_codes_string = recovery_codes_string |
|
|||
104 | .replace(/"/g, '"') |
|
|||
105 | .replace(/'/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=" |
|
1 | <%inherit file="base/root.mako"/> | |
|
2 | ||||
2 | <%def name="title()"> |
|
3 | <%def name="title()"> | |
3 |
${_(' |
|
4 | ${_('Verify 2FA')} | |
4 | %if c.rhodecode_name: |
|
5 | %if c.rhodecode_name: | |
5 | · ${h.branding(c.rhodecode_name)} |
|
6 | · ${h.branding(c.rhodecode_name)} | |
6 | %endif |
|
7 | %endif | |
7 | </%def> |
|
8 | </%def> | |
|
9 | <style>body{background-color:#eeeeee;}</style> | |||
8 |
|
10 | |||
9 | <div class="box"> |
|
11 | <div class="loginbox"> | |
10 |
<div class=" |
|
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 | ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')} |
|
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%;"> |
|
33 | <label for="totp">${_('Verification code')}:</label> | |
13 | <div class="field"> |
|
34 | ${h.text('totp', class_="form-control")} | |
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"> |
|
|||
24 |
|
|
35 | %if 'totp' in errors: | |
25 |
|
|
36 | <span class="error-message">${errors.get('totp')}</span> | |
26 |
|
|
37 | <br /> | |
27 |
|
|
38 | %endif | |
28 | </div> |
|
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> | |
29 | <br /> |
|
40 | ||
30 |
${h.submit('s |
|
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> |
|
51 | ||
37 | </div> |
|
52 | ||
|
53 | ||||
|
54 |
General Comments 0
You need to be logged in to leave comments.
Login now