Skip to content

Commit

Permalink
Shenghong/NCKU-70-login-module (#24)
Browse files Browse the repository at this point in the history
* feat: add login module
- Login, Logout
- Google oauth
- JWT token

* modify: user cookie to get file uploader id

* modify: fix robot comments

* chore: template.env

* docs: add comments to environment variables template

Add descriptive comments to template.env to improve configuration clarity
  • Loading branch information
ShengHongChen authored and owenowenisme committed Feb 10, 2025
1 parent b113cb6 commit efcf967
Show file tree
Hide file tree
Showing 16 changed files with 1,060 additions and 106 deletions.
37 changes: 37 additions & 0 deletions backend/core/interfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from abc import ABC, abstractmethod
from typing import Dict, Protocol, Tuple

from fastapi import Response
from sqlalchemy.orm import Session


class OAuthProvider(ABC):
@abstractmethod
def create_auth_flow(self) -> Tuple[str, str]:
pass

@abstractmethod
def process_oauth_callback(
self,
db: Session,
code: str,
) -> Dict:
pass

@abstractmethod
def verify_email_domain(self, email: str) -> bool:
pass

class TokenService(Protocol):
def create_token(self, data: Dict) -> str:
pass

def verify_token(self, token: str) -> Dict:
pass

class CookieService(Protocol):
def set_auth_cookie(self, response: Response, user_id: str) -> None:
pass

def clear_auth_cookie(self, response: Response) -> None:
pass
137 changes: 137 additions & 0 deletions backend/crud/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from typing import Dict, Tuple

from fastapi import HTTPException
from google.auth.transport import requests
from google.oauth2 import id_token
from google_auth_oauthlib.flow import Flow
from sqlalchemy.orm import Session

from core.config import get_settings
from core.interfaces import OAuthProvider
from crud.user import UserCRUD


class GoogleAuthProvider(OAuthProvider):
def __init__(self):
settings = get_settings()
self.client_config = {
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [settings.google_redirect_uri]
}
}
self.scopes = [
'openid',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'
]
self.user_crud = UserCRUD()

def create_auth_flow(self) -> Tuple[str, str]:
try:
settings = get_settings()
flow = Flow.from_client_config(
self.client_config,
scopes=self.scopes
)

flow.redirect_uri = settings.google_redirect_uri

authorization_url, state = flow.authorization_url(
access_type='offline',
include_granted_scopes='true',
prompt='consent',
hd=settings.google_allowed_domains
)

return authorization_url, state

except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to create authorization flow: {str(e)}"
)

def process_oauth_callback(
self,
db: Session,
code: str,
) -> Dict:
try:
settings = get_settings()
flow = Flow.from_client_config(
self.client_config,
scopes=self.scopes
)
flow.redirect_uri = settings.google_redirect_uri

flow.fetch_token(code=code)
credentials = flow.credentials

user_info = id_token.verify_oauth2_token(
credentials.id_token,
requests.Request(),
settings.google_client_id,
clock_skew_in_seconds=60
)

if not self.verify_email_domain(user_info['email']):
raise HTTPException(
status_code=401,
detail='Only NCKU accounts are allowed to use this service.'
)

google_user_info = {
'user_id': user_info['sub'],
'email': user_info['email'],
'username': user_info['name'],
'avatar': user_info.get('picture', '')
}

user_response = self.user_crud.get_or_create_user(
db=db,
google_user_info=google_user_info
)

return {
'user': user_response.data.model_dump(),
'access_token': credentials.token,
'refresh_token': credentials.refresh_token,
'token_expiry': credentials.expiry.timestamp() if credentials.expiry else None
}

except Exception:
raise HTTPException(
status_code=400,
detail='Failed to process OAuth callback'
)

def verify_email_domain(self, email: str) -> bool:
settings = get_settings()
return email.endswith(f'@{settings.google_allowed_domains}')

def _verify_oauth_token(self, code: str) -> Dict:
settings = get_settings()
flow = Flow.from_client_config(
self.client_config,
scopes=self.scopes
)
flow.redirect_uri = settings.google_redirect_uri
flow.fetch_token(code=code)

return id_token.verify_oauth2_token(
flow.credentials.id_token,
requests.Request(),
settings.google_client_id
)

def _extract_user_info(self, id_info: Dict) -> Dict:
return {
'id': id_info['sub'],
'email': id_info['email'],
'name': id_info['name'],
'picture': id_info.get('picture')
}
69 changes: 43 additions & 26 deletions backend/crud/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,19 @@ def _validate_file(self, upload_file: UploadFile):
async def create_file(
self, db: Session, file_data: FileCreateSchema, upload_file: UploadFile
) -> ResponseModel[FileResponseSchema]:
# TODO: file storage approach is still TBD

try:
self._validate_file(upload_file)
if file_data.uploader_id is not None:
user = db.query(User).filter(User.user_id == file_data.uploader_id).first()
if not user:
raise HTTPException(
status_code=404, detail=f'User with id {file_data.uploader_id} not found'
)

# TODO: File storage approach is still TBD
user = db.query(User).filter(User.user_id == file_data.uploader_id).first()
if not user:
raise HTTPException(
status_code=404,
detail=f'User with id ${file_data.uploader_id} not found.'
)

file_id = str(uuid4())

os.makedirs(self.UPLOAD_DIR, exist_ok=True)

Expand All @@ -54,27 +57,41 @@ async def create_file(
f.write(content)
except Exception:
raise HTTPException(status_code=500, detail='Failed to save file.')

try:
db_file = File(
filename=file_data.filename,
file_location=file_path,
uploader_id=file_data.uploader_id,
file_id=file_id
)

db.add(db_file)
db.commit()
db.refresh(db_file)

return ResponseModel(
status=ResponseStatus.SUCCESS,
message="File uploaded successfully",
data=db_file
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to create file record")

except HTTPException:
if file_path and os.path.exists(file_path):
os.remove(file_path)
raise

db_file = File(
filename=file_data.filename or upload_file.filename,
uploader_id=file_data.uploader_id,
file_location=file_path,
)
db.add(db_file)
db.commit()
db.refresh(db_file)
except Exception as e:
if file_path and os.path.exists(file_path):
os.remove(file_path)

return ResponseModel(
status=ResponseStatus.SUCCESS, data=FileResponseSchema.model_validate(db_file)
raise HTTPException(
status_code=500,
detail=f"Failed to process file upload: {str(e)}"
)

except HTTPException:
db.rollback()
raise
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail='Failed to create file.')

def read_all_file(self, db: Session) -> ResponseModel[List[FileResponseSchema]]:
try:
files = db.query(File).all()
Expand All @@ -86,7 +103,7 @@ def read_all_file(self, db: Session) -> ResponseModel[List[FileResponseSchema]]:
except Exception:
raise HTTPException(status_code=500, detail='Failed to fetch files.')

def get_file_by_id(self, db: Session, file_id: int) -> ResponseModel[FileResponseSchema]:
def get_file_by_id(self, db: Session, file_id: str) -> ResponseModel[FileResponseSchema]:
file = db.query(File).filter(File.file_id == file_id).first()
if not file:
raise HTTPException(status_code=404, detail=f'File with id {file_id} not found')
Expand All @@ -95,7 +112,7 @@ def get_file_by_id(self, db: Session, file_id: int) -> ResponseModel[FileRespons
status=ResponseStatus.SUCCESS, data=FileResponseSchema.model_validate(file)
)

def delete_file(self, db: Session, file_id: int) -> ResponseModel[None]:
def delete_file(self, db: Session, file_id: str) -> ResponseModel[None]:
try:
file = db.query(File).filter(File.file_id == file_id).first()
if not file:
Expand Down
Loading

0 comments on commit efcf967

Please sign in to comment.