diff --git a/auth/authenticate.py b/auth/authenticate.py index 68446540..95e55789 100644 --- a/auth/authenticate.py +++ b/auth/authenticate.py @@ -11,7 +11,6 @@ from auth.jwtcodec import JWTCodec from auth.tokenstorage import TokenStorage from base.exceptions import InvalidToken from services.auth.users import UserStorage -from settings import SESSION_TOKEN_HEADER class SessionToken: @@ -49,10 +48,10 @@ class JWTAuthenticate(AuthenticationBackend): async def authenticate( self, request: HTTPConnection ) -> Optional[Tuple[AuthCredentials, AuthUser]]: - if SESSION_TOKEN_HEADER not in request.headers: + if "Auth" not in request.headers: return AuthCredentials(scopes=[]), AuthUser(user_id=None) - token = request.headers[SESSION_TOKEN_HEADER] + token = request.headers.get("Auth", "") try: payload = await SessionToken.verify(token) except Exception as exc: @@ -77,6 +76,7 @@ class JWTAuthenticate(AuthenticationBackend): def login_required(func): @wraps(func) async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): + # print('[auth.authenticate] login required for %r with info %r' % (func, info)) # debug only auth: AuthCredentials = info.context["request"].auth if not auth.logged_in: return {"error": auth.error_message or "Please login"} diff --git a/auth/email.py b/auth/email.py index 928c515d..f711dd56 100644 --- a/auth/email.py +++ b/auth/email.py @@ -2,8 +2,8 @@ import requests from settings import MAILGUN_API_KEY, MAILGUN_DOMAIN -api_url = "https://api.mailgun.net/v3/%s/messages" % MAILGUN_DOMAIN -noreply = "discours.io " % 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" diff --git a/auth/jwtcodec.py b/auth/jwtcodec.py index 608cb392..ac8d23df 100644 --- a/auth/jwtcodec.py +++ b/auth/jwtcodec.py @@ -16,14 +16,21 @@ class JWTCodec: "exp": exp, "iat": datetime.utcnow(), } - return jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM) + try: + r = jwt.encode(payload, JWT_SECRET_KEY, JWT_ALGORITHM) + return r + except Exception as e: + print('[jwtcodec] JWT encode error %r' % e) @staticmethod def decode(token: str, verify_exp: bool = True) -> TokenPayload: - payload = jwt.decode( - token, - key=JWT_SECRET_KEY, - options={"verify_exp": verify_exp}, - algorithms=[JWT_ALGORITHM], - ) - return TokenPayload(**payload) + try: + payload = jwt.decode( + token, + key=JWT_SECRET_KEY, + options={"verify_exp": verify_exp}, + algorithms=[JWT_ALGORITHM], + ) + return TokenPayload(**payload) + except Exception as e: + print('[jwtcodec] JWT decode error %r' % e) diff --git a/main.py b/main.py index baa79775..10bd865e 100644 --- a/main.py +++ b/main.py @@ -55,7 +55,7 @@ async def shutdown(): routes = [ Route("/oauth/{provider}", endpoint=oauth_login), Route("/oauth-authorize", endpoint=oauth_authorize), - Route("/confirm/{token}", endpoint=confirm_email_handler), # should be called on client + Route("/confirm/{token}", endpoint=confirm_email_handler) ] app = Starlette( diff --git a/nginx.conf.sigil b/nginx.conf.sigil index 4aa79f82..7b627fb9 100644 --- a/nginx.conf.sigil +++ b/nginx.conf.sigil @@ -119,7 +119,7 @@ server { # # Custom headers and headers various browsers *should* be OK with but aren't # - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,auth'; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Auth'; add_header 'Access-Control-Allow-Credentials' 'true'; # # Tell client that this pre-flight info is valid for 20 days @@ -133,7 +133,7 @@ server { if ($request_method = 'POST') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,auth' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Auth' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Allow-Credentials' 'true' always; } @@ -141,7 +141,7 @@ server { if ($request_method = 'GET') { add_header 'Access-Control-Allow-Origin' '$allow_origin' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,auth' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Auth' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; add_header 'Access-Control-Allow-Credentials' 'true' always; } diff --git a/resolvers/auth.py b/resolvers/auth.py index 80093a29..08bf22ea 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -13,6 +13,7 @@ from auth.authenticate import login_required from auth.email import send_auth_email from auth.identity import Identity, Password from base.exceptions import ( + BaseHttpException, InvalidPassword, InvalidToken, ObjectNotExist, @@ -21,13 +22,13 @@ from base.exceptions import ( from base.orm import local_session from base.resolvers import mutation, query from orm import User, Role -from resolvers.profile import get_user_subscriptions -from settings import SESSION_TOKEN_HEADER +from resolvers.profile import user_subscriptions @mutation.field("refreshSession") @login_required async def get_current_user(_, info): + print('[resolvers.auth] get current user %r' % info) user = info.context["request"].user user.lastSeen = datetime.now() with local_session() as session: @@ -37,17 +38,17 @@ async def get_current_user(_, info): return { "token": token, "user": user, - "news": await get_user_subscriptions(user.slug), + "news": await user_subscriptions(user.slug), } @mutation.field("confirmEmail") -async def confirm_email(_, _info, code): +async def confirm_email(_, info, token): """confirm owning email address""" try: - payload = JWTCodec.decode(code) + payload = JWTCodec.decode(token) user_id = payload.user_id - await TokenStorage.get(f"{user_id}-{code}") + await TokenStorage.get(f"{user_id}-{token}") with local_session() as session: user = session.query(User).where(User.id == user_id).first() session_token = await TokenStorage.create_session(user) @@ -58,7 +59,7 @@ async def confirm_email(_, _info, code): return { "token": session_token, "user": user, - "news": await get_user_subscriptions(user.slug) + "news": await user_subscriptions(user.slug) } except InvalidToken as e: raise InvalidToken(e.message) @@ -70,10 +71,14 @@ async def confirm_email(_, _info, code): async def confirm_email_handler(request): token = request.path_params["token"] # one time request.session["token"] = token - res = await confirm_email(None, token) - response = RedirectResponse(url="https://new.discours.io/confirm") - response.set_cookie("token", res["token"]) # session token - return response + res = await confirm_email(None, {}, token) + # print('[resolvers.auth] confirm_email response: %r' % res) + if "error" in res: + raise BaseHttpException(res['error']) + else: + response = RedirectResponse(url="https://new.discours.io/confirm") + response.set_cookie("token", res["token"]) # session token + return response def create_user(user_dict): @@ -90,8 +95,8 @@ def generate_unique_slug(src): slug = translit(src, "ru", reversed=True).replace(".", "-").lower() if slug != src: print('[resolvers.auth] translited name: ' + slug) + c = 1 with local_session() as session: - c = 1 user = session.query(User).where(User.slug == slug).first() while user: user = session.query(User).where(User.slug == slug).first() @@ -111,7 +116,10 @@ async def register_by_email(_, _info, email: str, password: str = "", name: str if user: raise OperationNotAllowed("User already exist") else: - slug = generate_unique_slug(name or email.split('@')[0]) + 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 @@ -139,7 +147,7 @@ async def auth_send_link(_, _info, email, lang="ru"): @query.field("signIn") -async def login(_, _info, email: str, password: str = "", lang: str = "ru"): +async def login(_, info, email: str, password: str = "", lang: str = "ru"): with local_session() as session: orm_user = session.query(User).filter(User.email == email).first() @@ -168,7 +176,7 @@ async def login(_, _info, email: str, password: str = "", lang: str = "ru"): return { "token": session_token, "user": user, - "news": await get_user_subscriptions(user.slug), + "news": await user_subscriptions(user.slug), } except InvalidPassword: print(f"[auth] {email}: invalid password") @@ -179,7 +187,7 @@ async def login(_, _info, email: str, password: str = "", lang: str = "ru"): @query.field("signOut") @login_required async def sign_out(_, info: GraphQLResolveInfo): - token = info.context["request"].headers[SESSION_TOKEN_HEADER] + token = info.context["request"].headers.get("Auth", "") status = await TokenStorage.revoke(token) return status diff --git a/resolvers/community.py b/resolvers/community.py index e4f40263..e37e0e7e 100644 --- a/resolvers/community.py +++ b/resolvers/community.py @@ -118,7 +118,11 @@ def community_unfollow(user, slug): @query.field("userFollowedCommunities") -def get_followed_communities(_, user_slug) -> List[Community]: +def get_followed_communities(_, _info, user_slug) -> List[Community]: + return followed_communities(user_slug) + + +def followed_communities(user_slug) -> List[Community]: ccc = [] with local_session() as session: ccc = ( diff --git a/resolvers/profile.py b/resolvers/profile.py index 2ad57734..b8ade3fd 100644 --- a/resolvers/profile.py +++ b/resolvers/profile.py @@ -10,21 +10,21 @@ from orm.reaction import Reaction from orm.shout import Shout from orm.topic import Topic, TopicFollower from orm.user import User, UserRole, Role, UserRating, AuthorFollower -from .community import get_followed_communities +from .community import followed_communities from .inbox import get_unread_counter -from .reactions import get_reactions_for_shouts from .topics import get_topic_stat from services.auth.users import UserStorage from services.zine.shoutauthor import ShoutAuthorStorage +from services.stat.reacted import ReactedStorage -async def get_user_subscriptions(slug): +async def user_subscriptions(slug: str): return { "unread": await get_unread_counter(slug), # unread inbox messages counter - "topics": [t.slug for t in await get_followed_topics(0, slug)], # followed topics slugs - "authors": [a.slug for a in await get_followed_authors(0, slug)], # followed authors slugs - "reactions": [r.shout for r in await get_reactions_for_shouts(0, [slug, ])], # followed reacted shout - "communities": [c.slug for c in get_followed_communities(0, slug)], # followed communities slugs + "topics": [t.slug for t in await followed_topics(slug)], # followed topics slugs + "authors": [a.slug for a in await followed_authors(slug)], # followed authors slugs + "reactions": len(await ReactedStorage.get_shout(slug)), + "communities": [c.slug for c in followed_communities(slug)], # communities } @@ -59,6 +59,10 @@ async def get_user_reacted_shouts(_, slug, offset, limit) -> List[Shout]: @query.field("userFollowedTopics") @login_required async def get_followed_topics(_, info, slug) -> List[Topic]: + return await followed_topics(slug) + + +async def followed_topics(slug): topics = [] with local_session() as session: topics = ( @@ -74,6 +78,10 @@ async def get_followed_topics(_, info, slug) -> List[Topic]: @query.field("userFollowedAuthors") async def get_followed_authors(_, _info, slug) -> List[User]: + return await followed_authors(slug) + + +async def followed_authors(slug) -> List[User]: authors = [] with local_session() as session: authors = ( diff --git a/resolvers/reactions.py b/resolvers/reactions.py index 7d14cf4d..6970ef52 100644 --- a/resolvers/reactions.py +++ b/resolvers/reactions.py @@ -124,6 +124,26 @@ async def delete_reaction(_, info, rid): @query.field("reactionsForShouts") async def get_reactions_for_shouts(_, info, shouts, offset, limit): + return await reactions_for_shouts(shouts, offset, limit) + + +async def reactions_for_shouts(shouts, offset, limit): + reactions = [] + with local_session() as session: + for slug in shouts: + reactions += ( + session.query(Reaction) + .filter(Reaction.shout == slug) + .where(Reaction.deletedAt.is_not(None)) + .order_by(desc("createdAt")) + .offset(offset) + .limit(limit) + .all() + ) + for r in reactions: + r.stat = await get_reaction_stat(r.id) + r.createdBy = await UserStorage.get_user(r.createdBy or "discours") + return reactions reactions = [] with local_session() as session: for slug in shouts: diff --git a/server.py b/server.py index 7cf3eac1..1e8502e1 100644 --- a/server.py +++ b/server.py @@ -13,7 +13,7 @@ if __name__ == "__main__": ("Access-Control-Allow-Origin", "http://localhost:3000"), ( "Access-Control-Allow-Headers", - "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,auth", + "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Auth", ), ("Access-Control-Expose-Headers", "Content-Length,Content-Range"), ("Access-Control-Allow-Credentials", "true"), diff --git a/settings.py b/settings.py index 3ff5d282..7f2e9a8e 100644 --- a/settings.py +++ b/settings.py @@ -1,15 +1,13 @@ from os import environ PORT = 8080 -INBOX_SERVICE_PORT = 8081 DB_URL = ( environ.get("DATABASE_URL") or environ.get("DB_URL") or "postgresql://postgres@localhost:5432/discoursio" or "sqlite:///db.sqlite3" ) JWT_ALGORITHM = "HS256" -JWT_SECRET_KEY = "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key" -SESSION_TOKEN_HEADER = "Auth" +JWT_SECRET_KEY = environ.get("JWT_SECRET_KEY") or "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key" SESSION_TOKEN_LIFE_SPAN = 24 * 60 * 60 # seconds ONETIME_TOKEN_LIFE_SPAN = 1 * 60 * 60 # seconds REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"