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 23 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.
36 changes: 36 additions & 0 deletions api/app/auth/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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_exception import AuthException
from app.auth.auth_module import AuthModule, get_auth_module
from app.routers.http_excption_creator import create_http_excption

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:
try:
uid, email = auth_module.check_and_get_user_info(token)
except AuthException as auth_exception:
raise create_http_excption(auth_exception)
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
22 changes: 22 additions & 0 deletions api/app/auth/auth_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import enum


class AuthErrorType(str, enum.Enum):
UNAUTHORIZED = "unauthorized"
INTERNAL_SERVER_ERROR = "internal_server_error"
SERVICE_UNAVAILABLE = "service_unavailable"


class AuthException(Exception):
def __init__(self, error_type: AuthErrorType, message: str):
super().__init__()
self.__error_type = error_type
self.__message = message

@property
def error_type(self):
return self.__error_type

@property
def message(self):
return self.__message
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"},
)
146 changes: 146 additions & 0 deletions api/app/auth/firebase_auth_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import json
import os

import requests
from firebase_admin import auth, credentials, initialize_app

from app.auth.auth_exception import AuthErrorType, AuthException
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 AuthException(
AuthErrorType.INTERNAL_SERVER_ERROR, "Could not validate credentials"
) from firebase_timeout

data = resp.json()
if not resp.ok:
error_message = data["error"]["message"]
raise AuthException(
AuthErrorType.UNAUTHORIZED,
error_message if error_message else "Could not validate credentials",
)
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 AuthException(
AuthErrorType.INTERNAL_SERVER_ERROR,
"Could not refresh token",
) from firebase_timeout

data: dict = resp.json()
if not resp.ok:
error_message: str = data["error"]["message"]
raise AuthException(
AuthErrorType.UNAUTHORIZED,
error_message if error_message else "Could not refresh token",
)
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 AuthException(
AuthErrorType.UNAUTHORIZED,
"Token has expired",
) from error
except auth.RevokedIdTokenError as error:
raise AuthException(
AuthErrorType.UNAUTHORIZED,
"Token has revoked",
) from error
except auth.CertificateFetchError as error:
raise AuthException(
AuthErrorType.SERVICE_UNAVAILABLE,
"Failed to obtain required credentials",
) from error
except auth.UserDisabledError as error:
raise AuthException(
AuthErrorType.UNAUTHORIZED,
"Disabled user",
) from error
except (auth.InvalidIdTokenError, ValueError) as error:
raise AuthException(
AuthErrorType.UNAUTHORIZED,
"Could not validate credentials",
) from error

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 AuthException(
AuthErrorType.UNAUTHORIZED,
"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
Loading