Merge pull request 'agliullov_daniyar_lab_3 is ready' (#409) from agliullov_daniyar_lab_3 into main
Reviewed-on: #409
This commit is contained in:
commit
457efe5b11
10
agliullov_daniyar_lab_3/device_service/Dockerfile
Normal file
10
agliullov_daniyar_lab_3/device_service/Dockerfile
Normal file
@ -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"]
|
21
agliullov_daniyar_lab_3/device_service/config.py
Normal file
21
agliullov_daniyar_lab_3/device_service/config.py
Normal file
@ -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)
|
||||
|
49
agliullov_daniyar_lab_3/device_service/database.py
Normal file
49
agliullov_daniyar_lab_3/device_service/database.py
Normal file
@ -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()
|
@ -0,0 +1,5 @@
|
||||
from .models import *
|
||||
from .router import *
|
||||
from .schemas import *
|
||||
from .dependencies import *
|
||||
from .websocket_auth import *
|
@ -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
|
207
agliullov_daniyar_lab_3/device_service/device/dependencies.py
Normal file
207
agliullov_daniyar_lab_3/device_service/device/dependencies.py
Normal file
@ -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
|
158
agliullov_daniyar_lab_3/device_service/device/models.py
Normal file
158
agliullov_daniyar_lab_3/device_service/device/models.py
Normal file
@ -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)
|
154
agliullov_daniyar_lab_3/device_service/device/router.py
Normal file
154
agliullov_daniyar_lab_3/device_service/device/router.py
Normal file
@ -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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorize</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket Authorize</h1>
|
||||
<p>Token:</p>
|
||||
<textarea id="token" rows="4" cols="50"></textarea><br><br>
|
||||
<button onclick="websocketfun()">Send</button>
|
||||
<ul id='messages'>
|
||||
</ul>
|
||||
<script>
|
||||
const websocketfun = () => {
|
||||
let token = document.getElementById("token").value
|
||||
let ws = new WebSocket(`ws://testapi.danserver.keenetic.name/?token=${token}`)
|
||||
ws.onmessage = (event) => {
|
||||
let messages = document.getElementById('messages')
|
||||
let message = document.createElement('li')
|
||||
let content = document.createTextNode(event.data)
|
||||
message.appendChild(content)
|
||||
messages.appendChild(message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@websocket_router.get("/debug_form", tags=["Debug"])
|
||||
async def get():
|
||||
return HTMLResponse(html)
|
68
agliullov_daniyar_lab_3/device_service/device/schemas.py
Normal file
68
agliullov_daniyar_lab_3/device_service/device/schemas.py
Normal file
@ -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
|
@ -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"}}
|
89
agliullov_daniyar_lab_3/device_service/device/test_device.py
Normal file
89
agliullov_daniyar_lab_3/device_service/device/test_device.py
Normal file
@ -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
|
@ -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
|
||||
}
|
40
agliullov_daniyar_lab_3/device_service/jwt_config.py
Normal file
40
agliullov_daniyar_lab_3/device_service/jwt_config.py
Normal file
@ -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
|
67
agliullov_daniyar_lab_3/device_service/main.py
Normal file
67
agliullov_daniyar_lab_3/device_service/main.py
Normal file
@ -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)
|
9
agliullov_daniyar_lab_3/device_service/requirements.txt
Normal file
9
agliullov_daniyar_lab_3/device_service/requirements.txt
Normal file
@ -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
|
32
agliullov_daniyar_lab_3/docker-compose.yml
Normal file
32
agliullov_daniyar_lab_3/docker-compose.yml
Normal file
@ -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
|
11
agliullov_daniyar_lab_3/nginx/nginx.conf
Normal file
11
agliullov_daniyar_lab_3/nginx/nginx.conf
Normal file
@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location /users {
|
||||
proxy_pass http://user_service:8000;
|
||||
}
|
||||
|
||||
location /devices/ {
|
||||
proxy_pass http://device_service:8001;
|
||||
}
|
||||
}
|
48
agliullov_daniyar_lab_3/readme.md
Normal file
48
agliullov_daniyar_lab_3/readme.md
Normal file
@ -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)
|
10
agliullov_daniyar_lab_3/user_service/Dockerfile
Normal file
10
agliullov_daniyar_lab_3/user_service/Dockerfile
Normal file
@ -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"]
|
6
agliullov_daniyar_lab_3/user_service/auth/__init__.py
Normal file
6
agliullov_daniyar_lab_3/user_service/auth/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from .models import *
|
||||
from .router import *
|
||||
from .schemas import *
|
||||
from .manager import *
|
||||
from .base_config import *
|
||||
from .dependencies import *
|
34
agliullov_daniyar_lab_3/user_service/auth/base_config.py
Normal file
34
agliullov_daniyar_lab_3/user_service/auth/base_config.py
Normal file
@ -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)
|
13
agliullov_daniyar_lab_3/user_service/auth/dependencies.py
Normal file
13
agliullov_daniyar_lab_3/user_service/auth/dependencies.py
Normal file
@ -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)
|
99
agliullov_daniyar_lab_3/user_service/auth/manager.py
Normal file
99
agliullov_daniyar_lab_3/user_service/auth/manager.py
Normal file
@ -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)
|
52
agliullov_daniyar_lab_3/user_service/auth/models.py
Normal file
52
agliullov_daniyar_lab_3/user_service/auth/models.py
Normal file
@ -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
|
25
agliullov_daniyar_lab_3/user_service/auth/router.py
Normal file
25
agliullov_daniyar_lab_3/user_service/auth/router.py
Normal file
@ -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
|
39
agliullov_daniyar_lab_3/user_service/auth/schemas.py
Normal file
39
agliullov_daniyar_lab_3/user_service/auth/schemas.py
Normal file
@ -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
|
98
agliullov_daniyar_lab_3/user_service/auth/test_auth.py
Normal file
98
agliullov_daniyar_lab_3/user_service/auth/test_auth.py
Normal file
@ -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="<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
|
21
agliullov_daniyar_lab_3/user_service/config.py
Normal file
21
agliullov_daniyar_lab_3/user_service/config.py
Normal file
@ -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)
|
||||
|
49
agliullov_daniyar_lab_3/user_service/database.py
Normal file
49
agliullov_daniyar_lab_3/user_service/database.py
Normal file
@ -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()
|
81
agliullov_daniyar_lab_3/user_service/main.py
Normal file
81
agliullov_daniyar_lab_3/user_service/main.py
Normal file
@ -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)
|
7
agliullov_daniyar_lab_3/user_service/requirements.txt
Normal file
7
agliullov_daniyar_lab_3/user_service/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
fastapi-users
|
||||
sqlalchemy
|
||||
fastapi_users_db_sqlalchemy
|
||||
python-dotenv
|
||||
asyncpg
|
Loading…
Reference in New Issue
Block a user