This commit is contained in:
commit
31bd421e22
26
README.md
26
README.md
|
@ -1,3 +1,5 @@
|
||||||
|
# discoursio-api
|
||||||
|
|
||||||
## Техстек
|
## Техстек
|
||||||
|
|
||||||
- sqlalchemy
|
- sqlalchemy
|
||||||
|
@ -25,27 +27,3 @@ poetry run ruff check . --fix --select I # линтер и сортировка
|
||||||
poetry run ruff format . --line-length=120 # форматирование кода
|
poetry run ruff format . --line-length=120 # форматирование кода
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Подключенные сервисы
|
|
||||||
|
|
||||||
Для межсерверной коммуникации используются отдельные логики, папка `services/*` содержит адаптеры для использования базы данных, `redis`, кеширование и клиенты для запросов GraphQL.
|
|
||||||
|
|
||||||
### auth.py
|
|
||||||
|
|
||||||
Задайте переменную окружения `WEBHOOK_SECRET` чтобы принимать запросы по адресу `/new-author` от [сервера авторизации](https://dev.discours.io/devstack/authorizer). Событие ожидается при создании нового пользователя. Для авторизованных запросов и мутаций фронтенд добавляет к запросу токен авторизации в заголовок `Authorization`.
|
|
||||||
|
|
||||||
### viewed.py
|
|
||||||
|
|
||||||
Задайте переменные окружения `GOOGLE_KEYFILE_PATH` и `GOOGLE_PROPERTY_ID` для получения данных из [Google Analytics](https://developers.google.com/analytics?hl=ru).
|
|
||||||
|
|
||||||
### search.py
|
|
||||||
|
|
||||||
Позволяет получать результаты пользовательских поисковых запросов в кешируемом виде от ElasticSearch с оценкой `score`, объединенные с запросами к базе данных, запрашиваем через GraphQL API `load_shouts_search`. Требует установка `ELASTIC_HOST`, `ELASTIC_PORT`, `ELASTIC_USER` и `ELASTIC_PASSWORD`.
|
|
||||||
|
|
||||||
### notify.py
|
|
||||||
|
|
||||||
Отправка уведомлений по Redis PubSub каналам, согласно структуре данных, за которую отвечает [сервис уведомлений](https://dev.discours.io/discours.io/notifier)
|
|
||||||
|
|
||||||
### unread.py
|
|
||||||
|
|
||||||
Счетчик непрочитанных сообщений получается через Redis-запрос к данным [сервиса сообщений](https://dev.discours.io/discours.io/inbox).
|
|
||||||
|
|
76
alembic/env.py
Normal file
76
alembic/env.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from services.db import Base
|
||||||
|
from settings import DB_URL
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# override DB_URL
|
||||||
|
config.set_section_option(config.config_ini_section, "DB_URL", DB_URL)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = [Base.metadata]
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
81
auth/authenticate.py
Normal file
81
auth/authenticate.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from graphql.type import GraphQLResolveInfo
|
||||||
|
from sqlalchemy.orm import exc, joinedload
|
||||||
|
from starlette.authentication import AuthenticationBackend
|
||||||
|
from starlette.requests import HTTPConnection
|
||||||
|
|
||||||
|
from auth.credentials import AuthCredentials, AuthUser
|
||||||
|
from auth.exceptions import OperationNotAllowed
|
||||||
|
from auth.tokenstorage import SessionToken
|
||||||
|
from auth.usermodel import Role, User
|
||||||
|
from services.db import local_session
|
||||||
|
from settings import SESSION_TOKEN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthenticate(AuthenticationBackend):
|
||||||
|
async def authenticate(self, request: HTTPConnection) -> Optional[Tuple[AuthCredentials, AuthUser]]:
|
||||||
|
if SESSION_TOKEN_HEADER not in request.headers:
|
||||||
|
return AuthCredentials(scopes={}), AuthUser(user_id=None, username="")
|
||||||
|
|
||||||
|
token = request.headers.get(SESSION_TOKEN_HEADER)
|
||||||
|
if not token:
|
||||||
|
print("[auth.authenticate] no token in header %s" % SESSION_TOKEN_HEADER)
|
||||||
|
return AuthCredentials(scopes={}, error_message=str("no token")), AuthUser(user_id=None, username="")
|
||||||
|
|
||||||
|
if len(token.split(".")) > 1:
|
||||||
|
payload = await SessionToken.verify(token)
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
try:
|
||||||
|
user = (
|
||||||
|
session.query(User)
|
||||||
|
.options(
|
||||||
|
joinedload(User.roles).options(joinedload(Role.permissions)),
|
||||||
|
joinedload(User.ratings),
|
||||||
|
)
|
||||||
|
.filter(User.id == payload.user_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
scopes = {} # TODO: integrate await user.get_permission()
|
||||||
|
|
||||||
|
return (
|
||||||
|
AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True),
|
||||||
|
AuthUser(user_id=user.id, username=""),
|
||||||
|
)
|
||||||
|
except exc.NoResultFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return AuthCredentials(scopes={}, error_message=str("Invalid token")), AuthUser(user_id=None, username="")
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||||
|
# debug only
|
||||||
|
# print('[auth.authenticate] login required for %r with info %r' % (func, info))
|
||||||
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
|
# print(auth)
|
||||||
|
if not auth or not auth.logged_in:
|
||||||
|
# raise Unauthorized(auth.error_message or "Please login")
|
||||||
|
return {"error": "Please login first"}
|
||||||
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
def permission_required(resource, operation, func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):
|
||||||
|
print("[auth.authenticate] permission_required for %r with info %r" % (func, info)) # debug only
|
||||||
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
|
if not auth.logged_in:
|
||||||
|
raise OperationNotAllowed(auth.error_message or "Please login")
|
||||||
|
|
||||||
|
# TODO: add actual check permission logix here
|
||||||
|
|
||||||
|
return await func(parent, info, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrap
|
43
auth/credentials.py
Normal file
43
auth/credentials.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from typing import List, Optional, Text
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# from base.exceptions import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(BaseModel):
|
||||||
|
name: Text
|
||||||
|
|
||||||
|
|
||||||
|
class AuthCredentials(BaseModel):
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
scopes: Optional[dict] = {}
|
||||||
|
logged_in: bool = False
|
||||||
|
error_message: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self):
|
||||||
|
# TODO: check admin logix
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def permissions(self) -> List[Permission]:
|
||||||
|
if self.user_id is None:
|
||||||
|
# raise Unauthorized("Please login first")
|
||||||
|
return {"error": "Please login first"}
|
||||||
|
else:
|
||||||
|
# TODO: implement permissions logix
|
||||||
|
print(self.user_id)
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
|
class AuthUser(BaseModel):
|
||||||
|
user_id: Optional[int]
|
||||||
|
username: Optional[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
return self.user_id is not None
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def display_id(self) -> int:
|
||||||
|
# return self.user_id
|
30
auth/email.py
Normal file
30
auth/email.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN
|
||||||
|
|
||||||
|
api_url = "https://api.mailgun.net/v3/%s/messages" % (MAILGUN_DOMAIN or "discours.io")
|
||||||
|
noreply = "discours.io <noreply@%s>" % (MAILGUN_DOMAIN or "discours.io")
|
||||||
|
lang_subject = {"ru": "Подтверждение почты", "en": "Confirm email"}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_auth_email(user, token, lang="ru", template="email_confirmation"):
|
||||||
|
try:
|
||||||
|
to = "%s <%s>" % (user.name, user.email)
|
||||||
|
if lang not in ["ru", "en"]:
|
||||||
|
lang = "ru"
|
||||||
|
subject = lang_subject.get(lang, lang_subject["en"])
|
||||||
|
template = template + "_" + lang
|
||||||
|
payload = {
|
||||||
|
"from": noreply,
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"template": template,
|
||||||
|
"h:X-Mailgun-Variables": '{ "token": "%s" }' % token,
|
||||||
|
}
|
||||||
|
print("[auth.email] payload: %r" % payload)
|
||||||
|
# debug
|
||||||
|
# print('http://localhost:3000/?modal=auth&mode=confirm-email&token=%s' % token)
|
||||||
|
response = requests.post(api_url, auth=("api", MAILGUN_API_KEY), data=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
38
auth/exceptions.py
Normal file
38
auth/exceptions.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
from graphql.error import GraphQLError
|
||||||
|
|
||||||
|
# TODO: remove traceback from logs for defined exceptions
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHttpException(GraphQLError):
|
||||||
|
code = 500
|
||||||
|
message = "500 Server error"
|
||||||
|
|
||||||
|
|
||||||
|
class ExpiredToken(BaseHttpException):
|
||||||
|
code = 401
|
||||||
|
message = "401 Expired Token"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidToken(BaseHttpException):
|
||||||
|
code = 401
|
||||||
|
message = "401 Invalid Token"
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(BaseHttpException):
|
||||||
|
code = 401
|
||||||
|
message = "401 Unauthorized"
|
||||||
|
|
||||||
|
|
||||||
|
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 = 403
|
||||||
|
message = "403 Invalid Password"
|
96
auth/identity.py
Normal file
96
auth/identity.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
from binascii import hexlify
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
# from base.exceptions import InvalidPassword, InvalidToken
|
||||||
|
from base.orm import local_session
|
||||||
|
from jwt import DecodeError, ExpiredSignatureError
|
||||||
|
from passlib.hash import bcrypt
|
||||||
|
|
||||||
|
from auth.jwtcodec import JWTCodec
|
||||||
|
from auth.tokenstorage import TokenStorage
|
||||||
|
from orm import User
|
||||||
|
|
||||||
|
|
||||||
|
class Password:
|
||||||
|
@staticmethod
|
||||||
|
def _to_bytes(data: str) -> bytes:
|
||||||
|
return bytes(data.encode())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_sha256(cls, password: str) -> bytes:
|
||||||
|
bytes_password = cls._to_bytes(password)
|
||||||
|
return hexlify(sha256(bytes_password).digest())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode(password: str) -> str:
|
||||||
|
password_sha256 = Password._get_sha256(password)
|
||||||
|
return bcrypt.using(rounds=10).hash(password_sha256)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify(password: str, hashed: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify that password hash is equal to specified hash. Hash format:
|
||||||
|
|
||||||
|
$2a$10$Ro0CUfOqk6cXEKf3dyaM7OhSCvnwM9s4wIX9JeLapehKK5YdLxKcm
|
||||||
|
\__/\/ \____________________/\_____________________________/ # noqa: W605
|
||||||
|
| | Salt Hash
|
||||||
|
| Cost
|
||||||
|
Version
|
||||||
|
|
||||||
|
More info: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html
|
||||||
|
|
||||||
|
:param password: clear text password
|
||||||
|
:param hashed: hash of the password
|
||||||
|
:return: True if clear text password matches specified hash
|
||||||
|
"""
|
||||||
|
hashed_bytes = Password._to_bytes(hashed)
|
||||||
|
password_sha256 = Password._get_sha256(password)
|
||||||
|
|
||||||
|
return bcrypt.verify(password_sha256, hashed_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
class Identity:
|
||||||
|
@staticmethod
|
||||||
|
def password(orm_user: User, password: str) -> User:
|
||||||
|
user = User(**orm_user.dict())
|
||||||
|
if not user.password:
|
||||||
|
# raise InvalidPassword("User password is empty")
|
||||||
|
return {"error": "User password is empty"}
|
||||||
|
if not Password.verify(password, user.password):
|
||||||
|
# raise InvalidPassword("Wrong user password")
|
||||||
|
return {"error": "Wrong user password"}
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def oauth(inp) -> User:
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).filter(User.email == inp["email"]).first()
|
||||||
|
if not user:
|
||||||
|
user = User.create(**inp, emailConfirmed=True)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def onetime(token: str) -> User:
|
||||||
|
try:
|
||||||
|
print("[auth.identity] using one time token")
|
||||||
|
payload = JWTCodec.decode(token)
|
||||||
|
if not await TokenStorage.exist(f"{payload.user_id}-{payload.username}-{token}"):
|
||||||
|
# raise InvalidToken("Login token has expired, please login again")
|
||||||
|
return {"error": "Token has expired"}
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
# raise InvalidToken("Login token has expired, please try again")
|
||||||
|
return {"error": "Token has expired"}
|
||||||
|
except DecodeError:
|
||||||
|
# raise InvalidToken("token format error") from e
|
||||||
|
return {"error": "Token format error"}
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).filter_by(id=payload.user_id).first()
|
||||||
|
if not user:
|
||||||
|
# raise Exception("user not exist")
|
||||||
|
return {"error": "User does not exist"}
|
||||||
|
if not user.emailConfirmed:
|
||||||
|
user.emailConfirmed = True
|
||||||
|
session.commit()
|
||||||
|
return user
|
60
auth/jwtcodec.py
Normal file
60
auth/jwtcodec.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from auth.exceptions import ExpiredToken, InvalidToken
|
||||||
|
from settings import JWT_ALGORITHM, JWT_SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPayload(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
username: str
|
||||||
|
exp: datetime
|
||||||
|
iat: datetime
|
||||||
|
iss: str
|
||||||
|
|
||||||
|
|
||||||
|
class JWTCodec:
|
||||||
|
@staticmethod
|
||||||
|
def encode(user, exp: datetime) -> str:
|
||||||
|
payload = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.email or user.phone,
|
||||||
|
"exp": exp,
|
||||||
|
"iat": datetime.now(tz=timezone.utc),
|
||||||
|
"iss": "discours",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM)
|
||||||
|
except Exception as e:
|
||||||
|
print("[auth.jwtcodec] JWT encode error %r" % e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode(token: str, verify_exp: bool = True):
|
||||||
|
r = None
|
||||||
|
payload = None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
key=JWT_SECRET_KEY,
|
||||||
|
options={
|
||||||
|
"verify_exp": verify_exp,
|
||||||
|
# "verify_signature": False
|
||||||
|
},
|
||||||
|
algorithms=[JWT_ALGORITHM],
|
||||||
|
issuer="discours",
|
||||||
|
)
|
||||||
|
r = TokenPayload(**payload)
|
||||||
|
# print('[auth.jwtcodec] debug token %r' % r)
|
||||||
|
return r
|
||||||
|
except jwt.InvalidIssuedAtError:
|
||||||
|
print("[auth.jwtcodec] invalid issued at: %r" % payload)
|
||||||
|
raise ExpiredToken("check token issued time")
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
print("[auth.jwtcodec] expired signature %r" % payload)
|
||||||
|
raise ExpiredToken("check token lifetime")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise InvalidToken("token is not valid")
|
||||||
|
except jwt.InvalidSignatureError:
|
||||||
|
raise InvalidToken("token is not valid")
|
98
auth/oauth.py
Normal file
98
auth/oauth.py
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
|
from auth.identity import Identity
|
||||||
|
from auth.tokenstorage import TokenStorage
|
||||||
|
from settings import FRONTEND_URL, OAUTH_CLIENTS
|
||||||
|
|
||||||
|
oauth = OAuth()
|
||||||
|
|
||||||
|
oauth.register(
|
||||||
|
name="facebook",
|
||||||
|
client_id=OAUTH_CLIENTS["FACEBOOK"]["id"],
|
||||||
|
client_secret=OAUTH_CLIENTS["FACEBOOK"]["key"],
|
||||||
|
access_token_url="https://graph.facebook.com/v11.0/oauth/access_token",
|
||||||
|
access_token_params=None,
|
||||||
|
authorize_url="https://www.facebook.com/v11.0/dialog/oauth",
|
||||||
|
authorize_params=None,
|
||||||
|
api_base_url="https://graph.facebook.com/",
|
||||||
|
client_kwargs={"scope": "public_profile email"},
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth.register(
|
||||||
|
name="github",
|
||||||
|
client_id=OAUTH_CLIENTS["GITHUB"]["id"],
|
||||||
|
client_secret=OAUTH_CLIENTS["GITHUB"]["key"],
|
||||||
|
access_token_url="https://github.com/login/oauth/access_token",
|
||||||
|
access_token_params=None,
|
||||||
|
authorize_url="https://github.com/login/oauth/authorize",
|
||||||
|
authorize_params=None,
|
||||||
|
api_base_url="https://api.github.com/",
|
||||||
|
client_kwargs={"scope": "user:email"},
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth.register(
|
||||||
|
name="google",
|
||||||
|
client_id=OAUTH_CLIENTS["GOOGLE"]["id"],
|
||||||
|
client_secret=OAUTH_CLIENTS["GOOGLE"]["key"],
|
||||||
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid email profile"},
|
||||||
|
authorize_state="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def google_profile(client, request, token):
|
||||||
|
userinfo = token["userinfo"]
|
||||||
|
|
||||||
|
profile = {"name": userinfo["name"], "email": userinfo["email"], "id": userinfo["sub"]}
|
||||||
|
|
||||||
|
if userinfo["picture"]:
|
||||||
|
userpic = userinfo["picture"].replace("=s96", "=s600")
|
||||||
|
profile["userpic"] = userpic
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
async def facebook_profile(client, request, token):
|
||||||
|
profile = await client.get("me?fields=name,id,email", token=token)
|
||||||
|
return profile.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def github_profile(client, request, token):
|
||||||
|
profile = await client.get("user", token=token)
|
||||||
|
return profile.json()
|
||||||
|
|
||||||
|
|
||||||
|
profile_callbacks = {
|
||||||
|
"google": google_profile,
|
||||||
|
"facebook": facebook_profile,
|
||||||
|
"github": github_profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_login(request):
|
||||||
|
provider = request.path_params["provider"]
|
||||||
|
request.session["provider"] = provider
|
||||||
|
client = oauth.create_client(provider)
|
||||||
|
redirect_uri = "https://v2.discours.io/oauth-authorize"
|
||||||
|
return await client.authorize_redirect(request, redirect_uri)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_authorize(request):
|
||||||
|
provider = request.session["provider"]
|
||||||
|
client = oauth.create_client(provider)
|
||||||
|
token = await client.authorize_access_token(request)
|
||||||
|
get_profile = profile_callbacks[provider]
|
||||||
|
profile = await get_profile(client, request, token)
|
||||||
|
user_oauth_info = "%s:%s" % (provider, profile["id"])
|
||||||
|
user_input = {
|
||||||
|
"oauth": user_oauth_info,
|
||||||
|
"email": profile["email"],
|
||||||
|
"username": profile["name"],
|
||||||
|
"userpic": profile["userpic"],
|
||||||
|
}
|
||||||
|
user = Identity.oauth(user_input)
|
||||||
|
session_token = await TokenStorage.create_session(user)
|
||||||
|
response = RedirectResponse(url=FRONTEND_URL + "/confirm")
|
||||||
|
response.set_cookie("token", session_token)
|
||||||
|
return response
|
215
auth/resolvers.py
Normal file
215
auth/resolvers.py
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
from graphql.type import GraphQLResolveInfo
|
||||||
|
|
||||||
|
from auth.authenticate import login_required
|
||||||
|
from auth.credentials import AuthCredentials
|
||||||
|
from auth.email import send_auth_email
|
||||||
|
from auth.exceptions import InvalidPassword, InvalidToken, ObjectNotExist, Unauthorized
|
||||||
|
from auth.identity import Identity, Password
|
||||||
|
from auth.jwtcodec import JWTCodec
|
||||||
|
from auth.tokenstorage import TokenStorage
|
||||||
|
from orm import Role, User
|
||||||
|
from services.db import local_session
|
||||||
|
from services.schema import mutation, query
|
||||||
|
from settings import SESSION_TOKEN_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("getSession")
|
||||||
|
@login_required
|
||||||
|
async def get_current_user(_, info):
|
||||||
|
auth: AuthCredentials = info.context["request"].auth
|
||||||
|
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).where(User.id == auth.user_id).one()
|
||||||
|
user.lastSeen = datetime.now(tz=timezone.utc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"token": token, "user": user}
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("confirmEmail")
|
||||||
|
async def confirm_email(_, info, token):
|
||||||
|
"""confirm owning email address"""
|
||||||
|
try:
|
||||||
|
print("[resolvers.auth] confirm email by token")
|
||||||
|
payload = JWTCodec.decode(token)
|
||||||
|
user_id = payload.user_id
|
||||||
|
await TokenStorage.get(f"{user_id}-{payload.username}-{token}")
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).where(User.id == user_id).first()
|
||||||
|
session_token = await TokenStorage.create_session(user)
|
||||||
|
user.emailConfirmed = True
|
||||||
|
user.lastSeen = datetime.now(tz=timezone.utc)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return {"token": session_token, "user": user}
|
||||||
|
except InvalidToken as e:
|
||||||
|
raise InvalidToken(e.message)
|
||||||
|
except Exception as e:
|
||||||
|
print(e) # FIXME: debug only
|
||||||
|
return {"error": "email is not confirmed"}
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(user_dict):
|
||||||
|
user = User(**user_dict)
|
||||||
|
with local_session() as session:
|
||||||
|
user.roles.append(session.query(Role).first())
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def replace_translit(src):
|
||||||
|
ruchars = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя."
|
||||||
|
enchars = [
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"v",
|
||||||
|
"g",
|
||||||
|
"d",
|
||||||
|
"e",
|
||||||
|
"yo",
|
||||||
|
"zh",
|
||||||
|
"z",
|
||||||
|
"i",
|
||||||
|
"y",
|
||||||
|
"k",
|
||||||
|
"l",
|
||||||
|
"m",
|
||||||
|
"n",
|
||||||
|
"o",
|
||||||
|
"p",
|
||||||
|
"r",
|
||||||
|
"s",
|
||||||
|
"t",
|
||||||
|
"u",
|
||||||
|
"f",
|
||||||
|
"h",
|
||||||
|
"c",
|
||||||
|
"ch",
|
||||||
|
"sh",
|
||||||
|
"sch",
|
||||||
|
"",
|
||||||
|
"y",
|
||||||
|
"'",
|
||||||
|
"e",
|
||||||
|
"yu",
|
||||||
|
"ya",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
return src.translate(str.maketrans(ruchars, enchars))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unique_slug(src):
|
||||||
|
print("[resolvers.auth] generating slug from: " + src)
|
||||||
|
slug = replace_translit(src.lower())
|
||||||
|
slug = re.sub("[^0-9a-zA-Z]+", "-", slug)
|
||||||
|
if slug != src:
|
||||||
|
print("[resolvers.auth] translited name: " + slug)
|
||||||
|
c = 1
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).where(User.slug == slug).first()
|
||||||
|
while user:
|
||||||
|
user = session.query(User).where(User.slug == slug).first()
|
||||||
|
slug = slug + "-" + str(c)
|
||||||
|
c += 1
|
||||||
|
if not user:
|
||||||
|
unique_slug = slug
|
||||||
|
print("[resolvers.auth] " + unique_slug)
|
||||||
|
return quote_plus(unique_slug.replace("'", "")).replace("+", "-")
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("registerUser")
|
||||||
|
async def register_by_email(_, _info, email: str, password: str = "", name: str = ""):
|
||||||
|
email = email.lower()
|
||||||
|
"""creates new user account"""
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).filter(User.email == email).first()
|
||||||
|
if user:
|
||||||
|
raise Unauthorized("User already exist")
|
||||||
|
else:
|
||||||
|
slug = generate_unique_slug(name)
|
||||||
|
user = session.query(User).where(User.slug == slug).first()
|
||||||
|
if user:
|
||||||
|
slug = generate_unique_slug(email.split("@")[0])
|
||||||
|
user_dict = {
|
||||||
|
"email": email,
|
||||||
|
"username": email, # will be used to store phone number or some messenger network id
|
||||||
|
"name": name,
|
||||||
|
"slug": slug,
|
||||||
|
}
|
||||||
|
if password:
|
||||||
|
user_dict["password"] = Password.encode(password)
|
||||||
|
user = create_user(user_dict)
|
||||||
|
user = await auth_send_link(_, _info, email)
|
||||||
|
return {"user": user}
|
||||||
|
|
||||||
|
|
||||||
|
@mutation.field("sendLink")
|
||||||
|
async def auth_send_link(_, _info, email, lang="ru", template="email_confirmation"):
|
||||||
|
email = email.lower()
|
||||||
|
"""send link with confirm code to email"""
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).filter(User.email == email).first()
|
||||||
|
if not user:
|
||||||
|
raise ObjectNotExist("User not found")
|
||||||
|
else:
|
||||||
|
token = await TokenStorage.create_onetime(user)
|
||||||
|
await send_auth_email(user, token, lang, template)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@query.field("signIn")
|
||||||
|
async def login(_, info, email: str, password: str = "", lang: str = "ru"):
|
||||||
|
email = email.lower()
|
||||||
|
with local_session() as session:
|
||||||
|
orm_user = session.query(User).filter(User.email == email).first()
|
||||||
|
if orm_user is None:
|
||||||
|
print(f"[auth] {email}: email not found")
|
||||||
|
# return {"error": "email not found"}
|
||||||
|
raise ObjectNotExist("User not found") # contains webserver status
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
print(f"[auth] send confirm link to {email}")
|
||||||
|
token = await TokenStorage.create_onetime(orm_user)
|
||||||
|
await send_auth_email(orm_user, token, lang)
|
||||||
|
# FIXME: not an error, warning
|
||||||
|
return {"error": "no password, email link was sent"}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# sign in using password
|
||||||
|
if not orm_user.emailConfirmed:
|
||||||
|
# not an error, warns users
|
||||||
|
return {"error": "please, confirm email"}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
user = Identity.password(orm_user, password)
|
||||||
|
session_token = await TokenStorage.create_session(user)
|
||||||
|
print(f"[auth] user {email} authorized")
|
||||||
|
return {"token": session_token, "user": user}
|
||||||
|
except InvalidPassword:
|
||||||
|
print(f"[auth] {email}: invalid password")
|
||||||
|
raise InvalidPassword("invalid password") # contains webserver status
|
||||||
|
# return {"error": "invalid password"}
|
||||||
|
|
||||||
|
|
||||||
|
@query.field("signOut")
|
||||||
|
@login_required
|
||||||
|
async def sign_out(_, info: GraphQLResolveInfo):
|
||||||
|
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER, "")
|
||||||
|
status = await TokenStorage.revoke(token)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@query.field("isEmailUsed")
|
||||||
|
async def is_email_used(_, _info, email):
|
||||||
|
email = email.lower()
|
||||||
|
with local_session() as session:
|
||||||
|
user = session.query(User).filter(User.email == email).first()
|
||||||
|
return user is not None
|
74
auth/tokenstorage.py
Normal file
74
auth/tokenstorage.py
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from base.redis import redis
|
||||||
|
from validations.auth import AuthInput
|
||||||
|
|
||||||
|
from auth.jwtcodec import JWTCodec
|
||||||
|
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN
|
||||||
|
|
||||||
|
|
||||||
|
async def save(token_key, life_span, auto_delete=True):
|
||||||
|
await redis.execute("SET", token_key, "True")
|
||||||
|
if auto_delete:
|
||||||
|
expire_at = (datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)).timestamp()
|
||||||
|
await redis.execute("EXPIREAT", token_key, int(expire_at))
|
||||||
|
|
||||||
|
|
||||||
|
class SessionToken:
|
||||||
|
@classmethod
|
||||||
|
async def verify(cls, token: str):
|
||||||
|
"""
|
||||||
|
Rules for a token to be valid.
|
||||||
|
- token format is legal
|
||||||
|
- token exists in redis database
|
||||||
|
- token is not expired
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return JWTCodec.decode(token)
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get(cls, payload, token):
|
||||||
|
return await TokenStorage.get(f"{payload.user_id}-{payload.username}-{token}")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenStorage:
|
||||||
|
@staticmethod
|
||||||
|
async def get(token_key):
|
||||||
|
print("[tokenstorage.get] " + token_key)
|
||||||
|
# 2041-user@domain.zn-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyMDQxLCJ1c2VybmFtZSI6ImFudG9uLnJld2luK3Rlc3QtbG9hZGNoYXRAZ21haWwuY29tIiwiZXhwIjoxNjcxNzgwNjE2LCJpYXQiOjE2NjkxODg2MTYsImlzcyI6ImRpc2NvdXJzIn0.Nml4oV6iMjMmc6xwM7lTKEZJKBXvJFEIZ-Up1C1rITQ
|
||||||
|
return await redis.execute("GET", token_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_onetime(user: AuthInput) -> str:
|
||||||
|
life_span = ONETIME_TOKEN_LIFE_SPAN
|
||||||
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||||
|
one_time_token = JWTCodec.encode(user, exp)
|
||||||
|
await save(f"{user.id}-{user.username}-{one_time_token}", life_span)
|
||||||
|
return one_time_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_session(user: AuthInput) -> str:
|
||||||
|
life_span = SESSION_TOKEN_LIFE_SPAN
|
||||||
|
exp = datetime.now(tz=timezone.utc) + timedelta(seconds=life_span)
|
||||||
|
session_token = JWTCodec.encode(user, exp)
|
||||||
|
await save(f"{user.id}-{user.username}-{session_token}", life_span)
|
||||||
|
return session_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def revoke(token: str) -> bool:
|
||||||
|
payload = None
|
||||||
|
try:
|
||||||
|
print("[auth.tokenstorage] revoke token")
|
||||||
|
payload = JWTCodec.decode(token)
|
||||||
|
except: # noqa
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await redis.execute("DEL", f"{payload.user_id}-{payload.username}-{token}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def revoke_all(user: AuthInput):
|
||||||
|
tokens = await redis.execute("KEYS", f"{user.id}-*")
|
||||||
|
await redis.execute("DEL", *tokens)
|
106
auth/usermodel.py
Normal file
106
auth/usermodel.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
__tablename__ = "permission"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||||
|
resource = Column(String, nullable=False)
|
||||||
|
operation = Column(String, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base):
|
||||||
|
__tablename__ = "role"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
permissions = relationship(Permission)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizerUser(Base):
|
||||||
|
__tablename__ = "authorizer_users"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
||||||
|
key = Column(String)
|
||||||
|
email = Column(String, unique=True)
|
||||||
|
email_verified_at = Column(Integer)
|
||||||
|
family_name = Column(String)
|
||||||
|
gender = Column(String)
|
||||||
|
given_name = Column(String)
|
||||||
|
is_multi_factor_auth_enabled = Column(Boolean)
|
||||||
|
middle_name = Column(String)
|
||||||
|
nickname = Column(String)
|
||||||
|
password = Column(String)
|
||||||
|
phone_number = Column(String, unique=True)
|
||||||
|
phone_number_verified_at = Column(Integer)
|
||||||
|
# preferred_username = Column(String, nullable=False)
|
||||||
|
picture = Column(String)
|
||||||
|
revoked_timestamp = Column(Integer)
|
||||||
|
roles = Column(String, default="author,reader")
|
||||||
|
signup_methods = Column(String, default="magic_link_login")
|
||||||
|
created_at = Column(Integer, default=lambda: int(time.time()))
|
||||||
|
updated_at = Column(Integer, default=lambda: int(time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
class UserRating(Base):
|
||||||
|
__tablename__ = "user_rating"
|
||||||
|
|
||||||
|
id = None
|
||||||
|
rater: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
|
||||||
|
user: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
|
||||||
|
value: Column = Column(Integer)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def init_table():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(Base):
|
||||||
|
__tablename__ = "user_role"
|
||||||
|
|
||||||
|
id = None
|
||||||
|
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
|
||||||
|
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "user"
|
||||||
|
default_user = None
|
||||||
|
|
||||||
|
email = Column(String, unique=True, nullable=False, comment="Email")
|
||||||
|
username = Column(String, nullable=False, comment="Login")
|
||||||
|
password = Column(String, nullable=True, comment="Password")
|
||||||
|
bio = Column(String, nullable=True, comment="Bio") # status description
|
||||||
|
about = Column(String, nullable=True, comment="About") # long and formatted
|
||||||
|
userpic = Column(String, nullable=True, comment="Userpic")
|
||||||
|
name = Column(String, nullable=True, comment="Display name")
|
||||||
|
slug = Column(String, unique=True, comment="User's slug")
|
||||||
|
muted = Column(Boolean, default=False)
|
||||||
|
emailConfirmed = Column(Boolean, default=False)
|
||||||
|
createdAt = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at")
|
||||||
|
lastSeen = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Was online at")
|
||||||
|
deletedAt = Column(DateTime(timezone=True), nullable=True, comment="Deleted at")
|
||||||
|
links = Column(JSON, nullable=True, comment="Links")
|
||||||
|
oauth = Column(String, nullable=True)
|
||||||
|
ratings = relationship(UserRating, foreign_keys=UserRating.user)
|
||||||
|
roles = relationship(lambda: Role, secondary=UserRole.__tablename__)
|
||||||
|
oid = Column(String, nullable=True)
|
||||||
|
|
||||||
|
def get_permission(self):
|
||||||
|
scope = {}
|
||||||
|
for role in self.roles:
|
||||||
|
for p in role.permissions:
|
||||||
|
if p.resource not in scope:
|
||||||
|
scope[p.resource] = set()
|
||||||
|
scope[p.resource].add(p.operation)
|
||||||
|
print(scope)
|
||||||
|
return scope
|
||||||
|
|
||||||
|
|
||||||
|
# if __name__ == "__main__":
|
||||||
|
# print(User.get_permission(user_id=1))
|
30
orm/user.py
30
orm/user.py
|
@ -1,30 +0,0 @@
|
||||||
import time
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Integer, String
|
|
||||||
|
|
||||||
from services.db import Base
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "authorizer_users"
|
|
||||||
|
|
||||||
id = Column(String, primary_key=True, unique=True, nullable=False, default=None)
|
|
||||||
key = Column(String)
|
|
||||||
email = Column(String, unique=True)
|
|
||||||
email_verified_at = Column(Integer)
|
|
||||||
family_name = Column(String)
|
|
||||||
gender = Column(String)
|
|
||||||
given_name = Column(String)
|
|
||||||
is_multi_factor_auth_enabled = Column(Boolean)
|
|
||||||
middle_name = Column(String)
|
|
||||||
nickname = Column(String)
|
|
||||||
password = Column(String)
|
|
||||||
phone_number = Column(String, unique=True)
|
|
||||||
phone_number_verified_at = Column(Integer)
|
|
||||||
# preferred_username = Column(String, nullable=False)
|
|
||||||
picture = Column(String)
|
|
||||||
revoked_timestamp = Column(Integer)
|
|
||||||
roles = Column(String, default="author,reader")
|
|
||||||
signup_methods = Column(String, default="magic_link_login")
|
|
||||||
created_at = Column(Integer, default=lambda: int(time.time()))
|
|
||||||
updated_at = Column(Integer, default=lambda: int(time.time()))
|
|
|
@ -23,11 +23,15 @@ httpx = "^0.27.0"
|
||||||
dogpile-cache = "^1.3.1"
|
dogpile-cache = "^1.3.1"
|
||||||
colorlog = "^6.8.2"
|
colorlog = "^6.8.2"
|
||||||
fakeredis = "^2.25.1"
|
fakeredis = "^2.25.1"
|
||||||
|
pydantic = "^2.9.2"
|
||||||
|
jwt = "^1.3.1"
|
||||||
|
authlib = "^1.3.2"
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
ruff = "^0.4.7"
|
ruff = "^0.4.7"
|
||||||
isort = "^5.13.2"
|
isort = "^5.13.2"
|
||||||
|
pydantic = "^2.9.2"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|
|
@ -11,6 +11,7 @@ from services.db import local_session
|
||||||
from services.schema import query
|
from services.schema import query
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
@query.field("load_shouts_coauthored")
|
@query.field("load_shouts_coauthored")
|
||||||
@login_required
|
@login_required
|
||||||
async def load_shouts_coauthored(_, info, options):
|
async def load_shouts_coauthored(_, info, options):
|
||||||
|
|
Loading…
Reference in New Issue
Block a user