Prototype: OTP auth, styleguide overhaul, login/session rework

Replace password reset with email OTP verification flow.
Add auth_otp module, OTP templates, and email delivery.
Expand styleguide CSS with full component library.
Rework login, register, and admin views for cookie sessions.
Remove legacy 2FA templates and password reset flow.
Update SSH wrappers, forms, validators, and middleware.
This commit is contained in:
russell@unturf.com 2026-03-04 16:44:40 -05:00
parent 1dac65fc8a
commit 722c3bd369
38 changed files with 4100 additions and 1835 deletions

View file

@ -129,9 +129,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"
EXTRA_VIEWS_TO_IGNORE = [
"login", "register", "logout",
"login_otp_verify",
"sudo_otp_verify", "resend_otp",
]
def __init__(self, context, request):
self.request = request
@ -147,70 +149,6 @@ 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):
if view_name in self.DONT_CHECKOUT_VIEWS:
return
log.debug("Checking if user %s needs password change on view %s", user_obj, view_name)
skip_user_views = [
"logout",
"login",
"check_2fa",
"my_account_password",
"my_account_password_update",
]
if not user_obj:
return
if user_obj.username in [User.DEFAULT_USER, User.GHOST_USER, User.AI_USER, User.SECURITY_USER]:
return
now = time.time()
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)
h.flash(
"You are required to change your password",
"warning",
ignore_duplicate=True,
)
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.needs_2fa_configure and view_name != self.SETUP_2FA_VIEW:
h.flash(
"You are required to configure 2FA",
"warning",
ignore_duplicate=False,
)
# Special case for users created "on the fly" (ldap case for new user)
user_obj.check_2fa_required = 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 user_obj.check_2fa_required 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

View file

@ -34,6 +34,7 @@ from rhodecode.apps._base import BaseAppView, DataGridAppView
from rhodecode.lib.auth import LoginRequired, CSRFRequired, NotAnonymous, HasPermissionAny, HasRepoGroupPermissionAny
from rhodecode.lib import helpers as h, audit_logger
from rhodecode.lib.str_utils import safe_int, safe_str
from rhodecode.lib.utils import repo_name_slug
from rhodecode.model.forms import RepoGroupForm
from rhodecode.model.permission import PermissionModel
from rhodecode.model.repo_group import RepoGroupModel
@ -283,18 +284,23 @@ class AdminRepoGroupsView(BaseAppView, DataGridAppView):
can_create = self._can_create_repo_group(parent_group_id)
self._load_form_data(c)
# permissions for can create group based on parent_id are checked
# here in the Form
raw_group_name = self.request.POST.get("group_name", "").strip()
# Path-based creation: if the name contains "/" we create
# intermediate groups automatically (e.g. "org/team/project")
if "/" in raw_group_name:
return self._create_repo_group_path(c, raw_group_name, parent_group_id)
# Standard single-group creation via formencode
available_groups = list(map(lambda k: safe_str(k[0]), c.repo_groups))
repo_group_form = RepoGroupForm(
self.request.translate, available_groups=available_groups, can_create_in_root=can_create
)()
repo_group_name = self.request.POST.get("group_name")
try:
owner = self._rhodecode_user
form_result = repo_group_form.to_python(dict(self.request.POST))
copy_permissions = form_result.get("group_copy_permissions")
repo_group = RepoGroupModel().create(
group_name=form_result["group_name_full"],
group_description=form_result["group_description"],
@ -332,9 +338,97 @@ class AdminRepoGroupsView(BaseAppView, DataGridAppView):
return Response(html)
except Exception:
log.exception("Exception during creation of repository group")
h.flash(_("Error occurred during creation of repository group %s") % repo_group_name, category="error")
h.flash(_("Error occurred during creation of repository group %s") % raw_group_name, category="error")
raise HTTPFound(h.route_path("home"))
PermissionModel().trigger_permission_flush()
raise HTTPFound(h.route_path("repo_group_home", repo_group_name=form_result["group_name_full"]))
def _create_repo_group_path(self, c, path_input, parent_group_id):
"""
Create a chain of nested repo groups from a slash-separated path.
E.g. "org/team/project" creates org, org/team, org/team/project
(skipping any that already exist).
"""
_ = self.request.translate
owner = self._rhodecode_user
# Resolve parent group prefix
parent_prefix = ""
if parent_group_id and parent_group_id != -1:
parent_group = RepoGroup.get(parent_group_id)
if parent_group:
parent_prefix = parent_group.group_name + "/"
# Split path into segments and slugify each one
segments = [repo_name_slug(s.strip()) for s in path_input.split("/") if s.strip()]
if not segments:
h.flash(_("Invalid group path"), category="error")
raise HTTPFound(h.route_path("repo_group_new"))
copy_permissions = self.request.POST.get("group_copy_permissions") == "True"
description = self.request.POST.get("group_description", "")
created_groups = []
final_group_name = None
try:
for i, segment in enumerate(segments):
full_name = parent_prefix + "/".join(segments[: i + 1])
# Skip if group already exists
existing = RepoGroup.get_by_group_name(full_name)
if existing:
log.debug("Group %s already exists, skipping", full_name)
continue
# Only apply description to the final group
grp_description = description if i == len(segments) - 1 else segment
repo_group = RepoGroupModel().create(
group_name=full_name,
group_description=grp_description,
owner=owner.user_id,
copy_permissions=copy_permissions,
)
Session().flush()
audit_logger.store_web(
"repo_group.create",
action_data={"data": repo_group.get_api_data()},
user=self._rhodecode_user,
)
created_groups.append(full_name)
final_group_name = full_name
Session().commit()
if not final_group_name:
# All groups already existed; redirect to the deepest one
final_group_name = parent_prefix + "/".join(segments)
if len(created_groups) == 1:
msg = _("Created repository group %s") % h.link_to(
created_groups[0],
h.route_path("repo_group_home", repo_group_name=created_groups[0]),
)
elif created_groups:
msg = _("Created %d repository groups: %s") % (
len(created_groups),
", ".join(created_groups),
)
else:
msg = _("All groups already exist")
h.flash(h.literal(msg), category="success")
except Exception:
log.exception("Exception during path-based repository group creation")
h.flash(
_("Error creating repository group path: %s") % path_input,
category="error",
)
raise HTTPFound(h.route_path("home"))
PermissionModel().trigger_permission_flush()
raise HTTPFound(h.route_path("repo_group_home", repo_group_name=final_group_name))

View file

@ -45,6 +45,7 @@ from rhodecode.lib.exceptions import (
)
from rhodecode.lib import ext_json
from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired
from rhodecode.lib.auth_otp import SudoOTPRequired
from rhodecode.lib import helpers as h
from rhodecode.lib.helpers import SqlPage
from rhodecode.lib.utils2 import safe_int, safe_str, AttributeDict
@ -360,6 +361,7 @@ class UsersView(UserAppView):
@LoginRequired()
@HasPermissionAllDecorator("hg.admin")
@CSRFRequired()
@SudoOTPRequired
def user_delete(self):
_ = self.request.translate
c = self.load_default_context()

View file

@ -35,60 +35,61 @@ def includeme(config):
renderer="rhodecode:templates/login.mako",
)
config.add_route(name="login_otp_verify", pattern=ADMIN_PREFIX + "/login/verify")
config.add_view(
LoginView,
attr="login_otp_verify",
route_name="login_otp_verify",
request_method="GET",
renderer="rhodecode:templates/otp_verify.mako",
)
config.add_view(
LoginView,
attr="login_otp_verify_post",
route_name="login_otp_verify",
request_method="POST",
renderer="rhodecode:templates/otp_verify.mako",
)
config.add_route(name="resend_otp", pattern=ADMIN_PREFIX + "/otp/resend")
config.add_view(
LoginView,
attr="resend_otp",
route_name="resend_otp",
request_method="POST",
renderer="json",
)
config.add_route(name="logout", pattern=ADMIN_PREFIX + "/logout")
config.add_view(LoginView, attr="logout", route_name="logout", request_method="POST")
# Register redirects to login (unified flow)
config.add_route(name="register", pattern=ADMIN_PREFIX + "/register")
config.add_view(
LoginView,
attr="register",
route_name="register",
request_method="GET",
renderer="rhodecode:templates/register.mako",
)
config.add_view(
LoginView,
attr="register_post",
attr="register",
route_name="register",
request_method="POST",
renderer="rhodecode:templates/register.mako",
)
config.add_route(name="reset_password", pattern=ADMIN_PREFIX + "/password_reset")
config.add_route(name="sudo_otp_verify", pattern=ADMIN_PREFIX + "/sudo/verify")
config.add_view(
LoginView,
attr="password_reset",
route_name="reset_password",
request_method=("GET", "POST"),
renderer="rhodecode:templates/password_reset.mako",
)
config.add_route(name="reset_password_confirmation", pattern=ADMIN_PREFIX + "/password_reset_confirmation")
config.add_view(
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",
attr="sudo_otp_verify",
route_name="sudo_otp_verify",
request_method="GET",
renderer="rhodecode:templates/verify_2fa.mako",
renderer="rhodecode:templates/sudo_otp_verify.mako",
)
config.add_view(
LoginView,
attr="verify_2fa",
route_name="check_2fa",
attr="sudo_otp_verify_post",
route_name="sudo_otp_verify",
request_method="POST",
renderer="rhodecode:templates/verify_2fa.mako",
renderer="rhodecode:templates/sudo_otp_verify.mako",
)

View file

@ -16,9 +16,10 @@
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
import hashlib
import re
import secrets
import time
import pyotp
import qrcode
import dataclasses
import datetime
import formencode
@ -26,17 +27,12 @@ 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
import rhodecode
from rhodecode.apps._base import BaseAppView
from rhodecode.authentication.base import authenticate, HTTP_TYPE
from rhodecode.authentication.plugins import auth_rhodecode
from rhodecode.events import UserRegistered, trigger
from rhodecode.lib import helpers as h
from rhodecode.lib import audit_logger
@ -44,10 +40,9 @@ from rhodecode.lib.auth import AuthUser, HasPermissionAnyDecorator, CSRFRequired
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, TOTPForm
from rhodecode.model.db import User
from rhodecode.model.forms import LoginForm, OTPVerifyForm
from rhodecode.model.meta import Session
from rhodecode.model.auth_token import AuthTokenModel
from rhodecode.model.settings import SettingsModel
from rhodecode.model.user import UserModel
from rhodecode.translation import _
@ -84,15 +79,8 @@ def store_user_in_session(session, user_identifier, remember=False):
safe_cs["password"] = "****"
log.info("user %s is now authenticated and stored in session, session attrs %s", user_identifier, safe_cs)
# dumps session attrs back to cookie
if hasattr(session, '_update_cookie_out'):
session._update_cookie_out()
# we set new cookie
headers = None
if session.request["set_cookie"]:
# send set-cookie headers back to response to update cookie
headers = [("Set-Cookie", session.request["cookie_out"])]
return headers
# Cookie is set automatically by Pyramid's session response callback
return None
def get_came_from(request):
@ -114,6 +102,21 @@ def get_came_from(request):
return came_from or default_came_from
def _derive_username_from_email(email):
local_part = email.split("@")[0]
# sanitize: keep only alphanumeric, dash, underscore, dot
username = re.sub(r'[^a-zA-Z0-9._-]', '_', local_part).lower()
if not username:
username = 'user'
# ensure unique
base = username
suffix = 0
while User.get_by_username(username):
suffix += 1
username = f'{base}_{suffix}'
return username
class LoginView(BaseAppView):
def load_default_context(self):
c = self._get_local_tmpl_context()
@ -144,6 +147,28 @@ class LoginView(BaseAppView):
return captcha_status, captcha_message
def _send_otp_email(self, email, code, purpose):
from rhodecode.lib.celerylib import tasks, run_task
from rhodecode.model.notification import EmailNotificationModel
import rhodecode
smtp_server = rhodecode.CONFIG.get('smtp_server')
log_code = code if not smtp_server else 'X' * len(code)
log.info('[OTP] code=%s email=%s purpose=%s', log_code, email, purpose)
email_kwargs = {
'otp_code': code,
'purpose': purpose,
}
(subject, email_body, email_body_plaintext) = EmailNotificationModel().render_email(
EmailNotificationModel.TYPE_OTP_CODE, **email_kwargs
)
run_task(tasks.send_email, [email], subject, email_body_plaintext, email_body)
# ---- Login Step 1: Email ----
def login(self):
c = self.load_default_context()
auth_user = self._rhodecode_user
@ -171,46 +196,255 @@ class LoginView(BaseAppView):
login_form = LoginForm(self.request.translate)()
try:
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.check_2fa_required = True
email = form_result["email"]
remember = form_result["remember"]
headers = store_user_in_session(self.session, user_identifier=username, remember=form_result["remember"])
log.debug('Redirecting to "%s" after login.', c.came_from)
# Throttle to prevent enumeration
time.sleep(1)
audit_user = audit_logger.UserWrap(
username=self.request.POST.get("username"), ip_addr=self.request.remote_addr
)
action_data = {"user_agent": self.request.user_agent}
audit_logger.store_web("user.login.success", action_data=action_data, user=audit_user, commit=True)
user = User.get_by_email(email)
if user and user.active:
# Existing active user — generate OTP on user object
code = user.generate_otp("login")
Session().commit()
self._send_otp_email(email, code, "login")
self.session["otp_is_new_user"] = False
elif user and not user.active:
# Inactive user — don't send OTP but still redirect (anti-enumeration)
log.debug("login OTP request for inactive email %s", email)
self.session["otp_is_new_user"] = False
else:
# Unknown email — auto-register if allowed
default_user = User.get_default_user()
perms = default_user.AuthUser().permissions["global"]
registration_allowed = (
"hg.register.auto_activate" in perms
or "hg.register.manual_activate" in perms
)
if registration_allowed:
# Generate session-based OTP for new user registration
code = ''.join(str(secrets.randbelow(10)) for _ in range(6))
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
self.session["otp_hash"] = code_hash
self.session["otp_created_at"] = time.time()
self.session["otp_attempts"] = 0
self.session["otp_is_new_user"] = True
self._send_otp_email(email, code, "login")
else:
log.debug("login OTP request for unknown email %s, registration disabled", email)
self.session["otp_is_new_user"] = False
# Always store in session and redirect (prevent email enumeration)
self.session["otp_email"] = email
self.session["otp_remember"] = remember
self.session["otp_came_from"] = c.came_from
if hasattr(self.session, 'save'):
self.session.save()
raise HTTPFound(self.request.route_path("login_otp_verify"))
raise HTTPFound(c.came_from, headers=headers)
except formencode.Invalid as errors:
defaults = errors.value
# remove password from filling in form again
defaults.pop("password", None)
render_ctx = {
"errors": errors.error_dict,
"defaults": defaults,
}
audit_user = audit_logger.UserWrap(
username=self.request.POST.get("username"), ip_addr=self.request.remote_addr
)
action_data = {"user_agent": self.request.user_agent}
audit_logger.store_web("user.login.failure", action_data=action_data, user=audit_user, commit=True)
return self._get_template_context(c, **render_ctx)
except UserCreationError as e:
# headers auth or other auth functions that create users on
# the fly can throw this exception signaling that there's issue
# with user creation, explanation should be provided in
# Exception itself
h.flash(e, category="error")
return self._get_template_context(c)
# ---- Login Step 2: OTP Verify ----
def login_otp_verify(self):
c = self.load_default_context()
email = self.session.get("otp_email")
if not email:
raise HTTPFound(self.request.route_path("login"))
return self._get_template_context(c,
verify_route="login_otp_verify",
otp_digits=6,
purpose="login",
otp_email=email,
)
def login_otp_verify_post(self):
c = self.load_default_context()
email = self.session.get("otp_email")
is_new_user = self.session.get("otp_is_new_user", False)
log.debug("OTP verify: email=%s is_new_user=%s session_keys=%s",
email, is_new_user, list(self.session.keys()))
if not email:
raise HTTPFound(self.request.route_path("login"))
otp_form = OTPVerifyForm(self.request.translate, digits=6)()
try:
form_result = otp_form.to_python(self.request.POST)
code = form_result["otp"]
if is_new_user:
self._verify_new_user_otp(email, code, form_result)
else:
self._verify_existing_user_otp(email, code, form_result)
except formencode.Invalid as errors:
defaults = errors.value
render_ctx = {
"errors": errors.error_dict,
"defaults": defaults,
"verify_route": "login_otp_verify",
"otp_digits": 6,
"purpose": "login",
"otp_email": email,
}
return self._get_template_context(c, **render_ctx)
def _verify_existing_user_otp(self, email, code, form_result):
user = User.get_by_email(email)
if not user or not user.verify_otp(code, "login"):
Session().commit()
error_msg = _("Invalid or expired code. Please try again.")
raise formencode.Invalid(error_msg, form_result, None,
error_dict={"otp": error_msg})
Session().commit()
remember = self.session.get("otp_remember", False)
came_from = self.session.get("otp_came_from", h.route_path("home"))
# Clear OTP session data
self._clear_otp_session()
headers = store_user_in_session(self.session, user_identifier=email, remember=remember)
audit_user = audit_logger.UserWrap(
username=user.username, ip_addr=self.request.remote_addr
)
action_data = {"user_agent": self.request.user_agent}
audit_logger.store_web("user.login.success", action_data=action_data, user=audit_user, commit=True)
raise HTTPFound(came_from, headers=headers)
def _verify_new_user_otp(self, email, code, form_result):
from rhodecode.authentication.plugins import auth_rhodecode
stored_hash = self.session.get("otp_hash")
created_at = self.session.get("otp_created_at", 0)
attempts = self.session.get("otp_attempts", 0)
if attempts >= 5:
self._clear_otp_session()
h.flash(_("Too many attempts. Please start over."), category="error")
raise HTTPFound(self.request.route_path("login"))
if (time.time() - created_at) > 300:
self._clear_otp_session()
h.flash(_("Code expired. Please start over."), category="error")
raise HTTPFound(self.request.route_path("login"))
self.session["otp_attempts"] = attempts + 1
if hasattr(self.session, 'save'):
self.session.save()
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
if code_hash != stored_hash:
error_msg = _("Invalid code. Please try again.")
raise formencode.Invalid(error_msg, form_result, None,
error_dict={"otp": error_msg})
# OTP verified — create the user account
auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"]
username = _derive_username_from_email(email)
# First real user becomes admin (bootstrap)
system_users = {User.DEFAULT_USER, User.GHOST_USER, User.AI_USER, User.SECURITY_USER}
real_user_count = User.query().filter(~User.username.in_(system_users)).count()
is_first_user = real_user_count == 0
form_data = {
"username": username,
"password": secrets.token_hex(32),
"email": email,
"firstname": "",
"lastname": "",
"active": True if is_first_user else auto_active,
"admin": is_first_user,
}
extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid
extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
new_user = UserModel().create_registration(form_data, extern_name=extern_name, extern_type=extern_type)
action_data = {"data": new_user.get_api_data(), "user_agent": self.request.user_agent}
audit_user = audit_logger.UserWrap(
username=new_user.username, user_id=new_user.user_id, ip_addr=self.request.remote_addr
)
audit_logger.store_web("user.register", action_data=action_data, user=audit_user)
event = UserRegistered(user=new_user, session=self.session)
trigger(event)
Session().commit()
# Clean up OTP session data
self._clear_otp_session()
if is_first_user or auto_active:
headers = store_user_in_session(self.session, user_identifier=email, remember=False)
h.flash(_("Your account has been created. Welcome!"), category="success")
raise HTTPFound(h.route_path("home"), headers=headers)
else:
h.flash(_("Your account has been created and awaits admin approval."), category="success")
raise HTTPFound(self.request.route_path("login"))
def _clear_otp_session(self):
for key in list(self.session.keys()):
if key.startswith("otp_"):
self.session.pop(key, None)
if hasattr(self.session, 'save'):
self.session.save()
# ---- Resend OTP ----
def resend_otp(self):
email = self.session.get("otp_email")
if not email:
return {"status": "error", "message": "No pending verification."}
# Rate limit: one per 60s
last_sent = self.session.get("otp_last_sent", 0)
if time.time() - last_sent < 60:
return {"status": "error", "message": "Please wait before requesting a new code."}
is_new_user = self.session.get("otp_is_new_user", False)
if is_new_user:
# New user: regenerate OTP stored in session
code = ''.join(str(secrets.randbelow(10)) for _ in range(6))
code_hash = hashlib.sha256(code.encode('utf-8')).hexdigest()
self.session["otp_hash"] = code_hash
self.session["otp_created_at"] = time.time()
self.session["otp_attempts"] = 0
else:
# Existing user: regenerate on the user object
user = User.get_by_email(email)
if user and user.active:
code = user.generate_otp("login")
Session().commit()
else:
# Fake success to prevent enumeration
self.session["otp_last_sent"] = time.time()
if hasattr(self.session, 'save'):
self.session.save()
return {"status": "ok", "message": "Code sent."}
self._send_otp_email(email, code, "verification")
self.session["otp_last_sent"] = time.time()
if hasattr(self.session, 'save'):
self.session.save()
return {"status": "ok", "message": "Code sent."}
# ---- Logout ----
@CSRFRequired()
def logout(self):
@ -222,296 +456,51 @@ class LoginView(BaseAppView):
self.session.delete()
return HTTPFound(h.route_path("home"))
@HasPermissionAnyDecorator("hg.admin", "hg.register.auto_activate", "hg.register.manual_activate")
# ---- Registration (redirects to unified login flow) ----
def register(self, defaults=None, errors=None):
raise HTTPFound(self.request.route_path("login"))
# ---- Sudo OTP ----
@LoginRequired()
@NotAnonymous()
def sudo_otp_verify(self):
c = self.load_default_context()
defaults = defaults or {}
errors = errors or {}
return self._get_template_context(c)
settings = SettingsModel().get_all_settings()
register_message = settings.get("rhodecode_register_message") or ""
captcha = self._get_captcha_data()
auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"]
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
def sudo_otp_verify_post(self):
c = self.load_default_context()
user = self._rhodecode_db_user
render_ctx = self._get_template_context(c)
render_ctx.update(
{
"defaults": defaults,
"errors": errors,
"auto_active": auto_active,
"captcha_active": captcha.active,
"captcha_public_key": captcha.public_key,
"register_message": register_message,
}
)
return render_ctx
otp_form = OTPVerifyForm(self.request.translate, digits=8)()
@HasPermissionAnyDecorator("hg.admin", "hg.register.auto_activate", "hg.register.manual_activate")
def register_post(self):
from rhodecode.authentication.plugins import auth_rhodecode
self.load_default_context()
captcha = self._get_captcha_data()
auto_active = "hg.register.auto_activate" in User.get_default_user().AuthUser().permissions["global"]
extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid
extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
register_form = RegisterForm(self.request.translate)()
try:
form_result = register_form.to_python(self.request.POST)
form_result["active"] = auto_active
external_identity = self.request.POST.get("external_identity")
form_result = otp_form.to_python(self.request.POST)
code = form_result["otp"]
if external_identity:
extern_name = external_identity
extern_type = external_identity
if not user.verify_otp(code, "sudo"):
Session().commit()
error_msg = _("Invalid or expired code. Please try again.")
raise formencode.Invalid(error_msg, form_result, None,
error_dict={"otp": error_msg})
if captcha.active:
captcha_status, captcha_message = self.validate_captcha(captcha.private_key)
if not captcha_status:
_value = form_result
_msg = _("Bad captcha")
error_dict = {"recaptcha_field": captcha_message}
raise formencode.Invalid(_msg, _value, None, error_dict=error_dict)
new_user = UserModel().create_registration(form_result, extern_name=extern_name, extern_type=extern_type)
action_data = {"data": new_user.get_api_data(), "user_agent": self.request.user_agent}
if external_identity:
action_data["external_identity"] = external_identity
audit_user = audit_logger.UserWrap(
username=new_user.username, user_id=new_user.user_id, ip_addr=self.request.remote_addr
)
audit_logger.store_web("user.register", action_data=action_data, user=audit_user)
event = UserRegistered(user=new_user, session=self.session)
trigger(event)
h.flash(_("You have successfully registered with RhodeCode. You can log-in now."), category="success")
if external_identity:
h.flash(
_("Please use the {identity} button to log-in").format(identity=external_identity),
category="success",
)
Session().commit()
redirect_ro = self.request.route_path("login")
raise HTTPFound(redirect_ro)
self.session["sudo_otp_verified_at"] = time.time()
next_url = self.session.pop("sudo_next_url", h.route_path("home"))
if hasattr(self.session, 'save'):
self.session.save()
raise HTTPFound(next_url)
except formencode.Invalid as errors:
errors.value.pop("password", None)
errors.value.pop("password_confirmation", None)
return self.register(defaults=errors.value, errors=errors.error_dict)
except UserCreationError as e:
# container auth or other auth functions that create users on
# the fly can throw this exception signaling that there's issue
# with user creation, explanation should be provided in
# Exception itself
h.flash(e, category="error")
return self.register()
def password_reset(self):
c = self.load_default_context()
captcha = self._get_captcha_data()
template_context = {
"captcha_active": captcha.active,
"captcha_public_key": captcha.public_key,
"defaults": {},
"errors": {},
}
# always send implicit message to prevent from discovery of
# matching emails
msg = _("If such email exists, a password reset link was sent to it.")
def default_response():
log.debug("faking response on invalid password reset")
# make this take 2s, to prevent brute forcing.
time.sleep(2)
h.flash(msg, category="success")
return HTTPFound(self.request.route_path("reset_password"))
if self.request.POST:
if h.HasPermissionAny("hg.password_reset.disabled")():
_email = self.request.POST.get("email", "")
log.error("Failed attempt to reset password for `%s`.", _email)
h.flash(_("Password reset has been disabled."), category="error")
return HTTPFound(self.request.route_path("reset_password"))
password_reset_form = PasswordResetForm(self.request.translate)()
description = "Generated token for password reset from {}".format(datetime.datetime.now().isoformat())
try:
form_result = password_reset_form.to_python(self.request.POST)
user_email = form_result["email"]
if captcha.active:
captcha_status, captcha_message = self.validate_captcha(captcha.private_key)
if not captcha_status:
_value = form_result
_msg = _("Bad captcha")
error_dict = {"recaptcha_field": captcha_message}
raise formencode.Invalid(_msg, _value, None, error_dict=error_dict)
# Generate reset URL and send mail.
user = User.get_by_email(user_email)
# only allow rhodecode based users to reset their password
# external auth shouldn't allow password reset
if user and user.extern_type != auth_rhodecode.RhodeCodeAuthPlugin.uid:
log.warning(
"User %s with external type `%s` tried a password reset. This try was rejected",
user,
user.extern_type,
)
return default_response()
# generate password reset token that expires in 10 minutes
reset_token = UserModel().add_auth_token(
user=user,
lifetime_minutes=10,
role=UserModel.auth_token_role.ROLE_PASSWORD_RESET,
description=description,
)
Session().commit()
log.debug("Successfully created password recovery token")
password_reset_url = self.request.route_url(
"reset_password_confirmation", _query={"key": reset_token.api_key}
)
UserModel().reset_password_link(form_result, password_reset_url)
action_data = {"email": user_email, "user_agent": self.request.user_agent}
audit_logger.store_web(
"user.password.reset_request", action_data=action_data, user=self._rhodecode_user, commit=True
)
return default_response()
except formencode.Invalid as errors:
template_context.update(
{
"defaults": errors.value,
"errors": errors.error_dict,
}
)
if not self.request.POST.get("email"):
# case of empty email, we want to report that
return self._get_template_context(c, **template_context)
if "recaptcha_field" in errors.error_dict:
# case of failed captcha
return self._get_template_context(c, **template_context)
return default_response()
return self._get_template_context(c, **template_context)
def password_reset_confirmation(self):
self.load_default_context()
if key := self.request.GET.get("key"):
# make this take 2s, to prevent brute forcing.
time.sleep(2)
token = AuthTokenModel().get_auth_token(key)
# verify token is the correct role
if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
log.debug(
"Got token with role:%s expected is %s",
getattr(token, "role", "EMPTY_TOKEN"),
UserApiKeys.ROLE_PASSWORD_RESET,
)
h.flash(_("Given reset token is invalid"), category="error")
return HTTPFound(self.request.route_path("reset_password"))
try:
owner = token.user
data = {"email": owner.email, "token": token.api_key}
UserModel().reset_password(data)
h.flash(
_("Your password reset was successful, a new password has been sent to your email"),
category="success",
)
except Exception as e:
log.error(e)
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":
post_items = dict(self.request.POST)
try:
form_details = form.to_python(post_items)
secret = form_details["secret_totp"]
user_instance.init_2fa_recovery_codes(persist=True, force=True)
user_instance.secret_2fa = secret
Session().commit()
raise HTTPFound(self.request.route_path("my_account_configure_2fa", _query={"show-recovery-codes": 1}))
except formencode.Invalid as errors:
defaults = errors.value
render_ctx = {
"errors": errors.error_dict,
"defaults": defaults,
}
# NOTE: here we DO NOT persist the secret 2FA, since this is only for setup, once a setup is completed
# only then we should persist it
secret = user_instance.init_secret_2fa(persist=False)
instance_name = rhodecode.ConfigGet().get_str("app.base_url", "rhodecode")
totp_name = f"{instance_name}:{self.request.user.username}"
qr = qrcode.QRCode(version=1, box_size=5, border=4)
qr.add_data(pyotp.totp.TOTP(secret).provisioning_uri(name=totp_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, totp_name=totp_name, **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":
post_items = dict(self.request.POST)
# NOTE: inject secret, as it's a post configured saved item.
post_items["secret_totp"] = user_instance.secret_2fa
try:
totp_form.to_python(post_items)
user_instance.check_2fa_required = 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)
defaults = errors.value
render_ctx = {
"errors": errors.error_dict,
"defaults": defaults,
}
return self._get_template_context(c, **render_ctx)

View file

@ -58,7 +58,6 @@ from rhodecode.model.db import (
RepoGroup,
ChangesetStatus,
)
from rhodecode.model.forms import TOTPForm
from rhodecode.model.meta import Session
from rhodecode.model.pull_request import PullRequestModel
from rhodecode.model.user import UserModel
@ -257,26 +256,8 @@ class MyAccountView(BaseAppView, DataGridAppView):
@CSRFRequired()
def my_account_2fa_regenerate_recovery_codes(self):
_ = self.request.translate
c = self.load_default_context()
user_instance = c.auth_user.get_instance()
totp_form = TOTPForm(_, user_instance, allow_recovery_code_use=True)()
post_items = dict(self.request.POST)
# NOTE: inject secret, as it's a post configured saved item.
post_items["secret_totp"] = user_instance.secret_2fa
try:
totp_form.to_python(post_items)
user_instance.regenerate_2fa_recovery_codes()
Session().commit()
except formencode.Invalid as errors:
h.flash(_("Failed to generate new recovery codes: {}").format(errors), category="error")
raise HTTPFound(self.request.route_path("my_account_configure_2fa"))
except Exception as e:
h.flash(_("Failed to generate new recovery codes: {}").format(e), category="error")
raise HTTPFound(self.request.route_path("my_account_configure_2fa"))
raise HTTPFound(self.request.route_path("my_account_configure_2fa", _query={"show-recovery-codes": 1}))
h.flash(_("2FA recovery codes are no longer used."), category="warning")
raise HTTPFound(self.request.route_path("my_account_configure_2fa"))
@LoginRequired()
@NotAnonymous()

View file

@ -42,6 +42,7 @@ from rhodecode.lib.auth import (
NotAnonymous,
CSRFRequired,
)
from rhodecode.lib.auth_otp import SudoOTPRequired
from rhodecode.lib.utils2 import str2bool, safe_str, safe_int, aslist, retry
from rhodecode.lib.vcs.backends.base import EmptyCommit, UpdateFailureReason, unicode_to_reference
from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError, RepositoryRequirementError, EmptyRepositoryError
@ -1678,6 +1679,7 @@ class RepoPullRequestsView(RepoAppView, DataGridAppView):
@NotAnonymous()
@HasRepoPermissionAnyDecorator("repository.read", "repository.write", "repository.admin")
@CSRFRequired()
@SudoOTPRequired
def pull_request_merge(self):
"""
Merge will perform a server-side merge of the specified

View file

@ -26,6 +26,7 @@ from rhodecode.apps._base import RepoAppView
from rhodecode.lib import helpers as h
from rhodecode.lib import audit_logger
from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired, HasRepoPermissionAny
from rhodecode.lib.auth_otp import SudoOTPRequired
from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError, AttachedArtifactsError
from rhodecode.lib.type_utils import str2bool
from rhodecode.lib.utils2 import safe_int
@ -142,6 +143,7 @@ class RepoSettingsAdvancedView(RepoAppView):
@LoginRequired()
@HasRepoPermissionAnyDecorator("repository.admin")
@CSRFRequired()
@SudoOTPRequired
def edit_advanced_delete(self):
"""
Deletes the repository, or shows warnings if deletion is not possible

View file

@ -30,7 +30,7 @@ import click
from rhodecode.lib.pyramid_utils import bootstrap
from rhodecode.lib.statsd_client import StatsdClient
from rhodecode.config.patches import suppress_pkg_resources_warnings
from .backends import SshWrapper
from .utils import setup_custom_logging
@ -53,9 +53,6 @@ log = logging.getLogger(__name__)
@click.option("--shell", "-s", is_flag=True, help="Allow Shell")
@click.option("--debug", is_flag=True, help="Enabled detailed output logging")
def main(ini_path, mode, user, user_id, key_id, shell, debug):
# Apply pkg_resources deprecation warning suppression early
suppress_pkg_resources_warnings()
setup_custom_logging(ini_path, debug)
command = os.environ.get("SSH_ORIGINAL_COMMAND", "")

View file

@ -37,7 +37,7 @@ import logging
import click
from rhodecode.config.config_maker import sanitize_settings_and_apply_defaults
from rhodecode.config.patches import suppress_pkg_resources_warnings
from rhodecode.lib.request import Request
from rhodecode.lib.utils2 import AttributeDict
from rhodecode.lib.statsd_client import StatsdClient
@ -65,9 +65,6 @@ log = logging.getLogger(__name__)
@click.option("--shell", "-s", is_flag=True, help="Allow Shell")
@click.option("--debug", is_flag=True, help="Enabled detailed output logging")
def main(ini_path, mode, user, user_id, key_id, shell, debug):
# Apply pkg_resources deprecation warning suppression early
suppress_pkg_resources_warnings()
time_start = time.time()
setup_custom_logging(ini_path, debug)