diff --git a/CHECKS b/CHECKS new file mode 100644 index 00000000..bdfcc6fe --- /dev/null +++ b/CHECKS @@ -0,0 +1,5 @@ +WAIT=10 +TIMEOUT=10 +ATTEMPTS=3 + +/ diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..ac9d762f --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: python server.py diff --git a/README.md b/README.md index 795bb897..6f1ef42a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# discoursio-api + ## Техстек - sqlalchemy @@ -25,27 +27,3 @@ poetry run ruff check . --fix --select I # линтер и сортировка 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). diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 00000000..1957c1d3 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/auth/authenticate.py b/auth/authenticate.py new file mode 100644 index 00000000..3ccc4756 --- /dev/null +++ b/auth/authenticate.py @@ -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 diff --git a/auth/credentials.py b/auth/credentials.py new file mode 100644 index 00000000..3d7d5a36 --- /dev/null +++ b/auth/credentials.py @@ -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 diff --git a/auth/email.py b/auth/email.py new file mode 100644 index 00000000..a42cf1f7 --- /dev/null +++ b/auth/email.py @@ -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 " % (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) diff --git a/auth/exceptions.py b/auth/exceptions.py new file mode 100644 index 00000000..2cf7bdeb --- /dev/null +++ b/auth/exceptions.py @@ -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" diff --git a/auth/identity.py b/auth/identity.py new file mode 100644 index 00000000..5bbb6030 --- /dev/null +++ b/auth/identity.py @@ -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 diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py new file mode 100644 index 00000000..cad30686 --- /dev/null +++ b/auth/jwtcodec.py @@ -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") diff --git a/auth/oauth.py b/auth/oauth.py new file mode 100644 index 00000000..25cc280a --- /dev/null +++ b/auth/oauth.py @@ -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 diff --git a/auth/resolvers.py b/auth/resolvers.py new file mode 100644 index 00000000..1ea1a149 --- /dev/null +++ b/auth/resolvers.py @@ -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 diff --git a/auth/tokenstorage.py b/auth/tokenstorage.py new file mode 100644 index 00000000..3ee5e7fd --- /dev/null +++ b/auth/tokenstorage.py @@ -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) diff --git a/auth/usermodel.py b/auth/usermodel.py new file mode 100644 index 00000000..f16f48be --- /dev/null +++ b/auth/usermodel.py @@ -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)) diff --git a/orm/user.py b/orm/user.py deleted file mode 100644 index f381aa00..00000000 --- a/orm/user.py +++ /dev/null @@ -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())) diff --git a/pyproject.toml b/pyproject.toml index 2c1bb231..27abc13c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,15 @@ httpx = "^0.27.0" dogpile-cache = "^1.3.1" colorlog = "^6.8.2" fakeredis = "^2.25.1" +pydantic = "^2.9.2" +jwt = "^1.3.1" +authlib = "^1.3.2" [tool.poetry.group.dev.dependencies] ruff = "^0.4.7" isort = "^5.13.2" +pydantic = "^2.9.2" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/resolvers/feed.py b/resolvers/feed.py index 8dadf0b7..325554bc 100644 --- a/resolvers/feed.py +++ b/resolvers/feed.py @@ -11,6 +11,7 @@ from services.db import local_session from services.schema import query from utils.logger import root_logger as logger + @query.field("load_shouts_coauthored") @login_required async def load_shouts_coauthored(_, info, options):