add reset password api

This commit is contained in:
knst-kotov 2022-01-13 15:16:35 +03:00
parent 5341bb80a5
commit 65fa744ea5
7 changed files with 116 additions and 60 deletions

View File

@ -1,6 +1,8 @@
from functools import wraps from functools import wraps
from typing import Optional, Tuple from typing import Optional, Tuple
from datetime import datetime, timedelta
from graphql import GraphQLResolveInfo from graphql import GraphQLResolveInfo
from jwt import DecodeError, ExpiredSignatureError from jwt import DecodeError, ExpiredSignatureError
from starlette.authentication import AuthenticationBackend from starlette.authentication import AuthenticationBackend
@ -8,7 +10,7 @@ from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser from auth.credentials import AuthCredentials, AuthUser
from auth.token import Token from auth.token import Token
from auth.authorize import Authorize from auth.authorize import Authorize, TokenStorage
from exceptions import InvalidToken, OperationNotAllowed from exceptions import InvalidToken, OperationNotAllowed
from orm import User, UserStorage from orm import User, UserStorage
from orm.base import local_session from orm.base import local_session
@ -47,8 +49,7 @@ class _Authenticate:
@classmethod @classmethod
async def exists(cls, user_id, token): async def exists(cls, user_id, token):
token = await redis.execute("GET", f"{user_id}-{token}") return await TokenStorage.exist(f"{user_id}-{token}")
return token is not None
class JWTAuthenticate(AuthenticationBackend): class JWTAuthenticate(AuthenticationBackend):
@ -104,6 +105,28 @@ class EmailAuthenticate:
auth_token = await Authorize.authorize(user) auth_token = await Authorize.authorize(user)
return (auth_token, 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): def login_required(func):
@wraps(func) @wraps(func)
async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs): async def wrap(parent, info: GraphQLResolveInfo, *args, **kwargs):

View File

@ -5,22 +5,26 @@ from redis import redis
from settings import JWT_LIFE_SPAN from settings import JWT_LIFE_SPAN
from auth.validations import User 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: class Authorize:
@staticmethod @staticmethod
async def authorize(user: User, device: str = "pc", life_span = JWT_LIFE_SPAN, auto_delete=True) -> str: 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) exp = datetime.utcnow() + timedelta(seconds=life_span)
token = Token.encode(user, exp=exp, device=device) token = Token.encode(user, exp=exp, device=device)
await redis.execute("SET", f"{user.id}-{token}", "True") await TokenStorage.save(f"{user.id}-{token}", life_span, auto_delete)
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 return token
@staticmethod @staticmethod

View File

@ -2,9 +2,9 @@ import requests
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
from starlette.exceptions import HTTPException 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_API_URL = "https://api.mailgun.net/v3/%s/messages" % (MAILGUN_DOMAIN)
MAILGUN_FROM = "postmaster <postmaster@%s>" % (MAILGUN_DOMAIN) MAILGUN_FROM = "postmaster <postmaster@%s>" % (MAILGUN_DOMAIN)
@ -13,18 +13,23 @@ AUTH_URL = "%s/email_authorize" % (BACKEND_URL)
async def send_confirm_email(user): async def send_confirm_email(user):
text = "<html><body>To confirm registration follow the <a href='%s'>link</link></body></html>" text = "<html><body>To confirm registration follow the <a href='%s'>link</link></body></html>"
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): async def send_auth_email(user):
text = "<html><body>To enter the site follow the <a href='%s'>link</link></body></html>" text = "<html><body>To enter the site follow the <a href='%s'>link</link></body></html>"
await send_email(user, text)
async def send_email(user, text):
token = await EmailAuthenticate.get_email_token(user) token = await EmailAuthenticate.get_email_token(user)
await send_email(user, AUTH_URL, text, token)
async def send_reset_password_email(user):
text = "<html><body>To reset password follow the <a href='%s'>link</link></body></html>"
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) to = "%s <%s>" % (user.username, user.email)
auth_url_with_token = "%s/%s" % (AUTH_URL, token) url_with_token = "%s/%s" % (url, token)
text = text % (auth_url_with_token) text = text % (url_with_token)
response = requests.post( response = requests.post(
MAILGUN_API_URL, MAILGUN_API_URL,
auth = ("api", MAILGUN_API_KEY), auth = ("api", MAILGUN_API_KEY),

View File

@ -3,15 +3,15 @@ from datetime import datetime, timedelta
from transliterate import translit from transliterate import translit
from urllib.parse import quote_plus 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.authorize import Authorize
from auth.identity import Identity from auth.identity import Identity
from auth.password import Password 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 import User, UserStorage, Role, UserRole
from orm.base import local_session from orm.base import local_session
from resolvers.base import mutation, query from resolvers.base import mutation, query
from exceptions import InvalidPassword from exceptions import InvalidPassword, InvalidToken
from settings import JWT_AUTH_HEADER from settings import JWT_AUTH_HEADER
@ -54,6 +54,32 @@ async def register(*_, email: str, password: str = ""):
token = await Authorize.authorize(user) token = await Authorize.authorize(user)
return {"user": user, "token": token } 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") @query.field("signIn")
async def login(_, info: GraphQLResolveInfo, email: str, password: str = ""): async def login(_, info: GraphQLResolveInfo, email: str, password: str = ""):

View File

@ -99,12 +99,9 @@ type Mutation {
# auth # auth
confirmEmail(token: String!): AuthResult! confirmEmail(token: String!): AuthResult!
requestPasswordReset(email: String!): Boolean!
confirmPasswordReset(token: String!): Boolean!
registerUser(email: String!, password: String): AuthResult! registerUser(email: String!, password: String): AuthResult!
# updatePassword(password: String!, token: String!): Token! requestPasswordUpdate(email: String!): Result!
# invalidateAllTokens: Boolean! updatePassword(password: String!, token: String!): Result!
# invalidateTokenById(id: Int!): Boolean!
# requestEmailConfirmation: User! # requestEmailConfirmation: User!
# shout # shout

View File

@ -5,6 +5,7 @@ PORT = 8080
BACKEND_URL = environ.get("BACKEND_URL") or "https://localhost:8080" BACKEND_URL = environ.get("BACKEND_URL") or "https://localhost:8080"
OAUTH_CALLBACK_URL = environ.get("OAUTH_CALLBACK_URL") or "https://localhost:8080/authorized" 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" DB_URL = environ.get("DATABASE_URL") or environ.get("DB_URL") or "sqlite:///db.sqlite3"
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"