Files
security-alert-center/backend/app/services/mobile_enrollment.py
T

297 lines
9.6 KiB
Python

"""Создание и проверка кодов регистрации Seaca."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.mobile_device import MobileDevice
from app.models.mobile_enrollment_code import MobileEnrollmentCode
from app.models.mobile_settings import LOGIN_MODE_CODE_ONLY, LOGIN_MODE_PASSWORD, LOGIN_MODES
from app.models.user import User
from app.services.mobile_settings import get_effective_mobile_settings
from app.services.mobile_tokens import (
generate_enrollment_code,
hash_mobile_secret,
revoke_refresh_tokens_for_device,
store_refresh_token,
)
from app.services.user_auth import authenticate_user
@dataclass(frozen=True)
class EnrollmentCodeSummary:
id: int
label: str
code_prefix: str
target_user_id: int | None
target_username: str | None
login_mode: str
max_uses: int
use_count: int
expires_at: datetime
revoked_at: datetime | None
created_at: datetime
is_active: bool
@dataclass(frozen=True)
class EnrollResult:
access_token: str
refresh_token: str
device_id: int
username: str
role: str
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _code_is_usable(row: MobileEnrollmentCode, *, now: datetime) -> bool:
if row.revoked_at is not None:
return False
if _as_utc(row.expires_at) <= now:
return False
if row.use_count >= row.max_uses:
return False
return True
def list_enrollment_codes(db: Session) -> list[EnrollmentCodeSummary]:
rows = db.scalars(
select(MobileEnrollmentCode).order_by(MobileEnrollmentCode.created_at.desc())
).all()
usernames: dict[int, str] = {}
user_ids = {r.target_user_id for r in rows if r.target_user_id}
if user_ids:
for user in db.scalars(select(User).where(User.id.in_(user_ids))).all():
usernames[user.id] = user.username
now = datetime.now(timezone.utc)
return [
EnrollmentCodeSummary(
id=row.id,
label=row.label or "",
code_prefix=row.code_prefix,
target_user_id=row.target_user_id,
target_username=usernames.get(row.target_user_id) if row.target_user_id else None,
login_mode=row.login_mode,
max_uses=row.max_uses,
use_count=row.use_count,
expires_at=row.expires_at,
revoked_at=row.revoked_at,
created_at=row.created_at,
is_active=_code_is_usable(row, now=now),
)
for row in rows
]
def create_enrollment_code(
db: Session,
*,
created_by_user_id: int | None,
label: str,
target_user_id: int | None,
login_mode: str,
max_uses: int,
expires_in_hours: int,
) -> tuple[EnrollmentCodeSummary, str]:
if login_mode not in LOGIN_MODES:
raise ValueError(f"login_mode must be one of: {sorted(LOGIN_MODES)}")
if login_mode == LOGIN_MODE_CODE_ONLY and target_user_id is None:
raise ValueError("code_only requires target_user_id")
if max_uses < 1 or max_uses > 100:
raise ValueError("max_uses must be between 1 and 100")
if expires_in_hours < 1 or expires_in_hours > 24 * 30:
raise ValueError("expires_in_hours must be between 1 and 720")
if target_user_id is not None:
user = db.get(User, target_user_id)
if user is None or not user.is_active:
raise ValueError("target user not found or inactive")
plaintext, prefix, code_hash = generate_enrollment_code()
expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)
row = MobileEnrollmentCode(
label=(label or "").strip(),
code_hash=code_hash,
code_prefix=prefix,
created_by_user_id=created_by_user_id,
target_user_id=target_user_id,
login_mode=login_mode,
max_uses=max_uses,
use_count=0,
expires_at=expires_at,
)
db.add(row)
db.flush()
target_username = None
if row.target_user_id:
u = db.get(User, row.target_user_id)
target_username = u.username if u else None
summary = EnrollmentCodeSummary(
id=row.id,
label=row.label or "",
code_prefix=row.code_prefix,
target_user_id=row.target_user_id,
target_username=target_username,
login_mode=row.login_mode,
max_uses=row.max_uses,
use_count=row.use_count,
expires_at=row.expires_at,
revoked_at=row.revoked_at,
created_at=row.created_at,
is_active=True,
)
return summary, plaintext
def revoke_enrollment_code(db: Session, code_id: int) -> None:
row = db.get(MobileEnrollmentCode, code_id)
if row is None:
raise ValueError("enrollment code not found")
if row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
db.commit()
def _count_active_devices(db: Session, user_id: int) -> int:
return (
db.scalar(
select(func.count())
.select_from(MobileDevice)
.where(MobileDevice.user_id == user_id, MobileDevice.revoked_at.is_(None))
)
or 0
)
def enroll_device(
db: Session,
*,
enrollment_code: str,
username: str | None,
password: str | None,
device_uuid: str,
display_name: str,
platform: str,
app_version: str | None,
fcm_token: str | None,
create_access_token,
) -> EnrollResult:
from app.auth.jwt_auth import create_access_token as _create_access_token
token_factory = create_access_token or _create_access_token
mobile_cfg = get_effective_mobile_settings(db)
if not mobile_cfg.devices_allowed:
raise PermissionError("mobile enrollment is disabled")
cleaned_uuid = (device_uuid or "").strip()
if len(cleaned_uuid) < 8:
raise ValueError("device_uuid is required")
if mobile_cfg.min_app_version and app_version:
if _version_lt(app_version, mobile_cfg.min_app_version):
raise ValueError(f"app version {app_version} is below minimum {mobile_cfg.min_app_version}")
now = datetime.now(timezone.utc)
code_hash = hash_mobile_secret(enrollment_code.strip())
code_row = db.scalar(
select(MobileEnrollmentCode).where(MobileEnrollmentCode.code_hash == code_hash)
)
if code_row is None or not _code_is_usable(code_row, now=now):
raise ValueError("invalid or expired enrollment code")
if code_row.login_mode == LOGIN_MODE_PASSWORD:
if not username or not password:
raise ValueError("username and password required")
user = authenticate_user(db, username, password)
if user is None:
raise ValueError("invalid username or password")
else:
user = db.get(User, code_row.target_user_id) if code_row.target_user_id else None
if user is None or not user.is_active:
raise ValueError("enrollment code user is invalid")
if code_row.target_user_id is not None and user.id != code_row.target_user_id:
raise ValueError("enrollment code is bound to another user")
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
if existing is not None and existing.user_id != user.id:
raise ValueError("device_uuid already registered to another user")
active_count = _count_active_devices(db, user.id)
if (
existing is not None
and existing.user_id == user.id
and existing.revoked_at is None
):
active_count -= 1
if active_count >= mobile_cfg.max_devices_per_user:
raise ValueError("device limit reached for this user")
cleaned_display = (display_name or "").strip() or "Android"
cleaned_platform = (platform or "android").strip() or "android"
cleaned_app_version = (app_version or "").strip() or None
cleaned_fcm = (fcm_token or "").strip() or None
if existing is not None:
revoke_refresh_tokens_for_device(db, existing.id)
existing.revoked_at = None
existing.user_id = user.id
existing.display_name = cleaned_display
existing.platform = cleaned_platform
existing.app_version = cleaned_app_version
existing.fcm_token = cleaned_fcm
existing.fcm_token_updated_at = now if cleaned_fcm else None
existing.enrollment_code_id = code_row.id
existing.last_seen_at = now
device = existing
else:
device = MobileDevice(
user_id=user.id,
device_uuid=cleaned_uuid,
display_name=cleaned_display,
platform=cleaned_platform,
app_version=cleaned_app_version,
fcm_token=cleaned_fcm,
fcm_token_updated_at=now if cleaned_fcm else None,
enrollment_code_id=code_row.id,
last_seen_at=now,
)
db.add(device)
code_row.use_count += 1
db.flush()
refresh_plain = store_refresh_token(db, device_id=device.id)
access_token = token_factory(user.username, user.role, device_id=device.id)
db.commit()
return EnrollResult(
access_token=access_token,
refresh_token=refresh_plain,
device_id=device.id,
username=user.username,
role=user.role,
)
def _version_lt(current: str, minimum: str) -> bool:
def parse(v: str) -> tuple[int, ...]:
parts: list[int] = []
for piece in v.strip().split("."):
try:
parts.append(int(piece))
except ValueError:
parts.append(0)
return tuple(parts)
return parse(current) < parse(minimum)