Skip to content

feat: Social Logins #719

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a774cc8
google login done
yugantarjain Aug 10, 2020
fb6408f
requirements updated
yugantarjain Aug 11, 2020
9b8cedf
sign up using google added (new user is created if old user not found…
yugantarjain Aug 11, 2020
4cea797
apple sign in done
yugantarjain Aug 12, 2020
6340c57
creating and returning access tokens code refactored
yugantarjain Aug 13, 2020
dd9216a
social sign in table created and used
yugantarjain Aug 13, 2020
ba8528d
social sign in implementation finalized
yugantarjain Aug 13, 2020
c6b1aea
code documentation and logic improved
yugantarjain Aug 13, 2020
470d572
messages cosntants fixed. API code refactored and re-used.
yugantarjain Aug 13, 2020
f78e925
tests added and logic improved
yugantarjain Aug 14, 2020
29708e3
social sign in documentation added in user_authentication.md
yugantarjain Aug 14, 2020
a68ee39
code documentation improved
yugantarjain Aug 18, 2020
bb8c558
error unwrapping done to make code clearer. google auth client id mad…
yugantarjain Aug 19, 2020
a9fc3d9
google auth client id used from .env now
yugantarjain Aug 19, 2020
e488923
unnecessary if removed
yugantarjain Aug 21, 2020
191f51f
Update docs/user_authentication.md
yugantarjain Aug 24, 2020
665fb6c
Update docs/user_authentication.md
yugantarjain Aug 24, 2020
a0eda7b
Update docs/user_authentication.md
yugantarjain Aug 24, 2020
db33773
Update docs/user_authentication.md
yugantarjain Aug 24, 2020
d6d4aa3
assertEqual used in tests. doc improved.
yugantarjain Aug 24, 2020
ddc7a54
Merge branch 'develop' into social-sign-in
yugantarjain Aug 29, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ MAIL_SERVER = <mail-server>
APP_MAIL_USERNAME = <app-mail-username>
APP_MAIL_PASSWORD = <app-mail-password>
MOCK_EMAIL = <True-or-False>
GOOGLE_AUTH_CLIENT_ID = <google-auth-client-id-string>
52 changes: 52 additions & 0 deletions app/api/dao/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
14 changes: 12 additions & 2 deletions app/api/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down Expand Up @@ -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"),
Comment on lines +123 to +124
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to change? as far as I see you did not change anything to the POST /register API, you created new endpoints that follow a different request model.

Copy link
Author

@yugantarjain yugantarjain Aug 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Actually, when a user signs up using social login, we first try to find them in our database. If a pre-existing user is not found, we create a new one. To facilitate the creation of a new user, these fields were made optional. I did some testing and it works fine (because we have validation checks for them in any case) and all the unit tests passed.

If there is a better solution to this, please let me know. We also have an ongoing discussion about this on zulip mentorship ios channel.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good @yugantarjain thank you for your explanation!

"email": fields.String(required=True, description="User email"),
"terms_and_conditions_checked": fields.Boolean(
required=True, description="User check Terms and Conditions value"
Expand Down Expand Up @@ -152,6 +153,15 @@ 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",
Expand Down
134 changes: 112 additions & 22 deletions app/api/resources/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# return data

Is this line for debugging purposes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is just a comment, shall I remove it?

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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
46 changes: 46 additions & 0 deletions app/database/models/social_sign_in.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are using type hints in the other functions of this file, why not use here :)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not clear about this point.

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()
8 changes: 5 additions & 3 deletions app/database/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."}
Expand Down
1 change: 0 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import os
from datetime import timedelta


def get_mock_email_config() -> bool:
MOCK_EMAIL = os.getenv("MOCK_EMAIL")

Expand Down
17 changes: 17 additions & 0 deletions docs/user_authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading