Skip to content

Commit

Permalink
Merge pull request #7 from hotosm/feat-google-login
Browse files Browse the repository at this point in the history
Google Login Implementation
  • Loading branch information
nrjadkry authored Jun 23, 2024
2 parents 057a09e + ca66607 commit d03e298
Show file tree
Hide file tree
Showing 8 changed files with 1,323 additions and 764 deletions.
4 changes: 4 additions & 0 deletions src/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ def assemble_db_connection(cls, v: Optional[str], info: ValidationInfo) -> Any:
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 1 # 1 day
REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 day

GOOGLE_CLIENT_ID: str
GOOGLE_CLIENT_SECRET: str
GOOGLE_LOGIN_REDIRECT_URI: str = "http://localhost:8002"


@lru_cache
def get_settings():
Expand Down
3 changes: 2 additions & 1 deletion src/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from app.projects import project_routes
from app.waypoints import waypoint_routes
from fastapi.responses import RedirectResponse

from app.users import oauth_routes
from app.users import user_routes
from loguru import logger as log

Expand Down Expand Up @@ -85,6 +85,7 @@ def get_application() -> FastAPI:
_app.include_router(project_routes.router)
_app.include_router(waypoint_routes.router)
_app.include_router(user_routes.router)
_app.include_router(oauth_routes.router)

return _app

Expand Down
122 changes: 122 additions & 0 deletions src/backend/app/users/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Core logic for Google OAuth."""

import base64
import json
import logging

from itsdangerous import BadSignature, SignatureExpired
from itsdangerous.url_safe import URLSafeSerializer
from requests_oauthlib import OAuth2Session
from pydantic import BaseModel


log = logging.getLogger(__name__)


class Login(BaseModel):
login_url: str


class Token(BaseModel):
access_token: str


class Auth:
"""Main class for Google login."""

def __init__(
self,
authorization_url,
token_url,
client_id,
client_secret,
secret_key,
login_redirect_uri,
scope,
):
"""Set object params and get OAuth2 session."""
self.authorization_url = authorization_url
self.token_url = token_url
self.client_secret = client_secret
self.secret_key = secret_key
self.oauth = OAuth2Session(
client_id,
redirect_uri=login_redirect_uri,
scope=scope,
)

def login(self) -> dict:
"""Generate login URL from Google session.
Provides a login URL using the session created by Google
client id and redirect uri supplied.
Returns:
dict: {'login_url': 'URL'}
"""
login_url, _ = self.oauth.authorization_url(self.authorization_url)
return json.loads(Login(login_url=login_url).model_dump_json())

def callback(self, callback_url: str) -> str:
"""Performs token exchange between Google and the callback website.
Core will use Oauth secret key from configuration while deserializing token,
provides access token that can be used for authorized endpoints.
Args:
callback_url(str): Absolute URL should be passed which
is catched from login_redirect_uri.
Returns:
access_token(str): The decoded access token.
"""
self.oauth.fetch_token(
self.token_url,
authorization_response=callback_url,
client_secret=self.client_secret,
)

user_api_url = "https://www.googleapis.com/oauth2/v1/userinfo"
resp = self.oauth.get(user_api_url)
if resp.status_code != 200:
raise ValueError("Invalid response from Google")
data = resp.json().get("user")
serializer = URLSafeSerializer(self.secret_key)
user_data = {
"id": data.get("id"),
"username": data.get("display_name"),
"img_url": data.get("img").get("href") if data.get("img") else None,
}
token = serializer.dumps(user_data)
access_token = base64.b64encode(bytes(token, "utf-8")).decode("utf-8")
token = Token(access_token=access_token)
return json.loads(token.model_dump_json())

def deserialize_access_token(self, access_token: str) -> dict:
"""Returns the userdata as JSON from access token.
Can be used for login required decorator or to check
the access token provided.
Args:
access_token(str): The access token from Auth.callback()
Returns:
user_data(dict): A JSON of user data from Google.
"""
deserializer = URLSafeSerializer(self.secret_key)

try:
decoded_token = base64.b64decode(access_token)
except Exception as e:
log.error(e)
log.error(f"Could not decode token: {access_token}")
raise ValueError("Could not decode token") from e

try:
user_data = deserializer.loads(decoded_token)
except (SignatureExpired, BadSignature) as e:
log.error(e)
raise ValueError("Auth token is invalid or expired") from e

return user_data
53 changes: 53 additions & 0 deletions src/backend/app/users/oauth_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import os
import json
from loguru import logger as log
from fastapi import Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.db import database
from app.users.user_routes import router
from app.users.user_deps import init_google_auth, login_required
from app.users.user_schemas import AuthUser
from app.config import settings

if settings.DEBUG:
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"


@router.get("/google-login")
async def login_url(google_auth=Depends(init_google_auth)):
"""Get Login URL for Google Oauth Application.
The application must be registered on google oauth.
Open the download url returned to get access_token.
Args:
request: The GET request.
google_auth: The Auth object.
Returns:
login_url (string): URL to authorize user in Google OAuth.
Includes URL params: client_id, redirect_uri, permission scope.
"""
login_url = google_auth.login()
log.debug(f"Login URL returned: {login_url}")
return JSONResponse(content=login_url, status_code=200)


@router.get("/callback/")
async def callback(request: Request, google_auth=Depends(init_google_auth)):
"""Performs token exchange between Google and DTM API"""

callback_url = str(request.url)
access_token = google_auth.callback(callback_url).get("access_token")
return json.loads(access_token)


@router.get("/my-info/")
async def my_data(
db: Session = Depends(database.get_db),
user_data: AuthUser = Depends(login_required),
):
"""Read access token and get user details from Google"""

return user_data
44 changes: 42 additions & 2 deletions src/backend/app/users/user_deps.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import jwt
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status, Header
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
Expand All @@ -10,7 +10,9 @@
from app.db import database
from app.users import user_crud, user_schemas
from app.db.db_models import DbUser

from app.users.auth import Auth
from app.users.user_schemas import AuthUser
from loguru import logger as log

reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_PREFIX}/users/login")

Expand Down Expand Up @@ -53,3 +55,41 @@ def get_current_active_superuser(current_user: CurrentUser):
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user


async def init_google_auth():
"""Initialise Auth object for google login"""

return Auth(
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://www.googleapis.com/oauth2/v4/token",
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
secret_key=settings.SECRET_KEY,
login_redirect_uri=settings.GOOGLE_LOGIN_REDIRECT_URI,
scope=[
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
)


async def login_required(
request: Request, access_token: str = Header(None)
) -> AuthUser:
"""Dependency to inject into endpoints requiring login."""

google_auth = await init_google_auth()

if not access_token:
raise HTTPException(status_code=401, detail="No access token provided")

try:
google_user = google_auth.deserialize_access_token(access_token)
except ValueError as e:
log.error(e)
log.error("Failed to deserialise access token")
raise HTTPException(status_code=401, detail="Access token not valid") from e

return AuthUser(**google_user)
9 changes: 9 additions & 0 deletions src/backend/app/users/user_schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
from pydantic import BaseModel, EmailStr, ValidationInfo, Field
from pydantic.functional_validators import field_validator
from typing import Optional


class AuthUser(BaseModel):
"""The user model returned from Google OAuth2."""

id: int
email: EmailStr
img_url: Optional[str] = None


class UserBase(BaseModel):
Expand Down
Loading

0 comments on commit d03e298

Please sign in to comment.