From 9f0157255728337364a755f0918f87ac21c45560 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Jun 2021 12:08:09 +0300 Subject: [PATCH] wip: redis, sqlalchemy, structured, etc --- .gitignore | 131 +++++++++++++++++++++++ .pre-commit-config.yaml | 44 ++++++++ Dockerfile | 9 ++ Makefile | 19 ++++ auth/README.md | 9 ++ auth/authenticate.py | 78 ++++++++++++++ auth/authorize.py | 39 +++++++ auth/credentials.py | 34 ++++++ auth/identity.py | 17 +++ auth/password.py | 11 ++ auth/token.py | 23 ++++ auth/validations.py | 28 +++++ docker-compose.yml | 15 +++ exceptions.py | 26 +++++ main.py | 28 +++++ orm/__init__.py | 4 + orm/base.py | 45 ++++++++ orm/like.py | 17 +++ orm/message.py | 18 ++++ orm/proposal.py | 18 ++++ orm/rbac.py | 65 +++++++++++ orm/shout.py | 17 +++ orm/user.py | 26 +++++ redis/__init__.py | 5 + redis/client.py | 51 +++++++++ requirements.txt | 29 +++++ resolvers/__init__.py | 3 + resolvers/auth.py | 46 ++++++++ resolvers/base.py | 6 ++ resolvers/editor.py | 1 + resolvers/inbox.py | 27 +++++ schema.auth.graphql | 24 +++++ schema.graphql | 225 ++++++++++++++++++++++++++++----------- schema.messages.graphql | 62 +++++++++++ schema.prototype.graphql | 147 +++++++++++++++++++++++++ server.py | 4 + settings.py | 8 ++ 37 files changed, 1297 insertions(+), 62 deletions(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 auth/README.md create mode 100644 auth/authenticate.py create mode 100644 auth/authorize.py create mode 100644 auth/credentials.py create mode 100644 auth/identity.py create mode 100644 auth/password.py create mode 100644 auth/token.py create mode 100644 auth/validations.py create mode 100644 docker-compose.yml create mode 100644 exceptions.py create mode 100644 main.py create mode 100644 orm/__init__.py create mode 100644 orm/base.py create mode 100644 orm/like.py create mode 100644 orm/message.py create mode 100644 orm/proposal.py create mode 100644 orm/rbac.py create mode 100644 orm/shout.py create mode 100644 orm/user.py create mode 100644 redis/__init__.py create mode 100644 redis/client.py create mode 100644 requirements.txt create mode 100644 resolvers/__init__.py create mode 100644 resolvers/auth.py create mode 100644 resolvers/base.py create mode 100644 resolvers/editor.py create mode 100644 resolvers/inbox.py create mode 100644 schema.auth.graphql create mode 100644 schema.messages.graphql create mode 100644 schema.prototype.graphql create mode 100644 server.py create mode 100644 settings.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..73fb09c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +.idea +temp.* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..af489f3a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +exclude: | + (?x)( + ^tests/unit_tests/resource| + _grpc.py| + _pb2.py + ) + +default_language_version: + python: python3.8 + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-docstring-first + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/timothycrosley/isort + rev: 5.5.3 + hooks: + - id: isort + + - repo: https://github.com/ambv/black + rev: 20.8b1 + hooks: + - id: black + args: + - --line-length=100 + - --skip-string-normalization + + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.3 + hooks: + - id: flake8 + args: + - --max-line-length=100 + - --disable=protected-access diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7d6f09ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.9 + +WORKDIR /home/ruicore/auth + +COPY . /home/ruicore/auth + +RUN pip3 install --upgrade pip && pip3 install -r requirements.txt + +LABEL ruicore="hrui835@gmail.com" version="v.0.0.1" \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..fafc7c12 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +up-pip: + pip install --upgrade pip + +freeze: + pip freeze>requirements.txt + +install-pkg: + pip install -r requirements.txt + +uninstall-pkg: + pip freeze | xargs pip uninstall -y + +server-up: + docker-compose up -d --remove-orphans + docker-compose ps + +server-down: + docker-compose down + docker-compose ps diff --git a/auth/README.md b/auth/README.md new file mode 100644 index 00000000..68dfec9c --- /dev/null +++ b/auth/README.md @@ -0,0 +1,9 @@ +## Based on + +* pyjwt +* [ariadne](https://github.com/mirumee/ariadne) +* [aioredis](https://github.com/aio-libs/aioredis) +* [starlette](https://github.com/encode/starlette)、 +* sqlalchmy ORM + +token is valid for one day, user can choose to logout, logout is revoke token diff --git a/auth/authenticate.py b/auth/authenticate.py new file mode 100644 index 00000000..7e560ce6 --- /dev/null +++ b/auth/authenticate.py @@ -0,0 +1,78 @@ +from functools import wraps +from typing import Optional, Tuple + +from graphql import GraphQLResolveInfo +from jwt import DecodeError, ExpiredSignatureError +from starlette.authentication import AuthenticationBackend +from starlette.requests import HTTPConnection + +from auth.credentials import AuthCredentials, AuthUser +from auth.token import Token +from exceptions import InvalidToken, OperationNotAllowed +from orm import User +from redis import redis +from settings import JWT_AUTH_HEADER + + +class _Authenticate: + @classmethod + async def verify(cls, token: str): + """ + Rules for a token to be valid. + 1. token format is legal && + token exists in redis database && + token is not expired + 2. token format is legal && + token exists in redis database && + token is expired && + token is of specified type + """ + try: + payload = Token.decode(token) + except ExpiredSignatureError: + payload = Token.decode(token, verify_exp=False) + if not await cls.exists(payload.user_id, token): + raise InvalidToken("Login expired, please login again") + if payload.device == "mobile": # noqa + "we cat set mobile token to be valid forever" + return payload + except DecodeError as e: + raise InvalidToken("token format error") from e + else: + if not await cls.exists(payload.user_id, token): + raise InvalidToken("Login expired, please login again") + return payload + + @classmethod + async def exists(cls, user_id, token): + token = await redis.execute("GET", f"{user_id}-{token}") + return token is not None + + +class JWTAuthenticate(AuthenticationBackend): + async def authenticate( + self, request: HTTPConnection + ) -> Optional[Tuple[AuthCredentials, AuthUser]]: + if JWT_AUTH_HEADER not in request.headers: + return AuthCredentials(scopes=[]), AuthUser(user_id=None) + + auth = request.headers[JWT_AUTH_HEADER] + try: + scheme, token = auth.split() + payload = await _Authenticate.verify(token) + except Exception as exc: + return AuthCredentials(scopes=[], error_message=str(exc)), AuthUser(user_id=None) + + scopes = User.get_permission(user_id=payload.user_id) + return AuthCredentials(scopes=scopes, logged_in=True), AuthUser(user_id=payload.user_id) + + +def login_required(func): + @wraps(func) + async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): + auth: AuthCredentials = info.context["request"].auth + if not auth.logged_in: + raise OperationNotAllowed(auth.error_message or "Please login") + return await func(parent, info, *args, **kwargs) + + return wrap diff --git a/auth/authorize.py b/auth/authorize.py new file mode 100644 index 00000000..31535ba8 --- /dev/null +++ b/auth/authorize.py @@ -0,0 +1,39 @@ +from datetime import datetime, timedelta + +from auth.token import Token +from redis import redis +from settings import JWT_LIFE_SPAN +from validations import User + + +class Authorize: + @staticmethod + async def authorize(user: User, device: str = "pc", auto_delete=True) -> str: + """ + :param user: + :param device: + :param auto_delete: Whether the expiration is automatically deleted, the default is True + :return: + """ + exp = datetime.utcnow() + timedelta(seconds=JWT_LIFE_SPAN) + token = Token.encode(user, exp=exp, device=device) + await redis.execute("SET", f"{user.id}-{token}", "True") + if auto_delete: + expire_at = (exp + timedelta(seconds=JWT_LIFE_SPAN)).timestamp() + await redis.execute("EXPIREAT", f"{user.id}-{token}", int(expire_at)) + return token + + @staticmethod + async def revoke(token: str) -> bool: + try: + payload = Token.decode(token) + except: # noqa + pass + else: + await redis.execute("DEL", f"{payload.id}-{token}") + return True + + @staticmethod + async def revoke_all(user: User): + tokens = await redis.execute("KEYS", f"{user.id}-*") + await redis.execute("DEL", *tokens) diff --git a/auth/credentials.py b/auth/credentials.py new file mode 100644 index 00000000..47300b34 --- /dev/null +++ b/auth/credentials.py @@ -0,0 +1,34 @@ +from typing import List, Optional, Text + +from pydantic import BaseModel + + +class Permission(BaseModel): + name: Text + + +class AuthCredentials(BaseModel): + user_id: Optional[int] = None + scopes: Optional[set] = {} + logged_in: bool = False + error_message: str = "" + + @property + def is_admin(self): + return True + + async def permissions(self) -> List[Permission]: + assert self.user_id is not None, "Please login first" + return NotImplemented() + + +class AuthUser(BaseModel): + user_id: Optional[int] + + @property + def is_authenticated(self) -> bool: + return self.user_id is not None + + @property + def display_id(self) -> int: + return self.user_id diff --git a/auth/identity.py b/auth/identity.py new file mode 100644 index 00000000..cb219a79 --- /dev/null +++ b/auth/identity.py @@ -0,0 +1,17 @@ +from auth.password import Password +from exceptions import InvalidPassword, ObjectNotExist +from orm import User as OrmUser +from orm.base import global_session +from validations import User + + +class Identity: + @staticmethod + def identity(user_id: int, password: str) -> User: + user = global_session.query(OrmUser).filter_by(id=user_id).first() + if not user: + raise ObjectNotExist("User does not exist") + user = User(**user.dict()) + if not Password.verify(password, user.password): + raise InvalidPassword("Wrong user password") + return user diff --git a/auth/password.py b/auth/password.py new file mode 100644 index 00000000..c372e740 --- /dev/null +++ b/auth/password.py @@ -0,0 +1,11 @@ +from passlib.hash import pbkdf2_sha256 + + +class Password: + @staticmethod + def encode(password: str) -> str: + return pbkdf2_sha256.hash(password) + + @staticmethod + def verify(password: str, other: str) -> bool: + return pbkdf2_sha256.verify(password, other) diff --git a/auth/token.py b/auth/token.py new file mode 100644 index 00000000..b02abcd6 --- /dev/null +++ b/auth/token.py @@ -0,0 +1,23 @@ +from datetime import datetime + +import jwt + +from settings import JWT_ALGORITHM, JWT_SECRET_KEY +from validations import PayLoad, User + + +class Token: + @staticmethod + def encode(user: User, exp: datetime, device: str = "pc") -> str: + payload = {"user_id": user.id, "device": device, "exp": exp, "iat": datetime.utcnow()} + return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM).decode("UTF-8") + + @staticmethod + def decode(token: str, verify_exp: bool = True) -> PayLoad: + payload = jwt.decode( + token, + key=JWT_SECRET_KEY, + options={"verify_exp": verify_exp}, + algorithms=[JWT_ALGORITHM], + ) + return PayLoad(**payload) diff --git a/auth/validations.py b/auth/validations.py new file mode 100644 index 00000000..0c427641 --- /dev/null +++ b/auth/validations.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Optional, Text + +from pydantic import BaseModel + + +class User(BaseModel): + id: Optional[int] + # age: Optional[int] + username: Optional[Text] + # phone: Optional[Text] + password: Optional[Text] + + +class PayLoad(BaseModel): + user_id: int + device: Text + exp: datetime + iat: datetime + + +class CreateUser(BaseModel): + username: Text + # age: Optional[int] + # phone: Optional[Text] + password: Optional[Text] + +# TODO: update validations \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..bd7da9ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3' + +services: + redis: + image: redis:5.0.3-alpine + container_name: redis + ports: + - 6379:6379 + + server: + image: discoursio/api:v0.0.1 + container_name: api + ports: + - 8002:24579 + command: ["python", "server.py"] diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 00000000..34c6455b --- /dev/null +++ b/exceptions.py @@ -0,0 +1,26 @@ +from graphql import GraphQLError + + +class BaseHttpException(GraphQLError): + code = 500 + message = "500 Server error" + + +class InvalidToken(BaseHttpException): + code = 403 + message = "403 Invalid Token" + + +class ObjectNotExist(BaseHttpException): + code = 404 + message = "404 Object Does Not Exist" + + +class OperationNotAllowed(BaseHttpException): + code = 403 + message = "403 Operation is not allowed" + + +class InvalidPassword(BaseHttpException): + code = 401 + message = "401 Invalid Password" diff --git a/main.py b/main.py new file mode 100644 index 00000000..062b5747 --- /dev/null +++ b/main.py @@ -0,0 +1,28 @@ +from importlib import import_module + +from ariadne import load_schema_from_path, make_executable_schema +from ariadne.asgi import GraphQL +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.authentication import AuthenticationMiddleware + +from authority.authenticate import JWTAuthenticate +from redis import redis +from resolvers.base import resolvers + +import_module('resolvers') +schema = make_executable_schema(load_schema_from_path("schema.graphql"), resolvers) + +middleware = [Middleware(AuthenticationMiddleware, backend=JWTAuthenticate())] + + +async def start_up(): + await redis.connect() + + +async def shutdown(): + await redis.disconnect() + + +app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown], middleware=middleware) +app.mount("/", GraphQL(schema, debug=True)) diff --git a/orm/__init__.py b/orm/__init__.py new file mode 100644 index 00000000..4b92c681 --- /dev/null +++ b/orm/__init__.py @@ -0,0 +1,4 @@ +from orm.rbac import Operation, Permission, Role +from orm.user import User + +__all__ = ["User", "Role", "Operation", "Permission"] diff --git a/orm/base.py b/orm/base.py new file mode 100644 index 00000000..cf5ec589 --- /dev/null +++ b/orm/base.py @@ -0,0 +1,45 @@ +from typing import TypeVar, Any, Dict, Generic, Callable + +from sqlalchemy import create_engine, Column, Integer +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy.sql.schema import Table + +from settings import SQLITE_URI + +engine = create_engine(f'sqlite:///{SQLITE_URI}', convert_unicode=True, echo=False) +Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) +global_session = Session() + +T = TypeVar("T") + +REGISTRY: Dict[str, type] = {} + + +class Base(declarative_base()): + __table__: Table + __tablename__: str + __new__: Callable + __init__: Callable + + __abstract__: bool = True + __table_args__ = {"extend_existing": True} + id: int = Column(Integer, primary_key=True) + session = global_session + + def __init_subclass__(cls, **kwargs): + REGISTRY[cls.__name__] = cls + + @classmethod + def create(cls: Generic[T], **kwargs) -> Generic[T]: + instance = cls(**kwargs) + return instance.save() + + def save(self) -> Generic[T]: + self.session.add(self) + self.session.commit() + return self + + def dict(self) -> Dict[str, Any]: + column_names = self.__table__.columns.keys() + return {c: getattr(self, c) for c in column_names} diff --git a/orm/like.py b/orm/like.py new file mode 100644 index 00000000..9036c4c8 --- /dev/null +++ b/orm/like.py @@ -0,0 +1,17 @@ +from typing import List + +from sqlalchemy import Column, Integer, String, ForeignKey, Datetime + +from orm import Permission +from orm.base import Base + + +class Like(Base): + __tablename__ = 'like' + + author_id: str = Column(ForeignKey("user.id"), nullable=False, comment="Author") + value: str = Column(String, nullable=False, comment="Value") + shout: str = Column(ForeignKey("shout.id"), nullable=True, comment="Liked shout") + user: str = Column(ForeignKey("user.id"), nullable=True, comment="Liked user") + + # TODO: add resolvers, debug, etc. \ No newline at end of file diff --git a/orm/message.py b/orm/message.py new file mode 100644 index 00000000..e914e886 --- /dev/null +++ b/orm/message.py @@ -0,0 +1,18 @@ +from typing import List + +from sqlalchemy import Column, Integer, String, ForeignKey, Datetime + +from orm import Permission +from orm.base import Base + + +class Message(Base): + __tablename__ = 'message' + + sender: str = Column(ForeignKey("user.id"), nullable=False, comment="Sender") + body: str = Column(String, nullable=False, comment="Body") + createdAt: str = Column(Datetime, nullable=False, comment="Created at") + updatedAt: str = Column(Datetime, nullable=True, comment="Updated at") + replyTo: str = Column(ForeignKey("message.id", nullable=True, comment="Reply to")) + + # TODO: work in progress, udpate this code \ No newline at end of file diff --git a/orm/proposal.py b/orm/proposal.py new file mode 100644 index 00000000..ce405fb5 --- /dev/null +++ b/orm/proposal.py @@ -0,0 +1,18 @@ +from typing import List +from datetime import datetime +from sqlalchemy import Column, Integer, String, ForeignKey, Datetime + +from orm import Permission +from orm.base import Base + + +class Proposal(Base): + __tablename__ = 'proposal' + + author_id: str = Column(ForeignKey("user.id"), nullable=False, comment="Author") + body: str = Column(String, nullable=False, comment="Body") + createdAt: str = Column(datetime, nullable=False, comment="Created at") + shout: str = Column(ForeignKey("shout.id"), nullable=False, comment="Updated at") + range: str = Column(String, nullable=True, comment="Range in format :") + + # TODO: debug, logix \ No newline at end of file diff --git a/orm/rbac.py b/orm/rbac.py new file mode 100644 index 00000000..c8924fa4 --- /dev/null +++ b/orm/rbac.py @@ -0,0 +1,65 @@ +import warnings + +from typing import Type + +from sqlalchemy import String, Column, ForeignKey, types, UniqueConstraint + +from orm.base import Base, REGISTRY, engine, global_session + + +class ClassType(types.TypeDecorator): + impl = types.String + + @property + def python_type(self): + return NotImplemented + + def process_literal_param(self, value, dialect): + return NotImplemented + + def process_bind_param(self, value, dialect): + return value.__name__ if isinstance(value, type) else str(value) + + def process_result_value(self, value, dialect): + class_ = REGISTRY.get(value) + if class_ is None: + warnings.warn(f"Can't find class <{value}>,find it yourself 😊", stacklevel=2) + return class_ + + +class Role(Base): + __tablename__ = 'role' + name: str = Column(String, nullable=False, unique=True, comment="Role Name") + + +class Operation(Base): + __tablename__ = 'operation' + name: str = Column(String, nullable=False, unique=True, comment="Operation Name") + + +class Resource(Base): + __tablename__ = "resource" + resource_class: Type[Base] = Column(ClassType, nullable=False, unique=True, comment="Resource class") + name: str = Column(String, nullable=False, unique=True, comment="Resource name") + + +class Permission(Base): + __tablename__ = "permission" + __table_args__ = (UniqueConstraint("role_id", "operation_id", "resource_id"), {"extend_existing": True}) + + role_id: int = Column(ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role") + operation_id: int = Column(ForeignKey("operation.id", ondelete="CASCADE"), nullable=False, comment="Operation") + resource_id: int = Column(ForeignKey("operation.id", ondelete="CASCADE"), nullable=False, comment="Resource") + + +if __name__ == '__main__': + Base.metadata.create_all(engine) + ops = [ + Permission(role_id=1, operation_id=1, resource_id=1), + Permission(role_id=1, operation_id=2, resource_id=1), + Permission(role_id=1, operation_id=3, resource_id=1), + Permission(role_id=1, operation_id=4, resource_id=1), + Permission(role_id=2, operation_id=4, resource_id=1) + ] + global_session.add_all(ops) + global_session.commit() diff --git a/orm/shout.py b/orm/shout.py new file mode 100644 index 00000000..fe6ca3fe --- /dev/null +++ b/orm/shout.py @@ -0,0 +1,17 @@ +from typing import List +from datetime import datetime +from sqlalchemy import Column, Integer, String, ForeignKey, Datetime + +from orm import Permission +from orm.base import Base + + +class Shout(Base): + __tablename__ = 'shout' + + author_id: str = Column(ForeignKey("user.id"), nullable=False, comment="Author") + body: str = Column(String, nullable=False, comment="Body") + createdAt: str = Column(datetime, nullable=False, comment="Created at") + updatedAt: str = Column(datetime, nullable=False, comment="Updated at") + + # TODO: add all the fields \ No newline at end of file diff --git a/orm/user.py b/orm/user.py new file mode 100644 index 00000000..2cdab429 --- /dev/null +++ b/orm/user.py @@ -0,0 +1,26 @@ +from typing import List + +from sqlalchemy import Column, Integer, String, ForeignKey + +from orm import Permission +from orm.base import Base + + +class User(Base): + __tablename__ = 'user' + + name: str = Column(String, nullable=False, comment="Name") + password: str = Column(String, nullable=False, comment="Password") + # phone: str = Column(String, comment="Phone") + # age: int = Column(Integer, comment="Age") + role_id: int = Column(ForeignKey("role.id"), nullable=False, comment="Role") + + @classmethod + def get_permission(cls, user_id): + perms: List[Permission] = cls.session.query(Permission).join(User, User.role_id == Permission.role_id).filter( + User.id == user_id).all() + return {f"{p.operation_id}-{p.resource_id}" for p in perms} + + +if __name__ == '__main__': + print(User.get_permission(user_id=1)) diff --git a/redis/__init__.py b/redis/__init__.py new file mode 100644 index 00000000..a568ad6d --- /dev/null +++ b/redis/__init__.py @@ -0,0 +1,5 @@ +from redis.client import Redis + +redis = Redis() + +__all__ = ['redis'] diff --git a/redis/client.py b/redis/client.py new file mode 100644 index 00000000..ec1d6463 --- /dev/null +++ b/redis/client.py @@ -0,0 +1,51 @@ +from typing import Optional + +import aioredis +from aioredis import ConnectionsPool + +from settings import REDIS_URL + + +class Redis: + def __init__(self, uri=REDIS_URL): + self._uri: str = uri + self._pool: Optional[ConnectionsPool] = None + + async def connect(self): + if self._pool is not None: + return + pool = await aioredis.create_pool(self._uri) + self._pool = pool + + async def disconnect(self): + if self._pool is None: + return + self._pool.close() + await self._pool.wait_closed() + self._pool = None + + async def execute(self, command, *args, **kwargs): + return await self._pool.execute(command, *args, **kwargs, encoding="UTF-8") + + +async def test(): + redis = Redis() + from datetime import datetime + + await redis.connect() + await redis.execute("SET", "1-KEY1", 1) + await redis.execute("SET", "1-KEY2", 1) + await redis.execute("SET", "1-KEY3", 1) + await redis.execute("SET", "1-KEY4", 1) + await redis.execute("EXPIREAT", "1-KEY4", int(datetime.utcnow().timestamp())) + v = await redis.execute("KEYS", "1-*") + print(v) + await redis.execute("DEL", *v) + v = await redis.execute("KEYS", "1-*") + print(v) + + +if __name__ == '__main__': + import asyncio + + asyncio.run(test()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ef859210 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +aioredis==1.3.1 +appdirs==1.4.4 +ariadne==0.12.0 +async-timeout==3.0.1 +cffi==1.14.3 +cfgv==3.2.0 +click==7.1.2 +cryptography==3.2 +distlib==0.3.1 +filelock==3.0.12 +graphql-core==3.0.5 +h11==0.10.0 +hiredis==1.1.0 +identify==1.5.5 +jwt==1.0.0 +nodeenv==1.5.0 +passlib==1.7.2 +pre-commit==2.7.1 +pycparser==2.20 +pydantic==1.6.1 +PyJWT==1.7.1 +PyYAML==5.3.1 +six==1.15.0 +SQLAlchemy==1.3.19 +starlette==0.13.8 +toml==0.10.1 +typing-extensions==3.7.4.3 +uvicorn==0.12.1 +virtualenv==20.0.33 diff --git a/resolvers/__init__.py b/resolvers/__init__.py new file mode 100644 index 00000000..160ab3b0 --- /dev/null +++ b/resolvers/__init__.py @@ -0,0 +1,3 @@ +from resolvers.login import get_user, login, logout, register + +__all__ = ["get_user", "login", "logout", "register"] diff --git a/resolvers/auth.py b/resolvers/auth.py new file mode 100644 index 00000000..fa218a74 --- /dev/null +++ b/resolvers/auth.py @@ -0,0 +1,46 @@ +from graphql import GraphQLResolveInfo + +from auth.authenticate import login_required +from auth.authorize import Authorize +from auth.identity import Identity +from auth.password import Password +from auth.validations import CreateUser +from orm import User +from orm.base import global_session +from resolvers.base import mutation, query + +from settings import JWT_AUTH_HEADER + +@mutation.field("SignUp") +async def register(*_, create: dict = None) -> User: + create_user = CreateUser(**create) + create_user.password = Password.encode(create_user.password) + return User.create(**create_user.dict()) + + +@query.field("SignIn") +async def login(_, info: GraphQLResolveInfo, id: int, password: str) -> str: + try: + device = info.context["request"].headers['device'] + except KeyError: + device = "pc" + auto_delete = False if device == "mobile" else True + user = Identity.identity(user_id=id, password=password) + return await Authorize.authorize(user, device=device, auto_delete=auto_delete) + + +# TODO: implement some queries, ex. @query.field("isUsernameFree") + +@query.field("logout") +@login_required +async def logout(_, info: GraphQLResolveInfo, id: int) -> bool: + token = info.context["request"].headers[JWT_AUTH_HEADER] + return await Authorize.revoke(token) + + +@query.field("getUser") +@login_required +async def get_user(*_, id: int): + return global_session.query(User).filter(User.id == id).first() + + diff --git a/resolvers/base.py b/resolvers/base.py new file mode 100644 index 00000000..4803faa4 --- /dev/null +++ b/resolvers/base.py @@ -0,0 +1,6 @@ +from ariadne import MutationType, QueryType + +query = QueryType() +mutation = MutationType() + +resolvers = [query, mutation] diff --git a/resolvers/editor.py b/resolvers/editor.py new file mode 100644 index 00000000..f95ab230 --- /dev/null +++ b/resolvers/editor.py @@ -0,0 +1 @@ +# TODO: implement me \ No newline at end of file diff --git a/resolvers/inbox.py b/resolvers/inbox.py new file mode 100644 index 00000000..58f77944 --- /dev/null +++ b/resolvers/inbox.py @@ -0,0 +1,27 @@ +from orm import message, user + +from ariadne import ObjectType, convert_kwargs_to_snake_case + +query = ObjectType("Query") + + +@query.field("messages") +@convert_kwargs_to_snake_case +async def resolve_messages(obj, info, user_id): + def filter_by_userid(message): + return message["sender_id"] == user_id or \ + message["recipient_id"] == user_id + + user_messages = filter(filter_by_userid, messages) + return { + "success": True, + "messages": user_messages + } + + +@query.field("userId") +@convert_kwargs_to_snake_case +async def resolve_user_id(obj, info, username): + user = users.get(username) + if user: + return user["user_id"] \ No newline at end of file diff --git a/schema.auth.graphql b/schema.auth.graphql new file mode 100644 index 00000000..e51641e0 --- /dev/null +++ b/schema.auth.graphql @@ -0,0 +1,24 @@ +type User{ + id: ID! + name: String! + phone: String + age: Int +} + +input CreateUserInput{ + password: String! + name: String! + phone: String + age: Int +} + + +type Query{ + user(id: ID!): User! + login(id: ID!,password: String!): String! + logout(id: ID!): Boolean! +} + +type Mutation{ + register(create: CraeteUserInput): User! +} \ No newline at end of file diff --git a/schema.graphql b/schema.graphql index 21d18c53..79a55f1f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,62 +1,163 @@ -scalar DateTime - -type User { - createdAt: DateTime! - email: String - emailConfirmed: Boolean - id: Int! - muted: Boolean - rating: Int - updatedAt: DateTime! - username: String - userpic: String - userpicId: String - wasOnlineAt: DateTime -} - -type Message { - author: Int! - body: String! - createdAt: DateTime! - id: Int! - replyTo: Int - updatedAt: DateTime! - visibleForUsers: [Int] -} - -type createMessagePayload { - status: Boolean! - error: String - message: Message -} - -type deleteMessagePayload { - status: Boolean! - error: String -} - -input MessageInput { - body: String! - replyTo: Int -} - -input updateMessageInput { - id: Int! - body: String! -} - -type Query { - getMessages(count: Int = 100, page: Int = 1): [Message!]! -} - -type Mutation { - createMessage(input: MessageInput!): createMessagePayload! - updateMessage(input: updateMessageInput!): createMessagePayload! - deleteMessage(messageId: Int!): deleteMessagePayload! -} - -type Subscription { - messageCreated: Message! - messageUpdated: Message! - messageDeleted: Message! -} +scalar DateTime + +type createMessagePayload { + status: Boolean! + error: String + message: Message +} + +type deleteMessagePayload { + status: Boolean! + error: String +} + +input MessageInput { + body: String! + replyTo: Int +} + +input updateMessageInput { + id: Int! + body: String! +} + +type Message { + author: Int! + visibleForUsers: [Int] + body: String! + createdAt: DateTime! + id: Int! + replyTo: Int + updatedAt: DateTime! +} + +type Mutation { + # message + createMessage(input: MessageInput!): createMessagePayload! + updateMessage(input: updateMessageInput!): createMessagePayload! + deleteMessage(messageId: Int!): deleteMessagePayload! + + # auth + confirmEmail(token: String!): Token! + invalidateAllTokens: Boolean! + invalidateTokenById(id: Int!): Boolean! + requestEmailConfirmation: User! + requestPasswordReset(email: String!): Boolean! + resetPassword(password: String!, token: String!): Token! + signIn(email: String!, password: String!): Token! # login + signUp(email: String!, password: String!, username: String): User! # register + + # shout + createShout(body: String!, replyTo: [Int], title: String, versionOf: [Int], visibleForRoles: [Int], visibleForUsers: [Int]): Message! + deleteShout(shoutId: Int!): Message! + rateShout(value: Int!): Boolean! + + # profile + rateUser(value: Int!): Boolean! + updateOnlineStatus: Boolean! + updateUsername(username: String!): User! + + # proposal + createProposal(shout: Int!, range: String!): Boolean! + updateProposal(proposal: Int!, body: String!): Boolean! + removeProposal(proposal: Int!) + approveProposal(proposal: Int!): Boolean! +} + +type Query { + # auth + getCurrentUser: User! + logout: [Boolean!] + getTokens: [Token!]! + isUsernameFree(username: String!): Boolean! + + # profile + getUserById(id: Int!): User! + getUserRating(shout: Int): Int! + getOnline: [User!]! + + # message + getMessages(count: Int = 100, page: Int = 1): [Message!]! + + # shout + getShoutRating(shout: Int): Int! + shoutsByAuthor(author: Int): [Shout]! + shoutsByReplyTo(shout: Int): [Shout]! + shoutsByTags(tags: [String]): [Shout]! + shoutsByTime(time: DateTime): [Shout]! + topAuthors: [User]! + topShouts: [Shout]! + + # proposal + getShoutProposals(shout: Int): [Proposal]! +} + +type Role { + id: Int! + name: String! +} + +type Shout { + author: Int! + body: String! + createdAt: DateTime! + deletedAt: DateTime + deletedBy: Int + id: Int! + rating: Int + published: DateTime! # if there is no published field - it is not published + replyTo: Int # another shout + tags: [String] + title: String + updatedAt: DateTime! + versionOf: Int + visibleForRoles: [Role]! + visibleForUsers: [Int] +} + +type Proposal { + body: String! + shout: Int! + range: String # full / 0:2340 + author: Int! + createdAt: DateTime! +} + +type Subscription { + profileUpdate(user_id: Int!): User! + chatUpdate(user_id: Int!): Message! + onlineUpdate: [User!]! # userlist + shoutUpdate(shout_id: Int!): Shout! +} + +type Token { + createdAt: DateTime! + expiresAt: DateTime + id: Int! + ownerId: Int! + usedAt: DateTime + value: String! +} + +type User { + createdAt: DateTime! + email: String + emailConfirmed: Boolean + id: Int! + muted: Boolean + rating: Int + roles: [Role!]! + updatedAt: DateTime! + username: String + userpic: String + userpicId: String + wasOnlineAt: DateTime +} + +type Like { + author: Int! + id: Int! + shout: Int + user: Int + value: Int! +} \ No newline at end of file diff --git a/schema.messages.graphql b/schema.messages.graphql new file mode 100644 index 00000000..21d18c53 --- /dev/null +++ b/schema.messages.graphql @@ -0,0 +1,62 @@ +scalar DateTime + +type User { + createdAt: DateTime! + email: String + emailConfirmed: Boolean + id: Int! + muted: Boolean + rating: Int + updatedAt: DateTime! + username: String + userpic: String + userpicId: String + wasOnlineAt: DateTime +} + +type Message { + author: Int! + body: String! + createdAt: DateTime! + id: Int! + replyTo: Int + updatedAt: DateTime! + visibleForUsers: [Int] +} + +type createMessagePayload { + status: Boolean! + error: String + message: Message +} + +type deleteMessagePayload { + status: Boolean! + error: String +} + +input MessageInput { + body: String! + replyTo: Int +} + +input updateMessageInput { + id: Int! + body: String! +} + +type Query { + getMessages(count: Int = 100, page: Int = 1): [Message!]! +} + +type Mutation { + createMessage(input: MessageInput!): createMessagePayload! + updateMessage(input: updateMessageInput!): createMessagePayload! + deleteMessage(messageId: Int!): deleteMessagePayload! +} + +type Subscription { + messageCreated: Message! + messageUpdated: Message! + messageDeleted: Message! +} diff --git a/schema.prototype.graphql b/schema.prototype.graphql new file mode 100644 index 00000000..92f995de --- /dev/null +++ b/schema.prototype.graphql @@ -0,0 +1,147 @@ +scalar DateTime + +type Like { + author: Int! + id: Int! + shout: Int + user: Int + value: Int! +} + +type createMessagePayload { + status: Boolean! + error: String + message: Message +} + +type deleteMessagePayload { + status: Boolean! + error: String +} + +input MessageInput { + body: String! + replyTo: Int +} + +input updateMessageInput { + id: Int! + body: String! +} + +type Message { + author: Int! + body: String! + createdAt: DateTime! + id: Int! + replyTo: Int + updatedAt: DateTime! + visibleForUsers: [Int] +} + +type Mutation { + # message + createMessage(input: MessageInput!): createMessagePayload! + updateMessage(input: updateMessageInput!): createMessagePayload! + deleteMessage(messageId: Int!): deleteMessagePayload! + + # auth + confirmEmail(token: String!): Token! + invalidateAllTokens: Boolean! + invalidateTokenById(id: Int!): Boolean! + requestEmailConfirmation: User! + requestPasswordReset(email: String!): Boolean! + resetPassword(password: String!, token: String!): Token! + signIn(email: String!, password: String!): Token! + signUp(email: String!, password: String!, username: String): User! + + # shout + createShout(body: String!, replyTo: [Int], title: String, versionOf: [Int], visibleForRoles: [Int], visibleForUsers: [Int]): Message! + deleteShout(shoutId: Int!): Message! + rateShout(value: Int!): Boolean! + + # profile + rateUser(value: Int!): Boolean! + updateOnlineStatus: Boolean! + updateUsername(username: String!): User! +} + +type Query { + getCurrentUser: User! + getMessages(count: Int = 100, page: Int = 1): [Message!]! + getOnline: [User!]! + getShoutRating(shout: Int): Int! + getTokens: [Token!]! + getUserById(id: Int!): User! + getUserRating(shout: Int): Int! + isUsernameFree(username: String!): Boolean! + shoutsByAuthor(author: Int): [Shout]! + shoutsByReplyTo(shout: Int): [Shout]! + shoutsByTags(tags: [String]): [Shout]! + shoutsByTime(time: DateTime): [Shout]! + topAuthors: [User]! + topShouts: [Shout]! +} + +type Role { + id: Int! + name: String! +} + +type Shout { + author: Int! + body: String! + createdAt: DateTime! + deletedAt: DateTime + deletedBy: Int + id: Int! + rating: Int + published: DateTime! # if there is no published field - it is not published + replyTo: Int # another shout + tags: [String] + title: String + updatedAt: DateTime! + versionOf: Int + visibleForRoles: [Role]! + visibleForUsers: [Int] +} + +type Proposal { + body: String! + shout: Int! + range: String # full / 0:2340 + author: Int! + createdAt: DateTime! +} + +type Subscription { + messageCreated: Message! + messageDeleted: Message! + onlineUpdated: [User!]! + shoutUpdated: Shout! + userUpdated: User! +} + +type Token { + createdAt: DateTime! + expiresAt: DateTime + id: Int! + ownerId: Int! + usedAt: DateTime + value: String! +} + +type User { + createdAt: DateTime! + email: String + emailConfirmed: Boolean + id: Int! + muted: Boolean + rating: Int + roles: [Role!]! + updatedAt: DateTime! + username: String + userpic: String + userpicId: String + wasOnlineAt: DateTime +} \ No newline at end of file diff --git a/server.py b/server.py new file mode 100644 index 00000000..c1a38b88 --- /dev/null +++ b/server.py @@ -0,0 +1,4 @@ +import uvicorn + +if __name__ == '__main__': + uvicorn.run("main:app", host="0.0.0.0", port=24579, reload=True) diff --git a/settings.py b/settings.py new file mode 100644 index 00000000..aca824cb --- /dev/null +++ b/settings.py @@ -0,0 +1,8 @@ +from pathlib import Path + +SQLITE_URI = Path(__file__).parent / "database.sqlite3" +JWT_ALGORITHM = "HS256" +JWT_SECRET_KEY = "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key" +JWT_LIFE_SPAN = 24 * 60 * 60 # seconds +JWT_AUTH_HEADER = "Auth" +REDIS_URL = "redis://redis"