diff --git a/.env.template b/.env.template index b042d68ac..5690b8bff 100644 --- a/.env.template +++ b/.env.template @@ -6,3 +6,4 @@ MAIL_SERVER = APP_MAIL_USERNAME = APP_MAIL_PASSWORD = MOCK_EMAIL = +GOOGLE_AUTH_CLIENT_ID = \ No newline at end of file diff --git a/app/api/dao/user.py b/app/api/dao/user.py index dbec23e1f..f31dfa77f 100644 --- a/app/api/dao/user.py +++ b/app/api/dao/user.py @@ -10,6 +10,7 @@ from app.api.email_utils import confirm_token from app.database.models.mentorship_relation import MentorshipRelationModel from app.database.models.user import UserModel +from app.database.models.social_sign_in import SocialSignInModel from app.utils.decorator_utils import email_verification_required from app.utils.enum_utils import MentorshipRelationState from app.database.models.mentorship_relation import MentorshipRelationModel @@ -69,6 +70,57 @@ def create_user(data: Dict[str, str]): return messages.USER_WAS_CREATED_SUCCESSFULLY, HTTPStatus.CREATED + @staticmethod + def create_user_using_social_login(data: Dict[str, str], social_sign_in_type: str): + """ + Creates a new user using Google Auth. + + Arguments: + data: A list containing the user's name and email + social_sign_in_type: social sign in provider (apple, google) + + Returns: + The new user created + """ + + id_token = data["id_token"] + name = data["name"] + username = None + password = None + email = data["email"] + terms_and_conditions_checked = True + social_login = True + + # Check if user for this id_token already exists + if SocialSignInModel.find_by_id_token(id_token): + return messages.ANOTHER_USER_FOR_ID_TOKEN_EXISTS, HTTPStatus.BAD_REQUEST + + # create and save user + user = UserModel(name, username, password, email, terms_and_conditions_checked, social_login) + user.save_to_db() + + # create and save social sign in details for the user + social_sign_in_details = SocialSignInModel(user.id, social_sign_in_type, id_token, email, name) + social_sign_in_details.save_to_db() + + return user + + @staticmethod + def get_social_sign_in_details(user_id: int, social_sign_in_type: str): + """ + Returns social sign in details of the user. + + Arguments: + user_id: user_id whose details are to be fetched + social_sign_in_type: social sign in type whole details are to be fetched + + Returns: + social sign in details of the user for the specificed type + """ + + social_sign_in_details = SocialSignInModel.get_social_sign_in_details(user_id, social_sign_in_type) + return social_sign_in_details + @staticmethod @email_verification_required def delete_user(user_id: int): diff --git a/app/api/models/user.py b/app/api/models/user.py index afea683ce..cf3092b0a 100644 --- a/app/api/models/user.py +++ b/app/api/models/user.py @@ -16,6 +16,7 @@ def add_models_to_namespace(api_namespace): update_user_request_body_model.name ] = update_user_request_body_model api_namespace.models[login_request_body_model.name] = login_request_body_model + api_namespace.models[social_auth_body_model.name] = social_auth_body_model api_namespace.models[login_response_body_model.name] = login_response_body_model api_namespace.models[refresh_response_body_model.name] = refresh_response_body_model api_namespace.models[ @@ -119,8 +120,8 @@ def add_models_to_namespace(api_namespace): "User registration model", { "name": fields.String(required=True, description="User name"), - "username": fields.String(required=True, description="User username"), - "password": fields.String(required=True, description="User password"), + "username": fields.String(required=False, description="User username"), + "password": fields.String(required=False, description="User password"), "email": fields.String(required=True, description="User email"), "terms_and_conditions_checked": fields.Boolean( required=True, description="User check Terms and Conditions value" @@ -152,6 +153,16 @@ def add_models_to_namespace(api_namespace): }, ) +social_auth_body_model = Model( + "Social sign-in authentication data model", + { + "id_token": fields.String(required=True, description="User's idToken given by Google auth"), + "name": fields.String(required=True, description="User's name given by Google auth"), + "email": fields.String(required=True, description="User's email given by Google auth"), + }, +) + +# TODO: Remove 'expiry' after the android app refactoring. login_response_body_model = Model( "Login response data model", { diff --git a/app/api/resources/user.py b/app/api/resources/user.py index bdf5a0b62..fb55e9c7a 100644 --- a/app/api/resources/user.py +++ b/app/api/resources/user.py @@ -15,13 +15,63 @@ from app.api.email_utils import send_email_verification_message from app.api.models.user import * from app.api.dao.user import UserDAO +from app.database.models.user import UserModel from app.api.resources.common import auth_header_parser +from google.oauth2 import id_token +from google.auth.transport import requests +import os users_ns = Namespace("Users", description="Operations related to users") add_models_to_namespace(users_ns) DAO = UserDAO() # User data access object +def create_tokens_for_new_user_and_return(user): + # create tokens and expiry timestamps + access_token = create_access_token(identity=user.id) + refresh_token = create_refresh_token(identity=user.id) + + from run import application + access_expiry = datetime.utcnow() + application.config.get( + "JWT_ACCESS_TOKEN_EXPIRES" + ) + refresh_expiry = datetime.utcnow() + application.config.get( + "JWT_REFRESH_TOKEN_EXPIRES" + ) + + # return data + return ( + { + "access_token": access_token, + "access_expiry": access_expiry.timestamp(), + "refresh_token": refresh_token, + "refresh_expiry": refresh_expiry.timestamp(), + }, + HTTPStatus.OK, + ) + +def perform_social_sign_in_and_return_response(email:str, provider: str): + # get existing user + user = DAO.get_user_by_email(email) + + # if user not found, create a new user + if not user: + data = request.json + user = DAO.create_user_using_social_login(data, provider) + # If any error occured, return error + if not isinstance(user, UserModel): + # If variable user is not of type UserModel, then that means it contains the error message and the HTTP code. + error_message = user[0] + http_code = user[1] + return error_message, http_code + # if user found, confirm it is for the same social sign in provider + else: + social_sign_in_details = DAO.get_social_sign_in_details(user.id, provider) + # if details not present, return error + if not social_sign_in_details: + return messages.USER_NOT_SIGNED_IN_WITH_THIS_PROVIDER, HTTPStatus.NOT_FOUND + + return create_tokens_for_new_user_and_return(user) @users_ns.route("users") @users_ns.response( @@ -390,6 +440,67 @@ def post(cls): ) +@users_ns.route("apple/auth/callback") +class AppleAuth(Resource): + @classmethod + @users_ns.doc("apple-auth callback") + @users_ns.response(HTTPStatus.OK, "Successful login", login_response_body_model) + @users_ns.doc( + responses={ + HTTPStatus.BAD_REQUEST: f"{messages.ANOTHER_USER_FOR_ID_TOKEN_EXISTS}", + HTTPStatus.NOT_FOUND: f"{messages.USER_NOT_SIGNED_IN_WITH_THIS_PROVIDER}" + } + ) + @users_ns.expect(social_auth_body_model) + def post(cls): + """ + Login/Sign-in user using Apple Sign-In. + + The Apple user id (id_token) is recieved which becomes the primary basis for user identification. + If not found then that means user is using apple sign-in for first time. In this case, email is checked. + If email found, that account is used. Else, a new account is created. + """ + + email = request.json.get("email") + + return perform_social_sign_in_and_return_response(email, "apple") + +@users_ns.route("google/auth/callback") +class GoogleAuth(Resource): + @classmethod + @users_ns.doc("google-auth callback") + @users_ns.response(HTTPStatus.OK, "Successful login", login_response_body_model) + @users_ns.doc( + responses={ + HTTPStatus.UNAUTHORIZED: f"{messages.GOOGLE_AUTH_TOKEN_VERIFICATION_FAILED}", + HTTPStatus.BAD_REQUEST: f"{messages.ANOTHER_USER_FOR_ID_TOKEN_EXISTS}", + HTTPStatus.NOT_FOUND: f"{messages.USER_NOT_SIGNED_IN_WITH_THIS_PROVIDER}" + } + ) + @users_ns.expect(social_auth_body_model) + def post(cls): + """ + Login/Sign-in user using Google Sign-In. + + The Google user idToken is received from the client side, which is then verified for its authenticity. + On verification, the user is either logged-in or sign-up depending upon wheter it is an existing + or a new user. + """ + + token = request.json.get("id_token") + email = request.json.get("email") + + # Verify google auth id token + try: + idinfo = id_token.verify_oauth2_token(token, requests.Request(), os.getenv("GOOGLE_AUTH_CLIENT_ID")) + + # id_token is valid. Perform social sign in. + return perform_social_sign_in_and_return_response(email, "google") + + except ValueError: + return messages.GOOGLE_AUTH_TOKEN_VERIFICATION_FAILED, HTTPStatus.UNAUTHORIZED + + @users_ns.route("login") class LoginUser(Resource): @classmethod @@ -431,28 +542,7 @@ def post(cls): if not user.is_email_verified: return messages.USER_HAS_NOT_VERIFIED_EMAIL_BEFORE_LOGIN, HTTPStatus.FORBIDDEN - access_token = create_access_token(identity=user.id) - refresh_token = create_refresh_token(identity=user.id) - - from run import application - - access_expiry = datetime.utcnow() + application.config.get( - "JWT_ACCESS_TOKEN_EXPIRES" - ) - refresh_expiry = datetime.utcnow() + application.config.get( - "JWT_REFRESH_TOKEN_EXPIRES" - ) - - return ( - { - "access_token": access_token, - "access_expiry": access_expiry.timestamp(), - "refresh_token": refresh_token, - "refresh_expiry": refresh_expiry.timestamp(), - }, - HTTPStatus.OK, - ) - + return create_tokens_for_new_user_and_return(user) @users_ns.route("home") @users_ns.doc("home") diff --git a/app/database/models/social_sign_in.py b/app/database/models/social_sign_in.py new file mode 100644 index 000000000..0a67de868 --- /dev/null +++ b/app/database/models/social_sign_in.py @@ -0,0 +1,46 @@ +from app.database.models.user import UserModel +from app.database.sqlalchemy_extension import db + +class SocialSignInModel(db.Model): + """Data model representation of social sign in of a user. + + Attributes: + user_id: user_id, to identify the user in user model + social_sign_in_type: social sign in type (apple, google) + id_token: id_token sent by the social sign in provider + associated_email: email of the user associated with the social sign in provider + full_name: full name of the user associated with the social sign in provider + """ + + # Specifying database table used for SocialSignInModel + __tablename__ = "social_sign_in" + __table_args__ = {"extend_existing": True} + + # data properties + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + social_sign_in_type = db.Column(db.String(20)) + id_token = db.Column(db.String(100), primary_key=True) + associated_email = db.Column(db.String(50)) + full_name = db.Column(db.String(50)) + + def __init__(self, user_id, sign_in_type, id_token, email, name): + self.user_id = user_id + self.social_sign_in_type = sign_in_type + self.id_token = id_token + self.associated_email = email + self.full_name = name + + @classmethod + def get_social_sign_in_details(cls, user_id: int, social_sign_in_type: str): + """Returns social sign in details of the user for the specified type""" + return cls.query.filter_by(user_id=user_id, social_sign_in_type=social_sign_in_type).first() + + @classmethod + def find_by_id_token(cls, id_token: str): + """Finds user using id_token""" + return cls.query.filter_by(id_token=id_token).first() + + def save_to_db(self): + """Adds the social sign in details to the database.""" + db.session.add(self) + db.session.commit() \ No newline at end of file diff --git a/app/database/models/user.py b/app/database/models/user.py index 2f8ee9985..852158545 100644 --- a/app/database/models/user.py +++ b/app/database/models/user.py @@ -59,7 +59,7 @@ class UserModel(db.Model): need_mentoring = db.Column(db.Boolean) available_to_mentor = db.Column(db.Boolean) - def __init__(self, name, username, password, email, terms_and_conditions_checked): + def __init__(self, name, username, password, email, terms_and_conditions_checked, social_login=False): """Initialises userModel class with name, username, password, email, and terms_and_conditions_checked. """ ## required fields @@ -69,11 +69,13 @@ def __init__(self, name, username, password, email, terms_and_conditions_checked self.terms_and_conditions_checked = terms_and_conditions_checked # saving hash instead of saving password in plain text - self.set_password(password) + if not social_login: + self.set_password(password) # default values self.is_admin = True if self.is_empty() else False # first user is admin - self.is_email_verified = False + # email is verified (True) for social login, False for normal login + self.is_email_verified = social_login self.registration_date = time.time() ## optional fields diff --git a/app/messages.py b/app/messages.py index 83f3f538d..b10c1573e 100644 --- a/app/messages.py +++ b/app/messages.py @@ -205,6 +205,9 @@ EMAIL_VERIFICATION_MESSAGE = { "message": "Check your email, a new verification" " email was sent." } +GOOGLE_AUTH_TOKEN_VERIFICATION_FAILED = {"message": "Google auth token verification failed"} +USER_NOT_SIGNED_IN_WITH_THIS_PROVIDER = {"message": "An account for this email is already present with a different login method."} +ANOTHER_USER_FOR_ID_TOKEN_EXISTS = {"message": "User for this id token already exists. A new account can't be created."} # Success messages TASK_WAS_ALREADY_ACHIEVED = {"message": "Task was already achieved."} diff --git a/config.py b/config.py index 90f254e55..d9b54fd7d 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,6 @@ import os from datetime import timedelta - def get_mock_email_config() -> bool: MOCK_EMAIL = os.getenv("MOCK_EMAIL") diff --git a/docs/user_authentication.md b/docs/user_authentication.md index 30998c51b..e90938941 100644 --- a/docs/user_authentication.md +++ b/docs/user_authentication.md @@ -13,3 +13,20 @@ The user can then use this `access_token` when using a protected/restricted API, Here's an inside look at an `access_token` using [jwt.io](https://jwt.io) Debugger. ![image](https://user-images.githubusercontent.com/11148726/44627573-1de2f800-a928-11e8-87a7-0107b0a622bc.png) + +## Social Sign In + +In addition to authenticating a user using username and password using the (`POST /login`) API, convenient social sign-in using Apple and Google sign-in is also there. This is done using the (`POST /apple/auth/callback`) and (`POST /google/auth/callback`) APIs. + +All the three APIs return the same data model for a successful login, making the implementation in the client app simpler. The flow of social sign-in starts with the client app, where the user uses a provider (Apple or Google) to sign-in with. The provider authenticates the user on their end and sends a user unique `id_token`, `full_name`, and `email`. This data is then used in the callback APIs to authenticate the user on the Mentorship System backend. + +To enable the social sign-in functionality, a separate social sign-in table has been created which links with the `users` table using the user id and stores the social sign-in data such as id_token, associated_email, etc. The functionality has been designed in a way where linking of different accounts can be enabled as a future scope. + +The callback APIs work as follows: +1. The email of the user is used to find an existing user in the database. +2. If a user is not found for the email, a new user is created using the data. If a user with the unique `id_token` already exists on the system, an error message is returned. Else, tokens are generated and returned, successfully signing in the user. +3. If a user is found, the social sign-in details are verified for the user id and the exact provider (apple/google). If a social sign-in record is not found, an error is returned. Else, tokens are generated an returned, successfully signing in the user. + +Official Developer Documentation: +* [Sign In with Apple](https://developer.apple.com/sign-in-with-apple/get-started/) +* [Sign In with Google](https://developers.google.com/identity/sign-in/ios/start?ver=swift) diff --git a/requirements.txt b/requirements.txt index 24cee1fcb..8816f9a8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ Flask-SQLAlchemy==2.3.2 Flask-Testing==0.7.1 future==0.16.0 gunicorn==20.0.4 +google-auth==1.20.1 idna==2.6 itsdangerous==0.24 Jinja2==2.10 diff --git a/tests/users/test_api_social_sign_in.py b/tests/users/test_api_social_sign_in.py new file mode 100644 index 000000000..5fe169676 --- /dev/null +++ b/tests/users/test_api_social_sign_in.py @@ -0,0 +1,98 @@ +import unittest + +from flask import json + +from app import messages +from app.database.sqlalchemy_extension import db +from tests.base_test_case import BaseTestCase + +from app.database.models.user import UserModel +from app.api.dao.user import UserDAO +from http import HTTPStatus + +class TestSocialSignInAPI(BaseTestCase): + def test_create_new_user_for_social_sign_in(self): + with self.client: + response = self.client.post( + "/apple/auth/callback", + data=json.dumps( + dict(id_token="test_token", name="test_name", email="test_email") + ), + follow_redirects=True, + content_type="application/json", + ) + self.assertIsNotNone(response.json.get("access_token")) + self.assertIsNotNone(response.json.get("access_expiry")) + self.assertIsNotNone(response.json.get("refresh_token")) + self.assertIsNotNone(response.json.get("refresh_expiry")) + + def test_another_user_for_id_token_exists(self): + # Create user (token = test_token) + user_data = dict( + id_token="test_token", + name="test_name", + email="test_email" + ) + social_sign_in_type = "test_type" + UserDAO.create_user_using_social_login(user_data, social_sign_in_type) + + # Use API. Keep token same, other information different + with self.client: + response = self.client.post( + "/apple/auth/callback", + data=json.dumps( + dict(id_token="test_token", name="test_name_2", email="test_email_2") + ), + follow_redirects=True, + content_type="application/json", + ) + self.assertIsNone(response.json.get("access_token")) + self.assertIsNone(response.json.get("access_expiry")) + self.assertIsNone(response.json.get("refresh_token")) + self.assertIsNone(response.json.get("refresh_expiry")) + self.assertEqual({"message": response.json.get("message")}, messages.ANOTHER_USER_FOR_ID_TOKEN_EXISTS) + + def test_user_email_signed_in_with_different_provider(self): + # Create user (email = test_email) + user_data = dict( + id_token="test_token", + name="test_name", + email="test_email" + ) + social_sign_in_type = "google" + UserDAO.create_user_using_social_login(user_data, social_sign_in_type) + + # Use API. Keep email same, provider different (apple) + with self.client: + response = self.client.post( + "/apple/auth/callback", + data=json.dumps( + dict(id_token="test_token_2", name="test_name_2", email="test_email") + ), + follow_redirects=True, + content_type="application/json", + ) + self.assertIsNone(response.json.get("access_token")) + self.assertIsNone(response.json.get("access_expiry")) + self.assertIsNone(response.json.get("refresh_token")) + self.assertIsNone(response.json.get("refresh_expiry")) + self.assertEqual({"message": response.json.get("message")}, messages.USER_NOT_SIGNED_IN_WITH_THIS_PROVIDER) + + def test_google_auth_token_not_verified(self): + with self.client: + response = self.client.post( + "/google/auth/callback", + data=json.dumps( + dict(id_token="invalid_token", name="test_name", email="test_email") + ), + follow_redirects=True, + content_type="application/json", + ) + self.assertIsNone(response.json.get("access_token")) + self.assertIsNone(response.json.get("access_expiry")) + self.assertIsNone(response.json.get("refresh_token")) + self.assertIsNone(response.json.get("refresh_expiry")) + self.assertEqual({"message": response.json.get("message")}, messages.GOOGLE_AUTH_TOKEN_VERIFICATION_FAILED) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/users/test_dao_social_sign_in.py b/tests/users/test_dao_social_sign_in.py new file mode 100644 index 000000000..0ad7837e8 --- /dev/null +++ b/tests/users/test_dao_social_sign_in.py @@ -0,0 +1,33 @@ +import unittest + +from app.api.dao.user import UserDAO +from tests.base_test_case import BaseTestCase +from app.database.models.user import UserModel + + +class TestSocialSignIn(BaseTestCase): + def test_create_user_using_social_sign_in(self): + user_data = dict( + id_token="test_token", + name="test_name", + email="test_email" + ) + social_sign_in_type = "test_type" + + # Call create user using social sign in method + UserDAO.create_user_using_social_login(user_data, social_sign_in_type) + + # Test the user created + user = UserDAO.get_user_by_email("test_email") + self.assertEqual(user.name, user_data["name"]) + self.assertTrue(user.username is None) + self.assertTrue(user.password_hash is None) + + # Test the social sign in details of the user created + social_sign_in_details = UserDAO.get_social_sign_in_details(user.id, "test_type") + self.assertEqual(social_sign_in_details.id_token, user_data["id_token"]) + self.assertEqual(social_sign_in_details.associated_email, user_data["email"]) + self.assertEqual(social_sign_in_details.full_name, user_data["name"]) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file