diff --git a/agliullov_daniyar_lab_3/device_service/Dockerfile b/agliullov_daniyar_lab_3/device_service/Dockerfile new file mode 100644 index 0000000..bc6198d --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "--host", "0.0.0.0", "main:app", "--port", "8001"] diff --git a/agliullov_daniyar_lab_3/device_service/config.py b/agliullov_daniyar_lab_3/device_service/config.py new file mode 100644 index 0000000..5984cac --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/config.py @@ -0,0 +1,21 @@ +import os +import logging + +from dotenv import load_dotenv + +load_dotenv() + +DB_HOST = os.environ.get("DB_HOST") +DB_PORT = os.environ.get("DB_PORT") +DB_NAME = os.environ.get("DB_NAME") +DB_USER = os.environ.get("DB_USER") +DB_PASS = os.environ.get("DB_PASS") + + +SECRET_AUTH_TOKEN = os.environ.get("SECRET_AUTH_TOKEN") +SECRET_VERIFICATE_TOKEN = os.environ.get("SECRET_VERIFICATE_TOKEN") + +DEVICE_START_COUNTER = os.environ.get("DEVICE_START_COUNTER") + +logging.getLogger('passlib').setLevel(logging.ERROR) + diff --git a/agliullov_daniyar_lab_3/device_service/database.py b/agliullov_daniyar_lab_3/device_service/database.py new file mode 100644 index 0000000..fd324fc --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/database.py @@ -0,0 +1,49 @@ +import time +from sys import gettrace +from typing import AsyncGenerator + +from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder +from sqlalchemy import MetaData +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from config import DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_USER + +from pydantic import BaseModel +from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, Boolean, MetaData, PrimaryKeyConstraint +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + def fill_from_model(self, model: BaseModel | dict): + """Заполняет все поля из словаря или модели, помечая объект 'грязным' для корректного обновления""" + if isinstance(model, BaseModel): + items = model.model_dump().items() + else: + items = model.items() + + for key, value in items: + setattr(self, key, value) + +DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + +engine = create_async_engine(DATABASE_URL, echo=gettrace() is not None) +async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session + + +async def begin_transactions() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker().begin() as transaction: + try: + yield transaction.session + except Exception as e: + await transaction.rollback() + raise e + await transaction.commit() diff --git a/agliullov_daniyar_lab_3/device_service/device/__init__.py b/agliullov_daniyar_lab_3/device_service/device/__init__.py new file mode 100644 index 0000000..c02f0a1 --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/__init__.py @@ -0,0 +1,5 @@ +from .models import * +from .router import * +from .schemas import * +from .dependencies import * +from .websocket_auth import * diff --git a/agliullov_daniyar_lab_3/device_service/device/common_schemas.py b/agliullov_daniyar_lab_3/device_service/device/common_schemas.py new file mode 100644 index 0000000..1efe4c0 --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/common_schemas.py @@ -0,0 +1,31 @@ +"""Модуль содержащий общие схемы для хаба и устройства во избежание цикличного импорта""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +__all__ = ["RoleCreate", "RoleRead", "ActiveDeviceRead", "ActiveDeviceCreate", "ModelRead"] + + +class ModelRead(BaseModel, from_attributes=True, frozen=True): + name: str + version: str + + +class RoleCreate(BaseModel): + name: str + permissions: Optional[dict] + + +class RoleRead(RoleCreate, from_attributes=True): + id: int + + +class ActiveDeviceCreate(BaseModel, from_attributes=True): + last_connection: Optional[datetime] = None + role: RoleCreate = Field(default_factory=lambda: RoleCreate(name="admin", permissions={"include_all": True})) + + +class ActiveDeviceRead(ActiveDeviceCreate, from_attributes=True): + # id: uuid.UUID todo насколько необходимо + role: Optional[RoleRead] = None diff --git a/agliullov_daniyar_lab_3/device_service/device/dependencies.py b/agliullov_daniyar_lab_3/device_service/device/dependencies.py new file mode 100644 index 0000000..198004e --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/dependencies.py @@ -0,0 +1,207 @@ +import hashlib +import uuid +from typing import List, Tuple, Optional + +from fastapi import Depends, Path, HTTPException +from sqlalchemy import select, text +from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, JSON, Boolean, MetaData, DateTime, UUID, \ + ARRAY, DECIMAL, DOUBLE_PRECISION +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, relationship +from datetime import datetime + + +current_user = lambda: None + + +from database import get_async_session, Base +from jwt_config import tokens_data, denylist +from .schemas import DeviceRead, DeviceWebsocketAuth, JwtAccessClaimsForDeviceRequests, \ + JwtRefreshClaimsForDeviceRequests, TokensRead, TokenDataServer + +from device.models import Device, ActiveDevice, DeviceForInherit, Hub + +from fastapi import Depends +from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase +from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_async_session + +class User(SQLAlchemyBaseUserTable[uuid.UUID], Base): + __tablename__ = 'users' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String, nullable=False) + name = Column(String, nullable=False) + surname = Column(String, nullable=False) + patronymic = Column(String, nullable=True) + creation_on = Column(TIMESTAMP, default=datetime.utcnow) + last_entry = Column(TIMESTAMP, nullable=True) + hashed_password = Column(String(length=1024), nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + is_superuser = Column(Boolean, default=False, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + token = Column(String, nullable=True) + fcm_token = Column(String, nullable=True) # Идентификатор устройства для уведомлений + hashes_rfids = Column(ARRAY(String), default=[]) + face_embedding = Column(ARRAY(DOUBLE_PRECISION), nullable=True) + + devices = relationship("Device", secondary="active_devices", back_populates="users", viewonly=True) + active_devices = relationship("ActiveDevice") + group = relationship("Group", uselist=False, back_populates="users") + notifications = relationship("Notification", back_populates="user") + + async def include_relationship_for_read(self, session: AsyncSession): + await session.refresh(self, ["group"]) + return self + +async def get_user_db(session: AsyncSession = Depends(get_async_session)): + yield SQLAlchemyUserDatabase(session, User) + + + + +__all__ = ["get_active_devices", "get_active_devices_in_schema", "jwt_websocket_create_access_token", "get_tokens", + "get_tokens_from_refresh", + "optional_verifyed_access_token", "verifyed_access_token"] + + +async def get_active_devices(user: User = Depends(current_user), + session: AsyncSession = Depends(get_async_session), + cast_to_schema: bool = False) -> List[Device | DeviceRead]: + tables = [table for table in DeviceForInherit.__subclasses__() if table is not Device and table is not Hub] + stmt = ( + select(Device, ActiveDevice, *tables) # только one-to-one relathionship, иначе будет дублирование строк + .join(Device.active_devices) + .options(joinedload(ActiveDevice.role, innerjoin=True), + joinedload(Device.model)) + .where(ActiveDevice.user_id == user.id) + .where(Device.activated == True) + .join(Device.hub, isouter=True) + ) + for table in tables: + stmt = stmt.join(table, table.device_id == Device.id) + print("count join:", str(stmt).lower().count("join")) + devices = [] + async for device, active_device, *inherit_devices in await session.stream(stmt): + inherit_device = next((i for i in inherit_devices if i is not None), None) + device.__dict__["active_device"] = active_device + if inherit_device is None: + devices.append(device.get_schema() if cast_to_schema else inherit_device) + else: + inherit_device.device = device + devices.append(inherit_device.get_schema() if cast_to_schema else inherit_device) + return devices + + +async def get_active_devices_in_schema(user: User = Depends(current_user), + session: AsyncSession = Depends(get_async_session)) -> list[DeviceRead]: + return await get_active_devices(user, session, True) + + +def jwt_websocket_create_access_token(device: DeviceWebsocketAuth, + _=Depends(current_user), + authorize = None) -> str: + # Получение токена по пользователю для подключения устройства по вебсокетам + # При необходимости можно добавить ограничение по количествку токенов на пользователя или на устройства + return authorize.create_access_token(device.macaddress, user_claims=device.model_dump(), expires_time=False) + + +def jwt_request_create_access_token(claims: JwtAccessClaimsForDeviceRequests, authorize: None = Depends()) -> str: + return authorize.create_access_token(str(claims.device_id), user_claims=claims.model_dump(exclude="device_id")) + + +def jwt_request_create_refresh_token(claims: JwtRefreshClaimsForDeviceRequests, authorize: None = Depends()) -> str: + return authorize.create_refresh_token(claims.part_access, user_claims=claims.model_dump(mode='json')) + + +def get_tokens(device_id: uuid.UUID, authorize: None) -> TokensRead: + access_token = jwt_request_create_access_token(JwtAccessClaimsForDeviceRequests(device_id=device_id), authorize) + refresh_token = jwt_request_create_refresh_token( + JwtRefreshClaimsForDeviceRequests(device_id=device_id, part_access=access_token[:30]), + authorize + ) + tokens_data[authorize.get_raw_jwt(refresh_token)['jti']] = TokenDataServer.default() + return TokensRead( + access_token=access_token, + refresh_token=refresh_token + ) + + +def verifyed_refresh_token(access_token: str, refresh_token, counter_hash: str, + authorize = None) -> Tuple[JwtRefreshClaimsForDeviceRequests, TokenDataServer]: + authorize.jwt_refresh_token_required("websocket", token=refresh_token) + jwt_raw_refresh_token = authorize.get_raw_jwt(refresh_token) + jwt_raw_access_token = authorize.get_raw_jwt(access_token) + model = JwtRefreshClaimsForDeviceRequests.model_validate(jwt_raw_refresh_token) + + if model.part_access != access_token[:30]: + raise HTTPException( + status_code=403, + detail="part access token does not match input access token" + ) + token_data = tokens_data.get(jwt_raw_refresh_token['jti']) + # todo Если токены не сохраняться при падении сервера, тогда валидации не должно быть, + # если сохраняются всегда должны находиться данные по токену + if token_data is None: + token_data = TokenDataServer.default() + # raise HTTPException( + # status_code=403, + # detail="refresh token no save in server. Get refresh_token again in /get_tokens_from_user" + # ) + if token_data.hash_counter != counter_hash: + raise HTTPException( + status_code=403, + detail="invalid counter hash" + ) + # Проверка прошла успешно + data = TokenDataServer( + counter=token_data.counter + 1, + hash_counter=hashlib.md5(str(token_data.counter + 1).encode()).hexdigest() + ) + # Токены больше не актуальны, добавляем в чс + tokens_data.pop(jwt_raw_refresh_token['jti'], None) + tokens_data.pop(jwt_raw_access_token['jti'], None) + denylist.update({ + jwt_raw_refresh_token['jti'], + jwt_raw_access_token['jti'] + }) + return model, data + + +def verifyed_access_token(token: str | None, authorize = None + ) -> JwtAccessClaimsForDeviceRequests: + # Добавить доп валидацию при необходимости + authorize.jwt_required("websocket", token) + raw = authorize.get_raw_jwt(token) + return JwtAccessClaimsForDeviceRequests.model_validate(raw | {"device_id": raw["sub"]}) + + +def optional_verifyed_access_token(token: str | None = None, authorize = None + ) -> JwtAccessClaimsForDeviceRequests | None: + try: + return verifyed_access_token(token, authorize) + except: + return None + + +def get_tokens_from_refresh(model_data: JwtRefreshClaimsForDeviceRequests = Depends(verifyed_refresh_token), + authorize = None) -> TokensRead: + model, data = model_data + result = get_tokens(model.device_id, authorize) + tokens_data[authorize.get_raw_jwt(result.refresh_token)['jti']] = data + return result + + +async def device_activate(token: Optional[JwtAccessClaimsForDeviceRequests] = Depends(verifyed_access_token), + session: AsyncSession = Depends(get_async_session)) -> Device: + + device = await session.get(Device, ident=token.device_id) + if device is None: + raise EntityNotFound(Device, device_id=token.device_id, **token.model_dump()).get() + if device.activated: + raise HTTPException(status_code=400, detail="Устройство уже активировано") + device.activated = True + await session.commit() + return device diff --git a/agliullov_daniyar_lab_3/device_service/device/models.py b/agliullov_daniyar_lab_3/device_service/device/models.py new file mode 100644 index 0000000..bfc1b7f --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/models.py @@ -0,0 +1,158 @@ +import datetime +import abc +import uuid +from typing import Type + +from pydantic import BaseModel +from sqlalchemy import (Table, Column, Integer, String, TIMESTAMP, ForeignKey, Boolean, MetaData, + UUID, DateTime, Index, JSON, func) +from sqlalchemy.orm import relationship, class_mapper +from sqlalchemy.dialects.postgresql import MACADDR8, CIDR + +from database import Base + +__all__ = ["Device", "Model", "ActiveDevice", "Role", "DeviceForInherit", "Hub"] + + +from .schemas import DeviceCreate, ActiveDeviceCreate, DeviceRead + +current_user = lambda: None + +class DeviceForInherit(Base): + __abstract__ = True + + @classmethod + def create_from(cls, schema: DeviceCreate, + active_devices: list[ActiveDeviceCreate] | ActiveDeviceCreate | None = None, + user_id_in_active_devices: uuid.UUID | None = None) -> 'DeviceForInherit': + # overrides in inherits models + if not isinstance(schema, (DeviceCreate, "HubCreate")): + raise TypeError(f"schema must be a DeviceCreate or inherit from him, not {type(schema)}") + if cls == Device: + return cls.create_device(schema, active_devices, user_id_in_active_devices) + result = cls() + result.__dict__.update(schema.model_dump()) + result.device = cls.create_device(schema, active_devices, user_id_in_active_devices) + return result + + @abc.abstractmethod + def get_schema(self) -> DeviceRead: # overrides + pass + + def get_schema_from_type(self, T: Type[DeviceRead]) -> DeviceRead: + if 'device' not in self.__dict__: + raise ValueError(f'Property device must be loaded {self.__dict__}') + if 'hub' not in self.device.__dict__ and 'hub_id' in self.device.__dict__: + self.device.__dict__['hub'] = self.device.hub_id + elif isinstance(self.device.hub, Hub): + self.device.__dict__["hub"] = self.device.hub.__dict__ | self.device.hub.device.__dict__ + return T.model_validate(self.__dict__ | self.device.__dict__ | {"type_name": type(self).__name__}) + + @staticmethod + def create_device(schema: DeviceCreate, + active_devices: list[ActiveDeviceCreate] | ActiveDeviceCreate | None = None, + user_id_in_active_devices: uuid.UUID | None = None) -> 'Device': + schema_model = schema.model_dump(exclude={'hub', "model"}) + device = Device( + **{key: value for key, value in schema_model.items() if key in class_mapper(Device).attrs.keys()}, + ) + if hasattr(schema, 'hub') and isinstance(schema.hub, uuid.UUID): + device.hub_id = schema.hub + elif hasattr(schema, 'hub') and schema.hub is not None: + device.hub = Hub.create_from(schema.hub, active_devices, user_id_in_active_devices) + if isinstance(active_devices, list): + device.active_devices = [ActiveDevice.create_from(i, user_id_in_active_devices) for i in active_devices] + elif isinstance(active_devices, ActiveDeviceCreate): + device.active_devices = [ActiveDevice.create_from(active_devices, user_id_in_active_devices)] + if schema.model is not None: + device.model = Model(**schema.model.model_dump()) + return device + + +class Device(DeviceForInherit, Base): + __tablename__ = 'devices' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + model_id = Column(ForeignKey("models.id"), nullable=True) + + # use_alter - для разрешения цикличных зависимостей nullable - True, чтобы у хаба не было хаба + # (ограничение для остальных устройств будет на уровне схем) + # name - имя ограничения для субд и нормальной работы миграций и автотестов + hub_id = Column(ForeignKey("hubs.device_id", use_alter=True, name="fk_device_hub_id"), nullable=True) + + first_connection_date = Column(DateTime, default=datetime.datetime.utcnow) + last_connection_date = Column(DateTime, server_default=func.now(), onupdate=func.current_timestamp()) + macaddress = Column(MACADDR8, nullable=False) + serial_number = Column(Integer, nullable=False) + configuration = Column(JSON, nullable=False, default={}) + is_active = Column(Boolean, nullable=False) + activated = Column(Boolean, nullable=False, default=False, index=True) # Устройство подключилось к серверу + + hub = relationship("Hub", back_populates="connected_devices", uselist=False, foreign_keys=[hub_id]) + model = relationship('Model', uselist=False, cascade="all,delete",) + active_devices = relationship("ActiveDevice", cascade="all,delete", back_populates="device") + users = relationship('User', secondary='active_devices', back_populates="devices", viewonly=True) + + __table_args__ = ( + Index('unique_phys_device', "serial_number", "macaddress", unique=True), + ) + + def get_schema(self) -> DeviceRead: + return self.get_schema_from_type(DeviceRead) + + +class Hub(DeviceForInherit): + __tablename__ = 'hubs' + + device_id = Column(ForeignKey('devices.id'), primary_key=True) + ip_address = Column(CIDR, nullable=False) + port = Column(Integer, nullable=False) + + device = relationship("Device", uselist=False, foreign_keys=[device_id]) + connected_devices = relationship("Device", foreign_keys=[Device.hub_id], back_populates="hub") + + def get_schema(self) -> "HubRead": + return self.get_schema_from_type("HubRead") + + +class Model(Base): + __tablename__ = 'models' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + version = Column(String, nullable=False) + + +class Role(Base): + __tablename__ = 'roles' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + permissions = Column(JSON, nullable=False) # todo продумать структуру + + active_device = relationship("ActiveDevice", uselist=False, back_populates="role") + + +class ActiveDevice(Base): + __tablename__ = 'active_devices' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(ForeignKey('users.id')) + device_id = Column(ForeignKey('devices.id')) + role_id = Column(ForeignKey('roles.id'), nullable=False) + last_connection = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + + device = relationship(Device, uselist=False, back_populates="active_devices") + role = relationship(Role, uselist=False, back_populates="active_device") + user = relationship("User", uselist=False, back_populates="active_devices") + + __table_args__ = ( + Index('fk_index', "user_id", "device_id", unique=True), + ) + + @staticmethod + def create_from(schema: ActiveDeviceCreate, user_id: uuid.UUID | None = None) -> "ActiveDevice": + md = schema.model_dump(exclude={'role'}) + if user_id is not None: + md['user_id'] = user_id + return ActiveDevice(role=Role(**schema.role.model_dump()), **md) diff --git a/agliullov_daniyar_lab_3/device_service/device/router.py b/agliullov_daniyar_lab_3/device_service/device/router.py new file mode 100644 index 0000000..91c735f --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/router.py @@ -0,0 +1,154 @@ +from typing import List, Any, Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException +from fastapi.encoders import jsonable_encoder +from starlette.responses import HTMLResponse + +from jwt_config import denylist, jwt_websocket_required_auth +from . import Device +from .dependencies import get_active_devices_in_schema, get_tokens, get_tokens_from_refresh, device_activate +from .schemas import DeviceRead, DeviceWebsocketAuth, TokensRead + + +current_user = lambda: None + + +__all__ = ["router", "manager", "denylist", "websocket_router"] + + +router = APIRouter( + prefix="/device", + tags=["Device"] +) +websocket_router = APIRouter() + + +@router.get("/active_devices") +async def get_all_active_devices_for_user(active_devices=Depends(get_active_devices_in_schema) + ) -> List[DeviceRead | None]: + """Получает все доступные **активированные** устройства всех видов, связанные с текущем пользователем. + Внутри сущностей храниться **active_device**, в котором обозначена взаимодействие устройства с конкретным пользователем""" + return active_devices + + +@router.get("/token_from_user") +async def get_tokens_for_device_from_user( + tokens: TokensRead = Depends(get_tokens), + _=Depends(current_user)) -> TokensRead: + """Генерирует токены по переданому **device_id**, валидация devie_id не происходит, + просто токен с невалидным id нельзя будет нигде применить""" + return tokens + + +@router.patch("/activate", responses={400: {"description": "Устройство уже активировано"}}) +async def activate_added_device(device: Device = Depends(device_activate)) -> DeviceRead: + return device.__dict__ + + +@router.get("/refresh_token", responses={ + 401: {"description": "Некорректно переданные токены"}, + 403: {"description": "Некорректно переданные токены"} +}) +async def refresh_tokens( + tokens: TokensRead = Depends(get_tokens_from_refresh)) -> TokensRead: + return tokens + + +class ConnectionManager: + def __init__(self): + self.active_connections: dict[DeviceWebsocketAuth, WebSocket] = {} + + async def connect(self, websocket: WebSocket, + token: str, + authorize = None + ) -> Optional[DeviceWebsocketAuth]: + + decoded_token, device = jwt_websocket_required_auth(token, authorize) + + await websocket.accept() + old_websocket = self.active_connections.get(device) # теоретически на одно устройство одно вебсокет подключение + if old_websocket is not None: + await old_websocket.close() + self.active_connections[device] = websocket + denylist.add(decoded_token['jti']) + return device + + def disconnect(self, device: DeviceWebsocketAuth): + self.active_connections.pop(device, None) + + async def send_message_to_device(self, device: DeviceWebsocketAuth, message: str | Any) -> WebSocket: + device_auth = DeviceWebsocketAuth.model_validate(device, from_attributes=True) + websocket = self.active_connections.get(device_auth) + if websocket is None: + raise HTTPException(status_code=404, detail={ + "msg": "Устройство, статус которого пытаются изменить, не подключен к серверу", + "device": jsonable_encoder(device) + }) + if isinstance(message, str): + await websocket.send_text(message) + else: + await websocket.send_json(message) + return websocket + + async def broadcast(self, message: str): + for connection in self.active_connections.values(): + await connection.send_text(message) + + async def change_status_smart_lock(self, smart_lock: 'SmartLockRead'): + # При необходимости ограничить или типизировать отправляемый ответ + await self.send_message_to_device(smart_lock, smart_lock.model_dump()) + + +manager = ConnectionManager() + + +@websocket_router.websocket("/") +async def websocket_endpoint(websocket: WebSocket, token: str, authorize: None): + try: + device = await manager.connect(websocket, token, authorize) + except Exception: + await websocket.close() + return + try: + while True: + data = await websocket.receive_json() + except WebSocketDisconnect: + manager.disconnect(websocket) + + +# Debug + +html = """ + + + + Authorize + + +

WebSocket Authorize

+

Token:

+

+ + + + + +""" + + +@websocket_router.get("/debug_form", tags=["Debug"]) +async def get(): + return HTMLResponse(html) diff --git a/agliullov_daniyar_lab_3/device_service/device/schemas.py b/agliullov_daniyar_lab_3/device_service/device/schemas.py new file mode 100644 index 0000000..0e8a73a --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/schemas.py @@ -0,0 +1,68 @@ +import binascii +import hashlib +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import * +from pydantic_extra_types.mac_address import MacAddress + +from .common_schemas import * + +__all__ = ["ModelRead", "DeviceCreate", "DeviceRead", "RoleRead", "RoleCreate", + "ActiveDeviceCreate", "ActiveDeviceRead", "DeviceWebsocketAuth", "JwtRefreshClaimsForDeviceRequests", + "JwtAccessClaimsForDeviceRequests", "TokensRead", "TokenDataServer"] + + +class DeviceWebsocketAuth(BaseModel, frozen=True): + macaddress: MacAddress + last_connection_date: datetime + model: Optional[ModelRead] = None + + +class JwtAccessClaimsForDeviceRequests(BaseModel): + device_id: uuid.UUID + + +class JwtRefreshClaimsForDeviceRequests(BaseModel): + part_access: str + device_id: uuid.UUID + + +class TokenDataServer(BaseModel): + counter: int + hash_counter: str + + @staticmethod + def hash(counter: int | str): + return binascii.hexlify(hashlib.md5(str(counter).encode()).digest()) + + @staticmethod + def default(): + return TokenDataServer( + counter=0, + hash_counter=TokenDataServer.hash(0) + ) + + +class TokensRead(BaseModel): + access_token: str + refresh_token: str + + +class DeviceCreate(BaseModel, from_attributes=True): + macaddress: MacAddress + is_active: bool + configuration: Json | dict + serial_number: int + hub: uuid.UUID | None = None + model: Optional[ModelRead] = None + + +class DeviceRead(DeviceCreate): + id: uuid.UUID + type_name: str = "Device" + hub: uuid.UUID | None = None + activated: bool = False + active_device: Optional[ActiveDeviceRead] = None + last_connection_date: datetime | None = None diff --git a/agliullov_daniyar_lab_3/device_service/device/test_connect.json b/agliullov_daniyar_lab_3/device_service/device/test_connect.json new file mode 100644 index 0000000..cc66d9c --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/test_connect.json @@ -0,0 +1 @@ +{"doorlock": {"ip": "192.168.31.222", "host": "21488", "mac": "", "type": "phone"}, "phone": {"ip": "192.168.31.222", "host": "53970", "mac": "1", "type": "controller"}} \ No newline at end of file diff --git a/agliullov_daniyar_lab_3/device_service/device/test_device.py b/agliullov_daniyar_lab_3/device_service/device/test_device.py new file mode 100644 index 0000000..418ea43 --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/test_device.py @@ -0,0 +1,89 @@ +import hashlib +import uuid + +import pytest +from sqlalchemy import select + +from device import DeviceRead, Device, TokenDataServer +from smart_lock.schemas import SmartLockCreate, SmartLockRead +from test_database import * +from auth.test_auth import * +from smart_lock.test_smart_lock import get_json_for_create + + +@pytest.mark.asyncio +async def test_get_active_devices(auth_client: AsyncClient, session: AsyncSession): + # Добавляем устройства всех возможных типов + # todo более внятный тест + json_smart_lock = get_json_for_create() + json_smart_lock_2 = get_json_for_create() + json_smart_lock_2["smart_lock"]["hub"]["ip_address"] = "192.168.1.1/32" + json_smart_lock_2["smart_lock"]["serial_number"] = 344 + json_smart_lock_2["smart_lock"]["macaddress"] = "22:11:22:ff:fe:33:44:55" + json_smart_lock_2["smart_lock"]["hub"]["serial_number"] = 99 + + response = await auth_client.post("/smart_lock/", json=json_smart_lock) + response = await auth_client.patch("for_device/activate", params={"token": response.json()["access_token"]}) + response = await auth_client.post("/smart_lock/", json=json_smart_lock) + assert response.json()["activated"] is False + response = await auth_client.patch("for_device/activate", params={"token": response.json()["access_token"]}) + response = await auth_client.post("/smart_lock/", json=json_smart_lock_2) + response = await auth_client.patch("for_device/activate", params={"token": response.json()["access_token"]}) + + response = await auth_client.get("/device/active_devices") + assert response.status_code == 200 + json = response.json() + models: list[DeviceRead] = [DeviceRead.model_validate(i) for i in json] + assert len(models) == 2 + + # check polymorphism + excluded = {'id', 'last_interaction_date', 'active_device', "hub", "last_connection_date", "activated"} + json_smart_lock["smart_lock"]["hub"]["id"] = uuid.uuid4() + json_smart_lock_2["smart_lock"]["hub"]["id"] = uuid.uuid4() + assert (SmartLockRead(**json[0]).model_dump(exclude=excluded) == + SmartLockRead(**json_smart_lock["smart_lock"], id=uuid.uuid4()).model_dump(exclude=excluded)) + assert (SmartLockRead(**json[1]).model_dump(exclude=excluded) == + SmartLockRead(**json_smart_lock_2["smart_lock"], id=uuid.uuid4()).model_dump(exclude=excluded)) + + +@pytest.mark.asyncio +async def test_get_token_from_user(auth_client: AsyncClient, session: AsyncSession): + device = (await session.execute(select(Device))).scalar() + response = await auth_client.get("/device/token_from_user", params={"device_id": device.id}) + assert response.status_code == 200 + assert list(response.json().keys()) == ["access_token", "refresh_token"] + print(response.json()) + + +@pytest.mark.asyncio +async def test_refresh_token(auth_client: AsyncClient, session: AsyncSession): + device = (await session.execute(select(Device))).scalar() + response = await auth_client.get("/device/token_from_user", params={"device_id": device.id}) + assert response.status_code == 200 + json = response.json() + json["counter_hash"] = TokenDataServer.hash(0).decode() # Не было обновений, значит ставим 0 + + # 1 обновление токена + response = await auth_client.get("/for_device/refresh_token", params=json) + assert response.status_code == 200 + json1 = response.json() + + response = await auth_client.get("/for_device/refresh_token", params=json) + assert response.status_code in (401, 403) # Token has been revoked + + # 2 обновление токена со старым счетчиком (не должен сработать) + json1["counter_hash"] = json["counter_hash"] + response = await auth_client.get("/for_device/refresh_token", params=json1) + assert response.status_code in (401, 403) # invalid counter hash + + # 2 обновление токена с обновленным счетчиком + json1["counter_hash"] = TokenDataServer.hash(1).decode() + response = await auth_client.get("/for_device/refresh_token", params=json1) + assert response.status_code == 200 + + # 3 обновление токена с обновленным счетчиком + # hashlib.md5(str(2).encode()).hexdigest() + json2 = response.json() + json2["counter_hash"] = TokenDataServer.hash(2).decode() + response = await auth_client.get("/for_device/refresh_token", params=json2) + assert response.status_code == 200 diff --git a/agliullov_daniyar_lab_3/device_service/device/websocket_auth.py b/agliullov_daniyar_lab_3/device_service/device/websocket_auth.py new file mode 100644 index 0000000..e46da96 --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/device/websocket_auth.py @@ -0,0 +1,12 @@ +from fastapi import Depends + +from .dependencies import jwt_websocket_create_access_token +from .router import router +from .schemas import DeviceWebsocketAuth + + +@router.post("/ws/token") +def get_access_token_for_connection_to_device(access_token: str = Depends(jwt_websocket_create_access_token)): + return { + "access_token": access_token + } diff --git a/agliullov_daniyar_lab_3/device_service/jwt_config.py b/agliullov_daniyar_lab_3/device_service/jwt_config.py new file mode 100644 index 0000000..28d732c --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/jwt_config.py @@ -0,0 +1,40 @@ +import abc +import os +from typing import Tuple + +from fastapi import Depends, Query +from pydantic import BaseModel + +from device.schemas import DeviceWebsocketAuth, TokenDataServer + +# Множество заблокированных токенов доступа по вебсокетам. Токены одноразовые, но при дропе системы список должен +# очищаться (не хранится в постоянной памяти), чтобы устройства могли повторно подключиться к вебсокетам после +# восстановления +# todo перенести на redis +denylist = set() +tokens_data: dict[str, TokenDataServer] = dict() + + +class Settings(BaseModel): + authjwt_secret_key: str = os.environ.get("SECRET_DEVICE_AUTH_TOKEN") + authjwt_denylist_enabled: bool = True + authjwt_denylist_token_checks: set = {"access", "refresh"} + authjwt_refresh_token_expires: bool = False + + +def check_if_token_in_denylist(decrypted_token): + jti = decrypted_token['jti'] + return jti in denylist + + +def get_config(): + return Settings() + + +def jwt_websocket_required_auth(token: str = Query(...), authorize: None = Depends()) -> Tuple[dict, 'DeviceWebsocketAuth']: + authorize.jwt_required("websocket", token=token) + + decoded_token = authorize.get_raw_jwt(token) + device = DeviceWebsocketAuth.model_validate(decoded_token) + + return decoded_token, device diff --git a/agliullov_daniyar_lab_3/device_service/main.py b/agliullov_daniyar_lab_3/device_service/main.py new file mode 100644 index 0000000..a04bb03 --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/main.py @@ -0,0 +1,67 @@ +import datetime +import time +import json + +import uvicorn +from fastapi import FastAPI, Depends, Body +from fastapi.staticfiles import StaticFiles +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.exc import DBAPIError +from starlette.requests import Request +from starlette.responses import JSONResponse + +from device.router import router, websocket_router + +app = FastAPI( + title="Smart lock", + root_path="/devices" +) + +@app.get("/docs", include_in_schema=False) +async def custom_swagger_ui_html(req: Request): + root_path = req.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + app.openapi_url + return get_swagger_ui_html( + openapi_url=openapi_url, + title="API", + ) + +app.include_router(router) +app.include_router(websocket_router) + + +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + start_time = time.perf_counter() + response = await call_next(request) + process_time = time.perf_counter() - start_time + print(f"Finished in {process_time:.3f}") + response.headers["X-Process-Time"] = str(process_time) + return response + + +@app.exception_handler(DBAPIError) +def authjwt_exception_handler(request: Request, exc: DBAPIError): + return JSONResponse( + status_code=500, + content={"detail": jsonable_encoder(exc) | {"args": jsonable_encoder(exc.args)}} + ) + + +# @app.exception_handler(Exception) +# def authjwt_exception_handler(request: Request, exc: Exception): +# print(exc.with_traceback(None)) +# return JSONResponse( +# status_code=500, +# content={ +# "name_exception": exc.__class__.__name__, +# "args": getattr(exc, "args", []), +# "detail": jsonable_encoder(exc) +# } +# ) + + +if __name__ == '__main__': + uvicorn.run(app=app, host="0.0.0.0", port=8001) + # uvicorn.run(app="main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/agliullov_daniyar_lab_3/device_service/requirements.txt b/agliullov_daniyar_lab_3/device_service/requirements.txt new file mode 100644 index 0000000..53a6d7c --- /dev/null +++ b/agliullov_daniyar_lab_3/device_service/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.109.1 +uvicorn +sqlalchemy +fastapi-users==12.1.2 +python-dotenv +asyncpg +pydantic_extra_types +fastapi_jwt_auth +fastapi_users_db_sqlalchemy \ No newline at end of file diff --git a/agliullov_daniyar_lab_3/docker-compose.yml b/agliullov_daniyar_lab_3/docker-compose.yml new file mode 100644 index 0000000..3d425dd --- /dev/null +++ b/agliullov_daniyar_lab_3/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + user_service: + env_file: ".env" + build: + context: ./user_service + dockerfile: Dockerfile + environment: + - PORT=8000 + ports: + - "8000:8000" + + device_service: + env_file: ".env" + build: + context: ./device_service + dockerfile: Dockerfile + environment: + - PORT=8001 + ports: + - "8001:8001" + + nginx: + image: nginx:latest + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf + ports: + - "80:80" + depends_on: + - user_service + - device_service diff --git a/agliullov_daniyar_lab_3/nginx/nginx.conf b/agliullov_daniyar_lab_3/nginx/nginx.conf new file mode 100644 index 0000000..eb2412c --- /dev/null +++ b/agliullov_daniyar_lab_3/nginx/nginx.conf @@ -0,0 +1,11 @@ +server { + listen 80; + + location /users { + proxy_pass http://user_service:8000; + } + + location /devices/ { + proxy_pass http://device_service:8001; + } +} diff --git a/agliullov_daniyar_lab_3/readme.md b/agliullov_daniyar_lab_3/readme.md new file mode 100644 index 0000000..d27078d --- /dev/null +++ b/agliullov_daniyar_lab_3/readme.md @@ -0,0 +1,48 @@ +# Лабораторная работа №3: REST API, шлюз и синхронный обмен данными между микросервисами + +## Задание реализовать два микросервиса связанные единой базой и сущностями один ко многим + + - Первый микросервис user предоставляет методы для регистрации авторизации и выхода пользователей + - Второй микросервис реализует сущность устройства, которые связанные с пользователями связью многие ко многим + +### Реализация +Оба микросервиса написаны на fastapi и развернуты на вебсервере uvicorn. В самом докере развертывание осущетсвляется через веб сервер nginx, +который осуществляет роль прокси, прослушивая 80 http порт и перенправляя запрос на тот или иной микросервис + +Для корректной работы swagger-а необходимо перенести location из nginx.conf в fastapi, чтобы ему был известен корневой путь, для построения документации + +`app = FastAPI( + title="Smart lock", + root_path="/devices" +)` + +`location /devices/ { + +proxy_pass http://device_service:8001; + +}` + +### Развертывание +В корневую папку добавляем файл .env, где указываем значение переменных среды + +**Пример файла:** + +`DB_HOST = localhost +DB_PORT = 5432 +DB_NAME = +DB_USER = postgres +DB_PASS = 1234 + +SECRET_AUTH_TOKEN = 1 +SECRET_VERIFICATE_TOKEN = 1 +SECRET_DEVICE_AUTH_TOKEN = 1 + +DEVICE_START_COUNTER = 1 + +STATIC_URL = https://testapi.danserver.keenetic.name/static/` + +Далее указываем: + +docker-compose up --build + +[Видео](https://disk.yandex.ru/d/BGenDWYY6tIGaw) diff --git a/agliullov_daniyar_lab_3/user_service/Dockerfile b/agliullov_daniyar_lab_3/user_service/Dockerfile new file mode 100644 index 0000000..5863c11 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "--host", "0.0.0.0", "main:app", "--port", "8000"] diff --git a/agliullov_daniyar_lab_3/user_service/auth/__init__.py b/agliullov_daniyar_lab_3/user_service/auth/__init__.py new file mode 100644 index 0000000..31ddba4 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/__init__.py @@ -0,0 +1,6 @@ +from .models import * +from .router import * +from .schemas import * +from .manager import * +from .base_config import * +from .dependencies import * diff --git a/agliullov_daniyar_lab_3/user_service/auth/base_config.py b/agliullov_daniyar_lab_3/user_service/auth/base_config.py new file mode 100644 index 0000000..d617dba --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/base_config.py @@ -0,0 +1,34 @@ +# +import uuid + +from fastapi_users import FastAPIUsers +from fastapi_users.authentication import CookieTransport, AuthenticationBackend, BearerTransport +from fastapi_users.authentication import JWTStrategy + +from .models import User +from .manager import get_user_manager +from config import SECRET_AUTH_TOKEN + +__all__ = ["fastapi_users", "current_user", "auth_backend", "current_user_optional"] + +cookie_transport = CookieTransport() +bearer_transport = BearerTransport("/token") + + +def get_jwt_strategy() -> JWTStrategy: + return JWTStrategy(secret=SECRET_AUTH_TOKEN, lifetime_seconds=None) + + +auth_backend = AuthenticationBackend( + name="jwt", + transport=cookie_transport, + get_strategy=get_jwt_strategy, +) + +fastapi_users = FastAPIUsers[User, uuid.UUID]( + get_user_manager, + [auth_backend], +) + +current_user = fastapi_users.current_user() +current_user_optional = fastapi_users.current_user(optional=True) diff --git a/agliullov_daniyar_lab_3/user_service/auth/dependencies.py b/agliullov_daniyar_lab_3/user_service/auth/dependencies.py new file mode 100644 index 0000000..43a5c71 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/dependencies.py @@ -0,0 +1,13 @@ +# +from fastapi import Depends +from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase +from sqlalchemy.ext.asyncio import AsyncSession + +from .models import User +from database import get_async_session + +__all__ = ["get_user_db"] + + +async def get_user_db(session: AsyncSession = Depends(get_async_session)): + yield SQLAlchemyUserDatabase(session, User) diff --git a/agliullov_daniyar_lab_3/user_service/auth/manager.py b/agliullov_daniyar_lab_3/user_service/auth/manager.py new file mode 100644 index 0000000..7f5c649 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/manager.py @@ -0,0 +1,99 @@ +# +import uuid +from typing import Optional + +from fastapi import Depends, Request, Response, HTTPException +from fastapi.encoders import jsonable_encoder +from fastapi.security import OAuth2PasswordRequestForm +from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions, models, schemas +from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql.functions import user + +from database import get_async_session +from .dependencies import get_user_db +from .models import * +from config import SECRET_VERIFICATE_TOKEN as SECRET + +__all__ = ["UserManager", "get_user_manager"] + +from .schemas import UserUpdate + + +class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): + reset_password_token_secret = SECRET + verification_token_secret = SECRET + + async def on_after_register(self, user: User, request: Optional[Request] = None): + print(f"User {user.id} has registered.") + + async def on_after_login( + self, + user: models.UP, + request: Optional[Request] = None, + response: Optional[Response] = None, + ) -> None: + if user.fcm_token is not None: + await self.update(UserUpdate(fcm_token=user.fcm_token), user, request=request) + + async def create( + self, + user_create: schemas.UC, + safe: bool = False, + request: Optional[Request] = None, + ) -> models.UP: + await self.validate_password(user_create.password, user_create) + + existing_user = await self.user_db.get_by_email(user_create.email) + if existing_user is not None: + raise exceptions.UserAlreadyExists() + + user_dict = ( + user_create.create_update_dict() + if safe + else user_create.create_update_dict_superuser() + ) + password = user_dict.pop("password") + user_dict["hashed_password"] = self.password_helper.hash(password) + + self._link_or_create_entity(user_dict, "group", Group) + + try: + created_user = await self.user_db.create(user_dict) + except IntegrityError as e: + if 'group' not in e.statement: + raise e + detail = { + "msg": f"Некорректный идентификатор группы {user_dict.get('group_id')=} введенный при регистрации", + "inner_exception": jsonable_encoder(e) + } + raise HTTPException(status_code=403, detail=detail) + if hasattr(self.user_db, "session"): + await created_user.include_relationship_for_read(self.user_db.session) + + await self.on_after_register(created_user, request) + + return created_user + + async def authenticate( + self, credentials: OAuth2PasswordRequestForm + ) -> Optional[models.UP]: + user_auth = await super().authenticate(credentials) + if user_auth is None: + return None + user_auth.fcm_token = credentials.fcm_token + return user_auth + + @staticmethod + def _link_or_create_entity(user_dict: dict, name: str, EntityType): + entity = user_dict.get(name) + if isinstance(entity, (uuid.UUID, int)) or entity is None: + user_dict.pop(name, None) + user_dict[name + "_id"] = entity + else: + user_dict[name] = EntityType(**entity) + + + + +async def get_user_manager(user_db=Depends(get_user_db)): + yield UserManager(user_db) diff --git a/agliullov_daniyar_lab_3/user_service/auth/models.py b/agliullov_daniyar_lab_3/user_service/auth/models.py new file mode 100644 index 0000000..fa57c56 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/models.py @@ -0,0 +1,52 @@ +import uuid +from datetime import datetime + +from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTable +from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, JSON, Boolean, MetaData, DateTime, UUID, \ + ARRAY, DECIMAL, DOUBLE_PRECISION +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import relationship + +from database import Base + +__all__ = ["Group", "User"] + + +class Group(Base): + __tablename__ = "groups" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False) + comment = Column(String, nullable=True) + + users = relationship("User", back_populates="group") + + +class User(SQLAlchemyBaseUserTable[uuid.UUID], Base): + __tablename__ = 'users' + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String, nullable=False) + name = Column(String, nullable=False) + surname = Column(String, nullable=False) + patronymic = Column(String, nullable=True) + creation_on = Column(TIMESTAMP, default=datetime.utcnow) + last_entry = Column(TIMESTAMP, nullable=True) + group_id = Column(ForeignKey(Group.id), nullable=True) + hashed_password = Column(String(length=1024), nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + is_superuser = Column(Boolean, default=False, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + token = Column(String, nullable=True) + fcm_token = Column(String, nullable=True) # Идентификатор устройства для уведомлений + hashes_rfids = Column(ARRAY(String), default=[]) + face_embedding = Column(ARRAY(DOUBLE_PRECISION), nullable=True) + + devices = relationship("Device", secondary="active_devices", back_populates="users", viewonly=True) + active_devices = relationship("ActiveDevice") + group = relationship(Group, uselist=False, back_populates="users") + notifications = relationship("Notification", back_populates="user") + + async def include_relationship_for_read(self, session: AsyncSession): + await session.refresh(self, ["group"]) + return self diff --git a/agliullov_daniyar_lab_3/user_service/auth/router.py b/agliullov_daniyar_lab_3/user_service/auth/router.py new file mode 100644 index 0000000..c382c89 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/router.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_async_session +from . import User +from .base_config import current_user +from .schemas import UserRead + +__all__ = ["user_router"] + +user_router = APIRouter(prefix='/auth', tags=['Auth']) + + +@user_router.get("/current_user") +async def get_current_user(user=Depends(current_user), session=Depends(get_async_session)) -> UserRead: + return await user.include_relationship_for_read(session) + + +@user_router.patch("/fcm_token/{fcm_token}") +async def refresh_fcm_token(fcm_token: str | None = None, + user=Depends(current_user), session=Depends(get_async_session)) -> UserRead: + if fcm_token is not None: + user.fcm_token = fcm_token + await session.commit() + return user diff --git a/agliullov_daniyar_lab_3/user_service/auth/schemas.py b/agliullov_daniyar_lab_3/user_service/auth/schemas.py new file mode 100644 index 0000000..9e58cae --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/schemas.py @@ -0,0 +1,39 @@ +# +import datetime +import uuid +from typing import Optional, Union + +from sqlalchemy import TIMESTAMP +from fastapi_users import schemas +from pydantic import EmailStr, BaseModel, Json + +__all__ = ["GroupRead", "GroupCreate", "UserRead", "UserCreate", "UserUpdate"] + + +class GroupCreate(BaseModel): + name: str + comment: Optional[str] + + +class GroupRead(GroupCreate, from_attributes=True): + id: uuid.UUID + + +class UserRead(schemas.BaseUser[uuid.UUID], from_attributes=True): + name: str + surname: str + patronymic: Optional[str] + creation_on: datetime.datetime + fcm_token: Optional[str] = None + group: Optional[GroupRead] = None + + +class UserCreate(schemas.BaseUserCreate): + name: str + surname: str + patronymic: Optional[str] = None + group: Union[None, uuid.UUID, GroupCreate] = None + + +class UserUpdate(schemas.BaseUserUpdate): + fcm_token: Optional[str] = None diff --git a/agliullov_daniyar_lab_3/user_service/auth/test_auth.py b/agliullov_daniyar_lab_3/user_service/auth/test_auth.py new file mode 100644 index 0000000..6429cf3 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/auth/test_auth.py @@ -0,0 +1,98 @@ +import httpx +import pytest +from fastapi import Depends + +from test_database import * +from . import User +from .schemas import UserCreate, GroupCreate, UserRead + + +__all__ = ["client", "auth_client", "get_generate_user", "session"] + + +def _add_password(user: User, password: str): + (user if isinstance(user, dict) else user.__dict__)["password"] = password + return user + + +def get_generate_user(unique=False): + if not unique: + return UserCreate( + name="user", + surname="test", + group=GroupCreate(name="test", comment="test_comment"), + email="user@localhost.ru", + password="" + ) + + +@pytest_asyncio.fixture(scope="session") +async def auth_client(app) -> AsyncClient: + async with AsyncClient(app=app, base_url="http://127.0.0.1:9000") as client: + user = get_generate_user() + await client.post("/auth/register", json=user.model_dump()) + response = await client.post("/auth/login", data={ + 'username': user.email, + 'password': user.password + }) + cookie = list(dict(response.cookies).items())[-1] + client.headers["Cookie"] = "=".join(cookie) + yield client + + +@pytest.mark.asyncio +async def test_register_user(client, session) -> UserCreate: + user = get_generate_user() + + response = await client.post("/auth/register", json=user.model_dump()) + assert response.status_code == 201, response.json() + + result = response.json() + db_user = await (await session.get(User, result["id"])).include_relationship_for_read(session) + + assert db_user is not None + assert result.pop("id", None) is not None, response.json() + + compare_model = UserCreate.model_validate(_add_password(result, user.password)).model_dump() + db_compare_model = UserCreate.model_validate(_add_password(db_user, user.password), from_attributes=True).model_dump() + assert compare_model == db_compare_model + assert compare_model == user.model_dump() + assert user.model_dump() == db_compare_model + response = await client.post("/auth/register", json=user.model_dump()) + assert response.status_code == 400, response.json() + return user + + +@pytest.mark.asyncio +async def test_login_user(client) -> AsyncClient: + user = get_generate_user() + await client.post("/auth/register", json=user.model_dump()) + response = await client.post("/auth/login", data={ + 'username': user.email, + 'password': user.password + "0" + }) + assert response.status_code == 400, response.json() + response = await client.post("/auth/login", data={ + 'username': user.email, + 'password': user.password + }) + assert response.status_code == 200 or response.status_code == 204, response.json() + return user + + +@pytest.mark.asyncio +async def test_authorization_and_logout(auth_client): + client: httpx.AsyncClient = auth_client + response = await client.get("auth/current_user") + assert response.status_code == 200 + result_user = UserRead(**response.json()) + sourse_user = get_generate_user() + assert result_user.email == sourse_user.email + assert result_user.name == sourse_user.name + response = await client.post("/auth/logout") + assert response.status_code == 200 or response.status_code == 204 + assert not response.cookies + auth_cookies = client.headers.pop("Cookie") + response = await client.get("/auth/current_user") + assert response.status_code == 401 + client.headers["Cookie"] = auth_cookies diff --git a/agliullov_daniyar_lab_3/user_service/config.py b/agliullov_daniyar_lab_3/user_service/config.py new file mode 100644 index 0000000..5984cac --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/config.py @@ -0,0 +1,21 @@ +import os +import logging + +from dotenv import load_dotenv + +load_dotenv() + +DB_HOST = os.environ.get("DB_HOST") +DB_PORT = os.environ.get("DB_PORT") +DB_NAME = os.environ.get("DB_NAME") +DB_USER = os.environ.get("DB_USER") +DB_PASS = os.environ.get("DB_PASS") + + +SECRET_AUTH_TOKEN = os.environ.get("SECRET_AUTH_TOKEN") +SECRET_VERIFICATE_TOKEN = os.environ.get("SECRET_VERIFICATE_TOKEN") + +DEVICE_START_COUNTER = os.environ.get("DEVICE_START_COUNTER") + +logging.getLogger('passlib').setLevel(logging.ERROR) + diff --git a/agliullov_daniyar_lab_3/user_service/database.py b/agliullov_daniyar_lab_3/user_service/database.py new file mode 100644 index 0000000..fd324fc --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/database.py @@ -0,0 +1,49 @@ +import time +from sys import gettrace +from typing import AsyncGenerator + +from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder +from sqlalchemy import MetaData +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from config import DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_USER + +from pydantic import BaseModel +from sqlalchemy import Table, Column, Integer, String, TIMESTAMP, ForeignKey, Boolean, MetaData, PrimaryKeyConstraint +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + def fill_from_model(self, model: BaseModel | dict): + """Заполняет все поля из словаря или модели, помечая объект 'грязным' для корректного обновления""" + if isinstance(model, BaseModel): + items = model.model_dump().items() + else: + items = model.items() + + for key, value in items: + setattr(self, key, value) + +DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + +engine = create_async_engine(DATABASE_URL, echo=gettrace() is not None) +async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session + + +async def begin_transactions() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker().begin() as transaction: + try: + yield transaction.session + except Exception as e: + await transaction.rollback() + raise e + await transaction.commit() diff --git a/agliullov_daniyar_lab_3/user_service/main.py b/agliullov_daniyar_lab_3/user_service/main.py new file mode 100644 index 0000000..55515db --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/main.py @@ -0,0 +1,81 @@ +import datetime +import time +import json + +# import firebase_admin +import uvicorn +from fastapi import FastAPI, Depends, Body +from fastapi.staticfiles import StaticFiles +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.exc import DBAPIError +from starlette.requests import Request +from starlette.responses import JSONResponse + +from auth import * + +app = FastAPI( + title="Smart lock", + root_path="/users" +) + +@app.get("/docs", include_in_schema=False) +async def custom_swagger_ui_html(req: Request): + root_path = req.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + app.openapi_url + return get_swagger_ui_html( + openapi_url=openapi_url, + title="API", + ) + +app.include_router( + fastapi_users.get_auth_router(auth_backend), + prefix="/auth", + tags=["Auth"], +) + +app.include_router( + fastapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + tags=["Auth"], +) + +# firebase +# cred = credentials.Certificate("../serviceAccountKey.json") +# try: +# fire_app = firebase_admin.initialize_app(cred) +# except ValueError: +# pass + + +class Message(BaseModel): + msg: str + + +@app.post("/msg") +def debug_message_from_user(msg: Message = Body(), user: User = Depends(current_user_optional)): + print(datetime.datetime.now(), end=' ') + if user is not None: + print(user.__dict__) + print(msg.msg) + + +@app.middleware("http") +async def add_process_time_header(request: Request, call_next): + start_time = time.perf_counter() + response = await call_next(request) + process_time = time.perf_counter() - start_time + print(f"Finished in {process_time:.3f}") + response.headers["X-Process-Time"] = str(process_time) + return response + + +@app.exception_handler(DBAPIError) +def authjwt_exception_handler(request: Request, exc: DBAPIError): + return JSONResponse( + status_code=500, + content={"detail": jsonable_encoder(exc) | {"args": jsonable_encoder(exc.args)}} + ) + +if __name__ == '__main__': + uvicorn.run(app=app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/agliullov_daniyar_lab_3/user_service/requirements.txt b/agliullov_daniyar_lab_3/user_service/requirements.txt new file mode 100644 index 0000000..4f588b8 --- /dev/null +++ b/agliullov_daniyar_lab_3/user_service/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn +fastapi-users +sqlalchemy +fastapi_users_db_sqlalchemy +python-dotenv +asyncpg \ No newline at end of file