feat: Seaca mobile API, enrollment, FCM push and admin UI (0.9.0)

Adds mobile device registration by admin codes, refresh tokens, push channel
in notification policy, and Settings section for managing Seaca clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-10 13:19:14 +10:00
parent 86c602cf07
commit 563b836acc
32 changed files with 1916 additions and 45 deletions
+365
View File
@@ -0,0 +1,365 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import create_access_token, get_current_user, require_admin, CurrentUser
from app.database import get_db
from app.models.user import User
from app.models.mobile_settings import LOGIN_MODES
from app.services.mobile_devices import (
get_device_or_raise,
list_mobile_devices,
revoke_device,
touch_device,
update_fcm_token,
assert_device_active,
)
from app.services.mobile_enrollment import (
create_enrollment_code,
enroll_device,
list_enrollment_codes,
revoke_enrollment_code,
)
from app.services.mobile_notify import (
MobileNotConfiguredError,
MobileSendError,
get_effective_fcm_config,
send_test_push,
)
from app.services.mobile_settings import upsert_mobile_settings, get_effective_mobile_settings
from app.services.mobile_tokens import find_valid_refresh_token, revoke_refresh_tokens_for_device, store_refresh_token
router = APIRouter(prefix="/mobile", tags=["mobile"])
class MobileSettingsResponse(BaseModel):
devices_allowed: bool
max_devices_per_user: int
min_app_version: str | None = None
fcm_enabled: bool
fcm_configured: bool
source: str
class MobileSettingsUpdate(BaseModel):
devices_allowed: bool
max_devices_per_user: int = Field(ge=1, le=50)
min_app_version: str | None = None
class EnrollmentCodeCreate(BaseModel):
label: str = ""
target_user_id: int | None = None
login_mode: str = "password"
max_uses: int = Field(default=1, ge=1, le=100)
expires_in_hours: int = Field(default=24, ge=1, le=720)
class EnrollmentCodeResponse(BaseModel):
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
class EnrollmentCodeCreatedResponse(EnrollmentCodeResponse):
enrollment_code: str = Field(description="Показывается один раз при создании")
class MobileDeviceResponse(BaseModel):
id: int
user_id: int
username: str
device_uuid: str
display_name: str
platform: str
app_version: str | None
has_fcm_token: bool
enrolled_at: datetime
last_seen_at: datetime | None
revoked_at: datetime | None
is_active: bool
class EnrollRequest(BaseModel):
enrollment_code: str
username: str | None = None
password: str | None = None
device_uuid: str
display_name: str = "Android"
platform: str = "android"
app_version: str | None = None
fcm_token: str | None = None
class EnrollResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
device_id: int
username: str
role: str
class RefreshRequest(BaseModel):
refresh_token: str
device_uuid: str
class FcmTokenUpdate(BaseModel):
fcm_token: str
class TestPushResponse(BaseModel):
ok: bool = True
message: str
def _settings_response(db: Session) -> MobileSettingsResponse:
cfg = get_effective_mobile_settings(db)
fcm = get_effective_fcm_config()
return MobileSettingsResponse(
devices_allowed=cfg.devices_allowed,
max_devices_per_user=cfg.max_devices_per_user,
min_app_version=cfg.min_app_version,
fcm_enabled=fcm.enabled,
fcm_configured=fcm.configured,
source=cfg.source,
)
def _code_to_response(item) -> EnrollmentCodeResponse:
return EnrollmentCodeResponse(
id=item.id,
label=item.label,
code_prefix=item.code_prefix,
target_user_id=item.target_user_id,
target_username=item.target_username,
login_mode=item.login_mode,
max_uses=item.max_uses,
use_count=item.use_count,
expires_at=item.expires_at,
revoked_at=item.revoked_at,
created_at=item.created_at,
is_active=item.is_active,
)
def _device_to_response(item) -> MobileDeviceResponse:
return MobileDeviceResponse(
id=item.id,
user_id=item.user_id,
username=item.username,
device_uuid=item.device_uuid,
display_name=item.display_name,
platform=item.platform,
app_version=item.app_version,
has_fcm_token=item.has_fcm_token,
enrolled_at=item.enrolled_at,
last_seen_at=item.last_seen_at,
revoked_at=item.revoked_at,
is_active=item.is_active,
)
def _admin_user_id(db: Session, username: str) -> int | None:
user = db.scalar(select(User).where(func.lower(User.username) == username.lower()))
return user.id if user else None
@router.get("/settings", response_model=MobileSettingsResponse)
def get_mobile_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> MobileSettingsResponse:
return _settings_response(db)
@router.put("/settings", response_model=MobileSettingsResponse)
def update_mobile_settings(
body: MobileSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> MobileSettingsResponse:
try:
upsert_mobile_settings(
db,
devices_allowed=body.devices_allowed,
max_devices_per_user=body.max_devices_per_user,
min_app_version=body.min_app_version,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _settings_response(db)
@router.get("/enrollment-codes", response_model=list[EnrollmentCodeResponse])
def get_enrollment_codes(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> list[EnrollmentCodeResponse]:
return [_code_to_response(item) for item in list_enrollment_codes(db)]
@router.post("/enrollment-codes", response_model=EnrollmentCodeCreatedResponse)
def post_enrollment_code(
body: EnrollmentCodeCreate,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_admin),
) -> EnrollmentCodeCreatedResponse:
if body.login_mode not in LOGIN_MODES:
raise HTTPException(status_code=422, detail=f"login_mode must be one of: {sorted(LOGIN_MODES)}")
try:
summary, plaintext = create_enrollment_code(
db,
created_by_user_id=_admin_user_id(db, user.username),
label=body.label,
target_user_id=body.target_user_id,
login_mode=body.login_mode,
max_uses=body.max_uses,
expires_in_hours=body.expires_in_hours,
)
db.commit()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
base = _code_to_response(summary)
return EnrollmentCodeCreatedResponse(**base.model_dump(), enrollment_code=plaintext)
@router.delete("/enrollment-codes/{code_id}", status_code=204)
def delete_enrollment_code(
code_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> None:
try:
revoke_enrollment_code(db, code_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/devices", response_model=list[MobileDeviceResponse])
def get_devices(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> list[MobileDeviceResponse]:
return [_device_to_response(item) for item in list_mobile_devices(db)]
@router.delete("/devices/{device_id}", response_model=MobileDeviceResponse)
def delete_device(
device_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> MobileDeviceResponse:
try:
summary = revoke_device(db, device_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _device_to_response(summary)
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
def test_device_push(
device_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> TestPushResponse:
try:
send_test_push(db=db, device_id=device_id)
except MobileNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except MobileSendError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return TestPushResponse(message="Тестовое push отправлено на устройство")
@router.post("/enroll", response_model=EnrollResponse)
def post_enroll(body: EnrollRequest, db: Session = Depends(get_db)) -> EnrollResponse:
try:
result = enroll_device(
db,
enrollment_code=body.enrollment_code,
username=body.username,
password=body.password,
device_uuid=body.device_uuid,
display_name=body.display_name,
platform=body.platform,
app_version=body.app_version,
fcm_token=body.fcm_token,
create_access_token=create_access_token,
)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return EnrollResponse(
access_token=result.access_token,
refresh_token=result.refresh_token,
device_id=result.device_id,
username=result.username,
role=result.role,
)
@router.post("/auth/refresh", response_model=EnrollResponse)
def post_refresh(body: RefreshRequest, db: Session = Depends(get_db)) -> EnrollResponse:
token_row = find_valid_refresh_token(db, body.refresh_token.strip())
if token_row is None:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
device = get_device_or_raise(db, token_row.device_id)
try:
assert_device_active(device)
except PermissionError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
if device.device_uuid != body.device_uuid.strip():
raise HTTPException(status_code=401, detail="device_uuid mismatch")
user = db.get(User, device.user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="User inactive")
revoke_refresh_tokens_for_device(db, device.id)
refresh_plain = store_refresh_token(db, device_id=device.id)
touch_device(db, device)
access_token = create_access_token(user.username, user.role, device_id=device.id)
db.commit()
return EnrollResponse(
access_token=access_token,
refresh_token=refresh_plain,
device_id=device.id,
username=user.username,
role=user.role,
)
@router.put("/devices/me/fcm", status_code=204)
def put_my_fcm_token(
body: FcmTokenUpdate,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> None:
if user.device_id is None:
raise HTTPException(status_code=403, detail="Mobile device token required")
device = get_device_or_raise(db, user.device_id)
try:
assert_device_active(device)
update_fcm_token(db, device, body.fcm_token)
except PermissionError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream, system, users
from app.api.v1 import auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
api_router = APIRouter()
api_router.include_router(health.router)
@@ -13,3 +13,4 @@ api_router.include_router(settings.router)
api_router.include_router(system.router)
api_router.include_router(users.router)
api_router.include_router(stream.router)
api_router.include_router(mobile.router)
+10 -1
View File
@@ -23,6 +23,11 @@ from app.services.notification_settings import (
upsert_webhook_channel,
)
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
from app.services.webhook_notify import (
WebhookNotConfiguredError,
WebhookSendError,
send_webhook_test_message,
)
from app.services.event_severity_overrides import (
SOURCE_DB,
list_severity_override_items,
@@ -93,6 +98,7 @@ class NotificationPolicyResponse(BaseModel):
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool
source: str = Field(description="env или db")
@@ -108,6 +114,7 @@ class NotificationPolicyUpdate(BaseModel):
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool = False
class TelegramSettingsUpdate(BaseModel):
@@ -146,6 +153,7 @@ def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResp
use_telegram=cfg.use_telegram,
use_webhook=cfg.use_webhook,
use_email=cfg.use_email,
use_mobile=cfg.use_mobile,
source=cfg.source,
)
@@ -212,7 +220,7 @@ def update_notification_policy(
) -> NotificationPolicyResponse:
if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
if not any((body.use_telegram, body.use_webhook, body.use_email)):
if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)):
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
try:
policy = upsert_notification_policy(
@@ -221,6 +229,7 @@ def update_notification_policy(
use_telegram=body.use_telegram,
use_webhook=body.use_webhook,
use_email=body.use_email,
use_mobile=body.use_mobile,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
+44 -33
View File
@@ -67,14 +67,9 @@ bearer_scheme = HTTPBearer(auto_error=False)
class CurrentUser:
username: str
role: str
device_id: int | None = None
@@ -102,22 +97,12 @@ class CurrentUser:
def create_access_token(subject: str, role: str) -> str:
def create_access_token(subject: str, role: str, *, device_id: int | None = None) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "role": role, "exp": expire}
payload: dict[str, object] = {"sub": subject, "role": role, "exp": expire}
if device_id is not None:
payload["device_id"] = device_id
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
@@ -130,6 +115,33 @@ def create_access_token(subject: str, role: str) -> str:
def _assert_mobile_device_active(db: Session, device_id: int, user_id: int) -> None:
from app.models.mobile_device import MobileDevice
device = db.get(MobileDevice, device_id)
if device is None or device.user_id != user_id or device.revoked_at is not None:
raise HTTPException(status_code=401, detail="Mobile device revoked or invalid")
def get_token_device_id(token: str) -> int | None:
settings = get_settings()
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
except JWTError:
return None
raw = payload.get("device_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def _user_from_db(db: Session, username: str) -> User:
@@ -206,23 +218,22 @@ def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
parsed_device_id: int | None = None
raw_device_id = payload.get("device_id")
if raw_device_id is not None:
try:
parsed_device_id = int(raw_device_id)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
if db is not None:
user = _user_from_db(db, str(username))
return CurrentUser(username=user.username, role=user.role)
if parsed_device_id is not None:
_assert_mobile_device_active(db, parsed_device_id, user.id)
return CurrentUser(username=user.username, role=user.role, device_id=parsed_device_id)
role = str(payload.get("role") or USER_ROLE_ADMIN)
return CurrentUser(username=str(username), role=role)
return CurrentUser(username=str(username), role=role, device_id=parsed_device_id)
+6
View File
@@ -109,6 +109,12 @@ class Settings(BaseSettings):
sac_login_failure_window_minutes: int = 15
sac_login_alert_telegram: bool = True
# Seaca mobile (FCM push)
sac_fcm_enabled: bool = False
sac_fcm_project_id: str = ""
sac_fcm_service_account_json: str = ""
sac_mobile_refresh_expire_days: int = 90
@lru_cache
def get_settings() -> Settings:
+8
View File
@@ -9,6 +9,10 @@ from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UiSettings
from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User
from app.models.mobile_settings import MobileSettings
from app.models.mobile_enrollment_code import MobileEnrollmentCode
from app.models.mobile_device import MobileDevice
from app.models.mobile_refresh_token import MobileRefreshToken
__all__ = [
"ApiKey",
@@ -23,4 +27,8 @@ __all__ = [
"EventSeverityOverride",
"LoginAttempt",
"UiSettings",
"MobileSettings",
"MobileEnrollmentCode",
"MobileDevice",
"MobileRefreshToken",
]
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MobileDevice(Base):
__tablename__ = "mobile_devices"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("sac_users.id", ondelete="CASCADE"), index=True)
device_uuid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(128), default="")
platform: Mapped[str] = mapped_column(String(32), default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
fcm_token: Mapped[str | None] = mapped_column(String(512), nullable=True)
fcm_token_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
enrollment_code_id: Mapped[int | None] = mapped_column(
ForeignKey("mobile_enrollment_codes.id", ondelete="SET NULL"),
)
enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MobileEnrollmentCode(Base):
__tablename__ = "mobile_enrollment_codes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
label: Mapped[str] = mapped_column(String(128), default="")
code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
code_prefix: Mapped[str] = mapped_column(String(16))
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
login_mode: Mapped[str] = mapped_column(String(16), default="password")
max_uses: Mapped[int] = mapped_column(Integer, default=1)
use_count: Mapped[int] = mapped_column(Integer, default=0)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class MobileRefreshToken(Base):
__tablename__ = "mobile_refresh_tokens"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(ForeignKey("mobile_devices.id", ondelete="CASCADE"))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
MOBILE_SETTINGS_ROW_ID = 1
LOGIN_MODE_PASSWORD = "password"
LOGIN_MODE_CODE_ONLY = "code_only"
LOGIN_MODES = frozenset({LOGIN_MODE_PASSWORD, LOGIN_MODE_CODE_ONLY})
class MobileSettings(Base):
"""Singleton: глобальные настройки мобильного клиента Seaca."""
__tablename__ = "mobile_settings"
id: Mapped[int] = mapped_column(primary_key=True)
devices_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
max_devices_per_user: Mapped[int] = mapped_column(Integer, default=3)
min_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
@@ -16,3 +16,4 @@ class NotificationPolicy(Base):
use_telegram: Mapped[bool] = mapped_column(Boolean, default=True)
use_webhook: Mapped[bool] = mapped_column(Boolean, default=False)
use_email: Mapped[bool] = mapped_column(Boolean, default=False)
use_mobile: Mapped[bool] = mapped_column(Boolean, default=False)
+97
View File
@@ -0,0 +1,97 @@
"""Управление зарегистрированными устройствами Seaca."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.mobile_device import MobileDevice
from app.models.user import User
from app.services.mobile_tokens import revoke_refresh_tokens_for_device
@dataclass(frozen=True)
class MobileDeviceSummary:
id: int
user_id: int
username: str
device_uuid: str
display_name: str
platform: str
app_version: str | None
has_fcm_token: bool
enrolled_at: datetime
last_seen_at: datetime | None
revoked_at: datetime | None
is_active: bool
def list_mobile_devices(db: Session) -> list[MobileDeviceSummary]:
rows = db.scalars(select(MobileDevice).order_by(MobileDevice.enrolled_at.desc())).all()
user_ids = {r.user_id for r in rows}
usernames: dict[int, str] = {}
if user_ids:
for user in db.scalars(select(User).where(User.id.in_(user_ids))).all():
usernames[user.id] = user.username
return [_to_summary(row, usernames.get(row.user_id, "?")) for row in rows]
def _to_summary(row: MobileDevice, username: str) -> MobileDeviceSummary:
return MobileDeviceSummary(
id=row.id,
user_id=row.user_id,
username=username,
device_uuid=row.device_uuid,
display_name=row.display_name or "",
platform=row.platform or "android",
app_version=row.app_version,
has_fcm_token=bool((row.fcm_token or "").strip()),
enrolled_at=row.enrolled_at,
last_seen_at=row.last_seen_at,
revoked_at=row.revoked_at,
is_active=row.revoked_at is None,
)
def get_device_or_raise(db: Session, device_id: int) -> MobileDevice:
row = db.get(MobileDevice, device_id)
if row is None:
raise ValueError("device not found")
return row
def assert_device_active(device: MobileDevice) -> None:
if device.revoked_at is not None:
raise PermissionError("device has been revoked")
def touch_device(db: Session, device: MobileDevice, *, commit: bool = False) -> None:
device.last_seen_at = datetime.now(timezone.utc)
if commit:
db.commit()
def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary:
device = get_device_or_raise(db, device_id)
if device.revoked_at is None:
device.revoked_at = datetime.now(timezone.utc)
revoke_refresh_tokens_for_device(db, device.id)
db.commit()
db.refresh(device)
user = db.get(User, device.user_id)
username = user.username if user else "?"
return _to_summary(device, username)
def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None:
cleaned = (fcm_token or "").strip()
if not cleaned:
raise ValueError("fcm_token is required")
now = datetime.now(timezone.utc)
device.fcm_token = cleaned
device.fcm_token_updated_at = now
device.last_seen_at = now
db.commit()
+277
View File
@@ -0,0 +1,277 @@
"""Создание и проверка кодов регистрации 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")
active_count = _count_active_devices(db, user.id)
if active_count >= mobile_cfg.max_devices_per_user:
raise ValueError("device limit reached for this user")
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
if existing is not None:
if existing.revoked_at is None and existing.user_id != user.id:
raise ValueError("device_uuid already registered to another user")
if existing.revoked_at is None:
revoke_refresh_tokens_for_device(db, existing.id)
existing.revoked_at = now
existing = None
device = MobileDevice(
user_id=user.id,
device_uuid=cleaned_uuid,
display_name=(display_name or "").strip() or "Android",
platform=(platform or "android").strip() or "android",
app_version=(app_version or "").strip() or None,
fcm_token=(fcm_token or "").strip() or None,
fcm_token_updated_at=now if fcm_token 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)
+206
View File
@@ -0,0 +1,206 @@
"""Push-уведомления Seaca через Firebase Cloud Messaging (HTTP v1)."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event, Problem
from app.models.mobile_device import MobileDevice
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_policy import get_effective_notification_policy
logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
class MobileNotConfiguredError(Exception):
"""FCM отключён или не задан service account."""
class MobileSendError(Exception):
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class FcmConfig:
enabled: bool
project_id: str
service_account_path: str
@property
def configured(self) -> bool:
return bool(self.project_id.strip() and self.service_account_path.strip())
@property
def active(self) -> bool:
return self.enabled and self.configured
def get_effective_fcm_config() -> FcmConfig:
settings = get_settings()
return FcmConfig(
enabled=bool(settings.sac_fcm_enabled),
project_id=(settings.sac_fcm_project_id or "").strip(),
service_account_path=(settings.sac_fcm_service_account_json or "").strip(),
)
def _load_service_account(path: str) -> dict:
file_path = Path(path)
if not file_path.is_file():
raise MobileNotConfiguredError(f"FCM service account file not found: {path}")
with file_path.open(encoding="utf-8") as handle:
return json.load(handle)
def _fcm_access_token(service_account_path: str) -> str:
try:
from google.oauth2 import service_account
from google.auth.transport.requests import Request
except ImportError as exc:
raise MobileNotConfiguredError("google-auth package is required for FCM") from exc
creds = service_account.Credentials.from_service_account_file(
service_account_path,
scopes=[FCM_SCOPE],
)
creds.refresh(Request())
if not creds.token:
raise MobileSendError("Failed to obtain FCM access token")
return creds.token
def _active_device_tokens(db: Session | None) -> list[str]:
if db is None:
return []
rows = db.scalars(
select(MobileDevice).where(
MobileDevice.revoked_at.is_(None),
MobileDevice.fcm_token.is_not(None),
)
).all()
tokens: list[str] = []
for row in rows:
token = (row.fcm_token or "").strip()
if token:
tokens.append(token)
return tokens
def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: str) -> None:
if not tokens:
return
cfg = get_effective_fcm_config()
if not cfg.active:
return
access_token = _fcm_access_token(cfg.service_account_path)
url = f"https://fcm.googleapis.com/v1/projects/{cfg.project_id}/messages:send"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
with httpx.Client(timeout=12.0) as client:
for token in tokens:
payload = {
"message": {
"token": token,
"notification": {"title": title, "body": body},
"data": data,
"android": {"priority": "high"},
}
}
try:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail)
except Exception:
logger.exception("FCM send failed")
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
title = f"[{event.severity}] {event.title}"
body = (event.summary or "")[:200]
data = {
"kind": "event",
"id": str(event.id),
"severity": event.severity,
"type": event.type,
}
return title, body, data
def _problem_payload(problem: Problem) -> tuple[str, str, dict[str, str]]:
title = f"[{problem.severity}] {problem.title}"
body = (problem.summary or "")[:200]
data = {
"kind": "problem",
"id": str(problem.id),
"severity": problem.severity,
"status": problem.status,
}
return title, body, data
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
policy = get_effective_notification_policy(db)
if apply_policy_gate and not severity_meets_minimum(event.severity, policy.min_severity):
return
tokens = _active_device_tokens(db)
if not tokens:
return
title, body, data = _event_payload(event)
_send_fcm_data(tokens, data, title=title, body=body)
def notify_problem(
problem: Problem,
event: Event | None = None,
*,
db: Session | None = None,
apply_policy_gate: bool = True,
) -> None:
policy = get_effective_notification_policy(db)
if apply_policy_gate and not severity_meets_minimum(problem.severity, policy.min_severity):
return
tokens = _active_device_tokens(db)
if not tokens:
return
title, body, data = _problem_payload(problem)
_send_fcm_data(tokens, data, title=title, body=body)
def send_test_push(*, db: Session, device_id: int) -> None:
cfg = get_effective_fcm_config()
if not cfg.enabled:
raise MobileNotConfiguredError("FCM отключён (SAC_FCM_ENABLED=false)")
if not cfg.configured:
raise MobileNotConfiguredError("Не задан SAC_FCM_PROJECT_ID или SAC_FCM_SERVICE_ACCOUNT_JSON")
device = db.get(MobileDevice, device_id)
if device is None or device.revoked_at is not None:
raise ValueError("device not found or revoked")
token = (device.fcm_token or "").strip()
if not token:
raise ValueError("device has no FCM token")
_send_fcm_data(
[token],
{"kind": "test", "id": "0"},
title="SAC: тестовое push",
body="Канал Seaca (FCM) работает.",
)
+58
View File
@@ -0,0 +1,58 @@
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings
@dataclass(frozen=True)
class MobileSettingsConfig:
devices_allowed: bool
max_devices_per_user: int
min_app_version: str | None
source: str = "db"
def get_effective_mobile_settings(db: Session) -> MobileSettingsConfig:
row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
if row is None:
return MobileSettingsConfig(
devices_allowed=False,
max_devices_per_user=3,
min_app_version=None,
source="default",
)
return MobileSettingsConfig(
devices_allowed=bool(row.devices_allowed),
max_devices_per_user=max(1, int(row.max_devices_per_user or 3)),
min_app_version=(row.min_app_version or "").strip() or None,
source="db",
)
def upsert_mobile_settings(
db: Session,
*,
devices_allowed: bool,
max_devices_per_user: int,
min_app_version: str | None,
) -> MobileSettingsConfig:
if max_devices_per_user < 1 or max_devices_per_user > 50:
raise ValueError("max_devices_per_user must be between 1 and 50")
cleaned_version = (min_app_version or "").strip() or None
row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
if row is None:
row = MobileSettings(
id=MOBILE_SETTINGS_ROW_ID,
devices_allowed=devices_allowed,
max_devices_per_user=max_devices_per_user,
min_app_version=cleaned_version,
)
db.add(row)
else:
row.devices_allowed = devices_allowed
row.max_devices_per_user = max_devices_per_user
row.min_app_version = cleaned_version
db.commit()
db.refresh(row)
return get_effective_mobile_settings(db)
+68
View File
@@ -0,0 +1,68 @@
"""Хеширование enrollment/refresh токенов и генерация."""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.mobile_refresh_token import MobileRefreshToken
def hash_mobile_secret(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def generate_enrollment_code() -> tuple[str, str, str]:
"""(plaintext, prefix, hash)"""
raw = f"sacmob_{secrets.token_urlsafe(18)}"
prefix = raw[:12]
return raw, prefix, hash_mobile_secret(raw)
def generate_refresh_token() -> tuple[str, str]:
"""(plaintext, hash)"""
raw = secrets.token_urlsafe(48)
return raw, hash_mobile_secret(raw)
def store_refresh_token(db: Session, *, device_id: int) -> str:
settings = get_settings()
days = max(1, int(settings.sac_mobile_refresh_expire_days))
plaintext, token_hash = generate_refresh_token()
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
row = MobileRefreshToken(
device_id=device_id,
token_hash=token_hash,
expires_at=expires_at,
)
db.add(row)
return plaintext
def revoke_refresh_tokens_for_device(db: Session, device_id: int) -> None:
now = datetime.now(timezone.utc)
rows = db.scalars(
select(MobileRefreshToken).where(
MobileRefreshToken.device_id == device_id,
MobileRefreshToken.revoked_at.is_(None),
)
).all()
for row in rows:
row.revoked_at = now
def find_valid_refresh_token(db: Session, raw_token: str) -> MobileRefreshToken | None:
token_hash = hash_mobile_secret(raw_token)
now = datetime.now(timezone.utc)
return db.scalar(
select(MobileRefreshToken).where(
MobileRefreshToken.token_hash == token_hash,
MobileRefreshToken.revoked_at.is_(None),
MobileRefreshToken.expires_at > now,
)
)
+13 -3
View File
@@ -17,7 +17,8 @@ from app.services.notification_settings import (
VALID_SEVERITIES,
)
DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL})
CHANNEL_MOBILE = "mobile"
DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL, CHANNEL_MOBILE})
@dataclass(frozen=True)
@@ -26,6 +27,7 @@ class NotificationPolicyConfig:
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool
source: str # env | db
def allows_channel(self, channel: str) -> bool:
@@ -35,21 +37,24 @@ class NotificationPolicyConfig:
return self.use_webhook
if channel == CHANNEL_EMAIL:
return self.use_email
if channel == CHANNEL_MOBILE:
return self.use_mobile
return False
def _parse_channels_csv(value: str) -> tuple[bool, bool, bool]:
def _parse_channels_csv(value: str) -> tuple[bool, bool, bool, bool]:
parts = {p.strip().lower() for p in value.split(",") if p.strip()}
return (
CHANNEL_TELEGRAM in parts or "tg" in parts,
CHANNEL_WEBHOOK in parts,
CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts,
CHANNEL_MOBILE in parts or "push" in parts or "fcm" in parts,
)
def _policy_from_env() -> NotificationPolicyConfig:
settings = get_settings()
use_tg, use_wh, use_em = _parse_channels_csv(settings.notify_channels)
use_tg, use_wh, use_em, use_mob = _parse_channels_csv(settings.notify_channels)
min_sev = settings.notify_min_severity.strip() or "warning"
if min_sev not in VALID_SEVERITIES:
min_sev = "warning"
@@ -58,6 +63,7 @@ def _policy_from_env() -> NotificationPolicyConfig:
use_telegram=use_tg,
use_webhook=use_wh,
use_email=use_em,
use_mobile=use_mob,
source="env",
)
@@ -86,6 +92,7 @@ def get_effective_notification_policy(db: Session | None = None) -> Notification
use_telegram=bool(row.use_telegram),
use_webhook=bool(row.use_webhook),
use_email=bool(row.use_email),
use_mobile=bool(getattr(row, "use_mobile", False)),
source="db",
)
@@ -104,6 +111,7 @@ def upsert_notification_policy(
use_telegram: bool,
use_webhook: bool,
use_email: bool,
use_mobile: bool = False,
) -> NotificationPolicyConfig:
if min_severity not in VALID_SEVERITIES:
raise ValueError(f"invalid min_severity: {min_severity}")
@@ -116,6 +124,7 @@ def upsert_notification_policy(
use_telegram=use_telegram,
use_webhook=use_webhook,
use_email=use_email,
use_mobile=use_mobile,
)
db.add(row)
else:
@@ -123,6 +132,7 @@ def upsert_notification_policy(
row.use_telegram = use_telegram
row.use_webhook = use_webhook
row.use_email = use_email
row.use_mobile = use_mobile
_sync_channel_min_severity(db, min_severity)
db.commit()
+7 -1
View File
@@ -5,7 +5,7 @@ import logging
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services import email_notify, telegram_notify, webhook_notify
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
from app.services.notification_cooldown import should_notify_event, should_notify_problem
from app.services.notification_policy import get_effective_notification_policy
from app.services.notification_severity import severity_meets_minimum
@@ -35,6 +35,8 @@ def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> Non
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_mobile:
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None:
@@ -44,6 +46,8 @@ def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Ses
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_mobile:
mobile_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
def notify_event(event: Event, *, db: Session | None = None) -> None:
@@ -72,6 +76,8 @@ def _dispatch_lifecycle_channels(event: Event, *, db: Session | None, policy) ->
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_mobile:
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.8.3"
APP_VERSION = "0.9.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"