Show More
@@ -0,0 +1,67 b'' | |||||
|
1 | import pytest | |||
|
2 | ||||
|
3 | from rhodecode.model.meta import Session | |||
|
4 | from rhodecode.tests.fixture import Fixture | |||
|
5 | from rhodecode.tests.routes import route_path | |||
|
6 | from rhodecode.model.settings import SettingsModel | |||
|
7 | ||||
|
8 | fixture = Fixture() | |||
|
9 | ||||
|
10 | ||||
|
11 | @pytest.mark.usefixtures('app') | |||
|
12 | class Test2FA(object): | |||
|
13 | @classmethod | |||
|
14 | def setup_class(cls): | |||
|
15 | cls.password = 'valid-one' | |||
|
16 | ||||
|
17 | @classmethod | |||
|
18 | def teardown_class(cls): | |||
|
19 | SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', False) | |||
|
20 | ||||
|
21 | def test_redirect_to_2fa_setup_if_enabled_for_user(self, user_util): | |||
|
22 | user = user_util.create_user(password=self.password) | |||
|
23 | user.has_enabled_2fa = True | |||
|
24 | self.app.post( | |||
|
25 | route_path('login'), | |||
|
26 | {'username': user.username, | |||
|
27 | 'password': self.password}) | |||
|
28 | ||||
|
29 | response = self.app.get('/') | |||
|
30 | assert response.status_code == 302 | |||
|
31 | assert response.location.endswith(route_path('setup_2fa')) | |||
|
32 | ||||
|
33 | def test_redirect_to_2fa_check_if_2fa_configured(self, user_util): | |||
|
34 | user = user_util.create_user(password=self.password) | |||
|
35 | user.has_enabled_2fa = True | |||
|
36 | user.secret_2fa | |||
|
37 | Session().add(user) | |||
|
38 | Session().commit() | |||
|
39 | self.app.post( | |||
|
40 | route_path('login'), | |||
|
41 | {'username': user.username, | |||
|
42 | 'password': self.password}) | |||
|
43 | response = self.app.get('/') | |||
|
44 | assert response.status_code == 302 | |||
|
45 | assert response.location.endswith(route_path('check_2fa')) | |||
|
46 | ||||
|
47 | def test_2fa_recovery_codes_works_only_once(self, user_util): | |||
|
48 | user = user_util.create_user(password=self.password) | |||
|
49 | user.has_enabled_2fa = True | |||
|
50 | user.secret_2fa | |||
|
51 | recovery_cod_to_check = user.get_2fa_recovery_codes()[0] | |||
|
52 | Session().add(user) | |||
|
53 | Session().commit() | |||
|
54 | self.app.post( | |||
|
55 | route_path('login'), | |||
|
56 | {'username': user.username, | |||
|
57 | 'password': self.password}) | |||
|
58 | response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check}) | |||
|
59 | assert response.status_code == 302 | |||
|
60 | response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check}) | |||
|
61 | response.mustcontain('Code is invalid. Try again!') | |||
|
62 | ||||
|
63 | def test_2fa_state_when_forced_by_admin(self, user_util): | |||
|
64 | user = user_util.create_user(password=self.password) | |||
|
65 | user.has_enabled_2fa = False | |||
|
66 | SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', True) | |||
|
67 | assert user.has_enabled_2fa |
@@ -0,0 +1,140 b'' | |||||
|
1 | <%namespace name="base" file="/base/base.mako"/> | |||
|
2 | ||||
|
3 | <div class="panel panel-default"> | |||
|
4 | <div class="panel-heading"> | |||
|
5 | <h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3> | |||
|
6 | </div> | |||
|
7 | <div class="panel-body"> | |||
|
8 | <div class="form"> | |||
|
9 | <div class="fields"> | |||
|
10 | <div class="field"> | |||
|
11 | <div class="label"> | |||
|
12 | <label>${_('2FA status')}:</label> | |||
|
13 | </div> | |||
|
14 | <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: | |||
|
27 | <span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span> | |||
|
28 | % endif | |||
|
29 | </div> | |||
|
30 | </div> | |||
|
31 | </div> | |||
|
32 | <button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button> | |||
|
33 | </div> | |||
|
34 | <div id="codesPopup" class="modal"> | |||
|
35 | <div class="modal-content"> | |||
|
36 | <ul id="recoveryCodesList"></ul> | |||
|
37 | <button id="copyAllBtn" class="btn btn-primary">Copy All</button> | |||
|
38 | </div> | |||
|
39 | </div> | |||
|
40 | </div> | |||
|
41 | </div> | |||
|
42 | % if c.state_of_2fa: | |||
|
43 | <div class="panel panel-default"> | |||
|
44 | <div class="panel-heading"> | |||
|
45 | <h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3> | |||
|
46 | </div> | |||
|
47 | <div class="panel-body"> | |||
|
48 | <form id="2faForm"> | |||
|
49 | <input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}" | |||
|
50 | style="width: 20%"> | |||
|
51 | <button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button> | |||
|
52 | </form> | |||
|
53 | <div id="result"></div> | |||
|
54 | </div> | |||
|
55 | ||||
|
56 | </div> | |||
|
57 | % endif | |||
|
58 | <script> | |||
|
59 | function submitForm() { | |||
|
60 | let formData = new FormData(document.getElementById("2faForm")); | |||
|
61 | let xhr = new XMLHttpRequest(); | |||
|
62 | let success = function (response) { | |||
|
63 | let recovery_codes = response.recovery_codes; | |||
|
64 | const codesList = document.getElementById("recoveryCodesList"); | |||
|
65 | ||||
|
66 | codesList.innerHTML = ""; | |||
|
67 | recovery_codes.forEach(code => { | |||
|
68 | const listItem = document.createElement("li"); | |||
|
69 | listItem.textContent = code; | |||
|
70 | codesList.appendChild(listItem); | |||
|
71 | }); | |||
|
72 | } | |||
|
73 | xhr.onreadystatechange = function () { | |||
|
74 | if (xhr.readyState == 4 && xhr.status == 200) { | |||
|
75 | let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html"); | |||
|
76 | let contentToDisplay = responseDoc.querySelector('#formErrors'); | |||
|
77 | if (contentToDisplay) { | |||
|
78 | document.getElementById("result").innerHTML = contentToDisplay.innerHTML; | |||
|
79 | } else { | |||
|
80 | let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes'); | |||
|
81 | ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success); | |||
|
82 | showRecoveryCodesPopup(); | |||
|
83 | } | |||
|
84 | } | |||
|
85 | }; | |||
|
86 | let url = pyroutes.url('check_2fa'); | |||
|
87 | xhr.open("POST", url, true); | |||
|
88 | xhr.send(formData); | |||
|
89 | } | |||
|
90 | </script> | |||
|
91 | <script> | |||
|
92 | document.getElementById('2faEnabled').addEventListener('click', function () { | |||
|
93 | document.getElementById('2faDisabled').checked = false; | |||
|
94 | }); | |||
|
95 | document.getElementById('2faDisabled').addEventListener('click', function () { | |||
|
96 | document.getElementById('2faEnabled').checked = false; | |||
|
97 | }); | |||
|
98 | ||||
|
99 | function getStateValue() { | |||
|
100 | if (document.getElementById('2faEnabled').checked) { | |||
|
101 | return '1'; | |||
|
102 | } else { | |||
|
103 | return '0'; | |||
|
104 | } | |||
|
105 | }; | |||
|
106 | ||||
|
107 | function saveChanges(state) { | |||
|
108 | ||||
|
109 | let post_data = {'state': state, 'csrf_token': CSRF_TOKEN}; | |||
|
110 | let url = pyroutes.url('my_account_configure_2fa'); | |||
|
111 | ||||
|
112 | ajaxPOST(url, post_data, null) | |||
|
113 | }; | |||
|
114 | document.getElementById('saveBtn').addEventListener('click', function () { | |||
|
115 | var state = getStateValue(); | |||
|
116 | saveChanges(state); | |||
|
117 | }); | |||
|
118 | </script> | |||
|
119 | <script> | |||
|
120 | function showRecoveryCodesPopup() { | |||
|
121 | const popup = document.getElementById("codesPopup"); | |||
|
122 | popup.style.display = "block"; | |||
|
123 | } | |||
|
124 | ||||
|
125 | document.getElementById("copyAllBtn").addEventListener("click", function () { | |||
|
126 | const codesListItems = document.querySelectorAll("#recoveryCodesList li"); | |||
|
127 | const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", "); | |||
|
128 | ||||
|
129 | const textarea = document.createElement('textarea'); | |||
|
130 | textarea.value = allCodes; | |||
|
131 | document.body.appendChild(textarea); | |||
|
132 | ||||
|
133 | textarea.select(); | |||
|
134 | document.execCommand('copy'); | |||
|
135 | ||||
|
136 | document.body.removeChild(textarea); | |||
|
137 | const popup = document.getElementById("codesPopup"); | |||
|
138 | popup.style.display = "" | |||
|
139 | }); | |||
|
140 | </script> |
@@ -0,0 +1,153 b'' | |||||
|
1 | <%inherit file="base/root.mako"/> | |||
|
2 | ||||
|
3 | <%def name="title()"> | |||
|
4 | ${_('Setup authenticator app')} | |||
|
5 | %if c.rhodecode_name: | |||
|
6 | · ${h.branding(c.rhodecode_name)} | |||
|
7 | %endif | |||
|
8 | </%def> | |||
|
9 | <style>body{background-color:#eeeeee;}</style> | |||
|
10 | ||||
|
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 | <h1>Setup the authenticator app</h1> | |||
|
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 | <rhodecode-toast id="notifications"></rhodecode-toast> | |||
|
28 | ||||
|
29 | <div id="setup_2fa"> | |||
|
30 | <div class="sign-in-title"> | |||
|
31 | <h1>${_('Scan the QR code')}</h1> | |||
|
32 | </div> | |||
|
33 | <p>Use an authenticator app to scan.</p> | |||
|
34 | <img src="data:image/png;base64, ${qr}"/> | |||
|
35 | <p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p> | |||
|
36 | <div id="secretDiv" class="hidden"> | |||
|
37 | <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> | |||
|
40 | </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> | |||
|
48 | <div id="verify_2fa"> | |||
|
49 | ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} | |||
|
50 | <div class="form mt-4"> | |||
|
51 | <div class="field"> | |||
|
52 | <p> | |||
|
53 | <div class="label"> | |||
|
54 | <label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label> | |||
|
55 | </div> | |||
|
56 | </p> | |||
|
57 | <p> | |||
|
58 | <div> | |||
|
59 | <div class="input-group"> | |||
|
60 | ${h.text('totp', class_='form-control', style='width: 40%;')} | |||
|
61 | <div id="formErrors"> | |||
|
62 | %if 'totp' in errors: | |||
|
63 | <span class="error-message">${errors.get('totp')}</span> | |||
|
64 | <br /> | |||
|
65 | %endif | |||
|
66 | </div> | |||
|
67 | <div class="input-group-append"> | |||
|
68 | ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)} | |||
|
69 | </div> | |||
|
70 | </div> | |||
|
71 | </div> | |||
|
72 | </p> | |||
|
73 | </div> | |||
|
74 | </div> | |||
|
75 | </div> | |||
|
76 | </div> | |||
|
77 | </div> | |||
|
78 | </div> | |||
|
79 | <script> | |||
|
80 | document.addEventListener('DOMContentLoaded', function() { | |||
|
81 | let clipboardIcons = document.querySelectorAll('.clipboard-action'); | |||
|
82 | ||||
|
83 | clipboardIcons.forEach(function(icon) { | |||
|
84 | icon.addEventListener('click', function() { | |||
|
85 | var inputField = document.getElementById('secretField'); | |||
|
86 | inputField.select(); | |||
|
87 | document.execCommand('copy'); | |||
|
88 | ||||
|
89 | }); | |||
|
90 | }); | |||
|
91 | }); | |||
|
92 | </script> | |||
|
93 | <script> | |||
|
94 | document.getElementById('toggleLink').addEventListener('click', function() { | |||
|
95 | let hiddenField = document.getElementById('secretDiv'); | |||
|
96 | if (hiddenField.classList.contains('hidden')) { | |||
|
97 | hiddenField.classList.remove('hidden'); | |||
|
98 | } | |||
|
99 | }); | |||
|
100 | </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> |
@@ -0,0 +1,37 b'' | |||||
|
1 | <%inherit file="/base/root.mako"/> | |||
|
2 | <%def name="title()"> | |||
|
3 | ${_('Check 2FA')} | |||
|
4 | %if c.rhodecode_name: | |||
|
5 | · ${h.branding(c.rhodecode_name)} | |||
|
6 | %endif | |||
|
7 | </%def> | |||
|
8 | ||||
|
9 | <div class="box"> | |||
|
10 | <div class="verify2FA"> | |||
|
11 | ${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"> | |||
|
24 | %if 'totp' in errors: | |||
|
25 | <span class="error-message">${errors.get('totp')}</span> | |||
|
26 | <br /> | |||
|
27 | %endif | |||
|
28 | </div> | |||
|
29 | <br /> | |||
|
30 | ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')} | |||
|
31 | </div> | |||
|
32 | </div> | |||
|
33 | </p> | |||
|
34 | </div> | |||
|
35 | </div> | |||
|
36 | </div> | |||
|
37 | </div> |
@@ -290,6 +290,7 b' webhelpers2==2.1' | |||||
290 | six==1.16.0 |
|
290 | six==1.16.0 | |
291 | whoosh==2.7.4 |
|
291 | whoosh==2.7.4 | |
292 | zope.cachedescriptors==5.0.0 |
|
292 | zope.cachedescriptors==5.0.0 | |
|
293 | qrcode==7.4.2 | |||
293 |
|
294 | |||
294 | ## uncomment to add the debug libraries |
|
295 | ## uncomment to add the debug libraries | |
295 | #-r requirements_debug.txt |
|
296 | #-r requirements_debug.txt |
@@ -104,6 +104,11 b' class TemplateArgs(StrictAttributeDict):' | |||||
104 |
|
104 | |||
105 |
|
105 | |||
106 | class BaseAppView(object): |
|
106 | class BaseAppView(object): | |
|
107 | DONT_CHECKOUT_VIEWS = ["channelstream_connect", "ops_ping"] | |||
|
108 | EXTRA_VIEWS_TO_IGNORE = ['login', 'register', 'logout'] | |||
|
109 | SETUP_2FA_VIEW = 'setup_2fa' | |||
|
110 | VERIFY_2FA_VIEW = 'check_2fa' | |||
|
111 | ||||
107 | def __init__(self, context, request): |
|
112 | def __init__(self, context, request): | |
108 | self.request = request |
|
113 | self.request = request | |
109 | self.context = context |
|
114 | self.context = context | |
@@ -117,13 +122,19 b' class BaseAppView(object):' | |||||
117 |
|
122 | |||
118 | self._rhodecode_user = request.user # auth user |
|
123 | self._rhodecode_user = request.user # auth user | |
119 | self._rhodecode_db_user = self._rhodecode_user.get_instance() |
|
124 | self._rhodecode_db_user = self._rhodecode_user.get_instance() | |
|
125 | self.user_data = self._rhodecode_db_user.user_data if self._rhodecode_db_user else {} | |||
120 | self._maybe_needs_password_change( |
|
126 | self._maybe_needs_password_change( | |
121 | request.matched_route.name, self._rhodecode_db_user |
|
127 | request.matched_route.name, self._rhodecode_db_user | |
122 | ) |
|
128 | ) | |
|
129 | self._maybe_needs_2fa_configuration( | |||
|
130 | request.matched_route.name, self._rhodecode_db_user | |||
|
131 | ) | |||
|
132 | self._maybe_needs_2fa_check( | |||
|
133 | request.matched_route.name, self._rhodecode_db_user | |||
|
134 | ) | |||
123 |
|
135 | |||
124 | def _maybe_needs_password_change(self, view_name, user_obj): |
|
136 | def _maybe_needs_password_change(self, view_name, user_obj): | |
125 | dont_check_views = ["channelstream_connect", "ops_ping"] |
|
137 | if view_name in self.DONT_CHECKOUT_VIEWS: | |
126 | if view_name in dont_check_views: |
|
|||
127 | return |
|
138 | return | |
128 |
|
139 | |||
129 | log.debug( |
|
140 | log.debug( | |
@@ -144,7 +155,7 b' class BaseAppView(object):' | |||||
144 | return |
|
155 | return | |
145 |
|
156 | |||
146 | now = time.time() |
|
157 | now = time.time() | |
147 |
should_change = |
|
158 | should_change = self.user_data.get("force_password_change") | |
148 | change_after = safe_int(should_change) or 0 |
|
159 | change_after = safe_int(should_change) or 0 | |
149 | if should_change and now > change_after: |
|
160 | if should_change and now > change_after: | |
150 | log.debug("User %s requires password change", user_obj) |
|
161 | log.debug("User %s requires password change", user_obj) | |
@@ -157,6 +168,36 b' class BaseAppView(object):' | |||||
157 | if view_name not in skip_user_views: |
|
168 | if view_name not in skip_user_views: | |
158 | raise HTTPFound(self.request.route_path("my_account_password")) |
|
169 | raise HTTPFound(self.request.route_path("my_account_password")) | |
159 |
|
170 | |||
|
171 | def _maybe_needs_2fa_configuration(self, view_name, user_obj): | |||
|
172 | if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE: | |||
|
173 | return | |||
|
174 | ||||
|
175 | if not user_obj: | |||
|
176 | return | |||
|
177 | ||||
|
178 | if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode': | |||
|
179 | return | |||
|
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: | |||
|
184 | h.flash( | |||
|
185 | "You are required to configure 2FA", | |||
|
186 | "warning", | |||
|
187 | ignore_duplicate=False, | |||
|
188 | ) | |||
|
189 | raise HTTPFound(self.request.route_path(self.SETUP_2FA_VIEW)) | |||
|
190 | ||||
|
191 | def _maybe_needs_2fa_check(self, view_name, user_obj): | |||
|
192 | if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE: | |||
|
193 | return | |||
|
194 | ||||
|
195 | if not user_obj: | |||
|
196 | return | |||
|
197 | ||||
|
198 | if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW: | |||
|
199 | raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW)) | |||
|
200 | ||||
160 | def _log_creation_exception(self, e, repo_name): |
|
201 | def _log_creation_exception(self, e, repo_name): | |
161 | _ = self.request.translate |
|
202 | _ = self.request.translate | |
162 | reason = None |
|
203 | reason = None |
@@ -75,3 +75,27 b' def includeme(config):' | |||||
75 | LoginView, |
|
75 | LoginView, | |
76 | attr='password_reset_confirmation', |
|
76 | attr='password_reset_confirmation', | |
77 | route_name='reset_password_confirmation', request_method='GET') |
|
77 | route_name='reset_password_confirmation', request_method='GET') | |
|
78 | ||||
|
79 | config.add_route( | |||
|
80 | name='setup_2fa', | |||
|
81 | pattern=ADMIN_PREFIX + '/setup_2fa') | |||
|
82 | config.add_view( | |||
|
83 | LoginView, | |||
|
84 | attr='setup_2fa', | |||
|
85 | route_name='setup_2fa', request_method=['GET', 'POST'], | |||
|
86 | renderer='rhodecode:templates/configure_2fa.mako') | |||
|
87 | ||||
|
88 | config.add_route( | |||
|
89 | name='check_2fa', | |||
|
90 | pattern=ADMIN_PREFIX + '/check_2fa') | |||
|
91 | config.add_view( | |||
|
92 | LoginView, | |||
|
93 | attr='verify_2fa', | |||
|
94 | route_name='check_2fa', request_method='GET', | |||
|
95 | renderer='rhodecode:templates/verify_2fa.mako') | |||
|
96 | config.add_view( | |||
|
97 | LoginView, | |||
|
98 | attr='verify_2fa', | |||
|
99 | route_name='check_2fa', request_method='POST', | |||
|
100 | renderer='rhodecode:templates/verify_2fa.mako') | |||
|
101 |
@@ -17,6 +17,9 b'' | |||||
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 time | |
|
20 | import json | |||
|
21 | import pyotp | |||
|
22 | import qrcode | |||
20 | import collections |
|
23 | import collections | |
21 | import datetime |
|
24 | import datetime | |
22 | import formencode |
|
25 | import formencode | |
@@ -24,7 +27,11 b' import formencode.htmlfill' | |||||
24 | import logging |
|
27 | import logging | |
25 | import urllib.parse |
|
28 | import urllib.parse | |
26 | import requests |
|
29 | import requests | |
|
30 | from io import BytesIO | |||
|
31 | from base64 import b64encode | |||
27 |
|
32 | |||
|
33 | from pyramid.renderers import render | |||
|
34 | from pyramid.response import Response | |||
28 | from pyramid.httpexceptions import HTTPFound |
|
35 | from pyramid.httpexceptions import HTTPFound | |
29 |
|
36 | |||
30 |
|
37 | |||
@@ -35,12 +42,12 b' from rhodecode.events import UserRegiste' | |||||
35 | from rhodecode.lib import helpers as h |
|
42 | from rhodecode.lib import helpers as h | |
36 | from rhodecode.lib import audit_logger |
|
43 | from rhodecode.lib import audit_logger | |
37 | from rhodecode.lib.auth import ( |
|
44 | from rhodecode.lib.auth import ( | |
38 | AuthUser, HasPermissionAnyDecorator, CSRFRequired) |
|
45 | AuthUser, HasPermissionAnyDecorator, CSRFRequired, LoginRequired, NotAnonymous) | |
39 | from rhodecode.lib.base import get_ip_addr |
|
46 | from rhodecode.lib.base import get_ip_addr | |
40 | from rhodecode.lib.exceptions import UserCreationError |
|
47 | from rhodecode.lib.exceptions import UserCreationError | |
41 | from rhodecode.lib.utils2 import safe_str |
|
48 | from rhodecode.lib.utils2 import safe_str | |
42 | from rhodecode.model.db import User, UserApiKeys |
|
49 | from rhodecode.model.db import User, UserApiKeys | |
43 | from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm |
|
50 | from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm, TOTPForm | |
44 | from rhodecode.model.meta import Session |
|
51 | from rhodecode.model.meta import Session | |
45 | from rhodecode.model.auth_token import AuthTokenModel |
|
52 | from rhodecode.model.auth_token import AuthTokenModel | |
46 | from rhodecode.model.settings import SettingsModel |
|
53 | from rhodecode.model.settings import SettingsModel | |
@@ -179,9 +186,12 b' class LoginView(BaseAppView):' | |||||
179 | self.session.invalidate() |
|
186 | self.session.invalidate() | |
180 | form_result = login_form.to_python(self.request.POST) |
|
187 | form_result = login_form.to_python(self.request.POST) | |
181 | # form checks for username/password, now we're authenticated |
|
188 | # form checks for username/password, now we're authenticated | |
|
189 | username = form_result['username'] | |||
|
190 | if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa: | |||
|
191 | user.update_userdata(check_2fa=True) | |||
182 | headers = store_user_in_session( |
|
192 | headers = store_user_in_session( | |
183 | self.session, |
|
193 | self.session, | |
184 |
user_identifier= |
|
194 | user_identifier=username, | |
185 | remember=form_result['remember']) |
|
195 | remember=form_result['remember']) | |
186 | log.debug('Redirecting to "%s" after login.', c.came_from) |
|
196 | log.debug('Redirecting to "%s" after login.', c.came_from) | |
187 |
|
197 | |||
@@ -436,6 +446,8 b' class LoginView(BaseAppView):' | |||||
436 |
|
446 | |||
437 | return self._get_template_context(c, **template_context) |
|
447 | return self._get_template_context(c, **template_context) | |
438 |
|
448 | |||
|
449 | @LoginRequired() | |||
|
450 | @NotAnonymous() | |||
439 | def password_reset_confirmation(self): |
|
451 | def password_reset_confirmation(self): | |
440 | self.load_default_context() |
|
452 | self.load_default_context() | |
441 | if self.request.GET and self.request.GET.get('key'): |
|
453 | if self.request.GET and self.request.GET.get('key'): | |
@@ -467,3 +479,63 b' class LoginView(BaseAppView):' | |||||
467 | return HTTPFound(self.request.route_path('reset_password')) |
|
479 | return HTTPFound(self.request.route_path('reset_password')) | |
468 |
|
480 | |||
469 | return HTTPFound(self.request.route_path('login')) |
|
481 | return HTTPFound(self.request.route_path('login')) | |
|
482 | ||||
|
483 | @LoginRequired() | |||
|
484 | @NotAnonymous() | |||
|
485 | def setup_2fa(self): | |||
|
486 | _ = self.request.translate | |||
|
487 | c = self.load_default_context() | |||
|
488 | user_instance = self._rhodecode_db_user | |||
|
489 | form = TOTPForm(_, user_instance)() | |||
|
490 | render_ctx = {} | |||
|
491 | if self.request.method == 'POST': | |||
|
492 | try: | |||
|
493 | form.to_python(dict(self.request.POST)) | |||
|
494 | Session().commit() | |||
|
495 | raise HTTPFound(c.came_from) | |||
|
496 | except formencode.Invalid as errors: | |||
|
497 | defaults = errors.value | |||
|
498 | render_ctx = { | |||
|
499 | 'errors': errors.error_dict, | |||
|
500 | 'defaults': defaults, | |||
|
501 | } | |||
|
502 | 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)) | |||
|
509 | qr.make(fit=True) | |||
|
510 | img = qr.make_image(fill_color='black', back_color='white') | |||
|
511 | buffered = BytesIO() | |||
|
512 | img.save(buffered) | |||
|
513 | return self._get_template_context( | |||
|
514 | c, | |||
|
515 | qr=b64encode(buffered.getvalue()).decode("utf-8"), | |||
|
516 | key=secret, recovery_codes=json.dumps(recovery_codes), | |||
|
517 | codes_viewed=not bool(recovery_codes), | |||
|
518 | ** render_ctx | |||
|
519 | ) | |||
|
520 | ||||
|
521 | @LoginRequired() | |||
|
522 | @NotAnonymous() | |||
|
523 | def verify_2fa(self): | |||
|
524 | _ = self.request.translate | |||
|
525 | c = self.load_default_context() | |||
|
526 | render_ctx = {} | |||
|
527 | user_instance = self._rhodecode_db_user | |||
|
528 | totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)() | |||
|
529 | if self.request.method == 'POST': | |||
|
530 | try: | |||
|
531 | totp_form.to_python(dict(self.request.POST)) | |||
|
532 | user_instance.update_userdata(check_2fa=False) | |||
|
533 | Session().commit() | |||
|
534 | raise HTTPFound(c.came_from) | |||
|
535 | except formencode.Invalid as errors: | |||
|
536 | defaults = errors.value | |||
|
537 | render_ctx = { | |||
|
538 | 'errors': errors.error_dict, | |||
|
539 | 'defaults': defaults, | |||
|
540 | } | |||
|
541 | return self._get_template_context(c, **render_ctx) |
@@ -74,6 +74,34 b' def includeme(config):' | |||||
74 | route_name='my_account_password_update', request_method='POST', |
|
74 | route_name='my_account_password_update', request_method='POST', | |
75 | renderer='rhodecode:templates/admin/my_account/my_account.mako') |
|
75 | renderer='rhodecode:templates/admin/my_account/my_account.mako') | |
76 |
|
76 | |||
|
77 | # my account 2fa | |||
|
78 | config.add_route( | |||
|
79 | name='my_account_enable_2fa', | |||
|
80 | pattern=ADMIN_PREFIX + '/my_account/enable_2fa') | |||
|
81 | config.add_view( | |||
|
82 | MyAccountView, | |||
|
83 | attr='my_account_2fa', | |||
|
84 | route_name='my_account_enable_2fa', request_method='GET', | |||
|
85 | renderer='rhodecode:templates/admin/my_account/my_account.mako') | |||
|
86 | ||||
|
87 | config.add_route( | |||
|
88 | name='my_account_configure_2fa', | |||
|
89 | pattern=ADMIN_PREFIX + '/my_account/configure_2fa') | |||
|
90 | config.add_view( | |||
|
91 | MyAccountView, | |||
|
92 | attr='my_account_2fa_configure', | |||
|
93 | route_name='my_account_configure_2fa', request_method='POST', xhr=True, | |||
|
94 | renderer='json_ext') | |||
|
95 | ||||
|
96 | config.add_route( | |||
|
97 | name='my_account_regenerate_2fa_recovery_codes', | |||
|
98 | pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes') | |||
|
99 | config.add_view( | |||
|
100 | MyAccountView, | |||
|
101 | 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') | |||
|
104 | ||||
77 | # my account tokens |
|
105 | # my account tokens | |
78 | config.add_route( |
|
106 | config.add_route( | |
79 | name='my_account_auth_tokens', |
|
107 | name='my_account_auth_tokens', |
@@ -204,6 +204,33 b' class MyAccountView(BaseAppView, DataGri' | |||||
204 |
|
204 | |||
205 | @LoginRequired() |
|
205 | @LoginRequired() | |
206 | @NotAnonymous() |
|
206 | @NotAnonymous() | |
|
207 | def my_account_2fa(self): | |||
|
208 | _ = self.request.translate | |||
|
209 | c = self.load_default_context() | |||
|
210 | c.active = '2fa' | |||
|
211 | from rhodecode.model.settings import SettingsModel | |||
|
212 | user_instance = self._rhodecode_db_user | |||
|
213 | locked_by_admin = user_instance.has_forced_2fa | |||
|
214 | c.state_of_2fa = user_instance.has_enabled_2fa | |||
|
215 | c.locked_2fa = str2bool(locked_by_admin) | |||
|
216 | return self._get_template_context(c) | |||
|
217 | ||||
|
218 | @LoginRequired() | |||
|
219 | @NotAnonymous() | |||
|
220 | @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} | |||
|
225 | ||||
|
226 | @LoginRequired() | |||
|
227 | @NotAnonymous() | |||
|
228 | @CSRFRequired() | |||
|
229 | def my_account_2fa_regenerate_recovery_codes(self): | |||
|
230 | return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()} | |||
|
231 | ||||
|
232 | @LoginRequired() | |||
|
233 | @NotAnonymous() | |||
207 | def my_account_auth_tokens(self): |
|
234 | def my_account_auth_tokens(self): | |
208 | _ = self.request.translate |
|
235 | _ = self.request.translate | |
209 |
|
236 |
@@ -183,6 +183,14 b' class RhodeCodeAuthPlugin(RhodeCodeAuthP' | |||||
183 |
|
183 | |||
184 |
|
184 | |||
185 | class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase): |
|
185 | class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase): | |
|
186 | global_2fa = colander.SchemaNode( | |||
|
187 | colander.Bool(), | |||
|
188 | default=False, | |||
|
189 | description=_('Force all users to use two factor authentication by enabling this.'), | |||
|
190 | missing=False, | |||
|
191 | title=_('Global 2FA'), | |||
|
192 | widget='bool', | |||
|
193 | ) | |||
186 |
|
194 | |||
187 | auth_restriction_choices = [ |
|
195 | auth_restriction_choices = [ | |
188 | (RhodeCodeAuthPlugin.AUTH_RESTRICTION_NONE, 'All users'), |
|
196 | (RhodeCodeAuthPlugin.AUTH_RESTRICTION_NONE, 'All users'), |
@@ -33,6 +33,7 b' import functools' | |||||
33 | import traceback |
|
33 | import traceback | |
34 | import collections |
|
34 | import collections | |
35 |
|
35 | |||
|
36 | import pyotp | |||
36 | from sqlalchemy import ( |
|
37 | from sqlalchemy import ( | |
37 | or_, and_, not_, func, cast, TypeDecorator, event, select, |
|
38 | or_, and_, not_, func, cast, TypeDecorator, event, select, | |
38 | true, false, null, union_all, |
|
39 | true, false, null, union_all, | |
@@ -51,6 +52,7 b' from zope.cachedescriptors.property impo' | |||||
51 | from pyramid.threadlocal import get_current_request |
|
52 | from pyramid.threadlocal import get_current_request | |
52 | from webhelpers2.text import remove_formatting |
|
53 | from webhelpers2.text import remove_formatting | |
53 |
|
54 | |||
|
55 | from rhodecode import ConfigGet | |||
54 | from rhodecode.lib.str_utils import safe_bytes |
|
56 | from rhodecode.lib.str_utils import safe_bytes | |
55 | from rhodecode.translation import _ |
|
57 | from rhodecode.translation import _ | |
56 | from rhodecode.lib.vcs import get_vcs_instance, VCSError |
|
58 | from rhodecode.lib.vcs import get_vcs_instance, VCSError | |
@@ -586,6 +588,7 b' class User(Base, BaseModel):' | |||||
586 | DEFAULT_USER = 'default' |
|
588 | DEFAULT_USER = 'default' | |
587 | DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org' |
|
589 | DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org' | |
588 | DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}' |
|
590 | DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}' | |
|
591 | RECOVERY_CODES_COUNT = 10 | |||
589 |
|
592 | |||
590 | user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True) |
|
593 | user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True) | |
591 | username = Column("username", String(255), nullable=True, unique=None, default=None) |
|
594 | username = Column("username", String(255), nullable=True, unique=None, default=None) | |
@@ -793,6 +796,94 b' class User(Base, BaseModel):' | |||||
793 | Session.commit() |
|
796 | Session.commit() | |
794 | return artifact_token.api_key |
|
797 | return artifact_token.api_key | |
795 |
|
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) | |||
|
815 | return totp.verify(received_code) | |||
|
816 | ||||
|
817 | def is_2fa_recovery_code_valid(self, received_code): | |||
|
818 | 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)) | |||
|
827 | if received_code in recovery_codes: | |||
|
828 | encrypted_recovery_codes.pop(recovery_codes.index(received_code)) | |||
|
829 | self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes) | |||
|
830 | return True | |||
|
831 | return False | |||
|
832 | ||||
|
833 | @hybrid_property | |||
|
834 | def has_forced_2fa(self): | |||
|
835 | """ | |||
|
836 | Checks if 2fa was forced for ALL users (including current one) | |||
|
837 | """ | |||
|
838 | from rhodecode.model.settings import SettingsModel | |||
|
839 | # So now we're supporting only auth_rhodecode_global_2f | |||
|
840 | if value := SettingsModel().get_setting_by_name('auth_rhodecode_global_2fa'): | |||
|
841 | return value.app_settings_value | |||
|
842 | return False | |||
|
843 | ||||
|
844 | @hybrid_property | |||
|
845 | def has_enabled_2fa(self): | |||
|
846 | """ | |||
|
847 | Checks if 2fa was enabled by user | |||
|
848 | """ | |||
|
849 | if value := self.has_forced_2fa: | |||
|
850 | return value | |||
|
851 | return self.user_data.get('enabled_2fa', False) | |||
|
852 | ||||
|
853 | @has_enabled_2fa.setter | |||
|
854 | def has_enabled_2fa(self, val): | |||
|
855 | val = str2bool(val) | |||
|
856 | self.update_userdata(enabled_2fa=str2bool(val)) | |||
|
857 | if not val: | |||
|
858 | self.update_userdata(secret_2fa=None, recovery_codes_2fa=[]) | |||
|
859 | Session().commit() | |||
|
860 | ||||
|
861 | def get_2fa_recovery_codes(self): | |||
|
862 | """ | |||
|
863 | Creates 2fa recovery codes | |||
|
864 | """ | |||
|
865 | recovery_codes = self.user_data.get('recovery_codes_2fa', []) | |||
|
866 | encrypted_codes = [] | |||
|
867 | if not recovery_codes: | |||
|
868 | for _ in range(self.RECOVERY_CODES_COUNT): | |||
|
869 | recovery_code = pyotp.random_base32() | |||
|
870 | 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) | |||
|
873 | return recovery_codes | |||
|
874 | # User should not check the same recovery codes more than once | |||
|
875 | return [] | |||
|
876 | ||||
|
877 | def regenerate_2fa_recovery_codes(self): | |||
|
878 | """ | |||
|
879 | Regenerates 2fa recovery codes upon request | |||
|
880 | """ | |||
|
881 | self.update_userdata(recovery_codes_2fa=[]) | |||
|
882 | Session().flush() | |||
|
883 | new_recovery_codes = self.get_2fa_recovery_codes() | |||
|
884 | Session().commit() | |||
|
885 | return new_recovery_codes | |||
|
886 | ||||
796 | @classmethod |
|
887 | @classmethod | |
797 | def get(cls, user_id, cache=False): |
|
888 | def get(cls, user_id, cache=False): | |
798 | if not user_id: |
|
889 | if not user_id: |
@@ -104,6 +104,28 b' def LoginForm(localizer):' | |||||
104 | return _LoginForm |
|
104 | return _LoginForm | |
105 |
|
105 | |||
106 |
|
106 | |||
|
107 | def TOTPForm(localizer, user, allow_recovery_code_use=False): | |||
|
108 | _ = localizer | |||
|
109 | ||||
|
110 | class _TOTPForm(formencode.Schema): | |||
|
111 | allow_extra_fields = True | |||
|
112 | filter_extra_fields = False | |||
|
113 | totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$') | |||
|
114 | ||||
|
115 | def to_python(self, value, state=None): | |||
|
116 | validation_checks = [user.is_totp_valid] | |||
|
117 | if allow_recovery_code_use: | |||
|
118 | validation_checks.append(user.is_2fa_recovery_code_valid) | |||
|
119 | form_data = super().to_python(value, state) | |||
|
120 | received_code = form_data['totp'] | |||
|
121 | if not any(map(lambda x: x(received_code), validation_checks)): | |||
|
122 | error_msg = _('Code is invalid. Try again!') | |||
|
123 | raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg}) | |||
|
124 | return True | |||
|
125 | ||||
|
126 | return _TOTPForm | |||
|
127 | ||||
|
128 | ||||
107 | def UserForm(localizer, edit=False, available_languages=None, old_data=None): |
|
129 | def UserForm(localizer, edit=False, available_languages=None, old_data=None): | |
108 | old_data = old_data or {} |
|
130 | old_data = old_data or {} | |
109 | available_languages = available_languages or [] |
|
131 | available_languages = available_languages or [] |
@@ -224,6 +224,9 b' function registerRCRoutes() {' | |||||
224 | pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []); |
|
224 | pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []); | |
225 | pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []); |
|
225 | 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', []); |
|
226 | 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', []); | |||
227 | pyroutes.register('my_account_password', '/_admin/my_account/password', []); |
|
230 | pyroutes.register('my_account_password', '/_admin/my_account/password', []); | |
228 | pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []); |
|
231 | pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []); | |
229 | pyroutes.register('my_account_perms', '/_admin/my_account/perms', []); |
|
232 | pyroutes.register('my_account_perms', '/_admin/my_account/perms', []); |
@@ -28,6 +28,7 b'' | |||||
28 | <li class="${h.is_active(['profile', 'profile_edit'], c.active)}"><a href="${h.route_path('my_account_profile')}">${_('Profile')}</a></li> |
|
28 | <li class="${h.is_active(['profile', 'profile_edit'], c.active)}"><a href="${h.route_path('my_account_profile')}">${_('Profile')}</a></li> | |
29 | <li class="${h.is_active('emails', c.active)}"><a href="${h.route_path('my_account_emails')}">${_('Emails')}</a></li> |
|
29 | <li class="${h.is_active('emails', c.active)}"><a href="${h.route_path('my_account_emails')}">${_('Emails')}</a></li> | |
30 | <li class="${h.is_active('password', c.active)}"><a href="${h.route_path('my_account_password')}">${_('Password')}</a></li> |
|
30 | <li class="${h.is_active('password', c.active)}"><a href="${h.route_path('my_account_password')}">${_('Password')}</a></li> | |
|
31 | <li class="${h.is_active('2FA', c.active)}"><a href="${h.route_path('my_account_enable_2fa')}">${_('2FA')}</a></li> | |||
31 | <li class="${h.is_active('bookmarks', c.active)}"><a href="${h.route_path('my_account_bookmarks')}">${_('Bookmarks')}</a></li> |
|
32 | <li class="${h.is_active('bookmarks', c.active)}"><a href="${h.route_path('my_account_bookmarks')}">${_('Bookmarks')}</a></li> | |
32 | <li class="${h.is_active('auth_tokens', c.active)}"><a href="${h.route_path('my_account_auth_tokens')}">${_('Auth Tokens')}</a></li> |
|
33 | <li class="${h.is_active('auth_tokens', c.active)}"><a href="${h.route_path('my_account_auth_tokens')}">${_('Auth Tokens')}</a></li> | |
33 | <li class="${h.is_active(['ssh_keys', 'ssh_keys_generate'], c.active)}"><a href="${h.route_path('my_account_ssh_keys')}">${_('SSH Keys')}</a></li> |
|
34 | <li class="${h.is_active(['ssh_keys', 'ssh_keys_generate'], c.active)}"><a href="${h.route_path('my_account_ssh_keys')}">${_('SSH Keys')}</a></li> |
@@ -106,6 +106,7 b' def get_url_defs():' | |||||
106 | + "/gists/{gist_id}/rev/{revision}/{format}/{f_path}", |
|
106 | + "/gists/{gist_id}/rev/{revision}/{format}/{f_path}", | |
107 | "login": ADMIN_PREFIX + "/login", |
|
107 | "login": ADMIN_PREFIX + "/login", | |
108 | "logout": ADMIN_PREFIX + "/logout", |
|
108 | "logout": ADMIN_PREFIX + "/logout", | |
|
109 | "check_2fa": ADMIN_PREFIX + "/check_2fa", | |||
109 | "register": ADMIN_PREFIX + "/register", |
|
110 | "register": ADMIN_PREFIX + "/register", | |
110 | "reset_password": ADMIN_PREFIX + "/password_reset", |
|
111 | "reset_password": ADMIN_PREFIX + "/password_reset", | |
111 | "reset_password_confirmation": ADMIN_PREFIX + "/password_reset_confirmation", |
|
112 | "reset_password_confirmation": ADMIN_PREFIX + "/password_reset_confirmation", |
General Comments 0
You need to be logged in to leave comments.
Login now