feat(2fa): Added 2fa option. Fixes: RCCE-65

This commit is contained in:
Serhii Ilin 2024-04-15 12:13:03 +03:00
parent 31e3c38a18
commit 9678665ecd
16 changed files with 722 additions and 6 deletions

View file

@ -290,6 +290,7 @@ webhelpers2==2.1
six==1.16.0
whoosh==2.7.4
zope.cachedescriptors==5.0.0
qrcode==7.4.2
## uncomment to add the debug libraries
#-r requirements_debug.txt

View file

@ -104,6 +104,11 @@ class TemplateArgs(StrictAttributeDict):
class BaseAppView(object):
DONT_CHECKOUT_VIEWS = ["channelstream_connect", "ops_ping"]
EXTRA_VIEWS_TO_IGNORE = ['login', 'register', 'logout']
SETUP_2FA_VIEW = 'setup_2fa'
VERIFY_2FA_VIEW = 'check_2fa'
def __init__(self, context, request):
self.request = request
self.context = context
@ -117,13 +122,19 @@ class BaseAppView(object):
self._rhodecode_user = request.user # auth user
self._rhodecode_db_user = self._rhodecode_user.get_instance()
self.user_data = self._rhodecode_db_user.user_data if self._rhodecode_db_user else {}
self._maybe_needs_password_change(
request.matched_route.name, self._rhodecode_db_user
)
self._maybe_needs_2fa_configuration(
request.matched_route.name, self._rhodecode_db_user
)
self._maybe_needs_2fa_check(
request.matched_route.name, self._rhodecode_db_user
)
def _maybe_needs_password_change(self, view_name, user_obj):
dont_check_views = ["channelstream_connect", "ops_ping"]
if view_name in dont_check_views:
if view_name in self.DONT_CHECKOUT_VIEWS:
return
log.debug(
@ -144,7 +155,7 @@ class BaseAppView(object):
return
now = time.time()
should_change = user_obj.user_data.get("force_password_change")
should_change = self.user_data.get("force_password_change")
change_after = safe_int(should_change) or 0
if should_change and now > change_after:
log.debug("User %s requires password change", user_obj)
@ -157,6 +168,36 @@ class BaseAppView(object):
if view_name not in skip_user_views:
raise HTTPFound(self.request.route_path("my_account_password"))
def _maybe_needs_2fa_configuration(self, view_name, user_obj):
if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE:
return
if not user_obj:
return
if user_obj.has_forced_2fa and user_obj.extern_type != 'rhodecode':
return
if (user_obj.has_enabled_2fa
and not self.user_data.get('secret_2fa')) \
and view_name != self.SETUP_2FA_VIEW:
h.flash(
"You are required to configure 2FA",
"warning",
ignore_duplicate=False,
)
raise HTTPFound(self.request.route_path(self.SETUP_2FA_VIEW))
def _maybe_needs_2fa_check(self, view_name, user_obj):
if view_name in self.DONT_CHECKOUT_VIEWS + self.EXTRA_VIEWS_TO_IGNORE:
return
if not user_obj:
return
if self.user_data.get('check_2fa') and view_name != self.VERIFY_2FA_VIEW:
raise HTTPFound(self.request.route_path(self.VERIFY_2FA_VIEW))
def _log_creation_exception(self, e, repo_name):
_ = self.request.translate
reason = None

View file

@ -75,3 +75,27 @@ def includeme(config):
LoginView,
attr='password_reset_confirmation',
route_name='reset_password_confirmation', request_method='GET')
config.add_route(
name='setup_2fa',
pattern=ADMIN_PREFIX + '/setup_2fa')
config.add_view(
LoginView,
attr='setup_2fa',
route_name='setup_2fa', request_method=['GET', 'POST'],
renderer='rhodecode:templates/configure_2fa.mako')
config.add_route(
name='check_2fa',
pattern=ADMIN_PREFIX + '/check_2fa')
config.add_view(
LoginView,
attr='verify_2fa',
route_name='check_2fa', request_method='GET',
renderer='rhodecode:templates/verify_2fa.mako')
config.add_view(
LoginView,
attr='verify_2fa',
route_name='check_2fa', request_method='POST',
renderer='rhodecode:templates/verify_2fa.mako')

View file

@ -0,0 +1,67 @@
import pytest
from rhodecode.model.meta import Session
from rhodecode.tests.fixture import Fixture
from rhodecode.tests.routes import route_path
from rhodecode.model.settings import SettingsModel
fixture = Fixture()
@pytest.mark.usefixtures('app')
class Test2FA(object):
@classmethod
def setup_class(cls):
cls.password = 'valid-one'
@classmethod
def teardown_class(cls):
SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', False)
def test_redirect_to_2fa_setup_if_enabled_for_user(self, user_util):
user = user_util.create_user(password=self.password)
user.has_enabled_2fa = True
self.app.post(
route_path('login'),
{'username': user.username,
'password': self.password})
response = self.app.get('/')
assert response.status_code == 302
assert response.location.endswith(route_path('setup_2fa'))
def test_redirect_to_2fa_check_if_2fa_configured(self, user_util):
user = user_util.create_user(password=self.password)
user.has_enabled_2fa = True
user.secret_2fa
Session().add(user)
Session().commit()
self.app.post(
route_path('login'),
{'username': user.username,
'password': self.password})
response = self.app.get('/')
assert response.status_code == 302
assert response.location.endswith(route_path('check_2fa'))
def test_2fa_recovery_codes_works_only_once(self, user_util):
user = user_util.create_user(password=self.password)
user.has_enabled_2fa = True
user.secret_2fa
recovery_cod_to_check = user.get_2fa_recovery_codes()[0]
Session().add(user)
Session().commit()
self.app.post(
route_path('login'),
{'username': user.username,
'password': self.password})
response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check})
assert response.status_code == 302
response = self.app.post(route_path('check_2fa'), {'totp': recovery_cod_to_check})
response.mustcontain('Code is invalid. Try again!')
def test_2fa_state_when_forced_by_admin(self, user_util):
user = user_util.create_user(password=self.password)
user.has_enabled_2fa = False
SettingsModel().create_or_update_setting('auth_rhodecode_global_2fa', True)
assert user.has_enabled_2fa

View file

@ -17,6 +17,9 @@
# and proprietary license terms, please see https://rhodecode.com/licenses/
import time
import json
import pyotp
import qrcode
import collections
import datetime
import formencode
@ -24,7 +27,11 @@ import formencode.htmlfill
import logging
import urllib.parse
import requests
from io import BytesIO
from base64 import b64encode
from pyramid.renderers import render
from pyramid.response import Response
from pyramid.httpexceptions import HTTPFound
@ -35,12 +42,12 @@ from rhodecode.events import UserRegistered, trigger
from rhodecode.lib import helpers as h
from rhodecode.lib import audit_logger
from rhodecode.lib.auth import (
AuthUser, HasPermissionAnyDecorator, CSRFRequired)
AuthUser, HasPermissionAnyDecorator, CSRFRequired, LoginRequired, NotAnonymous)
from rhodecode.lib.base import get_ip_addr
from rhodecode.lib.exceptions import UserCreationError
from rhodecode.lib.utils2 import safe_str
from rhodecode.model.db import User, UserApiKeys
from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm, TOTPForm
from rhodecode.model.meta import Session
from rhodecode.model.auth_token import AuthTokenModel
from rhodecode.model.settings import SettingsModel
@ -179,9 +186,12 @@ class LoginView(BaseAppView):
self.session.invalidate()
form_result = login_form.to_python(self.request.POST)
# form checks for username/password, now we're authenticated
username = form_result['username']
if (user := User.get_by_username_or_primary_email(username)).has_enabled_2fa:
user.update_userdata(check_2fa=True)
headers = store_user_in_session(
self.session,
user_identifier=form_result['username'],
user_identifier=username,
remember=form_result['remember'])
log.debug('Redirecting to "%s" after login.', c.came_from)
@ -436,6 +446,8 @@ class LoginView(BaseAppView):
return self._get_template_context(c, **template_context)
@LoginRequired()
@NotAnonymous()
def password_reset_confirmation(self):
self.load_default_context()
if self.request.GET and self.request.GET.get('key'):
@ -467,3 +479,63 @@ class LoginView(BaseAppView):
return HTTPFound(self.request.route_path('reset_password'))
return HTTPFound(self.request.route_path('login'))
@LoginRequired()
@NotAnonymous()
def setup_2fa(self):
_ = self.request.translate
c = self.load_default_context()
user_instance = self._rhodecode_db_user
form = TOTPForm(_, user_instance)()
render_ctx = {}
if self.request.method == 'POST':
try:
form.to_python(dict(self.request.POST))
Session().commit()
raise HTTPFound(c.came_from)
except formencode.Invalid as errors:
defaults = errors.value
render_ctx = {
'errors': errors.error_dict,
'defaults': defaults,
}
qr = qrcode.QRCode(version=1, box_size=10, border=5)
secret = user_instance.secret_2fa
Session().flush()
recovery_codes = user_instance.get_2fa_recovery_codes()
Session().commit()
qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(
name=self.request.user.name))
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buffered = BytesIO()
img.save(buffered)
return self._get_template_context(
c,
qr=b64encode(buffered.getvalue()).decode("utf-8"),
key=secret, recovery_codes=json.dumps(recovery_codes),
codes_viewed=not bool(recovery_codes),
** render_ctx
)
@LoginRequired()
@NotAnonymous()
def verify_2fa(self):
_ = self.request.translate
c = self.load_default_context()
render_ctx = {}
user_instance = self._rhodecode_db_user
totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
if self.request.method == 'POST':
try:
totp_form.to_python(dict(self.request.POST))
user_instance.update_userdata(check_2fa=False)
Session().commit()
raise HTTPFound(c.came_from)
except formencode.Invalid as errors:
defaults = errors.value
render_ctx = {
'errors': errors.error_dict,
'defaults': defaults,
}
return self._get_template_context(c, **render_ctx)

View file

@ -74,6 +74,34 @@ def includeme(config):
route_name='my_account_password_update', request_method='POST',
renderer='rhodecode:templates/admin/my_account/my_account.mako')
# my account 2fa
config.add_route(
name='my_account_enable_2fa',
pattern=ADMIN_PREFIX + '/my_account/enable_2fa')
config.add_view(
MyAccountView,
attr='my_account_2fa',
route_name='my_account_enable_2fa', request_method='GET',
renderer='rhodecode:templates/admin/my_account/my_account.mako')
config.add_route(
name='my_account_configure_2fa',
pattern=ADMIN_PREFIX + '/my_account/configure_2fa')
config.add_view(
MyAccountView,
attr='my_account_2fa_configure',
route_name='my_account_configure_2fa', request_method='POST', xhr=True,
renderer='json_ext')
config.add_route(
name='my_account_regenerate_2fa_recovery_codes',
pattern=ADMIN_PREFIX + '/my_account/regenerate_recovery_codes')
config.add_view(
MyAccountView,
attr='my_account_2fa_regenerate_recovery_codes',
route_name='my_account_regenerate_2fa_recovery_codes', request_method='POST', xhr=True,
renderer='json_ext')
# my account tokens
config.add_route(
name='my_account_auth_tokens',

View file

@ -202,6 +202,33 @@ class MyAccountView(BaseAppView, DataGridAppView):
raise HTTPFound(self.request.route_path('my_account_password'))
@LoginRequired()
@NotAnonymous()
def my_account_2fa(self):
_ = self.request.translate
c = self.load_default_context()
c.active = '2fa'
from rhodecode.model.settings import SettingsModel
user_instance = self._rhodecode_db_user
locked_by_admin = user_instance.has_forced_2fa
c.state_of_2fa = user_instance.has_enabled_2fa
c.locked_2fa = str2bool(locked_by_admin)
return self._get_template_context(c)
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
def my_account_2fa_configure(self):
state = self.request.POST.get('state')
self._rhodecode_db_user.has_enabled_2fa = state
return {'state_of_2fa': state}
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
def my_account_2fa_regenerate_recovery_codes(self):
return {'recovery_codes': self._rhodecode_db_user.regenerate_2fa_recovery_codes()}
@LoginRequired()
@NotAnonymous()
def my_account_auth_tokens(self):

View file

@ -183,6 +183,14 @@ class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase):
global_2fa = colander.SchemaNode(
colander.Bool(),
default=False,
description=_('Force all users to use two factor authentication by enabling this.'),
missing=False,
title=_('Global 2FA'),
widget='bool',
)
auth_restriction_choices = [
(RhodeCodeAuthPlugin.AUTH_RESTRICTION_NONE, 'All users'),

View file

@ -33,6 +33,7 @@ import functools
import traceback
import collections
import pyotp
from sqlalchemy import (
or_, and_, not_, func, cast, TypeDecorator, event, select,
true, false, null, union_all,
@ -51,6 +52,7 @@ from zope.cachedescriptors.property import Lazy as LazyProperty
from pyramid.threadlocal import get_current_request
from webhelpers2.text import remove_formatting
from rhodecode import ConfigGet
from rhodecode.lib.str_utils import safe_bytes
from rhodecode.translation import _
from rhodecode.lib.vcs import get_vcs_instance, VCSError
@ -586,6 +588,7 @@ class User(Base, BaseModel):
DEFAULT_USER = 'default'
DEFAULT_USER_EMAIL = 'anonymous@rhodecode.org'
DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}'
RECOVERY_CODES_COUNT = 10
user_id = Column("user_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
username = Column("username", String(255), nullable=True, unique=None, default=None)
@ -793,6 +796,94 @@ class User(Base, BaseModel):
Session.commit()
return artifact_token.api_key
@hybrid_property
def secret_2fa(self):
if not self.user_data.get('secret_2fa'):
secret = pyotp.random_base32()
self.update_userdata(secret_2fa=safe_str(enc_utils.encrypt_value(secret, enc_key=ENCRYPTION_KEY)))
return secret
return safe_str(
enc_utils.decrypt_value(self.user_data['secret_2fa'],
enc_key=ENCRYPTION_KEY,
strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict',
missing=True)
)
)
def is_totp_valid(self, received_code):
totp = pyotp.TOTP(self.secret_2fa)
return totp.verify(received_code)
def is_2fa_recovery_code_valid(self, received_code):
encrypted_recovery_codes = self.user_data.get('recovery_codes_2fa', [])
recovery_codes = list(map(
lambda x: safe_str(
enc_utils.decrypt_value(
x,
enc_key=ENCRYPTION_KEY,
strict_mode=ConfigGet().get_bool('rhodecode.encrypted_values.strict', missing=True)
)),
encrypted_recovery_codes))
if received_code in recovery_codes:
encrypted_recovery_codes.pop(recovery_codes.index(received_code))
self.update_userdata(recovery_codes_2fa=encrypted_recovery_codes)
return True
return False
@hybrid_property
def has_forced_2fa(self):
"""
Checks if 2fa was forced for ALL users (including current one)
"""
from rhodecode.model.settings import SettingsModel
# So now we're supporting only auth_rhodecode_global_2f
if value := SettingsModel().get_setting_by_name('auth_rhodecode_global_2fa'):
return value.app_settings_value
return False
@hybrid_property
def has_enabled_2fa(self):
"""
Checks if 2fa was enabled by user
"""
if value := self.has_forced_2fa:
return value
return self.user_data.get('enabled_2fa', False)
@has_enabled_2fa.setter
def has_enabled_2fa(self, val):
val = str2bool(val)
self.update_userdata(enabled_2fa=str2bool(val))
if not val:
self.update_userdata(secret_2fa=None, recovery_codes_2fa=[])
Session().commit()
def get_2fa_recovery_codes(self):
"""
Creates 2fa recovery codes
"""
recovery_codes = self.user_data.get('recovery_codes_2fa', [])
encrypted_codes = []
if not recovery_codes:
for _ in range(self.RECOVERY_CODES_COUNT):
recovery_code = pyotp.random_base32()
recovery_codes.append(recovery_code)
encrypted_codes.append(safe_str(enc_utils.encrypt_value(recovery_code, enc_key=ENCRYPTION_KEY)))
self.update_userdata(recovery_codes_2fa=encrypted_codes)
return recovery_codes
# User should not check the same recovery codes more than once
return []
def regenerate_2fa_recovery_codes(self):
"""
Regenerates 2fa recovery codes upon request
"""
self.update_userdata(recovery_codes_2fa=[])
Session().flush()
new_recovery_codes = self.get_2fa_recovery_codes()
Session().commit()
return new_recovery_codes
@classmethod
def get(cls, user_id, cache=False):
if not user_id:

View file

@ -104,6 +104,28 @@ def LoginForm(localizer):
return _LoginForm
def TOTPForm(localizer, user, allow_recovery_code_use=False):
_ = localizer
class _TOTPForm(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = False
totp = v.Regex(r'^(?:\d{6}|[A-Z0-9]{32})$')
def to_python(self, value, state=None):
validation_checks = [user.is_totp_valid]
if allow_recovery_code_use:
validation_checks.append(user.is_2fa_recovery_code_valid)
form_data = super().to_python(value, state)
received_code = form_data['totp']
if not any(map(lambda x: x(received_code), validation_checks)):
error_msg = _('Code is invalid. Try again!')
raise formencode.Invalid(error_msg, v, state, error_dict={'totp': error_msg})
return True
return _TOTPForm
def UserForm(localizer, edit=False, available_languages=None, old_data=None):
old_data = old_data or {}
available_languages = available_languages or []

View file

@ -224,6 +224,9 @@ function registerRCRoutes() {
pyroutes.register('my_account_notifications', '/_admin/my_account/notifications', []);
pyroutes.register('my_account_notifications_test_channelstream', '/_admin/my_account/test_channelstream', []);
pyroutes.register('my_account_notifications_toggle_visibility', '/_admin/my_account/toggle_visibility', []);
pyroutes.register('check_2fa', '/_admin/check_2fa', []);
pyroutes.register('my_account_configure_2fa', '/_admin/my_account/configure_2fa', []);
pyroutes.register('my_account_regenerate_2fa_recovery_codes', '/_admin/my_account/regenerate_recovery_codes', []);
pyroutes.register('my_account_password', '/_admin/my_account/password', []);
pyroutes.register('my_account_password_update', '/_admin/my_account/password/update', []);
pyroutes.register('my_account_perms', '/_admin/my_account/perms', []);

View file

@ -28,6 +28,7 @@
<li class="${h.is_active(['profile', 'profile_edit'], c.active)}"><a href="${h.route_path('my_account_profile')}">${_('Profile')}</a></li>
<li class="${h.is_active('emails', c.active)}"><a href="${h.route_path('my_account_emails')}">${_('Emails')}</a></li>
<li class="${h.is_active('password', c.active)}"><a href="${h.route_path('my_account_password')}">${_('Password')}</a></li>
<li class="${h.is_active('2FA', c.active)}"><a href="${h.route_path('my_account_enable_2fa')}">${_('2FA')}</a></li>
<li class="${h.is_active('bookmarks', c.active)}"><a href="${h.route_path('my_account_bookmarks')}">${_('Bookmarks')}</a></li>
<li class="${h.is_active('auth_tokens', c.active)}"><a href="${h.route_path('my_account_auth_tokens')}">${_('Auth Tokens')}</a></li>
<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>

View file

@ -0,0 +1,140 @@
<%namespace name="base" file="/base/base.mako"/>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('Enable/Disable 2FA for your account')}</h3>
</div>
<div class="panel-body">
<div class="form">
<div class="fields">
<div class="field">
<div class="label">
<label>${_('2FA status')}:</label>
</div>
<div class="checkboxes">
<div class="form-check">
<label class="form-check-label">
<input type="radio" id="2faEnabled" value="1" ${'checked' if c.state_of_2fa else ''}>
${_('Enabled')}
</label>
<label class="form-check-label">
<input type="radio" id="2faDisabled" value="0" ${'checked' if not c.state_of_2fa else ''}>
${_('Disabled')}
</label>
</div>
% if c.locked_2fa:
<span class="help-block">${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')}</span>
% endif
</div>
</div>
</div>
<button id="saveBtn" class="btn btn-primary" ${'disabled' if c.locked_2fa else ''}>${_('Save')}</button>
</div>
<div id="codesPopup" class="modal">
<div class="modal-content">
<ul id="recoveryCodesList"></ul>
<button id="copyAllBtn" class="btn btn-primary">Copy All</button>
</div>
</div>
</div>
</div>
% if c.state_of_2fa:
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('Regenerate 2FA recovery codes for your account')}</h3>
</div>
<div class="panel-body">
<form id="2faForm">
<input type="text" name="totp" placeholder="${_('Verify the code from the app')}" pattern="\d{6}"
style="width: 20%">
<button type="button" class="btn btn-primary" onclick="submitForm()">Verify</button>
</form>
<div id="result"></div>
</div>
</div>
% endif
<script>
function submitForm() {
let formData = new FormData(document.getElementById("2faForm"));
let xhr = new XMLHttpRequest();
let success = function (response) {
let recovery_codes = response.recovery_codes;
const codesList = document.getElementById("recoveryCodesList");
codesList.innerHTML = "";
recovery_codes.forEach(code => {
const listItem = document.createElement("li");
listItem.textContent = code;
codesList.appendChild(listItem);
});
}
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
let responseDoc = new DOMParser().parseFromString(xhr.responseText, "text/html");
let contentToDisplay = responseDoc.querySelector('#formErrors');
if (contentToDisplay) {
document.getElementById("result").innerHTML = contentToDisplay.innerHTML;
} else {
let regenerate_url = pyroutes.url('my_account_regenerate_2fa_recovery_codes');
ajaxPOST(regenerate_url, {'csrf_token': CSRF_TOKEN}, success);
showRecoveryCodesPopup();
}
}
};
let url = pyroutes.url('check_2fa');
xhr.open("POST", url, true);
xhr.send(formData);
}
</script>
<script>
document.getElementById('2faEnabled').addEventListener('click', function () {
document.getElementById('2faDisabled').checked = false;
});
document.getElementById('2faDisabled').addEventListener('click', function () {
document.getElementById('2faEnabled').checked = false;
});
function getStateValue() {
if (document.getElementById('2faEnabled').checked) {
return '1';
} else {
return '0';
}
};
function saveChanges(state) {
let post_data = {'state': state, 'csrf_token': CSRF_TOKEN};
let url = pyroutes.url('my_account_configure_2fa');
ajaxPOST(url, post_data, null)
};
document.getElementById('saveBtn').addEventListener('click', function () {
var state = getStateValue();
saveChanges(state);
});
</script>
<script>
function showRecoveryCodesPopup() {
const popup = document.getElementById("codesPopup");
popup.style.display = "block";
}
document.getElementById("copyAllBtn").addEventListener("click", function () {
const codesListItems = document.querySelectorAll("#recoveryCodesList li");
const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
const textarea = document.createElement('textarea');
textarea.value = allCodes;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
const popup = document.getElementById("codesPopup");
popup.style.display = ""
});
</script>

View file

@ -0,0 +1,153 @@
<%inherit file="base/root.mako"/>
<%def name="title()">
${_('Setup authenticator app')}
%if c.rhodecode_name:
&middot; ${h.branding(c.rhodecode_name)}
%endif
</%def>
<style>body{background-color:#eeeeee;}</style>
<div class="loginbox">
<div class="header-account">
<div id="header-inner" class="title">
<div id="logo">
% if c.rhodecode_name:
<div class="branding">
<a href="${h.route_path('home')}">${h.branding(c.rhodecode_name)}</a>
</div>
% endif
</div>
</div>
</div>
<div class="loginwrapper">
<h1>Setup the authenticator app</h1>
<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>
<rhodecode-toast id="notifications"></rhodecode-toast>
<div id="setup_2fa">
<div class="sign-in-title">
<h1>${_('Scan the QR code')}</h1>
</div>
<p>Use an authenticator app to scan.</p>
<img src="data:image/png;base64, ${qr}"/>
<p>${_('Unable to scan?')} <a id="toggleLink">${_('Click here')}</a></p>
<div id="secretDiv" class="hidden">
<p>${_('Copy and use this code to manually setup an authenticator app')}</p>
<input type="text" id="secretField" value=${key}>
<i class="tooltip icon-clipboard clipboard-action" data-clipboard-text="" title="${_('Copy the secret key')}"></i>
</div>
<div id="codesPopup" class="modal">
<div class="modal-content">
<ul id="recoveryCodesList"></ul>
<button id="copyAllBtn" class="btn btn-primary">Copy All</button>
</div>
</div>
<br><br>
<div id="verify_2fa">
${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')}
<div class="form mt-4">
<div class="field">
<p>
<div class="label">
<label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
</div>
</p>
<p>
<div>
<div class="input-group">
${h.text('totp', class_='form-control', style='width: 40%;')}
<div id="formErrors">
%if 'totp' in errors:
<span class="error-message">${errors.get('totp')}</span>
<br />
%endif
</div>
<div class="input-group-append">
${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)}
</div>
</div>
</div>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
let clipboardIcons = document.querySelectorAll('.clipboard-action');
clipboardIcons.forEach(function(icon) {
icon.addEventListener('click', function() {
var inputField = document.getElementById('secretField');
inputField.select();
document.execCommand('copy');
});
});
});
</script>
<script>
document.getElementById('toggleLink').addEventListener('click', function() {
let hiddenField = document.getElementById('secretDiv');
if (hiddenField.classList.contains('hidden')) {
hiddenField.classList.remove('hidden');
}
});
</script>
<script>
const recovery_codes_string = '${recovery_codes}';
const cleaned_recovery_codes_string = recovery_codes_string
.replace(/&#34;/g, '"')
.replace(/&#39;/g, "'");
const recovery_codes = JSON.parse(cleaned_recovery_codes_string);
const cleaned_recovery_codes = recovery_codes.map(code => code.replace(/['"]/g, ''));
function showRecoveryCodesPopup() {
const popup = document.getElementById("codesPopup");
const codesList = document.getElementById("recoveryCodesList");
const verify_btn = document.getElementById('save')
if (verify_btn.disabled) {
codesList.innerHTML = "";
cleaned_recovery_codes.forEach(code => {
const listItem = document.createElement("li");
listItem.textContent = code;
codesList.appendChild(listItem);
});
popup.style.display = "block";
verify_btn.disabled = false;
}
}
document.getElementById("save").addEventListener("mouseover", showRecoveryCodesPopup);
const popup = document.getElementById("codesPopup");
const closeButton = document.querySelector(".close");
window.onclick = function(event) {
if (event.target === popup || event.target === closeButton) {
popup.style.display = "none";
}
}
document.getElementById("copyAllBtn").addEventListener("click", function() {
const codesListItems = document.querySelectorAll("#recoveryCodesList li");
const allCodes = Array.from(codesListItems).map(item => item.textContent).join(", ");
const textarea = document.createElement('textarea');
textarea.value = allCodes;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
});
</script>

View file

@ -0,0 +1,37 @@
<%inherit file="/base/root.mako"/>
<%def name="title()">
${_('Check 2FA')}
%if c.rhodecode_name:
&middot; ${h.branding(c.rhodecode_name)}
%endif
</%def>
<div class="box">
<div class="verify2FA">
${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')}
<div class="form mt-4" style="position: relative; margin-left: 35%; margin-top: 20%;">
<div class="field">
<p>
<div class="label">
<label for="totp" class="form-label text-dark font-weight-bold" style="text-align: left;">${_('Verify the code from the app')}:</label>
</div>
</p>
<p>
<div>
<div class="input-group">
${h.text('totp', class_="form-control", style='width: 38%;')}
<div id="formErrors">
%if 'totp' in errors:
<span class="error-message">${errors.get('totp')}</span>
<br />
%endif
</div>
<br />
${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')}
</div>
</div>
</p>
</div>
</div>
</div>
</div>

View file

@ -106,6 +106,7 @@ def get_url_defs():
+ "/gists/{gist_id}/rev/{revision}/{format}/{f_path}",
"login": ADMIN_PREFIX + "/login",
"logout": ADMIN_PREFIX + "/logout",
"check_2fa": ADMIN_PREFIX + "/check_2fa",
"register": ADMIN_PREFIX + "/register",
"reset_password": ADMIN_PREFIX + "/password_reset",
"reset_password_confirmation": ADMIN_PREFIX + "/password_reset_confirmation",