diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/rhodecode/apps/_base/__init__.py b/rhodecode/apps/_base/__init__.py --- a/rhodecode/apps/_base/__init__.py +++ b/rhodecode/apps/_base/__init__.py @@ -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 diff --git a/rhodecode/apps/login/__init__.py b/rhodecode/apps/login/__init__.py --- a/rhodecode/apps/login/__init__.py +++ b/rhodecode/apps/login/__init__.py @@ -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') + diff --git a/rhodecode/apps/login/tests/test_2fa.py b/rhodecode/apps/login/tests/test_2fa.py new file mode 100644 --- /dev/null +++ b/rhodecode/apps/login/tests/test_2fa.py @@ -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 diff --git a/rhodecode/apps/login/views.py b/rhodecode/apps/login/views.py --- a/rhodecode/apps/login/views.py +++ b/rhodecode/apps/login/views.py @@ -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 UserRegiste 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) diff --git a/rhodecode/apps/my_account/__init__.py b/rhodecode/apps/my_account/__init__.py --- a/rhodecode/apps/my_account/__init__.py +++ b/rhodecode/apps/my_account/__init__.py @@ -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', diff --git a/rhodecode/apps/my_account/views/my_account.py b/rhodecode/apps/my_account/views/my_account.py --- a/rhodecode/apps/my_account/views/my_account.py +++ b/rhodecode/apps/my_account/views/my_account.py @@ -204,6 +204,33 @@ class MyAccountView(BaseAppView, DataGri @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): _ = self.request.translate diff --git a/rhodecode/authentication/plugins/auth_rhodecode.py b/rhodecode/authentication/plugins/auth_rhodecode.py --- a/rhodecode/authentication/plugins/auth_rhodecode.py +++ b/rhodecode/authentication/plugins/auth_rhodecode.py @@ -183,6 +183,14 @@ class RhodeCodeAuthPlugin(RhodeCodeAuthP 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'), diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -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 impo 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: diff --git a/rhodecode/model/forms.py b/rhodecode/model/forms.py --- a/rhodecode/model/forms.py +++ b/rhodecode/model/forms.py @@ -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 [] diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -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', []); diff --git a/rhodecode/templates/admin/my_account/my_account.mako b/rhodecode/templates/admin/my_account/my_account.mako --- a/rhodecode/templates/admin/my_account/my_account.mako +++ b/rhodecode/templates/admin/my_account/my_account.mako @@ -28,6 +28,7 @@
  • ${_('Profile')}
  • ${_('Emails')}
  • ${_('Password')}
  • +
  • ${_('2FA')}
  • ${_('Bookmarks')}
  • ${_('Auth Tokens')}
  • ${_('SSH Keys')}
  • diff --git a/rhodecode/templates/admin/my_account/my_account_2fa.mako b/rhodecode/templates/admin/my_account/my_account_2fa.mako new file mode 100644 --- /dev/null +++ b/rhodecode/templates/admin/my_account/my_account_2fa.mako @@ -0,0 +1,140 @@ +<%namespace name="base" file="/base/base.mako"/> + +
    +
    +

    ${_('Enable/Disable 2FA for your account')}

    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    + + +
    + % if c.locked_2fa: + ${_('2FA settings cannot be changed here, because 2FA was forced enabled by RhodeCode Administrator.')} + % endif +
    +
    +
    + +
    + +
    +
    +% if c.state_of_2fa: +
    +
    +

    ${_('Regenerate 2FA recovery codes for your account')}

    +
    +
    +
    + + +
    +
    +
    + +
    +% endif + + + diff --git a/rhodecode/templates/configure_2fa.mako b/rhodecode/templates/configure_2fa.mako new file mode 100644 --- /dev/null +++ b/rhodecode/templates/configure_2fa.mako @@ -0,0 +1,153 @@ +<%inherit file="base/root.mako"/> + +<%def name="title()"> + ${_('Setup authenticator app')} + %if c.rhodecode_name: + · ${h.branding(c.rhodecode_name)} + %endif + + + +
    +
    +
    + +
    +
    + +
    +

    Setup the authenticator app

    +

    Authenticator apps like Google Authenticator, etc. generate one-time passwords that are used as a second factor to verify you identity.

    + + +
    + +

    Use an authenticator app to scan.

    + +

    ${_('Unable to scan?')} ${_('Click here')}

    + + +

    +
    + ${h.secure_form(h.route_path('setup_2fa'), request=request, id='totp_form')} +
    +
    +

    +

    + +
    +

    +

    +

    +
    + ${h.text('totp', class_='form-control', style='width: 40%;')} +
    + %if 'totp' in errors: + ${errors.get('totp')} +
    + %endif +
    +
    + ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;', disabled=not codes_viewed)} +
    +
    +
    +

    +
    +
    +
    +
    +
    +
    + + + diff --git a/rhodecode/templates/verify_2fa.mako b/rhodecode/templates/verify_2fa.mako new file mode 100644 --- /dev/null +++ b/rhodecode/templates/verify_2fa.mako @@ -0,0 +1,37 @@ +<%inherit file="/base/root.mako"/> +<%def name="title()"> + ${_('Check 2FA')} + %if c.rhodecode_name: + · ${h.branding(c.rhodecode_name)} + %endif + + +
    +
    + ${h.secure_form(h.route_path('check_2fa'), request=request, id='totp_form')} +
    +
    +

    +

    + +
    +

    +

    +

    +
    + ${h.text('totp', class_="form-control", style='width: 38%;')} +
    + %if 'totp' in errors: + ${errors.get('totp')} +
    + %endif +
    +
    + ${h.submit('save',_('Verify'),class_="btn btn-primary", style='width: 40%;')} +
    +
    +

    +
    +
    +
    +
    diff --git a/rhodecode/tests/routes.py b/rhodecode/tests/routes.py --- a/rhodecode/tests/routes.py +++ b/rhodecode/tests/routes.py @@ -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",