diff --git a/auth/authenticate.py b/auth/authenticate.py index 847730e5..86e5eb9a 100644 --- a/auth/authenticate.py +++ b/auth/authenticate.py @@ -1,6 +1,8 @@ from functools import wraps from typing import Optional, Tuple +from datetime import datetime, timedelta + from graphql import GraphQLResolveInfo from jwt import DecodeError, ExpiredSignatureError from starlette.authentication import AuthenticationBackend @@ -8,7 +10,7 @@ from starlette.requests import HTTPConnection from auth.credentials import AuthCredentials, AuthUser from auth.token import Token -from auth.authorize import Authorize +from auth.authorize import Authorize, TokenStorage from exceptions import InvalidToken, OperationNotAllowed from orm import User, UserStorage from orm.base import local_session @@ -47,8 +49,7 @@ class _Authenticate: @classmethod async def exists(cls, user_id, token): - token = await redis.execute("GET", f"{user_id}-{token}") - return token is not None + return await TokenStorage.exist(f"{user_id}-{token}") class JWTAuthenticate(AuthenticationBackend): @@ -104,6 +105,28 @@ class EmailAuthenticate: auth_token = await Authorize.authorize(user) return (auth_token, user) +class ResetPassword: + @staticmethod + async def get_reset_token(user): + exp = datetime.utcnow() + timedelta(seconds=EMAIL_TOKEN_LIFE_SPAN) + token = Token.encode(user, exp=exp, device="pc") + await TokenStorage.save(f"{user.id}-reset-{token}", EMAIL_TOKEN_LIFE_SPAN, True) + return token + + @staticmethod + async def verify(token): + try: + payload = Token.decode(token) + except ExpiredSignatureError: + raise InvalidToken("Login expired, please login again") + except DecodeError as e: + raise InvalidToken("token format error") from e + else: + if not await TokenStorage.exist(f"{payload.user_id}-reset-{token}"): + raise InvalidToken("Login expired, please login again") + + return payload.user_id + def login_required(func): @wraps(func) async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): diff --git a/auth/authorize.py b/auth/authorize.py index 682876fe..c9cd573d 100644 --- a/auth/authorize.py +++ b/auth/authorize.py @@ -5,35 +5,39 @@ from redis import redis from settings import JWT_LIFE_SPAN from auth.validations import User +class TokenStorage: + @staticmethod + async def save(token_key, life_span, auto_delete=True): + await redis.execute("SET", token_key, "True") + if auto_delete: + expire_at = (datetime.now() + timedelta(seconds=life_span)).timestamp() + print(expire_at) + await redis.execute("EXPIREAT", token_key, int(expire_at)) + + @staticmethod + async def exist(token_key): + return await redis.execute("GET", token_key) + class Authorize: - @staticmethod - async def authorize(user: User, device: str = "pc", life_span = JWT_LIFE_SPAN, 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=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 authorize(user: User, device: str = "pc", life_span = JWT_LIFE_SPAN, auto_delete=True) -> str: + exp = datetime.utcnow() + timedelta(seconds=life_span) + token = Token.encode(user, exp=exp, device=device) + await TokenStorage.save(f"{user.id}-{token}", life_span, auto_delete) + return token - @staticmethod - async def revoke(token: str) -> bool: - try: - payload = Token.decode(token) - except: # noqa - pass - else: - await redis.execute("DEL", f"{payload.user_id}-{token}") - return True + @staticmethod + async def revoke(token: str) -> bool: + try: + payload = Token.decode(token) + except: # noqa + pass + else: + await redis.execute("DEL", f"{payload.user_id}-{token}") + return True - @staticmethod - async def revoke_all(user: User): - tokens = await redis.execute("KEYS", f"{user.id}-*") - await redis.execute("DEL", *tokens) + @staticmethod + async def revoke_all(user: User): + tokens = await redis.execute("KEYS", f"{user.id}-*") + await redis.execute("DEL", *tokens) diff --git a/auth/email.py b/auth/email.py index 2c2f91b2..b282d78b 100644 --- a/auth/email.py +++ b/auth/email.py @@ -2,9 +2,9 @@ import requests from starlette.responses import PlainTextResponse from starlette.exceptions import HTTPException -from auth.authenticate import EmailAuthenticate +from auth.authenticate import EmailAuthenticate, ResetPassword -from settings import BACKEND_URL, MAILGUN_API_KEY, MAILGUN_DOMAIN +from settings import BACKEND_URL, MAILGUN_API_KEY, MAILGUN_DOMAIN, RESET_PWD_URL MAILGUN_API_URL = "https://api.mailgun.net/v3/%s/messages" % (MAILGUN_DOMAIN) MAILGUN_FROM = "postmaster " % (MAILGUN_DOMAIN) @@ -13,18 +13,23 @@ AUTH_URL = "%s/email_authorize" % (BACKEND_URL) async def send_confirm_email(user): text = "To confirm registration follow the link" - await send_email(user, text) + token = await EmailAuthenticate.get_email_token(user) + await send_email(user, AUTH_URL, text, token) async def send_auth_email(user): text = "To enter the site follow the link" - await send_email(user, text) - -async def send_email(user, text): token = await EmailAuthenticate.get_email_token(user) + await send_email(user, AUTH_URL, text, token) +async def send_reset_password_email(user): + text = "To reset password follow the link" + token = await ResetPassword.get_reset_token(user) + await send_email(user, RESET_PWD_URL, text, token) + +async def send_email(user, url, text, token): to = "%s <%s>" % (user.username, user.email) - auth_url_with_token = "%s/%s" % (AUTH_URL, token) - text = text % (auth_url_with_token) + url_with_token = "%s/%s" % (url, token) + text = text % (url_with_token) response = requests.post( MAILGUN_API_URL, auth = ("api", MAILGUN_API_KEY), diff --git a/auth/token.py b/auth/token.py index 74b9b38e..e71db18c 100644 --- a/auth/token.py +++ b/auth/token.py @@ -7,17 +7,17 @@ from auth.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) + @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) - @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) + @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/resolvers/auth.py b/resolvers/auth.py index 3d44f48b..f32d78a4 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -3,15 +3,15 @@ from datetime import datetime, timedelta from transliterate import translit from urllib.parse import quote_plus -from auth.authenticate import login_required +from auth.authenticate import login_required, ResetPassword from auth.authorize import Authorize from auth.identity import Identity from auth.password import Password -from auth.email import send_confirm_email, send_auth_email +from auth.email import send_confirm_email, send_auth_email, send_reset_password_email from orm import User, UserStorage, Role, UserRole from orm.base import local_session from resolvers.base import mutation, query -from exceptions import InvalidPassword +from exceptions import InvalidPassword, InvalidToken from settings import JWT_AUTH_HEADER @@ -54,6 +54,32 @@ async def register(*_, email: str, password: str = ""): token = await Authorize.authorize(user) return {"user": user, "token": token } +@mutation.field("requestPasswordUpdate") +async def request_password_update(_, info, email): + with local_session() as session: + user = session.query(User).filter(User.email == email).first() + if not user: + return {"error" : "user not exist"} + + await send_reset_password_email(user) + + return {} + +@mutation.field("updatePassword") +async def update_password(_, info, password, token): + try: + user_id = await ResetPassword.verify(token) + except InvalidToken as e: + return {"error" : e.message} + + with local_session() as session: + user = session.query(User).filter_by(id = user_id).first() + if not user: + return {"error" : "user not exist"} + user.password = Password.encode(password) + session.commit() + + return {} @query.field("signIn") async def login(_, info: GraphQLResolveInfo, email: str, password: str = ""): diff --git a/schema.graphql b/schema.graphql index fadf2f37..ec826dc8 100644 --- a/schema.graphql +++ b/schema.graphql @@ -99,12 +99,9 @@ type Mutation { # auth confirmEmail(token: String!): AuthResult! - requestPasswordReset(email: String!): Boolean! - confirmPasswordReset(token: String!): Boolean! registerUser(email: String!, password: String): AuthResult! - # updatePassword(password: String!, token: String!): Token! - # invalidateAllTokens: Boolean! - # invalidateTokenById(id: Int!): Boolean! + requestPasswordUpdate(email: String!): Result! + updatePassword(password: String!, token: String!): Result! # requestEmailConfirmation: User! # shout diff --git a/settings.py b/settings.py index dc921bb5..3088a24f 100644 --- a/settings.py +++ b/settings.py @@ -5,6 +5,7 @@ PORT = 8080 BACKEND_URL = environ.get("BACKEND_URL") or "https://localhost:8080" OAUTH_CALLBACK_URL = environ.get("OAUTH_CALLBACK_URL") or "https://localhost:8080/authorized" +RESET_PWD_URL = environ.get("RESET_PWD_URL") or "https://localhost:8080/reset_pwd" DB_URL = environ.get("DATABASE_URL") or environ.get("DB_URL") or "sqlite:///db.sqlite3" JWT_ALGORITHM = "HS256"