diff --git a/README.md b/README.md index 68feb50..9a7f46e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Статус -**Версия:** `0.8.3` +**Версия:** `0.9.0` **Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE **Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`) diff --git a/backend/alembic/versions/014_mobile_seaca.py b/backend/alembic/versions/014_mobile_seaca.py new file mode 100644 index 0000000..3294f6f --- /dev/null +++ b/backend/alembic/versions/014_mobile_seaca.py @@ -0,0 +1,105 @@ +"""mobile settings, enrollment codes, devices, refresh tokens; policy use_mobile + +Revision ID: 014 +Revises: 013 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "014" +down_revision: Union[str, None] = "013" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +MOBILE_SETTINGS_ROW_ID = 1 + + +def upgrade() -> None: + op.add_column( + "notification_policy", + sa.Column("use_mobile", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + op.create_table( + "mobile_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("devices_allowed", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("max_devices_per_user", sa.Integer(), nullable=False, server_default="3"), + sa.Column("min_app_version", sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.execute( + sa.text( + "INSERT INTO mobile_settings (id, devices_allowed, max_devices_per_user) " + f"VALUES ({MOBILE_SETTINGS_ROW_ID}, false, 3)" + ) + ) + + op.create_table( + "mobile_enrollment_codes", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("label", sa.String(length=128), nullable=False, server_default=""), + sa.Column("code_hash", sa.String(length=64), nullable=False), + sa.Column("code_prefix", sa.String(length=16), nullable=False), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("target_user_id", sa.Integer(), nullable=True), + sa.Column("login_mode", sa.String(length=16), nullable=False, server_default="password"), + sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"), + sa.Column("use_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["created_by_user_id"], ["sac_users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["target_user_id"], ["sac_users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mobile_enrollment_codes_code_hash", "mobile_enrollment_codes", ["code_hash"], unique=True) + + op.create_table( + "mobile_devices", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("device_uuid", sa.String(length=64), nullable=False), + sa.Column("display_name", sa.String(length=128), nullable=False, server_default=""), + sa.Column("platform", sa.String(length=32), nullable=False, server_default="android"), + sa.Column("app_version", sa.String(length=32), nullable=True), + sa.Column("fcm_token", sa.String(length=512), nullable=True), + sa.Column("fcm_token_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("enrollment_code_id", sa.Integer(), nullable=True), + sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["sac_users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["enrollment_code_id"], ["mobile_enrollment_codes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mobile_devices_device_uuid", "mobile_devices", ["device_uuid"], unique=True) + op.create_index("ix_mobile_devices_user_id", "mobile_devices", ["user_id"]) + + op.create_table( + "mobile_refresh_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("device_id", sa.Integer(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["device_id"], ["mobile_devices.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_mobile_refresh_tokens_token_hash", "mobile_refresh_tokens", ["token_hash"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_mobile_refresh_tokens_token_hash", table_name="mobile_refresh_tokens") + op.drop_table("mobile_refresh_tokens") + op.drop_index("ix_mobile_devices_user_id", table_name="mobile_devices") + op.drop_index("ix_mobile_devices_device_uuid", table_name="mobile_devices") + op.drop_table("mobile_devices") + op.drop_index("ix_mobile_enrollment_codes_code_hash", table_name="mobile_enrollment_codes") + op.drop_table("mobile_enrollment_codes") + op.drop_table("mobile_settings") + op.drop_column("notification_policy", "use_mobile") diff --git a/backend/app/api/v1/mobile.py b/backend/app/api/v1/mobile.py new file mode 100644 index 0000000..d7c53dd --- /dev/null +++ b/backend/app/api/v1/mobile.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index d761a02..c0c32e5 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index f8e9f8d..9cde1bf 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -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 diff --git a/backend/app/auth/jwt_auth.py b/backend/app/auth/jwt_auth.py index 3f55345..e58d02f 100644 --- a/backend/app/auth/jwt_auth.py +++ b/backend/app/auth/jwt_auth.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index bda05ef..c8d83be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d9259ff..39283b8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/mobile_device.py b/backend/app/models/mobile_device.py new file mode 100644 index 0000000..a998ebe --- /dev/null +++ b/backend/app/models/mobile_device.py @@ -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) diff --git a/backend/app/models/mobile_enrollment_code.py b/backend/app/models/mobile_enrollment_code.py new file mode 100644 index 0000000..f10cf8f --- /dev/null +++ b/backend/app/models/mobile_enrollment_code.py @@ -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()) diff --git a/backend/app/models/mobile_refresh_token.py b/backend/app/models/mobile_refresh_token.py new file mode 100644 index 0000000..b02cc11 --- /dev/null +++ b/backend/app/models/mobile_refresh_token.py @@ -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) diff --git a/backend/app/models/mobile_settings.py b/backend/app/models/mobile_settings.py new file mode 100644 index 0000000..6219be7 --- /dev/null +++ b/backend/app/models/mobile_settings.py @@ -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) diff --git a/backend/app/models/notification_policy.py b/backend/app/models/notification_policy.py index 189aefb..4cadd65 100644 --- a/backend/app/models/notification_policy.py +++ b/backend/app/models/notification_policy.py @@ -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) diff --git a/backend/app/services/mobile_devices.py b/backend/app/services/mobile_devices.py new file mode 100644 index 0000000..28739cc --- /dev/null +++ b/backend/app/services/mobile_devices.py @@ -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() diff --git a/backend/app/services/mobile_enrollment.py b/backend/app/services/mobile_enrollment.py new file mode 100644 index 0000000..b1bba41 --- /dev/null +++ b/backend/app/services/mobile_enrollment.py @@ -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) diff --git a/backend/app/services/mobile_notify.py b/backend/app/services/mobile_notify.py new file mode 100644 index 0000000..73ac3db --- /dev/null +++ b/backend/app/services/mobile_notify.py @@ -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) работает.", + ) diff --git a/backend/app/services/mobile_settings.py b/backend/app/services/mobile_settings.py new file mode 100644 index 0000000..109b312 --- /dev/null +++ b/backend/app/services/mobile_settings.py @@ -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) diff --git a/backend/app/services/mobile_tokens.py b/backend/app/services/mobile_tokens.py new file mode 100644 index 0000000..00fcd02 --- /dev/null +++ b/backend/app/services/mobile_tokens.py @@ -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, + ) + ) diff --git a/backend/app/services/notification_policy.py b/backend/app/services/notification_policy.py index 6df53e2..c5cfdd3 100644 --- a/backend/app/services/notification_policy.py +++ b/backend/app/services/notification_policy.py @@ -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() diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index b58eca9..2890be1 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -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: diff --git a/backend/app/version.py b/backend/app/version.py index 07c33a7..64884a1 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -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}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 59f2bf7..ef30cfc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,6 +10,7 @@ jsonschema>=4.23.0,<5 passlib[bcrypt]>=1.7.4,<2 python-jose[cryptography]>=3.3.0,<4 httpx>=0.28.0,<1 +google-auth>=2.36.0,<3 psutil>=6.1.0,<8 # dev diff --git a/backend/tests/test_daily_report.py b/backend/tests/test_daily_report.py index 98e17ef..bc5c45c 100644 --- a/backend/tests/test_daily_report.py +++ b/backend/tests/test_daily_report.py @@ -223,6 +223,7 @@ def test_notify_daily_report_bypasses_min_severity(): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 19ec46a..757ec17 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.8.3" + assert APP_VERSION == "0.9.0" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.8.3" + assert APP_VERSION_LABEL == "Security Alert Center v.0.9.0" diff --git a/backend/tests/test_mobile_api.py b/backend/tests/test_mobile_api.py new file mode 100644 index 0000000..bd88da9 --- /dev/null +++ b/backend/tests/test_mobile_api.py @@ -0,0 +1,150 @@ +"""Mobile / Seaca API.""" + +from datetime import datetime, timedelta, timezone + +from app.models.mobile_device import MobileDevice +from app.models.mobile_enrollment_code import MobileEnrollmentCode +from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings +from app.services.mobile_tokens import hash_mobile_secret + + +def _enable_mobile(db_session): + row = db_session.get(MobileSettings, MOBILE_SETTINGS_ROW_ID) + if row is None: + row = MobileSettings(id=MOBILE_SETTINGS_ROW_ID, devices_allowed=True, max_devices_per_user=3) + db_session.add(row) + else: + row.devices_allowed = True + db_session.commit() + + +def test_mobile_settings_admin(jwt_headers, client, db_session): + _enable_mobile(db_session) + response = client.get("/api/v1/mobile/settings", headers=jwt_headers) + assert response.status_code == 200 + body = response.json() + assert body["devices_allowed"] is True + assert body["max_devices_per_user"] == 3 + + +def test_create_enrollment_code_and_enroll(jwt_headers, client, db_session): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"label": "test phone", "login_mode": "password", "expires_in_hours": 1}, + ) + assert create.status_code == 200 + created = create.json() + assert created["enrollment_code"].startswith("sacmob_") + assert created["code_prefix"] + + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": created["enrollment_code"], + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-test-device-001", + "display_name": "Pixel Test", + "fcm_token": "fcm-token-test", + }, + ) + assert enroll.status_code == 200 + body = enroll.json() + assert body["username"] == "test-monitor" + assert body["role"] == "monitor" + assert body["device_id"] > 0 + assert body["access_token"] + assert body["refresh_token"] + + devices = client.get("/api/v1/mobile/devices", headers=jwt_headers) + assert devices.status_code == 200 + assert len(devices.json()) == 1 + assert devices.json()[0]["display_name"] == "Pixel Test" + + +def test_enroll_blocked_when_disabled(client, db_session, jwt_headers): + code_plain = "sacmob_testcode123456" + db_session.add( + MobileEnrollmentCode( + label="x", + code_hash=hash_mobile_secret(code_plain), + code_prefix=code_plain[:12], + login_mode="password", + max_uses=1, + use_count=0, + expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + ) + ) + db_session.commit() + + response = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code_plain, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-disabled-001", + }, + ) + assert response.status_code == 403 + + +def test_refresh_token_rotation(client, db_session, jwt_headers): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ) + code = create.json()["enrollment_code"] + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code, + "username": "test-admin", + "password": "test-admin-password", + "device_uuid": "uuid-refresh-001", + }, + ) + data = enroll.json() + refresh = client.post( + "/api/v1/mobile/auth/refresh", + json={"refresh_token": data["refresh_token"], "device_uuid": "uuid-refresh-001"}, + ) + assert refresh.status_code == 200 + refreshed = refresh.json() + assert refreshed["refresh_token"] != data["refresh_token"] + assert refreshed["device_id"] == data["device_id"] + + +def test_revoke_device_blocks_token(client, db_session, jwt_headers): + _enable_mobile(db_session) + create = client.post( + "/api/v1/mobile/enrollment-codes", + headers=jwt_headers, + json={"login_mode": "password"}, + ) + code = create.json()["enrollment_code"] + enroll = client.post( + "/api/v1/mobile/enroll", + json={ + "enrollment_code": code, + "username": "test-monitor", + "password": "test-monitor-password", + "device_uuid": "uuid-revoke-001", + }, + ) + data = enroll.json() + headers = {"Authorization": f"Bearer {data['access_token']}"} + + revoke = client.delete(f"/api/v1/mobile/devices/{data['device_id']}", headers=jwt_headers) + assert revoke.status_code == 200 + + me = client.put( + "/api/v1/mobile/devices/me/fcm", + headers=headers, + json={"fcm_token": "new-token"}, + ) + assert me.status_code == 401 diff --git a/backend/tests/test_notification_policy.py b/backend/tests/test_notification_policy.py index fe2aef7..dd28629 100644 --- a/backend/tests/test_notification_policy.py +++ b/backend/tests/test_notification_policy.py @@ -25,6 +25,7 @@ def test_put_policy_persists_db(jwt_headers, client, db_session, monkeypatch): "use_telegram": True, "use_webhook": True, "use_email": False, + "use_mobile": False, }, ) assert response.status_code == 200 @@ -58,6 +59,7 @@ def test_upsert_policy_syncs_channel_min_severity(db_session, monkeypatch): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, ) row = db_session.get(NotificationChannel, "telegram") assert row.min_severity == "critical" diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py index 03c6b19..b94de70 100644 --- a/backend/tests/test_notify_dispatch.py +++ b/backend/tests/test_notify_dispatch.py @@ -25,6 +25,7 @@ def test_notify_event_calls_selected_channels(): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -50,6 +51,7 @@ def test_notify_event_calls_selected_channels(): use_telegram=True, use_webhook=True, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -80,6 +82,7 @@ def test_notify_event_skipped_by_cooldown(): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -107,6 +110,7 @@ def test_notify_auth_login_bypasses_min_severity(): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -134,6 +138,7 @@ def test_notify_rdg_connection_bypasses_min_severity(): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -161,6 +166,7 @@ def test_notify_auth_login_skips_telegram_when_via_agent(): use_telegram=True, use_webhook=True, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -190,6 +196,7 @@ def test_notify_lifecycle_bypasses_min_severity(): use_telegram=True, use_webhook=False, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -217,6 +224,7 @@ def test_notify_lifecycle_skips_telegram_when_via_agent(): use_telegram=True, use_webhook=True, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -246,6 +254,7 @@ def test_notify_daily_report_skips_telegram_when_via_agent(): use_telegram=True, use_webhook=True, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): @@ -275,6 +284,7 @@ def test_notify_daily_report_skips_telegram_when_via_agent(): use_telegram=True, use_webhook=True, use_email=False, + use_mobile=False, source="db", ) with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): diff --git a/backend/tests/test_problems.py b/backend/tests/test_problems.py index e220b40..59d997a 100644 --- a/backend/tests/test_problems.py +++ b/backend/tests/test_problems.py @@ -4,6 +4,7 @@ import uuid from datetime import datetime, timedelta, timezone from app.models import Host, Problem +from tests.test_ingest import VALID_EVENT def _event_payload(**overrides): diff --git a/deploy/env.native.example b/deploy/env.native.example index 6408ee7..817400c 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -87,4 +87,11 @@ SAC_LOGIN_MAX_FAILURES=3 SAC_LOGIN_FAILURE_WINDOW_MINUTES=15 SAC_LOGIN_ALERT_TELEGRAM=true +# Seaca mobile push (FCM HTTP v1) +SAC_FCM_ENABLED=false +SAC_FCM_PROJECT_ID= +# Путь к JSON service account Firebase (chmod 600, владелец sac) +SAC_FCM_SERVICE_ACCOUNT_JSON= +SAC_MOBILE_REFRESH_EXPIRE_DAYS=90 + CORS_ORIGINS=* diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c12c31b..c9ae810 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -289,6 +289,97 @@ export interface TopTypeItem { count: number; } +export interface MobileSettings { + devices_allowed: boolean; + max_devices_per_user: number; + min_app_version: string | null; + fcm_enabled: boolean; + fcm_configured: boolean; + source: string; +} + +export interface MobileEnrollmentCode { + id: number; + label: string; + code_prefix: string; + target_user_id: number | null; + target_username: string | null; + login_mode: string; + max_uses: number; + use_count: number; + expires_at: string; + revoked_at: string | null; + created_at: string; + is_active: boolean; +} + +export interface MobileEnrollmentCodeCreated extends MobileEnrollmentCode { + enrollment_code: string; +} + +export interface MobileDevice { + id: number; + user_id: number; + username: string; + device_uuid: string; + display_name: string; + platform: string; + app_version: string | null; + has_fcm_token: boolean; + enrolled_at: string; + last_seen_at: string | null; + revoked_at: string | null; + is_active: boolean; +} + +export function fetchMobileSettings(): Promise { + return apiFetch("/api/v1/mobile/settings"); +} + +export function updateMobileSettings(payload: { + devices_allowed: boolean; + max_devices_per_user: number; + min_app_version?: string | null; +}): Promise { + return apiFetch("/api/v1/mobile/settings", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function fetchMobileEnrollmentCodes(): Promise { + return apiFetch("/api/v1/mobile/enrollment-codes"); +} + +export function createMobileEnrollmentCode(payload: { + label?: string; + target_user_id?: number | null; + login_mode?: string; + max_uses?: number; + expires_in_hours?: number; +}): Promise { + return apiFetch("/api/v1/mobile/enrollment-codes", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function revokeMobileEnrollmentCode(codeId: number): Promise { + return apiFetch(`/api/v1/mobile/enrollment-codes/${codeId}`, { method: "DELETE" }); +} + +export function fetchMobileDevices(): Promise { + return apiFetch("/api/v1/mobile/devices"); +} + +export function revokeMobileDevice(deviceId: number): Promise { + return apiFetch(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" }); +} + +export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> { + return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" }); +} + export interface DashboardSummary { events_last_24h: number; hosts_total: number; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 05c5446..f0d9903 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,3 +1,3 @@ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.8.3"; +export const APP_VERSION = "0.9.0"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 6f8fbb8..18d21fe 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -62,6 +62,10 @@ Email +

Источник: {{ policyLoaded.source }} @@ -74,6 +78,144 @@ +

+

Мобильные устройства (Seaca)

+

+ Подключение Android-клиента по коду регистрации. Управление пользователями SAC — в разделе «Пользователи». +

+
+ + + +

+ FCM: {{ mobileLoaded.fcm_configured ? "настроен" : "не настроен" }} + · источник: {{ mobileLoaded.source }} +

+
+ +
+
+ +

Коды регистрации

+
+ + + + +
+ +
+
+

+ Код (скопируйте сейчас): {{ lastCreatedCode }} +

+
+ + + + + + + + + + + + + + + + + + + + + +
МеткаПрефиксПользовательВходИспользованийДействия
{{ code.label || "—" }}{{ code.code_prefix }}…{{ code.target_username || "—" }}{{ code.login_mode }}{{ code.use_count }}/{{ code.max_uses }} + +
+
+ +

Подключённые устройства

+
+ + + + + + + + + + + + + + + + + + + +
ИмяПользовательFCMПоследняя активностьДействия
{{ device.display_name }}{{ device.username }}{{ device.has_fcm_token ? "да" : "нет" }}{{ device.last_seen_at || device.enrolled_at }} + + +
+
+
+

Severity по типу события

@@ -268,7 +410,22 @@