Skip to content
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

API supabase #593

Merged
merged 25 commits into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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 .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ env:
FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
SENDGRID_API_KEY: fake_api_key_for_sendgrid_test
SYSTEM_EMAIL: [email protected]
AUTH_SERVICE: FIREBASE

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ cython_debug/

# Addition for Threatconnectome
/data*
/volumes/db/data*
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/macOS.gitignore

Expand All @@ -165,7 +166,8 @@ cython_debug/
.LSOverride

# Icon must end with two \r
Icon
Icon


# Thumbnails
._*
Expand Down
2 changes: 2 additions & 0 deletions api/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ packageurl-python = "*"
sendgrid = "*"
email-validator = "*"
pillow = "*"
supabase = "*"
deprecation = "*"

[dev-packages]
mypy = "*"
Expand Down
2,554 changes: 1,631 additions & 923 deletions api/Pipfile.lock

Large diffs are not rendered by default.

91 changes: 0 additions & 91 deletions api/app/auth.py

This file was deleted.

Empty file added api/app/auth/__init__.py
Empty file.
31 changes: 31 additions & 0 deletions api/app/auth/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session

from app import persistence
from app.auth.auth_module import AuthModule, get_auth_module

from ..database import get_db
from ..models import Account


def get_current_user(
token: HTTPAuthorizationCredentials = Depends(
HTTPBearer(scheme_name=None, description=None, auto_error=False)
),
auth_module: AuthModule = Depends(get_auth_module),
db: Session = Depends(get_db),
) -> Account:
uid, email = auth_module.check_and_get_user_info(token)
user = persistence.get_account_by_uid(db, uid)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such user",
)
if user.disabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
Comment on lines +26 to +35
Copy link
Collaborator

@mshim03 mshim03 Feb 18, 2025

Choose a reason for hiding this comment

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

router以外で、HTTPExceptionはできれば避けたいが、現状HTTPBearerからトークンを取得して認証するので、HTTP専用の関数となっている。一旦このままでも問題なさそう

return user
29 changes: 29 additions & 0 deletions api/app/auth/auth_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from fastapi import HTTPException, status

from ..schemas import Token


def get_auth_module():
return AuthModule(None)


class AuthModule:
def __init__(self):
pass

def login_for_access_token(self, username, password) -> Token:
return Token(access_token="", token_type="bearer", refresh_token="")

def refresh_access_token(self, refresh_token) -> Token:
return Token(access_token="", token_type="bearer", refresh_token="")

def check_and_get_user_info(self, token):
pass

def check_token(self, token):
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
152 changes: 152 additions & 0 deletions api/app/auth/firebase_auth_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import json
import os

import requests
from fastapi import HTTPException, status
from firebase_admin import auth, credentials, initialize_app

from app.auth.auth_module import AuthModule

from ..schemas import Token


class FirebaseAuthModule(AuthModule):
def __init__(self):
super().__init__()
self.cred = credentials.Certificate(os.environ["FIREBASE_CRED"])
initialize_app(self.cred)

def login_for_access_token(self, username, password) -> Token:
payload = {
"email": username,
"password": password.get_secret_value(),
"returnSecureToken": True,
}

api_key = os.getenv("FIREBASE_API_KEY", "")
# https://github.com/firebase/firebase-admin-python/blob/master/firebase_admin/_auth_utils.py
id_toolkit = "identitytoolkit.googleapis.com/v1"
sign_in_url_without_scheme = f"{id_toolkit}/accounts:signInWithPassword?key={api_key}"
emulator_host = os.getenv("FIREBASE_AUTH_EMULATOR_HOST", "")
if emulator_host != "":
sign_in_url = f"http://{emulator_host}/{sign_in_url_without_scheme}"
else:
sign_in_url = f"https://{sign_in_url_without_scheme}"

try:
resp = requests.post(
sign_in_url,
json.dumps(payload),
headers={"Content-Type": "application/json"},
timeout=30,
)
except requests.exceptions.Timeout as firebase_timeout:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from firebase_timeout

data = resp.json()
if not resp.ok:
error_message = data["error"]["message"]
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=error_message if error_message else "Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

ここではHTTPExceptionでなく、Exceptionを発出し、router関数の中 (/token) でHTTPExceptionを返すようにして欲しいです (どこでHTTPExceptionを発行しているのかがわからなくなる)

return Token(
access_token=data["idToken"], token_type="bearer", refresh_token=data["refreshToken"]
)

def refresh_access_token(self, refresh_token) -> Token:
payload = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}

# see https://firebase.google.com/docs/reference/rest/auth#section-refresh-token
api_key = os.environ["FIREBASE_API_KEY"]
refresh_path = f"securetoken.googleapis.com/v1/token?key={api_key}"
emulator_host = os.getenv("FIREBASE_AUTH_EMULATOR_HOST", "")
if emulator_host != "":
refresh_token_url = f"http://{emulator_host}/{refresh_path}"
else:
refresh_token_url = f"https://{refresh_path}"

try:
resp = requests.post(
refresh_token_url,
json.dumps(payload),
headers={"Content-Type": "application/json"},
timeout=30,
)
except requests.exceptions.Timeout as firebase_timeout:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not refresh token",
headers={"WWW-Authenticate": "Bearer"},
) from firebase_timeout

data: dict = resp.json()
if not resp.ok:
error_message: str = data["error"]["message"]
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=error_message if error_message else "Could not refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

こちらも同様に、HTTPException をここで発出するのを避けて欲しいです

return Token(
access_token=data["id_token"],
token_type=data["token_type"],
refresh_token=data["refresh_token"],
)

def check_and_get_user_info(self, token):
super().check_token(token)
try:
decoded_token = auth.verify_id_token(token.credentials, check_revoked=True)
except auth.ExpiredIdTokenError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
) from error
except auth.RevokedIdTokenError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has revoked",
) from error
except auth.CertificateFetchError as error:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to obtain required credentials",
) from error
except auth.UserDisabledError as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Disabled user",
) from error
except (auth.InvalidIdTokenError, ValueError) as error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from error

Copy link
Collaborator

Choose a reason for hiding this comment

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

HTTPExceptionについて同様

user_info = auth.get_user(decoded_token["uid"])

# check email verified if not using firebase emulator
emulator_host = os.getenv("FIREBASE_AUTH_EMULATOR_HOST", "")
if emulator_host == "" and user_info.email_verified is False:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email is not verified. Try logging in on UI and verify email.",
)

email = user_info.email
# if user_info.email is empty, get email from auth provider data
if email is None:
if len(user_info.provider_data) > 0:
email = user_info.provider_data[0].email

return user_info.uid, email
43 changes: 43 additions & 0 deletions api/app/auth/supabase_auth_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import os

from supabase import create_client

from app.auth.auth_module import AuthModule

from ..schemas import Token


class SupabaseAuthModule(AuthModule):
def __init__(self):
super().__init__()

url = os.getenv("SUPABASE_URL")
if url is None:
raise Exception(f"Unsupported SUPABASE_URL: {url}")
key = os.getenv("SUPABASE_ANON_KEY")
if key is None:
raise Exception(f"Unsupported SUPABASE_ANON_KEY: {key}")

self.supabase = create_client(url, key)

def login_for_access_token(self, username, password) -> Token:
payload = {
"email": username,
"password": password.get_secret_value(),
}
user_data = self.supabase.auth.sign_in_with_password(payload)
session = user_data.model_dump().get("session")
return Token(
access_token=session.get("access_token"),
token_type="bearer",
refresh_token=session.get("refresh_token"),
)

def refresh_access_token(self, refresh_token) -> Token:
return Token(access_token="", token_type="bearer", refresh_token="")

def check_and_get_user_info(self, token):
super().check_token(token)
user_data = self.supabase.auth.get_user(token.credentials)
user = user_data.model_dump().get("user")
return user.get("id"), user.get("email")
Loading