This commit is contained in:
Untone 2021-08-26 18:48:48 +03:00
commit b1dd4c52b5
12 changed files with 142 additions and 47 deletions

View File

@ -13,13 +13,15 @@ passlib = "*"
PyJWT = "*" PyJWT = "*"
SQLAlchemy = "*" SQLAlchemy = "*"
itsdangerous = "*" itsdangerous = "*"
httpx = "*" httpx = "<0.18.2"
psycopg2-binary = "*" psycopg2-binary = "*"
Authlib = "*" Authlib = "*"
bson = "*" bson = "*"
python-frontmatter = "*" python-frontmatter = "*"
bs4 = "*" bs4 = "*"
transliterate = "*" transliterate = "*"
psycopg2 = "*"
requests = "*"
[dev-packages] [dev-packages]

View File

@ -8,10 +8,12 @@ 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 exceptions import InvalidToken, OperationNotAllowed from exceptions import InvalidToken, OperationNotAllowed
from orm import User from orm import User
from orm.base import local_session
from redis import redis from redis import redis
from settings import JWT_AUTH_HEADER from settings import JWT_AUTH_HEADER, EMAIL_TOKEN_LIFE_SPAN
class _Authenticate: class _Authenticate:
@ -65,9 +67,38 @@ class JWTAuthenticate(AuthenticationBackend):
if payload is None: if payload is None:
return AuthCredentials(scopes=[]), AuthUser(user_id=None) return AuthCredentials(scopes=[]), AuthUser(user_id=None)
if not payload.device in ("pc", "mobile"):
return AuthCredentials(scopes=[]), AuthUser(user_id=None)
scopes = User.get_permission(user_id=payload.user_id) scopes = User.get_permission(user_id=payload.user_id)
return AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True), AuthUser(user_id=payload.user_id) return AuthCredentials(user_id=payload.user_id, scopes=scopes, logged_in=True), AuthUser(user_id=payload.user_id)
class EmailAuthenticate:
@staticmethod
async def get_email_token(user):
token = await Authorize.authorize(
user,
device="email",
life_span=EMAIL_TOKEN_LIFE_SPAN
)
return token
@staticmethod
async def authenticate(token):
payload = await _Authenticate.verify(token)
if payload is None:
raise InvalidToken("invalid token")
if payload.device != "email":
raise InvalidToken("invalid token")
with local_session() as session:
user = session.query(User).filter_by(id=payload.user_id).first()
if not user:
raise Exception("user not exist")
if not user.emailConfirmed:
user.emailConfirmed = True
session.commit()
auth_token = await Authorize.authorize(user)
return (auth_token, user)
def login_required(func): def login_required(func):
@wraps(func) @wraps(func)

View File

@ -8,14 +8,14 @@ from auth.validations import User
class Authorize: class Authorize:
@staticmethod @staticmethod
async def authorize(user: User, device: str = "pc", auto_delete=True) -> str: async def authorize(user: User, device: str = "pc", life_span = JWT_LIFE_SPAN, auto_delete=True) -> str:
""" """
:param user: :param user:
:param device: :param device:
:param auto_delete: Whether the expiration is automatically deleted, the default is True :param auto_delete: Whether the expiration is automatically deleted, the default is True
:return: :return:
""" """
exp = datetime.utcnow() + timedelta(seconds=JWT_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 redis.execute("SET", f"{user.id}-{token}", "True")
if auto_delete: if auto_delete:
@ -37,13 +37,3 @@ class Authorize:
async def revoke_all(user: User): async def revoke_all(user: User):
tokens = await redis.execute("KEYS", f"{user.id}-*") tokens = await redis.execute("KEYS", f"{user.id}-*")
await redis.execute("DEL", *tokens) await redis.execute("DEL", *tokens)
@staticmethod
async def confirm(token: str):
try:
# NOTE: auth_token and email_token are different
payload = Token.decode(token) # TODO: check to decode here the proper way
auth_token = self.authorize(payload.user)
return auth_token, payload.user
except:
pass

45
auth/email.py Normal file
View File

@ -0,0 +1,45 @@
import requests
from starlette.responses import PlainTextResponse
from starlette.exceptions import HTTPException
from auth.authenticate import EmailAuthenticate
from settings import BACKEND_URL, MAILGUN_API_KEY, MAILGUN_DOMAIN
MAILGUN_API_URL = "https://api.mailgun.net/v3/%s/messages" % (MAILGUN_DOMAIN)
MAILGUN_FROM = "postmaster <postmaster@%s>" % (MAILGUN_DOMAIN)
AUTH_URL = "%s/email_authorize" % (BACKEND_URL)
async def send_confirm_email(user):
text = "<html><body>To confirm registration follow the <a href='%s'>link</link></body></html>"
await send_email(user, text)
async def send_auth_email(user):
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)
to = "%s <%s>" % (user.username, user.email)
auth_url_with_token = "%s?token=%s" % (AUTH_URL, token)
text = text % (auth_url_with_token)
response = requests.post(
MAILGUN_API_URL,
auth = ("api", MAILGUN_API_KEY),
data = {
"from": MAILGUN_FROM,
"to": to,
"subject": "authorize log in",
"html": text
}
)
response.raise_for_status()
async def email_authorize(request):
token = request.query_params.get('token')
if not token:
raise HTTPException(500, "invalid url")
auth_token, user = await EmailAuthenticate.authenticate(token)
return PlainTextResponse(auth_token)

View File

@ -17,7 +17,7 @@ oauth.register(
authorize_url='https://www.facebook.com/v11.0/dialog/oauth', authorize_url='https://www.facebook.com/v11.0/dialog/oauth',
authorize_params=None, authorize_params=None,
api_base_url='https://graph.facebook.com/', api_base_url='https://graph.facebook.com/',
client_kwargs={'scope': 'user:email'}, client_kwargs={'scope': 'public_profile email'},
) )
oauth.register( oauth.register(
@ -36,14 +36,30 @@ oauth.register(
name='google', name='google',
client_id=OAUTH_CLIENTS["GOOGLE"]["id"], client_id=OAUTH_CLIENTS["GOOGLE"]["id"],
client_secret=OAUTH_CLIENTS["GOOGLE"]["key"], client_secret=OAUTH_CLIENTS["GOOGLE"]["key"],
access_token_url='https://oauth2.googleapis.com/token', server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
access_token_params=None,
authorize_url='https://accounts.google.com/o/oauth2/v2/auth',
authorize_params=None,
api_base_url='https://oauth2.googleapis.com/',
client_kwargs={'scope': 'openid email profile'} client_kwargs={'scope': 'openid email profile'}
) )
async def google_profile(client, request, token):
profile = await client.parse_id_token(request, token)
profile["id"] = profile["sub"]
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): async def oauth_login(request):
provider = request.path_params['provider'] provider = request.path_params['provider']
request.session['provider'] = provider request.session['provider'] = provider
@ -55,14 +71,14 @@ async def oauth_authorize(request):
provider = request.session['provider'] provider = request.session['provider']
client = oauth.create_client(provider) client = oauth.create_client(provider)
token = await client.authorize_access_token(request) token = await client.authorize_access_token(request)
resp = await client.get('user', token=token) get_profile = profile_callbacks[provider]
profile = resp.json() profile = await get_profile(client, request, token)
oauth = profile["id"] user_oauth_info = "%s:%s" % (provider, profile["id"])
user_input = { user_input = {
"oauth" : oauth, "oauth" : user_oauth_info,
"email" : profile["email"], "email" : profile["email"],
"username" : profile["name"] "username" : profile["name"]
} }
user = Identity.identity_oauth(user_input) user = Identity.identity_oauth(user_input)
token = await Authorize.authorize(user, device="pc", auto_delete=False) token = await Authorize.authorize(user, device="pc")
return PlainTextResponse(token) return PlainTextResponse(token)

View File

@ -10,6 +10,7 @@ from starlette.routing import Route
from auth.authenticate import JWTAuthenticate from auth.authenticate import JWTAuthenticate
from auth.oauth import oauth_login, oauth_authorize from auth.oauth import oauth_login, oauth_authorize
from auth.email import email_authorize
from redis import redis from redis import redis
from resolvers.base import resolvers from resolvers.base import resolvers
from resolvers.zine import GitTask from resolvers.zine import GitTask
@ -34,7 +35,8 @@ async def shutdown():
routes = [ routes = [
Route("/oauth/{provider}", endpoint=oauth_login), Route("/oauth/{provider}", endpoint=oauth_login),
Route("/authorize", endpoint=oauth_authorize) Route("/oauth_authorize", endpoint=oauth_authorize),
Route("/email_authorize", endpoint=email_authorize)
] ]
app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown], middleware=middleware, routes=routes) app = Starlette(debug=True, on_startup=[start_up], on_shutdown=[shutdown], middleware=middleware, routes=routes)

View File

@ -56,7 +56,7 @@ class Operation(Base):
class Resource(Base): class Resource(Base):
__tablename__ = "resource" __tablename__ = "resource"
resource_class: Type[Base] = Column(ClassType, nullable=False, unique=True, comment="Resource class") resource_class: str = Column(String, nullable=False, unique=True, comment="Resource class")
name: str = Column(String, nullable=False, unique=True, comment="Resource name") name: str = Column(String, nullable=False, unique=True, comment="Resource name")
@staticmethod @staticmethod

View File

@ -1,4 +1,5 @@
from typing import List from typing import List
from datetime import datetime
from sqlalchemy import Table, Column, Integer, String, ForeignKey, Boolean, DateTime, JSON as JSONType from sqlalchemy import Table, Column, Integer, String, ForeignKey, Boolean, DateTime, JSON as JSONType
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -42,8 +43,8 @@ class User(Base):
slug: str = Column(String, unique=True, comment="User's slug") slug: str = Column(String, unique=True, comment="User's slug")
muted: bool = Column(Boolean, default=False) muted: bool = Column(Boolean, default=False)
emailConfirmed: bool = Column(Boolean, default=False) emailConfirmed: bool = Column(Boolean, default=False)
createdAt: DateTime = Column(DateTime, nullable=False, comment="Created at") createdAt: DateTime = Column(DateTime, nullable=False, default = datetime.now, comment="Created at")
wasOnlineAt: DateTime = Column(DateTime, nullable=False, comment="Was online at") wasOnlineAt: DateTime = Column(DateTime, nullable=False, default = datetime.now, comment="Was online at")
links: JSONType = Column(JSONType, nullable=True, comment="Links") links: JSONType = Column(JSONType, nullable=True, comment="Links")
oauth: str = Column(String, nullable=True) oauth: str = Column(String, nullable=True)
notifications = relationship(lambda: UserNotifications) notifications = relationship(lambda: UserNotifications)

View File

@ -5,6 +5,7 @@ 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.validations import CreateUser from auth.validations import CreateUser
from auth.email import send_confirm_email, send_auth_email
from orm import User from orm import User
from orm.base import local_session from orm.base import local_session
from resolvers.base import mutation, query from resolvers.base import mutation, query
@ -29,11 +30,8 @@ async def register(*_, email: str, password: str = ""):
create_user = CreateUser(**inp) create_user = CreateUser(**inp)
create_user.username = email.split('@')[0] create_user.username = email.split('@')[0]
if not password: if not password:
# NOTE: 1 hour confirm_token expire user = User.create(**create_user.dict())
confirm_token = Token.encode(create_user, datetime.now() + timedelta(hours = 1) , "email") await send_confirm_email(user)
# TODO: sendAuthEmail(confirm_token)
# без пароля не возвращаем, а высылаем токен на почту
#
return { "user": user } return { "user": user }
else: else:
create_user.password = Password.encode(create_user.password) create_user.password = Password.encode(create_user.password)
@ -43,12 +41,16 @@ async def register(*_, email: str, password: str = ""):
@query.field("signIn") @query.field("signIn")
async def login(_, info: GraphQLResolveInfo, email: str, password: str): async def login(_, info: GraphQLResolveInfo, email: str, password: str = ""):
with local_session() as session: with local_session() as session:
orm_user = session.query(User).filter(User.email == email).first() orm_user = session.query(User).filter(User.email == email).first()
if orm_user is None: if orm_user is None:
return {"error" : "invalid email"} return {"error" : "invalid email"}
if not password:
await send_auth_email(orm_user)
return {}
try: try:
device = info.context["request"].headers['device'] device = info.context["request"].headers['device']
except KeyError: except KeyError:

View File

@ -58,7 +58,7 @@ type Mutation {
confirmEmail(token: String!): AuthResult! confirmEmail(token: String!): AuthResult!
requestPasswordReset(email: String!): Boolean! requestPasswordReset(email: String!): Boolean!
confirmPasswordReset(token: String!): Boolean! confirmPasswordReset(token: String!): Boolean!
registerUser(email: String!, password: String!): AuthResult! registerUser(email: String!, password: String): AuthResult!
# updatePassword(password: String!, token: String!): Token! # updatePassword(password: String!, token: String!): Token!
# invalidateAllTokens: Boolean! # invalidateAllTokens: Boolean!
# invalidateTokenById(id: Int!): Boolean! # invalidateTokenById(id: Int!): Boolean!
@ -81,7 +81,7 @@ type Mutation {
type Query { type Query {
# auth # auth
isEmailFree(email: String!): Result! isEmailFree(email: String!): Result!
signIn(email: String!, password: String!): AuthResult! signIn(email: String!, password: String): AuthResult!
signOut: Result! signOut: Result!
# user profile # user profile
getCurrentUser: UserResult! getCurrentUser: UserResult!

View File

@ -3,13 +3,19 @@ from os import environ
PORT = 8080 PORT = 8080
BACKEND_URL = "https://localhost:8080"
DB_URL = environ.get("DB_URL") or "sqlite:///db.sqlite3" DB_URL = environ.get("DB_URL") or "sqlite:///db.sqlite3"
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
JWT_SECRET_KEY = "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key" JWT_SECRET_KEY = "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key"
JWT_LIFE_SPAN = 24 * 60 * 60 # seconds JWT_LIFE_SPAN = 24 * 60 * 60 # seconds
JWT_AUTH_HEADER = "Auth" JWT_AUTH_HEADER = "Auth"
EMAIL_TOKEN_LIFE_SPAN = 1 * 60 * 60 # seconds
REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1" REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
MAILGUN_API_KEY = environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = "sandbox6afe2b71cd354c8fa59e0b868c20a23b.mailgun.org"
OAUTH_PROVIDERS = ("GITHUB", "FACEBOOK", "GOOGLE") OAUTH_PROVIDERS = ("GITHUB", "FACEBOOK", "GOOGLE")
OAUTH_CLIENTS = {} OAUTH_CLIENTS = {}
for provider in OAUTH_PROVIDERS: for provider in OAUTH_PROVIDERS: