diff --git a/Pipfile b/Pipfile index 5810d5c1..de4616d0 100644 --- a/Pipfile +++ b/Pipfile @@ -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] diff --git a/auth/authenticate.py b/auth/authenticate.py index 5c4764c8..ad07a39e 100644 --- a/auth/authenticate.py +++ b/auth/authenticate.py @@ -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) diff --git a/auth/authorize.py b/auth/authorize.py index 46b8a324..682876fe 100644 --- a/auth/authorize.py +++ b/auth/authorize.py @@ -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 diff --git a/auth/email.py b/auth/email.py new file mode 100644 index 00000000..8acfd689 --- /dev/null +++ b/auth/email.py @@ -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 " % (MAILGUN_DOMAIN) + +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) + +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) + + 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) diff --git a/auth/oauth.py b/auth/oauth.py index ea70792b..3d71be48 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -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) diff --git a/create_crt.sh b/create_crt.sh index 3867257a..d36eec87 100644 --- a/create_crt.sh +++ b/create_crt.sh @@ -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" diff --git a/main.py b/main.py index 37a5c5b4..3ef0979b 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/orm/rbac.py b/orm/rbac.py index 64c3d17a..07563a12 100644 --- a/orm/rbac.py +++ b/orm/rbac.py @@ -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 diff --git a/orm/user.py b/orm/user.py index 1cbdd42e..f55fde40 100644 --- a/orm/user.py +++ b/orm/user.py @@ -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) diff --git a/resolvers/auth.py b/resolvers/auth.py index b9341eb5..c9e7be08 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -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: diff --git a/schema.graphql b/schema.graphql index 51e98c10..fa658eb5 100644 --- a/schema.graphql +++ b/schema.graphql @@ -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! diff --git a/settings.py b/settings.py index 5660bdc6..74ad764f 100644 --- a/settings.py +++ b/settings.py @@ -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: