##// END OF EJS Templates
feat(2fa): Added 2fa option. Fixes: RCCE-65
ilin.s -
r5360:4cbf1ad2 default
parent child Browse files
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 &middot; ${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(/&#34;/g, '"')
105 .replace(/&#39;/g, "'");
106
107 const recovery_codes = JSON.parse(cleaned_recovery_codes_string);
108
109 const cleaned_recovery_codes = recovery_codes.map(code => code.replace(/['"]/g, ''));
110
111 function showRecoveryCodesPopup() {
112 const popup = document.getElementById("codesPopup");
113 const codesList = document.getElementById("recoveryCodesList");
114 const verify_btn = document.getElementById('save')
115
116 if (verify_btn.disabled) {
117 codesList.innerHTML = "";
118
119 cleaned_recovery_codes.forEach(code => {
120 const listItem = document.createElement("li");
121 listItem.textContent = code;
122 codesList.appendChild(listItem);
123 });
124
125 popup.style.display = "block";
126 verify_btn.disabled = false;
127 }
128 }
129
130 document.getElementById("save").addEventListener("mouseover", showRecoveryCodesPopup);
131
132 const popup = document.getElementById("codesPopup");
133 const closeButton = document.querySelector(".close");
134 window.onclick = function(event) {
135 if (event.target === popup || event.target === closeButton) {
136 popup.style.display = "none";
137 }
138 }
139
140 document.getElementById("copyAllBtn").addEventListener("click", function() {
141 const codesListItems = document.querySelectorAll("#recoveryCodesList li");
142 const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
143
144 const textarea = document.createElement('textarea');
145 textarea.value = allCodes;
146 document.body.appendChild(textarea);
147
148 textarea.select();
149 document.execCommand('copy');
150
151 document.body.removeChild(textarea);
152 });
153 </script>
@@ -0,0 +1,37 b''
1 <%inherit file="/base/root.mako"/>
2 <%def name="title()">
3 ${_('Check 2FA')}
4 %if c.rhodecode_name:
5 &middot; ${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 = user_obj.user_data.get("force_password_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=form_result['username'],
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