-
Notifications
You must be signed in to change notification settings - Fork 447
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
feat: Social Logins #719
Changes from 20 commits
a774cc8
fb6408f
9b8cedf
4cea797
6340c57
dd9216a
ba8528d
c6b1aea
470d572
f78e925
29708e3
a68ee39
bb8c558
a9fc3d9
e488923
191f51f
665fb6c
a0eda7b
db33773
d6d4aa3
ddc7a54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -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 | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Is this line for debugging purposes? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||||
|
@@ -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") | ||||
|
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): | ||
isabelcosta marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 :) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!