Browse Source

access denied fix

dev
Константин Лебединский 2 weeks ago
parent
commit
ebb58d4b5a
  1. 5
      flask_app/app/auth/routes.py
  2. 86
      flask_app/app/auth/services.py
  3. 4
      flask_app/app/services/test_access.py
  4. 15
      flask_app/app/templates/auth/login.html

5
flask_app/app/auth/routes.py

@ -14,7 +14,7 @@ from flask import (
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 .decorators import login_required, current_user
from .services import AuthError, authenticate_credentials
@ -50,6 +50,7 @@ def login_page():
'auth/login.html',
next=_safe_next('/'),
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('/'),
login=login,
dev_fio_enabled=bool(get_dev_fio_password()),
hr_auth_enabled=is_hr_auth_enabled(),
), e.status
except Exception:
log.exception('login_submit failed')
@ -75,6 +77,7 @@ def login_submit():
next=_safe_next('/'),
login=login,
dev_fio_enabled=bool(get_dev_fio_password()),
hr_auth_enabled=is_hr_auth_enabled(),
), 500
return redirect(_safe_next('/'))

86
flask_app/app/auth/services.py

@ -92,6 +92,39 @@ def _normalize_fio(s: str) -> str:
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:
login = (login or '').strip()
password = password or ''
@ -122,25 +155,8 @@ def _authenticate_dev_fio(fio_raw: str) -> AuthUser:
if not fio_needle:
raise AuthError(401, RU['invalidCredentials'])
needle = fio_needle.lower()
with hr_eng.connect() as hr_conn:
rows = hr_conn.execute(
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()
rows = _staff_rows_by_normalized_fio(hr_conn, fio_raw)
if len(rows) != 1:
raise AuthError(401, RU['invalidCredentials'])
s = rows[0]
@ -208,41 +224,51 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser:
if hr_eng is None:
raise AuthError(500, RU['hrDatabaseUrlMissing'])
with hr_eng.connect() as hr_conn:
u = hr_conn.execute(
text(
user_row_sql = text(
'SELECT id, username, password_hash, role FROM users '
'WHERE LOWER(TRIM(username)) = LOWER(TRIM(:login))'
),
{'login': login},
).mappings().first()
)
with hr_eng.connect() as hr_conn:
u = hr_conn.execute(user_row_sql, {'login': login}).mappings().first()
if not u:
wl = _single_web_login_from_fio(hr_conn, login)
if wl:
u = hr_conn.execute(user_row_sql, {'login': wl}).mappings().first()
if not u or not u['password_hash']:
raise AuthError(401, RU['invalidCredentials'])
if not _verify_password(password, u['password_hash']):
raise AuthError(401, RU['invalidCredentials'])
hr_username = (u['username'] or '').strip()
if not hr_username:
raise AuthError(401, RU['invalidCredentials'])
s = hr_conn.execute(
text(
'SELECT id, fio FROM staff_members '
"WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM(:login))"
),
{'login': login},
{'login': hr_username},
).mappings().first()
if not s:
raise AuthError(403, RU['noStaffForLogin'])
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'])
canonical_login = hr_username
# UPSERT через ORM: ищем по staff_id, затем по login, затем создаём
# UPSERT через ORM: ищем по staff_id, затем по логину HR, затем по вводу (ФИО/старый логин)
session = get_session()
user = session.query(User).filter(User.staff_id == staff_id).first()
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()
if user:
user.login = login
user.login = canonical_login
user.full_name = fio
user.role = app_role
user.password_hash = HR_MANAGED_PASSWORD_PLACEHOLDER
@ -250,7 +276,7 @@ def _authenticate_via_hr(login: str, password: str) -> AuthUser:
user.is_active = True
else:
user = User(
login=login,
login=canonical_login,
password_hash=HR_MANAGED_PASSWORD_PLACEHOLDER,
full_name=fio,
role=app_role,

4
flask_app/app/services/test_access.py

@ -38,6 +38,10 @@ def user_has_test_access(user_id: str, test_id: str) -> AccessResult:
if is_test_author(test.created_by, uid):
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(
exists().where(
TestAssignmentTarget.target_type == 'user',

15
flask_app/app/templates/auth/login.html

@ -2,6 +2,7 @@
{% block title %}Вход — Тестирование{% endblock %}
{% block content %}
{% set fio_as_login = dev_fio_enabled or hr_auth_enabled %}
{% if ui_variant == 'legacy' %}
<div class="login-page">
<div class="login-shell">
@ -26,17 +27,21 @@
<p style="font-size:0.8rem; color:#4b7b78; margin-bottom:0.75rem; line-height:1.4;">
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
</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 %}
<form method="post" action="{{ url_for('auth.login_submit') }}" novalidate>
<input type="hidden" name="next" value="{{ next or '/' }}">
<div class="form-field">
<label class="form-label" for="login-username">
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
</label>
<input id="login-username" class="form-input" type="text" name="login"
value="{{ login or '' }}" required autofocus autocomplete="username"
placeholder="{% if dev_fio_enabled %}Иванов Иван Иванович{% endif %}" />
placeholder="{% if fio_as_login %}Иванов Иван Иванович{% endif %}" />
</div>
<div class="form-field">
@ -61,7 +66,7 @@
{% if dev_fio_enabled %}
Введите <b>ФИО</b> из кадровой системы и общий dev-пароль — или обычный логин/пароль.
{% elif hr_auth_enabled %}
Учётка кадровой системы (HR).
Учётная запись HR: можно ввести <b>логин</b> или <b>ФИО</b> (если в базе только один такой сотрудник), и пароль.
{% else %}
Используйте логин и пароль.
{% endif %}
@ -86,10 +91,10 @@
<label class="block">
<span class="text-sm font-medium text-ink-700">
{% if dev_fio_enabled %}ФИО или логин{% else %}Логин{% endif %}
{% if fio_as_login %}ФИО или логин{% else %}Логин{% endif %}
</span>
<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
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" />
</label>

Loading…
Cancel
Save