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 = "*"
SQLAlchemy = "*"
itsdangerous = "*"
httpx = "*"
httpx = "<0.18.2"
psycopg2-binary = "*"
Authlib = "*"
bson = "*"
python-frontmatter = "*"
bs4 = "*"
transliterate = "*"
psycopg2 = "*"
requests = "*"
[dev-packages]

View File

@ -8,10 +8,12 @@ from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials, AuthUser
from auth.token import Token
from auth.authorize import Authorize
from exceptions import InvalidToken, OperationNotAllowed
from orm import User
from orm.base import local_session
from redis import redis
from settings import JWT_AUTH_HEADER
from settings import JWT_AUTH_HEADER, EMAIL_TOKEN_LIFE_SPAN
class _Authenticate:
@ -65,9 +67,38 @@ class JWTAuthenticate(AuthenticationBackend):
if payload is 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)
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):
@wraps(func)

View File

@ -8,14 +8,14 @@ from auth.validations import User
class Authorize:
@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 device:
:param auto_delete: Whether the expiration is automatically deleted, the default is True
:return:
"""
exp = datetime.utcnow() + timedelta(seconds=JWT_LIFE_SPAN)
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:
@ -37,13 +37,3 @@ class Authorize:
async def revoke_all(user: User):
tokens = await redis.execute("KEYS", f"{user.id}-*")
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_params=None,
api_base_url='https://graph.facebook.com/',
client_kwargs={'scope': 'user:email'},
client_kwargs={'scope': 'public_profile email'},
)
oauth.register(
@ -36,14 +36,30 @@ oauth.register(
name='google',
client_id=OAUTH_CLIENTS["GOOGLE"]["id"],
client_secret=OAUTH_CLIENTS["GOOGLE"]["key"],
access_token_url='https://oauth2.googleapis.com/token',
access_token_params=None,
authorize_url='https://accounts.google.com/o/oauth2/v2/auth',
authorize_params=None,
api_base_url='https://oauth2.googleapis.com/',
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
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):
provider = request.path_params['provider']
request.session['provider'] = provider
@ -55,14 +71,14 @@ async def oauth_authorize(request):
provider = request.session['provider']
client = oauth.create_client(provider)
token = await client.authorize_access_token(request)
resp = await client.get('user', token=token)
profile = resp.json()
oauth = profile["id"]
get_profile = profile_callbacks[provider]
profile = await get_profile(client, request, token)
user_oauth_info = "%s:%s" % (provider, profile["id"])
user_input = {
"oauth" : oauth,
"oauth" : user_oauth_info,
"email" : profile["email"],
"username" : profile["name"]
}
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)

View File

@ -1,10 +1,10 @@
#!/bin/bash
openssl req -newkey rsa:4096 \
-x509 \
-sha256 \
-days 3650 \
-nodes \
-out discours.crt \
-keyout discours.key \
-subj "/C=RU/ST=Moscow/L=Moscow/O=Discours/OU=Site/CN=test-api.discours.io"
#!/bin/bash
openssl req -newkey rsa:4096 \
-x509 \
-sha256 \
-days 3650 \
-nodes \
-out discours.crt \
-keyout discours.key \
-subj "/C=RU/ST=Moscow/L=Moscow/O=Discours/OU=Site/CN=test-api.discours.io"

View File

@ -10,6 +10,7 @@ from starlette.routing import Route
from auth.authenticate import JWTAuthenticate
from auth.oauth import oauth_login, oauth_authorize
from auth.email import email_authorize
from redis import redis
from resolvers.base import resolvers
from resolvers.zine import GitTask
@ -34,7 +35,8 @@ async def shutdown():
routes = [
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)

View File

@ -56,7 +56,7 @@ class Operation(Base):
class Resource(Base):
__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")
@staticmethod

View File

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

View File

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

View File

@ -58,7 +58,7 @@ type Mutation {
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!
# invalidateAllTokens: Boolean!
# invalidateTokenById(id: Int!): Boolean!
@ -81,7 +81,7 @@ type Mutation {
type Query {
# auth
isEmailFree(email: String!): Result!
signIn(email: String!, password: String!): AuthResult!
signIn(email: String!, password: String): AuthResult!
signOut: Result!
# user profile
getCurrentUser: UserResult!

View File

@ -3,13 +3,19 @@ from os import environ
PORT = 8080
BACKEND_URL = "https://localhost:8080"
DB_URL = environ.get("DB_URL") or "sqlite:///db.sqlite3"
JWT_ALGORITHM = "HS256"
JWT_SECRET_KEY = "8f1bd7696ffb482d8486dfbc6e7d16dd-secret-key"
JWT_LIFE_SPAN = 24 * 60 * 60 # seconds
JWT_AUTH_HEADER = "Auth"
EMAIL_TOKEN_LIFE_SPAN = 1 * 60 * 60 # seconds
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_CLIENTS = {}
for provider in OAUTH_PROVIDERS: