Skip to content

Commit d476953

Browse files
mmiqballemma-x1ebwu95janealshsunbagel
authored
Implement Authentication Logic, Routes, Middleware (#21)
## Notion ticket link <!-- Please replace with your ticket's URL --> [Authentication Logic and Routes](https://www.notion.so/Authentication-logic-and-routes-11110f3fb1dc804bb31dd26b9079284d?pvs=4) <!-- Give a quick summary of the implementation details, provide design justifications if necessary --> ## Implementation description _Auth Service_ - implements functionality related to authentication, such as checking authorization and roles, generating and revoking tokens etc. _Auth Routes_ - routes for logging in, refreshing access tokens, logging out - related schemas added to the `schemas` directory _Middleware_ - Basic authorization checks for Firebase access tokens implemented using the format in [FastAPI's docs](https://fastapi.tiangolo.com/advanced/middleware/) - Role based access control added as dependency injections ## Local Testing <!-- What should the reviewer do to verify your changes? Describe expected results and include screenshots when appropriate --> Make sure your local backend is running: - `docker compose up -d db` in `llsc` to get the database running - `pdm run dev` in `llsc/backend` to get server running Access route schemas and definitions from `{server url}/docs` and test with Postman. Recommended order is to: - create a user with the schema, verify it shows up in both the docker container db and in Firebase - login using their credentials - use test endpoints to verify if auth token middleware and the role-based access control dependency works as expected - refresh the user's token - logout <!-- Draw attention to the substantial parts of your PR or anything you'd like a second opinion on --> ## What should reviewers focus on? - General approach and architecture - Any design concerns that may be affected - Is the implementation flexible enough to accommodate changes that will definitely come up later on? ## Checklist - [x] My PR name is descriptive and in imperative tense - [x] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits - [ ] I have run the appropriate linter(s) - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR --------- Co-authored-by: emma-x1 <[email protected]> Co-authored-by: Evan Wu <[email protected]> Co-authored-by: ebwu95 <[email protected]> Co-authored-by: janealsh <[email protected]> Co-authored-by: Alex Lu <[email protected]> Co-authored-by: Emily Nie <[email protected]>
1 parent 8ea3933 commit d476953

File tree

19 files changed

+777
-36
lines changed

19 files changed

+777
-36
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
**/.DS_Store
99
**/*.cache
1010
**/*.egg-info
11+
**/test.db

backend/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,6 @@ cython_debug/
160160
# and can be added to the global gitignore or merged into this file. For a more nuclear
161161
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
162162
#.idea/
163+
164+
# Firebase
165+
serviceAccountKey.json

backend/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ pdm run ruff format .
110110
## Environment Variables
111111
Environment variables are currently stored in an .env file within the base repository (not the backend folder). You will need to copy the local environment variables stored in the following notion [page](https://www.notion.so/uwblueprintexecs/Environment-Variables-11910f3fb1dc80e4bc67d35c3d65d073?pvs=4) to get the database working.
112112

113+
### Firebase Configuration
114+
To set up Firebase authentication:
115+
116+
1. Place your `serviceAccountKey.json` file in the `backend/` directory
117+
- This file should be obtained from your Firebase Console
118+
- Go to Project Settings > Service Accounts > Generate New Private Key
119+
- The file contains sensitive credentials and is automatically gitignored
120+
121+
2. Ensure your `.env` file includes the following Firebase-related variables:
122+
```
123+
FIREBASE_WEB_API_KEY=your_web_api_key
124+
```
125+
You can find these values in your Firebase Console under Project Settings.
126+
127+
Note: Never commit `serviceAccountKey.json` to version control. It's already added to `.gitignore` for security.
113128
114129
## Adding a new model
115130
When adding a new model, make sure to add it to `app/models/__init__.py` so that the migration script can pick it up when autogenerating the new migration.

backend/app/middleware/auth.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import logging
2+
from typing import List
3+
4+
from fastapi import Depends, HTTPException, Request, status
5+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6+
7+
from ..utilities.constants import LOGGER_NAME
8+
from ..utilities.service_utils import get_auth_service
9+
10+
security = HTTPBearer()
11+
logger = logging.getLogger(LOGGER_NAME("auth"))
12+
13+
14+
def has_roles(required_roles: List[str]):
15+
"""
16+
FastAPI dependency that checks if the authenticated user has
17+
one of the required roles.
18+
19+
Args:
20+
required_roles: List of roles that can access the endpoint
21+
22+
Returns:
23+
A dependency that validates the user has one of the specified roles
24+
25+
Example:
26+
@app.get("/admin-only")
27+
async def admin_endpoint(authorized: bool = has_roles(["admin"])):
28+
return {"message": "You have admin access"}
29+
"""
30+
31+
async def role_validator(
32+
request: Request,
33+
credentials: HTTPAuthorizationCredentials = Depends(security),
34+
auth_service=Depends(get_auth_service),
35+
) -> bool:
36+
# Get the token from authorization header
37+
token = credentials.credentials
38+
39+
# Use the auth service to check if the user has the required role
40+
is_authorized = auth_service.is_authorized_by_role(token, set(required_roles))
41+
42+
if not is_authorized:
43+
logger.warning(
44+
f"Access denied: user doesn't have required roles: {required_roles}"
45+
)
46+
raise HTTPException(
47+
status_code=status.HTTP_403_FORBIDDEN,
48+
detail=f"Access denied: requires one of these roles: {required_roles}",
49+
)
50+
51+
return True
52+
53+
return Depends(role_validator)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import logging
2+
from typing import List
3+
4+
import firebase_admin.auth
5+
from starlette.middleware.base import BaseHTTPMiddleware
6+
from starlette.requests import Request
7+
from starlette.responses import JSONResponse, Response
8+
from starlette.types import ASGIApp
9+
10+
from app.utilities.constants import LOGGER_NAME
11+
12+
13+
class AuthMiddleware(BaseHTTPMiddleware):
14+
def __init__(self, app: ASGIApp, public_paths: List[str] = None):
15+
super().__init__(app)
16+
self.public_paths = public_paths or []
17+
self.logger = logging.getLogger(LOGGER_NAME("auth_middleware"))
18+
19+
def is_public_path(self, path: str) -> bool:
20+
return path in self.public_paths
21+
22+
async def dispatch(self, request: Request, call_next):
23+
if self.is_public_path(request.url.path):
24+
self.logger.info(f"Skipping auth for public path: {request.url.path}")
25+
return await call_next(request)
26+
27+
# Get authentication token from header
28+
auth_header = request.headers.get("Authorization")
29+
if not auth_header or not auth_header.startswith("Bearer "):
30+
self.logger.warning(
31+
f"Missing or invalid auth header for {request.url.path}"
32+
)
33+
return JSONResponse(
34+
status_code=401, content={"detail": "Authentication required"}
35+
)
36+
37+
token = auth_header.split(" ")[1]
38+
try:
39+
# Verify the token with Firebase
40+
self.logger.info(f"Verifying token for request to {request.url.path}")
41+
decoded_token = firebase_admin.auth.verify_id_token(
42+
token, check_revoked=True
43+
)
44+
45+
# Get Firebase user information
46+
firebase_user = firebase_admin.auth.get_user(decoded_token["uid"])
47+
48+
request.state.user_id = decoded_token["uid"]
49+
request.state.user_email = decoded_token.get("email")
50+
request.state.email_verified = firebase_user.email_verified
51+
request.state.user_claims = decoded_token.get("claims", {})
52+
53+
# Add complete user info for convenience
54+
request.state.user_info = {
55+
"uid": decoded_token["uid"],
56+
"email": decoded_token.get("email"),
57+
"name": decoded_token.get("name", ""),
58+
"picture": decoded_token.get("picture", ""),
59+
"email_verified": firebase_user.email_verified,
60+
}
61+
62+
response = await call_next(request)
63+
64+
if isinstance(response, Response):
65+
response.headers["X-Auth-User-ID"] = decoded_token["uid"]
66+
67+
return response
68+
69+
except firebase_admin.auth.RevokedIdTokenError:
70+
self.logger.warning(f"Token has been revoked: {request.url.path}")
71+
return JSONResponse(
72+
status_code=401,
73+
content={"detail": "Token has been revoked. Please reauthenticate."},
74+
)
75+
except firebase_admin.auth.ExpiredIdTokenError:
76+
self.logger.warning(f"Token has expired: {request.url.path}")
77+
return JSONResponse(
78+
status_code=401,
79+
content={"detail": "Token has expired. Please reauthenticate."},
80+
)
81+
except firebase_admin.auth.InvalidIdTokenError as e:
82+
self.logger.warning(f"Invalid token: {request.url.path}, error: {str(e)}")
83+
return JSONResponse(
84+
status_code=401, content={"detail": "Invalid authentication token"}
85+
)
86+
except Exception as e:
87+
self.logger.error(f"Authentication error for {request.url.path}: {str(e)}")
88+
return JSONResponse(
89+
status_code=401, content={"detail": f"Authentication failed: {str(e)}"}
90+
)

backend/app/routes/auth.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from fastapi import APIRouter, Depends, HTTPException, Request
2+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3+
4+
from ..middleware.auth import UserRole
5+
from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token
6+
from ..schemas.user import UserCreateRequest, UserCreateResponse
7+
from ..services.implementations.auth_service import AuthService
8+
from ..services.implementations.user_service import UserService
9+
from ..utilities.service_utils import get_auth_service, get_user_service
10+
11+
router = APIRouter(prefix="/auth", tags=["auth"])
12+
security = HTTPBearer()
13+
14+
15+
# TODO: ADD RATE LIMITING
16+
@router.post("/register", response_model=UserCreateResponse)
17+
async def register_user(
18+
user: UserCreateRequest, user_service: UserService = Depends(get_user_service)
19+
):
20+
try:
21+
return await user_service.create_user(user)
22+
except HTTPException as http_ex:
23+
raise http_ex
24+
except Exception as e:
25+
raise HTTPException(status_code=500, detail=str(e))
26+
27+
28+
@router.post("/login", response_model=AuthResponse)
29+
async def login(
30+
request: Request,
31+
credentials: LoginRequest,
32+
auth_service: AuthService = Depends(get_auth_service),
33+
):
34+
try:
35+
is_admin_portal = request.headers.get("X-Admin-Portal") == "true"
36+
auth_response = auth_service.generate_token(
37+
credentials.email, credentials.password
38+
)
39+
if is_admin_portal and not auth_service.is_authorized_by_role(
40+
auth_response.access_token, {UserRole.ADMIN}
41+
):
42+
raise HTTPException(
43+
status_code=403,
44+
detail="Access denied. Admin privileges required for admin portal",
45+
)
46+
47+
return auth_response
48+
49+
except HTTPException as http_ex:
50+
raise http_ex
51+
except Exception as e:
52+
raise HTTPException(status_code=500, detail=str(e))
53+
54+
55+
@router.post("/logout")
56+
async def logout(
57+
request: Request,
58+
credentials: HTTPAuthorizationCredentials = Depends(security),
59+
auth_service: AuthService = Depends(get_auth_service),
60+
):
61+
try:
62+
user_id = request.state.user_id
63+
if not user_id:
64+
raise HTTPException(status_code=401, detail="Authentication required")
65+
66+
auth_service.revoke_tokens(user_id)
67+
return {"message": "Successfully logged out"}
68+
except Exception as e:
69+
raise HTTPException(status_code=500, detail=str(e))
70+
71+
72+
@router.post("/refresh", response_model=Token)
73+
async def refresh(
74+
refresh_data: RefreshRequest, auth_service: AuthService = Depends(get_auth_service)
75+
):
76+
try:
77+
return auth_service.renew_token(refresh_data.refresh_token)
78+
except Exception as e:
79+
raise HTTPException(status_code=401, detail=str(e))

backend/app/routes/test.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from typing import Any, Dict
2+
3+
from fastapi import APIRouter, Request
4+
5+
from ..middleware.auth import has_roles
6+
from ..schemas.user import UserRole
7+
8+
router = APIRouter(prefix="/test", tags=["test"])
9+
10+
11+
@router.get("/middleware")
12+
async def test_middleware(request: Request) -> Dict[str, Any]:
13+
state_dict = {
14+
key: getattr(request.state, key)
15+
for key in dir(request.state)
16+
if not key.startswith("_") and not callable(getattr(request.state, key))
17+
}
18+
19+
return {
20+
"message": "Authentication successful! User info from Firebase token:",
21+
"middleware_state": state_dict,
22+
"user_id": getattr(request.state, "user_id", None),
23+
"user_email": getattr(request.state, "user_email", None),
24+
"email_verified": getattr(request.state, "email_verified", None),
25+
"user_claims": getattr(request.state, "user_claims", None),
26+
"user_info": getattr(request.state, "user_info", None),
27+
"request_id": getattr(request.state, "request_id", None),
28+
"authorization_header": request.headers.get("Authorization", "Not provided"),
29+
}
30+
31+
32+
@router.get("/middleware-public")
33+
async def test_middleware_public(request: Request) -> Dict[str, Any]:
34+
state_dict = {
35+
key: getattr(request.state, key)
36+
for key in dir(request.state)
37+
if not key.startswith("_") and not callable(getattr(request.state, key))
38+
}
39+
40+
auth_header = request.headers.get("Authorization")
41+
auth_message = "No authentication required for this endpoint"
42+
if auth_header:
43+
auth_message += " (but you provided an auth header anyway)"
44+
45+
return {
46+
"message": "Public endpoint - No authentication required",
47+
"auth_status": auth_message,
48+
"middleware_state": state_dict,
49+
"request_id": getattr(request.state, "request_id", None),
50+
"authorization_header": request.headers.get("Authorization", "Not provided"),
51+
}
52+
53+
54+
@router.get("/role-admin")
55+
async def test_role_admin(
56+
request: Request, authorized: bool = has_roles([UserRole.ADMIN])
57+
) -> Dict[str, Any]:
58+
return {
59+
"message": "Successfully accessed an admin-only endpoint",
60+
"user_id": request.state.user_id,
61+
"user_email": request.state.user_email,
62+
"role": "admin",
63+
}
64+
65+
66+
@router.get("/role-volunteer")
67+
async def test_role_volunteer(
68+
request: Request, authorized: bool = has_roles([UserRole.VOLUNTEER])
69+
) -> Dict[str, Any]:
70+
return {
71+
"message": "Successfully accessed a volunteer-only endpoint",
72+
"user_id": request.state.user_id,
73+
"user_email": request.state.user_email,
74+
"role": "volunteer",
75+
}
76+
77+
78+
@router.get("/role-participant")
79+
async def test_role_participant(
80+
request: Request, authorized: bool = has_roles([UserRole.PARTICIPANT])
81+
) -> Dict[str, Any]:
82+
return {
83+
"message": "Successfully accessed a participant-only endpoint",
84+
"user_id": request.state.user_id,
85+
"user_email": request.state.user_email,
86+
"role": "participant",
87+
}
88+
89+
90+
@router.get("/role-multiple")
91+
async def test_role_multiple(
92+
request: Request, authorized: bool = has_roles([UserRole.ADMIN, UserRole.VOLUNTEER])
93+
) -> Dict[str, Any]:
94+
return {
95+
"message": "Successfully accessed an admin or volunteer endpoint",
96+
"user_id": request.state.user_id,
97+
"user_email": request.state.user_email,
98+
"roles_allowed": ["admin", "volunteer"],
99+
}

backend/app/routes/user.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from fastapi import APIRouter, Depends, HTTPException
2-
from sqlalchemy.orm import Session
32

4-
from app.schemas.user import UserCreateRequest, UserCreateResponse
3+
from app.middleware.auth import has_roles
4+
from app.schemas.user import UserCreateRequest, UserCreateResponse, UserRole
55
from app.services.implementations.user_service import UserService
6-
from app.utilities.db_utils import get_db
6+
from app.utilities.service_utils import get_user_service
77

88
router = APIRouter(
99
prefix="/users",
@@ -15,13 +15,12 @@
1515
# allow signup methods other than email (like sign up w Google)??
1616

1717

18-
def get_user_service(db: Session = Depends(get_db)):
19-
return UserService(db)
20-
21-
18+
# admin only manually create user, not sure if this is needed
2219
@router.post("/", response_model=UserCreateResponse)
2320
async def create_user(
24-
user: UserCreateRequest, user_service: UserService = Depends(get_user_service)
21+
user: UserCreateRequest,
22+
user_service: UserService = Depends(get_user_service),
23+
authorized: bool = has_roles([UserRole.ADMIN]),
2524
):
2625
try:
2726
return await user_service.create_user(user)

0 commit comments

Comments
 (0)