Задача 037: двухфакторная аутентификация TOTP по выбору пользователя
Пользователь сам включает TOTP (QR, приложение-аутентификатор); 10 одноразовых кодов восстановления; superadmin сбрасывает 2FA другому пользователю. Вход в два шага: /auth/login → mfa_token, /auth/login/2fa — под теми же лимитами и advisory-lock, что пароль. Секрет шифруется Fernet-ключом TOTP_ENC_KEY, защита от повторного использования кода (totp_last_step), лимиты перебора кода при отключении. Миграция 0015, зависимости pyotp, segno, cryptography. UI: второй шаг входа, диалоги 2FA в меню логина, бейдж и сброс в «Пользователях»; submitDialog не закрывает окно, открытое следующим шагом цепочки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c852f47f09
commit
d3495fb077
18 files changed
+835
-56
No files matched your search
+90
-32
@@ -9,11 +9,13 @@ from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import totp
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import KnownLogin, LoginAttempt, User
|
||||
from app.request_context import request_meta
|
||||
from app.schemas import LoginIn, TokenOut, UserOut
|
||||
from app.security import create_token, current_user, hash_password, verify_password
|
||||
from app.schemas import LoginIn, MfaLoginIn, TokenOut, UserOut
|
||||
from app.security import create_mfa_token, create_token, current_user, decode_mfa_token, hash_password, verify_password
|
||||
from app.services import ANONYMOUS, KNOWN_IP_DAYS, LOGIN_WINDOW, MAX_PER_IP, MAX_PER_LOGIN, MAX_PER_LOGIN_IP, audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -80,19 +82,57 @@ def _locked(retry: int) -> HTTPException:
|
||||
headers={"Retry-After": str(retry)})
|
||||
|
||||
|
||||
def _lock_pair(db: Session, name: str, ip: str | None) -> None:
|
||||
"""Сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
|
||||
порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
|
||||
разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
|
||||
параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
|
||||
и при массовом входе время ответа растёт на время проверки argon2/TOTP."""
|
||||
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
|
||||
if ip is not None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip))))
|
||||
|
||||
|
||||
def _record_failure(db: Session, name: str, ip: str | None, known: bool, label: str, action: str, message: str) -> int:
|
||||
"""Общий учёт неудачной попытки (пароль или второй фактор, изменение 037): та же запись в LoginAttempt и
|
||||
тот же выбор события в журнал (`session.locked`, если попытка впервые пересекла лимит; иначе `session.<action>`,
|
||||
только первая неудача по этому логину в окне). Возвращает секунды до конца блокировки (0 — не заблокирован)."""
|
||||
_, _, before = _retry_after(db, name, ip, known)
|
||||
db.add(LoginAttempt(client_ip=ip, username=name))
|
||||
db.flush()
|
||||
retry, why, after = _retry_after(db, name, ip, known)
|
||||
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
|
||||
attempts = after[why]
|
||||
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
|
||||
if why == "ip":
|
||||
diff["distinct_logins"] = _distinct_logins(db, ip)
|
||||
scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why]
|
||||
audit(db, ANONYMOUS, "session", None, "locked", label, diff,
|
||||
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
|
||||
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
|
||||
audit(db, ANONYMOUS, "session", None, action, label, message=message)
|
||||
db.commit()
|
||||
return retry
|
||||
|
||||
|
||||
def _complete_login(db: Session, user: User, ip: str | None, name: str, diff: dict | None = None) -> TokenOut:
|
||||
"""Общее завершение успешного входа — паролем (без 2FA) или вторым фактором (изменение 037): очистка попыток,
|
||||
отметка известного IP, `last_login_*`, запись `session.login` в журнал, выдача токена доступа."""
|
||||
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||
_remember_login(db, name, ip)
|
||||
user.last_login_at = func.now() # изменение 035
|
||||
user.last_login_ip = ip # изменение 035
|
||||
audit(db, user, "session", None, "login", user.username, diff, message=f"Вход в UI: {user.username}")
|
||||
db.commit()
|
||||
return TokenOut(access_token=create_token(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
def login(body: LoginIn, db: Session = Depends(get_db)):
|
||||
ctx = request_meta.get()
|
||||
ip = ctx["client_ip"] if ctx else None
|
||||
name = body.username.strip().lower()
|
||||
# сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
|
||||
# порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
|
||||
# разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
|
||||
# параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
|
||||
# и при массовом входе время ответа растёт на время проверки argon2.
|
||||
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
|
||||
if ip is not None:
|
||||
db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip))))
|
||||
_lock_pair(db, name, ip)
|
||||
known = _is_known(db, name, ip)
|
||||
retry, _, _ = _retry_after(db, name, ip, known)
|
||||
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
|
||||
@@ -101,31 +141,49 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
|
||||
if user is None:
|
||||
verify_password(body.password, _DUMMY_HASH)
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
_, _, before = _retry_after(db, name, ip, known)
|
||||
db.add(LoginAttempt(client_ip=ip, username=name))
|
||||
db.flush()
|
||||
retry, why, after = _retry_after(db, name, ip, known)
|
||||
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
|
||||
attempts = after[why]
|
||||
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
|
||||
if why == "ip":
|
||||
diff["distinct_logins"] = _distinct_logins(db, ip)
|
||||
scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why]
|
||||
audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff,
|
||||
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
|
||||
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
|
||||
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
|
||||
db.commit()
|
||||
retry = _record_failure(db, name, ip, known, body.username[:100], "failed", "Неудачная попытка входа в UI")
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
raise HTTPException(401, "Неверный логин или пароль")
|
||||
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||
_remember_login(db, name, ip)
|
||||
user.last_login_at = func.now() # изменение 035
|
||||
user.last_login_ip = ip # изменение 035
|
||||
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
|
||||
db.commit()
|
||||
return TokenOut(access_token=create_token(user))
|
||||
if user.totp_enabled: # изменение 037: второй фактор — токен доступа не выдаётся, попытки/last_login_* не трогаем
|
||||
if not settings.totp_enc_key:
|
||||
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
|
||||
return TokenOut(mfa_required=True, mfa_token=create_mfa_token(user))
|
||||
return _complete_login(db, user, ip, name)
|
||||
|
||||
|
||||
@router.post("/login/2fa", response_model=TokenOut)
|
||||
def login_2fa(body: MfaLoginIn, db: Session = Depends(get_db)):
|
||||
"""Второй шаг входа (изменение 037): код TOTP либо код восстановления. Те же лимиты и advisory-lock, что у пароля —
|
||||
неверный код тоже считается неудачной попыткой входа этого логина."""
|
||||
ctx = request_meta.get()
|
||||
ip = ctx["client_ip"] if ctx else None
|
||||
user = decode_mfa_token(body.mfa_token, db)
|
||||
name = user.username.lower()
|
||||
_lock_pair(db, name, ip)
|
||||
# изменение 037: user был загружен ДО advisory-lock — под конкурентным запросом с тем же кодом это устаревший
|
||||
# снимок (totp_last_step/totp_enabled ещё не видят чужой commit). После получения блокировки перечитываем
|
||||
# пользователя из БД, иначе два параллельных запроса с одинаковым кодом оба могут пройти проверку (гонка).
|
||||
db.refresh(user)
|
||||
known = _is_known(db, name, ip)
|
||||
retry, _, _ = _retry_after(db, name, ip, known)
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
if not user.totp_enabled: # 2FA отключена между шагами входа — токен больше не действителен
|
||||
raise HTTPException(401, "Недействительный токен")
|
||||
if not settings.totp_enc_key:
|
||||
raise HTTPException(503, "2FA не настроена на сервере (TOTP_ENC_KEY)")
|
||||
step = totp.verify(user, body.code)
|
||||
if step is not None:
|
||||
user.totp_last_step = step
|
||||
return _complete_login(db, user, ip, name)
|
||||
remaining = totp.use_recovery_code(db, user, body.code)
|
||||
if remaining is not None:
|
||||
return _complete_login(db, user, ip, name, diff={"recovery_code": True, "recovery_codes_left": remaining})
|
||||
retry = _record_failure(db, name, ip, known, user.username, "2fa_failed", "Неверный код 2FA при входе в UI")
|
||||
if retry:
|
||||
raise _locked(retry)
|
||||
raise HTTPException(401, "Неверный код")
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
|
||||
Reference in new issue
Block a user