access denied fix
This commit is contained in:
@@ -14,7 +14,7 @@ from flask import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui
|
from ..config import get_dev_fio_password, is_assignment_feature_enabled, is_dev_ui, is_hr_auth_enabled
|
||||||
from ..messages import RU
|
from ..messages import RU
|
||||||
from .decorators import login_required, current_user
|
from .decorators import login_required, current_user
|
||||||
from .services import AuthError, authenticate_credentials
|
from .services import AuthError, authenticate_credentials
|
||||||
@@ -50,6 +50,7 @@ def login_page():
|
|||||||
'auth/login.html',
|
'auth/login.html',
|
||||||
next=_safe_next('/'),
|
next=_safe_next('/'),
|
||||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||||
|
hr_auth_enabled=is_hr_auth_enabled(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ def login_submit():
|
|||||||
next=_safe_next('/'),
|
next=_safe_next('/'),
|
||||||
login=login,
|
login=login,
|
||||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||||
|
hr_auth_enabled=is_hr_auth_enabled(),
|
||||||
), e.status
|
), e.status
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception('login_submit failed')
|
log.exception('login_submit failed')
|
||||||
@@ -75,6 +77,7 @@ def login_submit():
|
|||||||
next=_safe_next('/'),
|
next=_safe_next('/'),
|
||||||
login=login,
|
login=login,
|
||||||
dev_fio_enabled=bool(get_dev_fio_password()),
|
dev_fio_enabled=bool(get_dev_fio_password()),
|
||||||
|
hr_auth_enabled=is_hr_auth_enabled(),
|
||||||
), 500
|
), 500
|
||||||
return redirect(_safe_next('/'))
|
return redirect(_safe_next('/'))
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,39 @@ def _normalize_fio(s: str) -> str:
|
|||||||
return re.sub(r'\s+', ' ', t).strip()
|
return re.sub(r'\s+', ' ', t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
_FIO_STAFF_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT id, fio, web_login
|
||||||
|
FROM staff_members
|
||||||
|
WHERE fio IS NOT NULL
|
||||||
|
AND lower(
|
||||||
|
regexp_replace(
|
||||||
|
trim(replace(replace(coalesce(fio, ''), chr(160), ' '), E'\\t', ' ')),
|
||||||
|
'[[:space:]]+', ' ', 'g'
|
||||||
|
)
|
||||||
|
) = :needle
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _staff_rows_by_normalized_fio(hr_conn, fio_raw: str) -> list:
|
||||||
|
"""Строки staff_members с тем же ФИО, что и ввод, после нормализации (регистр не важен)."""
|
||||||
|
fio_needle = _normalize_fio(fio_raw)
|
||||||
|
if not fio_needle:
|
||||||
|
return []
|
||||||
|
needle = fio_needle.lower()
|
||||||
|
return list(hr_conn.execute(_FIO_STAFF_SQL, {'needle': needle}).mappings().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _single_web_login_from_fio(hr_conn, fio_raw: str) -> str | None:
|
||||||
|
"""Если по ФИО найден ровно один сотрудник — его web_login для входа в HR; иначе None."""
|
||||||
|
rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw)
|
||||||
|
if len(rows) != 1:
|
||||||
|
return None
|
||||||
|
wl = (rows[0]['web_login'] or '').strip()
|
||||||
|
return wl or None
|
||||||
|
|
||||||
|
|
||||||
def authenticate_credentials(login: str, password: str) -> AuthUser:
|
def authenticate_credentials(login: str, password: str) -> AuthUser:
|
||||||
login = (login or '').strip()
|
login = (login or '').strip()
|
||||||
password = password or ''
|
password = password or ''
|
||||||
@@ -122,25 +155,8 @@ def _authenticate_dev_fio(fio_raw: str) -> AuthUser:
|
|||||||
if not fio_needle:
|
if not fio_needle:
|
||||||
raise AuthError(401, RU['invalidCredentials'])
|
raise AuthError(401, RU['invalidCredentials'])
|
||||||
|
|
||||||
needle = fio_needle.lower()
|
|
||||||
|
|
||||||
with hr_eng.connect() as hr_conn:
|
with hr_eng.connect() as hr_conn:
|
||||||
rows = hr_conn.execute(
|
rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw)
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT id, fio, web_login
|
|
||||||
FROM staff_members
|
|
||||||
WHERE fio IS NOT NULL
|
|
||||||
AND lower(
|
|
||||||
regexp_replace(
|
|
||||||
trim(replace(replace(coalesce(fio, ''), chr(160), ' '), E'\\t', ' ')),
|
|
||||||
'[[:space:]]+', ' ', 'g'
|
|
||||||
)
|
|
||||||
) = :needle
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{'needle': needle},
|
|
||||||
).mappings().all()
|
|
||||||
if len(rows) != 1:
|
if len(rows) != 1:
|
||||||
raise AuthError(401, RU['invalidCredentials'])
|
raise AuthError(401, RU['invalidCredentials'])
|
||||||
s = rows[0]
|
s = rows[0]
|
||||||
@@ -208,41 +224,51 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser:
|
|||||||
if hr_eng is None:
|
if hr_eng is None:
|
||||||
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
raise AuthError(500, RU['hrDatabaseUrlMissing'])
|
||||||
|
|
||||||
|
user_row_sql = text(
|
||||||
|
'SELECT id, username, password_hash, role FROM users '
|
||||||
|
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))'
|
||||||
|
)
|
||||||
|
|
||||||
with hr_eng.connect() as hr_conn:
|
with hr_eng.connect() as hr_conn:
|
||||||
u = hr_conn.execute(
|
u = hr_conn.execute(user_row_sql, {'login': login}).mappings().first()
|
||||||
text(
|
if not u:
|
||||||
'SELECT id, username, password_hash, role FROM users '
|
wl = _single_web_login_from_fio(hr_conn, login)
|
||||||
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))'
|
if wl:
|
||||||
),
|
u = hr_conn.execute(user_row_sql, {'login': wl}).mappings().first()
|
||||||
{'login': login},
|
|
||||||
).mappings().first()
|
|
||||||
if not u or not u['password_hash']:
|
if not u or not u['password_hash']:
|
||||||
raise AuthError(401, RU['invalidCredentials'])
|
raise AuthError(401, RU['invalidCredentials'])
|
||||||
if not _verify_password(password, u['password_hash']):
|
if not _verify_password(password, u['password_hash']):
|
||||||
raise AuthError(401, RU['invalidCredentials'])
|
raise AuthError(401, RU['invalidCredentials'])
|
||||||
|
|
||||||
|
hr_username = (u['username'] or '').strip()
|
||||||
|
if not hr_username:
|
||||||
|
raise AuthError(401, RU['invalidCredentials'])
|
||||||
|
|
||||||
s = hr_conn.execute(
|
s = hr_conn.execute(
|
||||||
text(
|
text(
|
||||||
'SELECT id, fio FROM staff_members '
|
'SELECT id, fio FROM staff_members '
|
||||||
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))"
|
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))"
|
||||||
),
|
),
|
||||||
{'login': login},
|
{'login': hr_username},
|
||||||
).mappings().first()
|
).mappings().first()
|
||||||
if not s:
|
if not s:
|
||||||
raise AuthError(403, RU['noStaffForLogin'])
|
raise AuthError(403, RU['noStaffForLogin'])
|
||||||
|
|
||||||
staff_id = int(s['id'])
|
staff_id = int(s['id'])
|
||||||
fio = s['fio'] or login
|
fio = s['fio'] or hr_username
|
||||||
app_role = map_hr_role_to_app(u['role'])
|
app_role = map_hr_role_to_app(u['role'])
|
||||||
|
canonical_login = hr_username
|
||||||
|
|
||||||
# UPSERT через ORM: ищем по staff_id, затем по login, затем создаём
|
# UPSERT через ORM: ищем по staff_id, затем по логину HR, затем по вводу (ФИО/старый логин)
|
||||||
session = get_session()
|
session = get_session()
|
||||||
user = session.query(User).filter(User.staff_id == staff_id).first()
|
user = session.query(User).filter(User.staff_id == staff_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
# при первом входе staff_id ещё не проставлен — ищем по login
|
user = session.query(User).filter(User.login == canonical_login).first()
|
||||||
|
if not user:
|
||||||
user = session.query(User).filter(User.login == login).first()
|
user = session.query(User).filter(User.login == login).first()
|
||||||
if user:
|
if user:
|
||||||
user.login = login
|
user.login = canonical_login
|
||||||
user.full_name = fio
|
user.full_name = fio
|
||||||
user.role = app_role
|
user.role = app_role
|
||||||
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
|
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
|
||||||
@@ -250,7 +276,7 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser:
|
|||||||
user.is_active = True
|
user.is_active = True
|
||||||
else:
|
else:
|
||||||
user = User(
|
user = User(
|
||||||
login=login,
|
login=canonical_login,
|
||||||
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
|
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||||
full_name=fio,
|
full_name=fio,
|
||||||
role=app_role,
|
role=app_role,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
|
|||||||
if is_test_author(test.created_by, uid):
|
if is_test_author(test.created_by, uid):
|
||||||
return AccessResult(ok=True, is_author=True, not_found=False)
|
return AccessResult(ok=True, is_author=True, not_found=False)
|
||||||
|
|
||||||
|
# Глобальная политика доступа: любой активный тест доступен всем сотрудникам.
|
||||||
|
if bool(test.is_active):
|
||||||
|
return AccessResult(ok=True, is_author=False, not_found=False)
|
||||||
|
|
||||||
assigned = session.query(
|
assigned = session.query(
|
||||||
exists().where(
|
exists().where(
|
||||||
TestAssignmentTarget.target_type == 'user',
|
TestAssignmentTarget.target_type == 'user',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block title %}Вход — Тестирование{% endblock %}
|
{% block title %}Вход — Тестирование{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set fio_as_login = dev_fio_enabled or hr_auth_enabled %}
|
||||||
{% if ui_variant == 'legacy' %}
|
{% if ui_variant == 'legacy' %}
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-shell">
|
<div class="login-shell">
|
||||||
@@ -26,17 +27,21 @@
|
|||||||
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
||||||
</p>
|
</p>
|
||||||
|
{% elif hr_auth_enabled %}
|
||||||
|
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
|
||||||
|
Можно ввести <b>логин</b> из HR или <b>ФИО</b> (как в кадровой системе), если совпадение одно, и пароль учётной записи HR.
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
|
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
|
||||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label" for="login-username">
|
<label class="form-label" for="login-username">
|
||||||
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
|
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<input id="login-username" class="form-input" type="text" name="login"
|
<input id="login-username" class="form-input" type="text" name="login"
|
||||||
value="{{ login or '' }}" required autofocus autocomplete="username"
|
value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||||
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}" />
|
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@@ -61,7 +66,7 @@
|
|||||||
{% if dev_fio_enabled %}
|
{% if dev_fio_enabled %}
|
||||||
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
|
||||||
{% elif hr_auth_enabled %}
|
{% elif hr_auth_enabled %}
|
||||||
Учётка кадровой системы (HR).
|
Учётная запись HR: можно ввести <b>логин</b> или <b>ФИО</b> (если в базе только один такой сотрудник), и пароль.
|
||||||
{% else %}
|
{% else %}
|
||||||
Используйте логин и пароль.
|
Используйте логин и пароль.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -86,10 +91,10 @@
|
|||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm font-medium text-ink-700">
|
<span class="text-sm font-medium text-ink-700">
|
||||||
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
|
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
<input type="text" name="login" value="{{ login or '' }}" required autofocus autocomplete="username"
|
||||||
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}"
|
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}"
|
||||||
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
class="mt-1 w-full rounded-lg border border-ink-300 bg-white px-3 py-2 text-ink-900
|
||||||
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user